mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 08:04:48 +00:00
Integrate OpenHandoff factory workspace (#212)
This commit is contained in:
parent
3d9476ed0b
commit
bf282199b5
251 changed files with 42824 additions and 692 deletions
475
factory/packages/backend/src/providers/daytona/index.ts
Normal file
475
factory/packages/backend/src/providers/daytona/index.ts
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import type {
|
||||
AgentEndpoint,
|
||||
AttachTarget,
|
||||
AttachTargetRequest,
|
||||
CreateSandboxRequest,
|
||||
DestroySandboxRequest,
|
||||
EnsureAgentRequest,
|
||||
ExecuteSandboxCommandRequest,
|
||||
ExecuteSandboxCommandResult,
|
||||
ProviderCapabilities,
|
||||
ReleaseSandboxRequest,
|
||||
ResumeSandboxRequest,
|
||||
SandboxHandle,
|
||||
SandboxHealth,
|
||||
SandboxHealthRequest,
|
||||
SandboxProvider
|
||||
} from "../provider-api/index.js";
|
||||
import type { DaytonaDriver } from "../../driver.js";
|
||||
import { Image } from "@daytonaio/sdk";
|
||||
|
||||
export interface DaytonaProviderConfig {
|
||||
endpoint?: string;
|
||||
apiKey?: string;
|
||||
image: string;
|
||||
target?: string;
|
||||
/**
|
||||
* Auto-stop interval in minutes. If omitted, Daytona's default applies.
|
||||
* Set to `0` to disable auto-stop.
|
||||
*/
|
||||
autoStopInterval?: number;
|
||||
}
|
||||
|
||||
export class DaytonaProvider implements SandboxProvider {
|
||||
constructor(
|
||||
private readonly config: DaytonaProviderConfig,
|
||||
private readonly daytona?: DaytonaDriver
|
||||
) {}
|
||||
|
||||
private static readonly SANDBOX_AGENT_PORT = 2468;
|
||||
private static readonly SANDBOX_AGENT_VERSION = "0.3.0";
|
||||
private static readonly DEFAULT_ACP_REQUEST_TIMEOUT_MS = 120_000;
|
||||
private static readonly AGENT_IDS = ["codex", "claude"] as const;
|
||||
private static readonly PASSTHROUGH_ENV_KEYS = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
"CEREBRAS_API_KEY",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
] as const;
|
||||
|
||||
private getRequestTimeoutMs(): number {
|
||||
const parsed = Number(process.env.HF_DAYTONA_REQUEST_TIMEOUT_MS ?? "120000");
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 120_000;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
private getAcpRequestTimeoutMs(): number {
|
||||
const parsed = Number(
|
||||
process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS
|
||||
?? DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS.toString()
|
||||
);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
private async withTimeout<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
const timeoutMs = this.getRequestTimeoutMs();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
fn(),
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`daytona ${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
const apiKey = this.config.apiKey?.trim();
|
||||
if (!apiKey) {
|
||||
return undefined;
|
||||
}
|
||||
const endpoint = this.config.endpoint?.trim();
|
||||
|
||||
return this.daytona?.createClient({
|
||||
...(endpoint ? { apiUrl: endpoint } : {}),
|
||||
apiKey,
|
||||
target: this.config.target,
|
||||
});
|
||||
}
|
||||
|
||||
private requireClient() {
|
||||
const client = this.getClient();
|
||||
if (client) {
|
||||
return client;
|
||||
}
|
||||
|
||||
if (!this.daytona) {
|
||||
throw new Error("daytona provider requires backend daytona driver");
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"daytona provider is not configured: missing apiKey. " +
|
||||
"Set HF_DAYTONA_API_KEY (or DAYTONA_API_KEY). " +
|
||||
"Optionally set HF_DAYTONA_ENDPOINT (or DAYTONA_ENDPOINT)."
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureStarted(sandboxId: string): Promise<void> {
|
||||
const client = this.requireClient();
|
||||
|
||||
const sandbox = await this.withTimeout("get sandbox", () => client.getSandbox(sandboxId));
|
||||
const state = String(sandbox.state ?? "unknown").toLowerCase();
|
||||
if (state === "started" || state === "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the sandbox is stopped (or any non-started state), try starting it.
|
||||
// Daytona preserves the filesystem across stop/start, which is what we rely on for faster git setup.
|
||||
await this.withTimeout("start sandbox", () => client.startSandbox(sandboxId, 60));
|
||||
}
|
||||
|
||||
private buildEnvVars(): Record<string, string> {
|
||||
const envVars: Record<string, string> = {};
|
||||
|
||||
for (const key of DaytonaProvider.PASSTHROUGH_ENV_KEYS) {
|
||||
const value = process.env[key];
|
||||
if (value) {
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
private buildSnapshotImage() {
|
||||
// Use Daytona image build + snapshot caching so base tooling (git + sandbox-agent)
|
||||
// is prepared once and reused for subsequent sandboxes.
|
||||
return Image.base(this.config.image).runCommands(
|
||||
"apt-get update && apt-get install -y curl ca-certificates git openssh-client nodejs npm",
|
||||
`curl -fsSL https://releases.rivet.dev/sandbox-agent/${DaytonaProvider.SANDBOX_AGENT_VERSION}/install.sh | sh`,
|
||||
`bash -lc 'export PATH="$HOME/.local/bin:$PATH"; sandbox-agent install-agent codex || true; sandbox-agent install-agent claude || true'`
|
||||
);
|
||||
}
|
||||
|
||||
private async runCheckedCommand(
|
||||
sandboxId: string,
|
||||
command: string,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const client = this.requireClient();
|
||||
|
||||
const result = await this.withTimeout(`execute command (${label})`, () =>
|
||||
client.executeCommand(sandboxId, command)
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`daytona ${label} failed (${result.exitCode}): ${result.result}`);
|
||||
}
|
||||
}
|
||||
|
||||
id() {
|
||||
return "daytona" as const;
|
||||
}
|
||||
|
||||
capabilities(): ProviderCapabilities {
|
||||
return {
|
||||
remote: true,
|
||||
supportsSessionReuse: true
|
||||
};
|
||||
}
|
||||
|
||||
async validateConfig(input: unknown): Promise<Record<string, unknown>> {
|
||||
return (input as Record<string, unknown> | undefined) ?? {};
|
||||
}
|
||||
|
||||
async createSandbox(req: CreateSandboxRequest): Promise<SandboxHandle> {
|
||||
const client = this.requireClient();
|
||||
const emitDebug = req.debug ?? (() => {});
|
||||
|
||||
emitDebug("daytona.createSandbox.start", {
|
||||
workspaceId: req.workspaceId,
|
||||
repoId: req.repoId,
|
||||
handoffId: req.handoffId,
|
||||
branchName: req.branchName
|
||||
});
|
||||
|
||||
const createStartedAt = Date.now();
|
||||
const sandbox = await this.withTimeout("create sandbox", () =>
|
||||
client.createSandbox({
|
||||
image: this.buildSnapshotImage(),
|
||||
envVars: this.buildEnvVars(),
|
||||
labels: {
|
||||
"openhandoff.workspace": req.workspaceId,
|
||||
"openhandoff.handoff": req.handoffId,
|
||||
"openhandoff.repo_id": req.repoId,
|
||||
"openhandoff.repo_remote": req.repoRemote,
|
||||
"openhandoff.branch": req.branchName,
|
||||
},
|
||||
autoStopInterval: this.config.autoStopInterval,
|
||||
})
|
||||
);
|
||||
emitDebug("daytona.createSandbox.created", {
|
||||
sandboxId: sandbox.id,
|
||||
durationMs: Date.now() - createStartedAt,
|
||||
state: sandbox.state ?? null
|
||||
});
|
||||
|
||||
const repoDir = `/home/daytona/openhandoff/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`;
|
||||
|
||||
// Prepare a working directory for the agent. This must succeed for the handoff to work.
|
||||
const installStartedAt = Date.now();
|
||||
await this.runCheckedCommand(
|
||||
sandbox.id,
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; export DEBIAN_FRONTEND=noninteractive; if command -v git >/dev/null 2>&1 && command -v npx >/dev/null 2>&1; then exit 0; fi; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y git openssh-client ca-certificates nodejs npm >/tmp/apt-install.log 2>&1'`
|
||||
].join(" "),
|
||||
"install git + node toolchain"
|
||||
);
|
||||
emitDebug("daytona.createSandbox.install_toolchain.done", {
|
||||
sandboxId: sandbox.id,
|
||||
durationMs: Date.now() - installStartedAt
|
||||
});
|
||||
|
||||
const cloneStartedAt = Date.now();
|
||||
await this.runCheckedCommand(
|
||||
sandbox.id,
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`${JSON.stringify(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
"export GIT_TERMINAL_PROMPT=0",
|
||||
"export GIT_ASKPASS=/bin/echo",
|
||||
`rm -rf "${repoDir}"`,
|
||||
`mkdir -p "${repoDir}"`,
|
||||
`rmdir "${repoDir}"`,
|
||||
// Clone without embedding credentials. Auth for pushing is configured by the agent at runtime.
|
||||
`git clone "${req.repoRemote}" "${repoDir}"`,
|
||||
`cd "${repoDir}"`,
|
||||
`git fetch origin --prune`,
|
||||
// The handoff branch may not exist remotely yet (agent push creates it). Base off current branch (default branch).
|
||||
`if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`,
|
||||
`git config user.email "openhandoff@local" >/dev/null 2>&1 || true`,
|
||||
`git config user.name "OpenHandoff" >/dev/null 2>&1 || true`,
|
||||
].join("; ")
|
||||
)}`
|
||||
].join(" "),
|
||||
"clone repo"
|
||||
);
|
||||
emitDebug("daytona.createSandbox.clone_repo.done", {
|
||||
sandboxId: sandbox.id,
|
||||
durationMs: Date.now() - cloneStartedAt
|
||||
});
|
||||
|
||||
return {
|
||||
sandboxId: sandbox.id,
|
||||
switchTarget: `daytona://${sandbox.id}`,
|
||||
metadata: {
|
||||
endpoint: this.config.endpoint ?? null,
|
||||
image: this.config.image,
|
||||
snapshot: sandbox.snapshot ?? null,
|
||||
remote: true,
|
||||
state: sandbox.state ?? null,
|
||||
cwd: repoDir,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async resumeSandbox(req: ResumeSandboxRequest): Promise<SandboxHandle> {
|
||||
const client = this.requireClient();
|
||||
|
||||
await this.ensureStarted(req.sandboxId);
|
||||
|
||||
// Reconstruct cwd from sandbox labels written at create time.
|
||||
const info = await this.withTimeout("resume get sandbox", () =>
|
||||
client.getSandbox(req.sandboxId)
|
||||
);
|
||||
const labels = info.labels ?? {};
|
||||
const workspaceId = labels["openhandoff.workspace"] ?? req.workspaceId;
|
||||
const repoId = labels["openhandoff.repo_id"] ?? "";
|
||||
const handoffId = labels["openhandoff.handoff"] ?? "";
|
||||
const cwd =
|
||||
repoId && handoffId
|
||||
? `/home/daytona/openhandoff/${workspaceId}/${repoId}/${handoffId}/repo`
|
||||
: null;
|
||||
|
||||
return {
|
||||
sandboxId: req.sandboxId,
|
||||
switchTarget: `daytona://${req.sandboxId}`,
|
||||
metadata: {
|
||||
resumed: true,
|
||||
endpoint: this.config.endpoint ?? null,
|
||||
...(cwd ? { cwd } : {}),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async destroySandbox(_req: DestroySandboxRequest): Promise<void> {
|
||||
const client = this.getClient();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.withTimeout("delete sandbox", () => client.deleteSandbox(_req.sandboxId));
|
||||
} catch (error) {
|
||||
// Ignore not-found style cleanup failures.
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
if (text.toLowerCase().includes("not found")) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async releaseSandbox(req: ReleaseSandboxRequest): Promise<void> {
|
||||
const client = this.getClient();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.withTimeout("stop sandbox", () => client.stopSandbox(req.sandboxId, 60));
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
if (text.toLowerCase().includes("not found")) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSandboxAgent(req: EnsureAgentRequest): Promise<AgentEndpoint> {
|
||||
const client = this.requireClient();
|
||||
const acpRequestTimeoutMs = this.getAcpRequestTimeoutMs();
|
||||
|
||||
await this.ensureStarted(req.sandboxId);
|
||||
|
||||
await this.runCheckedCommand(
|
||||
req.sandboxId,
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; if command -v curl >/dev/null 2>&1; then exit 0; fi; export DEBIAN_FRONTEND=noninteractive; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y curl ca-certificates >/tmp/apt-install.log 2>&1'`
|
||||
].join(" "),
|
||||
"install curl"
|
||||
);
|
||||
|
||||
await this.runCheckedCommand(
|
||||
req.sandboxId,
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; if command -v npx >/dev/null 2>&1; then exit 0; fi; export DEBIAN_FRONTEND=noninteractive; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y nodejs npm >/tmp/apt-install.log 2>&1'`
|
||||
].join(" "),
|
||||
"install node toolchain"
|
||||
);
|
||||
|
||||
await this.runCheckedCommand(
|
||||
req.sandboxId,
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; if sandbox-agent --version 2>/dev/null | grep -q "${DaytonaProvider.SANDBOX_AGENT_VERSION}"; then exit 0; fi; curl -fsSL https://releases.rivet.dev/sandbox-agent/${DaytonaProvider.SANDBOX_AGENT_VERSION}/install.sh | sh'`
|
||||
].join(" "),
|
||||
"install sandbox-agent"
|
||||
);
|
||||
|
||||
for (const agentId of DaytonaProvider.AGENT_IDS) {
|
||||
try {
|
||||
await this.runCheckedCommand(
|
||||
req.sandboxId,
|
||||
["bash", "-lc", `'export PATH="$HOME/.local/bin:$PATH"; sandbox-agent install-agent ${agentId}'`].join(" "),
|
||||
`install agent ${agentId}`
|
||||
);
|
||||
} catch {
|
||||
// Some sandbox-agent builds may not ship every agent plugin; treat this as best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
await this.runCheckedCommand(
|
||||
req.sandboxId,
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; command -v sandbox-agent >/dev/null 2>&1; if pgrep -x sandbox-agent >/dev/null; then exit 0; fi; nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=${acpRequestTimeoutMs} sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &'`
|
||||
].join(" "),
|
||||
"start sandbox-agent"
|
||||
);
|
||||
|
||||
await this.runCheckedCommand(
|
||||
req.sandboxId,
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'for i in $(seq 1 45); do curl -fsS "http://127.0.0.1:${DaytonaProvider.SANDBOX_AGENT_PORT}/v1/health" >/dev/null && exit 0; sleep 1; done; echo "sandbox-agent failed to become healthy" >&2; tail -n 80 /tmp/sandbox-agent.log >&2; exit 1'`
|
||||
].join(" "),
|
||||
"wait for sandbox-agent health"
|
||||
);
|
||||
|
||||
const preview = await this.withTimeout("get preview endpoint", () =>
|
||||
client.getPreviewEndpoint(req.sandboxId, DaytonaProvider.SANDBOX_AGENT_PORT)
|
||||
);
|
||||
|
||||
return {
|
||||
endpoint: preview.url,
|
||||
token: preview.token
|
||||
};
|
||||
}
|
||||
|
||||
async health(req: SandboxHealthRequest): Promise<SandboxHealth> {
|
||||
const client = this.getClient();
|
||||
if (!client) {
|
||||
return {
|
||||
status: "degraded",
|
||||
message: "daytona driver not configured",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const sandbox = await this.withTimeout("health get sandbox", () =>
|
||||
client.getSandbox(req.sandboxId)
|
||||
);
|
||||
const state = String(sandbox.state ?? "unknown");
|
||||
if (state.toLowerCase().includes("error")) {
|
||||
return {
|
||||
status: "down",
|
||||
message: `daytona sandbox in error state: ${state}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "healthy",
|
||||
message: `daytona sandbox state: ${state}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
status: "down",
|
||||
message: `daytona sandbox health check failed: ${text}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async attachTarget(req: AttachTargetRequest): Promise<AttachTarget> {
|
||||
return {
|
||||
target: `daytona://${req.sandboxId}`
|
||||
};
|
||||
}
|
||||
|
||||
async executeCommand(req: ExecuteSandboxCommandRequest): Promise<ExecuteSandboxCommandResult> {
|
||||
const client = this.requireClient();
|
||||
await this.ensureStarted(req.sandboxId);
|
||||
return await this.withTimeout(`execute command (${req.label ?? "command"})`, () =>
|
||||
client.executeCommand(req.sandboxId, req.command)
|
||||
);
|
||||
}
|
||||
}
|
||||
71
factory/packages/backend/src/providers/index.ts
Normal file
71
factory/packages/backend/src/providers/index.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { ProviderId } from "@openhandoff/shared";
|
||||
import type { AppConfig } from "@openhandoff/shared";
|
||||
import type { BackendDriver } from "../driver.js";
|
||||
import { DaytonaProvider } from "./daytona/index.js";
|
||||
import { LocalProvider } from "./local/index.js";
|
||||
import type { SandboxProvider } from "./provider-api/index.js";
|
||||
|
||||
export interface ProviderRegistry {
|
||||
get(providerId: ProviderId): SandboxProvider;
|
||||
availableProviderIds(): ProviderId[];
|
||||
defaultProviderId(): ProviderId;
|
||||
}
|
||||
|
||||
export function createProviderRegistry(config: AppConfig, driver?: BackendDriver): ProviderRegistry {
|
||||
const gitDriver = driver?.git ?? {
|
||||
validateRemote: async () => {
|
||||
throw new Error("local provider requires backend git driver");
|
||||
},
|
||||
ensureCloned: async () => {
|
||||
throw new Error("local provider requires backend git driver");
|
||||
},
|
||||
fetch: async () => {
|
||||
throw new Error("local provider requires backend git driver");
|
||||
},
|
||||
listRemoteBranches: async () => {
|
||||
throw new Error("local provider requires backend git driver");
|
||||
},
|
||||
remoteDefaultBaseRef: async () => {
|
||||
throw new Error("local provider requires backend git driver");
|
||||
},
|
||||
revParse: async () => {
|
||||
throw new Error("local provider requires backend git driver");
|
||||
},
|
||||
ensureRemoteBranch: async () => {
|
||||
throw new Error("local provider requires backend git driver");
|
||||
},
|
||||
diffStatForBranch: async () => {
|
||||
throw new Error("local provider requires backend git driver");
|
||||
},
|
||||
conflictsWithMain: async () => {
|
||||
throw new Error("local provider requires backend git driver");
|
||||
},
|
||||
};
|
||||
|
||||
const local = new LocalProvider({
|
||||
rootDir: config.providers.local.rootDir,
|
||||
sandboxAgentPort: config.providers.local.sandboxAgentPort,
|
||||
}, gitDriver);
|
||||
const daytona = new DaytonaProvider({
|
||||
endpoint: config.providers.daytona.endpoint,
|
||||
apiKey: config.providers.daytona.apiKey,
|
||||
image: config.providers.daytona.image
|
||||
}, driver?.daytona);
|
||||
|
||||
const map: Record<ProviderId, SandboxProvider> = {
|
||||
local,
|
||||
daytona
|
||||
};
|
||||
|
||||
return {
|
||||
get(providerId: ProviderId): SandboxProvider {
|
||||
return map[providerId];
|
||||
},
|
||||
availableProviderIds(): ProviderId[] {
|
||||
return Object.keys(map) as ProviderId[];
|
||||
},
|
||||
defaultProviderId(): ProviderId {
|
||||
return config.providers.daytona.apiKey ? "daytona" : "local";
|
||||
}
|
||||
};
|
||||
}
|
||||
251
factory/packages/backend/src/providers/local/index.ts
Normal file
251
factory/packages/backend/src/providers/local/index.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { InMemorySessionPersistDriver, SandboxAgent } from "sandbox-agent";
|
||||
import type {
|
||||
AgentEndpoint,
|
||||
AttachTarget,
|
||||
AttachTargetRequest,
|
||||
CreateSandboxRequest,
|
||||
DestroySandboxRequest,
|
||||
EnsureAgentRequest,
|
||||
ExecuteSandboxCommandRequest,
|
||||
ExecuteSandboxCommandResult,
|
||||
ProviderCapabilities,
|
||||
ReleaseSandboxRequest,
|
||||
ResumeSandboxRequest,
|
||||
SandboxHandle,
|
||||
SandboxHealth,
|
||||
SandboxHealthRequest,
|
||||
SandboxProvider,
|
||||
} from "../provider-api/index.js";
|
||||
import type { GitDriver } from "../../driver.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_SANDBOX_AGENT_PORT = 2468;
|
||||
|
||||
export interface LocalProviderConfig {
|
||||
rootDir?: string;
|
||||
sandboxAgentPort?: number;
|
||||
}
|
||||
|
||||
function expandHome(value: string): string {
|
||||
if (value === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return resolve(homedir(), value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function branchExists(repoPath: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("git", [
|
||||
"-C",
|
||||
repoPath,
|
||||
"show-ref",
|
||||
"--verify",
|
||||
`refs/remotes/origin/${branchName}`,
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkoutBranch(repoPath: string, branchName: string, git: GitDriver): Promise<void> {
|
||||
await git.fetch(repoPath);
|
||||
const targetRef = (await branchExists(repoPath, branchName))
|
||||
? `origin/${branchName}`
|
||||
: await git.remoteDefaultBaseRef(repoPath);
|
||||
await execFileAsync("git", ["-C", repoPath, "checkout", "-B", branchName, targetRef], {
|
||||
env: process.env as Record<string, string>,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalProvider implements SandboxProvider {
|
||||
private sdkPromise: Promise<SandboxAgent> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly config: LocalProviderConfig,
|
||||
private readonly git: GitDriver,
|
||||
) {}
|
||||
|
||||
private rootDir(): string {
|
||||
return expandHome(
|
||||
this.config.rootDir?.trim() || "~/.local/share/openhandoff/local-sandboxes",
|
||||
);
|
||||
}
|
||||
|
||||
private sandboxRoot(workspaceId: string, sandboxId: string): string {
|
||||
return resolve(this.rootDir(), workspaceId, sandboxId);
|
||||
}
|
||||
|
||||
private repoDir(workspaceId: string, sandboxId: string): string {
|
||||
return resolve(this.sandboxRoot(workspaceId, sandboxId), "repo");
|
||||
}
|
||||
|
||||
private sandboxHandle(
|
||||
workspaceId: string,
|
||||
sandboxId: string,
|
||||
repoDir: string,
|
||||
): SandboxHandle {
|
||||
return {
|
||||
sandboxId,
|
||||
switchTarget: `local://${repoDir}`,
|
||||
metadata: {
|
||||
cwd: repoDir,
|
||||
repoDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async sandboxAgent(): Promise<SandboxAgent> {
|
||||
if (!this.sdkPromise) {
|
||||
const sandboxAgentHome = resolve(this.rootDir(), ".sandbox-agent-home");
|
||||
mkdirSync(sandboxAgentHome, { recursive: true });
|
||||
const spawnHome = process.env.HOME?.trim() || sandboxAgentHome;
|
||||
this.sdkPromise = SandboxAgent.start({
|
||||
persist: new InMemorySessionPersistDriver(),
|
||||
spawn: {
|
||||
enabled: true,
|
||||
host: "127.0.0.1",
|
||||
port: this.config.sandboxAgentPort ?? DEFAULT_SANDBOX_AGENT_PORT,
|
||||
log: "silent",
|
||||
env: {
|
||||
HOME: spawnHome,
|
||||
...(process.env.ANTHROPIC_API_KEY ? { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY } : {}),
|
||||
...(process.env.CLAUDE_API_KEY ? { CLAUDE_API_KEY: process.env.CLAUDE_API_KEY } : {}),
|
||||
...(process.env.OPENAI_API_KEY ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY } : {}),
|
||||
...(process.env.CODEX_API_KEY ? { CODEX_API_KEY: process.env.CODEX_API_KEY } : {}),
|
||||
...(process.env.GH_TOKEN ? { GH_TOKEN: process.env.GH_TOKEN } : {}),
|
||||
...(process.env.GITHUB_TOKEN ? { GITHUB_TOKEN: process.env.GITHUB_TOKEN } : {}),
|
||||
},
|
||||
},
|
||||
}).then(async (sdk) => {
|
||||
for (const agentName of ["claude", "codex"] as const) {
|
||||
try {
|
||||
const agent = await sdk.getAgent(agentName, { config: true });
|
||||
if (!agent.installed) {
|
||||
await sdk.installAgent(agentName);
|
||||
}
|
||||
} catch {
|
||||
// The local provider can still function if the agent is already available
|
||||
// through the user's PATH or the install check is unsupported.
|
||||
}
|
||||
}
|
||||
return sdk;
|
||||
});
|
||||
}
|
||||
return this.sdkPromise;
|
||||
}
|
||||
|
||||
id() {
|
||||
return "local" as const;
|
||||
}
|
||||
|
||||
capabilities(): ProviderCapabilities {
|
||||
return {
|
||||
remote: false,
|
||||
supportsSessionReuse: true,
|
||||
};
|
||||
}
|
||||
|
||||
async validateConfig(input: unknown): Promise<Record<string, unknown>> {
|
||||
return (input as Record<string, unknown> | undefined) ?? {};
|
||||
}
|
||||
|
||||
async createSandbox(req: CreateSandboxRequest): Promise<SandboxHandle> {
|
||||
const sandboxId = req.handoffId || `local-${randomUUID()}`;
|
||||
const repoDir = this.repoDir(req.workspaceId, sandboxId);
|
||||
mkdirSync(dirname(repoDir), { recursive: true });
|
||||
await this.git.ensureCloned(req.repoRemote, repoDir);
|
||||
await checkoutBranch(repoDir, req.branchName, this.git);
|
||||
return this.sandboxHandle(req.workspaceId, sandboxId, repoDir);
|
||||
}
|
||||
|
||||
async resumeSandbox(req: ResumeSandboxRequest): Promise<SandboxHandle> {
|
||||
const repoDir = this.repoDir(req.workspaceId, req.sandboxId);
|
||||
if (!existsSync(repoDir)) {
|
||||
throw new Error(`local sandbox repo is missing: ${repoDir}`);
|
||||
}
|
||||
return this.sandboxHandle(req.workspaceId, req.sandboxId, repoDir);
|
||||
}
|
||||
|
||||
async destroySandbox(req: DestroySandboxRequest): Promise<void> {
|
||||
rmSync(this.sandboxRoot(req.workspaceId, req.sandboxId), {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
async releaseSandbox(_req: ReleaseSandboxRequest): Promise<void> {
|
||||
// Local sandboxes stay warm on disk to preserve session state and repo context.
|
||||
}
|
||||
|
||||
async ensureSandboxAgent(_req: EnsureAgentRequest): Promise<AgentEndpoint> {
|
||||
const sdk = await this.sandboxAgent();
|
||||
const { baseUrl, token } = sdk as unknown as {
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
};
|
||||
if (!baseUrl) {
|
||||
throw new Error("sandbox-agent baseUrl is unavailable");
|
||||
}
|
||||
return token ? { endpoint: baseUrl, token } : { endpoint: baseUrl };
|
||||
}
|
||||
|
||||
async health(req: SandboxHealthRequest): Promise<SandboxHealth> {
|
||||
try {
|
||||
const repoDir = this.repoDir(req.workspaceId, req.sandboxId);
|
||||
if (!existsSync(repoDir)) {
|
||||
return {
|
||||
status: "down",
|
||||
message: "local sandbox repo is missing",
|
||||
};
|
||||
}
|
||||
const sdk = await this.sandboxAgent();
|
||||
const health = await sdk.getHealth();
|
||||
return {
|
||||
status: health.status === "ok" ? "healthy" : "degraded",
|
||||
message: health.status,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "down",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async attachTarget(req: AttachTargetRequest): Promise<AttachTarget> {
|
||||
return { target: this.repoDir(req.workspaceId, req.sandboxId) };
|
||||
}
|
||||
|
||||
async executeCommand(req: ExecuteSandboxCommandRequest): Promise<ExecuteSandboxCommandResult> {
|
||||
const cwd = this.repoDir(req.workspaceId, req.sandboxId);
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync("bash", ["-lc", req.command], {
|
||||
cwd,
|
||||
env: process.env as Record<string, string>,
|
||||
maxBuffer: 1024 * 1024 * 16,
|
||||
});
|
||||
return {
|
||||
exitCode: 0,
|
||||
result: [stdout, stderr].filter(Boolean).join(""),
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error as { stdout?: string; stderr?: string; code?: number };
|
||||
return {
|
||||
exitCode: typeof detail.code === "number" ? detail.code : 1,
|
||||
result: [detail.stdout, detail.stderr, error instanceof Error ? error.message : String(error)]
|
||||
.filter(Boolean)
|
||||
.join(""),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
99
factory/packages/backend/src/providers/provider-api/index.ts
Normal file
99
factory/packages/backend/src/providers/provider-api/index.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { ProviderId } from "@openhandoff/shared";
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
remote: boolean;
|
||||
supportsSessionReuse: boolean;
|
||||
}
|
||||
|
||||
export interface CreateSandboxRequest {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoRemote: string;
|
||||
branchName: string;
|
||||
handoffId: string;
|
||||
debug?: (message: string, context?: Record<string, unknown>) => void;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ResumeSandboxRequest {
|
||||
workspaceId: string;
|
||||
sandboxId: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DestroySandboxRequest {
|
||||
workspaceId: string;
|
||||
sandboxId: string;
|
||||
}
|
||||
|
||||
export interface ReleaseSandboxRequest {
|
||||
workspaceId: string;
|
||||
sandboxId: string;
|
||||
}
|
||||
|
||||
export interface EnsureAgentRequest {
|
||||
workspaceId: string;
|
||||
sandboxId: string;
|
||||
}
|
||||
|
||||
export interface SandboxHealthRequest {
|
||||
workspaceId: string;
|
||||
sandboxId: string;
|
||||
}
|
||||
|
||||
export interface AttachTargetRequest {
|
||||
workspaceId: string;
|
||||
sandboxId: string;
|
||||
}
|
||||
|
||||
export interface ExecuteSandboxCommandRequest {
|
||||
workspaceId: string;
|
||||
sandboxId: string;
|
||||
command: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface SandboxHandle {
|
||||
sandboxId: string;
|
||||
switchTarget: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentEndpoint {
|
||||
endpoint: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface SandboxHealth {
|
||||
status: "healthy" | "degraded" | "down";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AttachTarget {
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface ExecuteSandboxCommandResult {
|
||||
exitCode: number;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export interface SandboxProvider {
|
||||
id(): ProviderId;
|
||||
capabilities(): ProviderCapabilities;
|
||||
validateConfig(input: unknown): Promise<Record<string, unknown>>;
|
||||
|
||||
createSandbox(req: CreateSandboxRequest): Promise<SandboxHandle>;
|
||||
resumeSandbox(req: ResumeSandboxRequest): Promise<SandboxHandle>;
|
||||
destroySandbox(req: DestroySandboxRequest): Promise<void>;
|
||||
/**
|
||||
* Release resources for a sandbox without deleting its filesystem/state.
|
||||
* For remote providers, this typically maps to "stop"/"suspend".
|
||||
*/
|
||||
releaseSandbox(req: ReleaseSandboxRequest): Promise<void>;
|
||||
|
||||
ensureSandboxAgent(req: EnsureAgentRequest): Promise<AgentEndpoint>;
|
||||
health(req: SandboxHealthRequest): Promise<SandboxHealth>;
|
||||
attachTarget(req: AttachTargetRequest): Promise<AttachTarget>;
|
||||
executeCommand(req: ExecuteSandboxCommandRequest): Promise<ExecuteSandboxCommandResult>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue