sandbox-agent/factory/packages/backend/src/providers/local/index.ts
Nathan Flurry d2346bafb3
Configure lefthook formatter checks (#231)
* Add lefthook formatter checks

* Fix SDK mode hydration

* Stabilize SDK mode integration test
2026-03-10 23:03:11 -07:00

235 lines
7.7 KiB
TypeScript

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(""),
};
}
}
}