From a1e830900feb60608c760b1dda820ba872c31a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CSid?= Date: Thu, 16 Apr 2026 12:09:23 -0400 Subject: [PATCH] feat: add OpenComputer sandbox provider --- docs/opencomputer-provider.md | 67 +++ packages/control-plane/src/router.ts | 30 ++ .../src/sandbox/opencomputer-client.ts | 269 +++++++++++ .../src/sandbox/provider-name.test.ts | 10 + .../src/sandbox/provider-name.ts | 6 +- .../providers/opencomputer-provider.test.ts | 282 +++++++++++ .../providers/opencomputer-provider.ts | 450 ++++++++++++++++++ .../control-plane/src/session/contracts.ts | 1 + .../src/session/durable-object.ts | 85 +++- .../src/session/http/routes.test.ts | 2 + .../control-plane/src/session/http/routes.ts | 6 + packages/control-plane/src/types.ts | 7 +- .../src/sandbox_runtime/bridge.py | 3 +- .../src/sandbox_runtime/entrypoint.py | 7 +- .../build-opencomputer-runtime-snapshot.mjs | 79 +++ 15 files changed, 1286 insertions(+), 18 deletions(-) create mode 100644 docs/opencomputer-provider.md create mode 100644 packages/control-plane/src/sandbox/opencomputer-client.ts create mode 100644 packages/control-plane/src/sandbox/providers/opencomputer-provider.test.ts create mode 100644 packages/control-plane/src/sandbox/providers/opencomputer-provider.ts create mode 100644 scripts/build-opencomputer-runtime-snapshot.mjs diff --git a/docs/opencomputer-provider.md b/docs/opencomputer-provider.md new file mode 100644 index 000000000..38cf81d7a --- /dev/null +++ b/docs/opencomputer-provider.md @@ -0,0 +1,67 @@ +# OpenComputer Provider + +Local OpenComputer development uses a prebuilt snapshot that contains the Open Inspect sandbox +runtime. + +## Required control-plane env + +Add these to `packages/control-plane/.dev.vars`: + +```env +SANDBOX_PROVIDER=opencomputer +OPENCOMPUTER_API_KEY=... +OPENCOMPUTER_SNAPSHOT=open-inspect-runtime- +``` + +`OPENCOMPUTER_TEMPLATE_ID` is also supported as a fallback, but the recommended path is a named +snapshot with the runtime preinstalled. + +`WORKER_URL` must be a public URL when using OpenComputer. Remote sandboxes cannot connect back to +Docker-only hostnames like `http://control-plane:8787` or `http://localhost:8787`. + +## Build a runtime snapshot + +Run the builder script with the OpenComputer SDK available at execution time: + +```bash +OPENCOMPUTER_API_KEY=... \ + npm exec --yes --package=@opencomputer/sdk \ + node scripts/build-opencomputer-runtime-snapshot.mjs +``` + +Optional snapshot name: + +```bash +OPENCOMPUTER_API_KEY=... \ + npm exec --yes --package=@opencomputer/sdk \ + node scripts/build-opencomputer-runtime-snapshot.mjs open-inspect-runtime-my-snapshot +``` + +The script prints `SNAPSHOT_NAME=...` when the build completes. Copy that value into +`OPENCOMPUTER_SNAPSHOT`. + +## Snapshot contents + +The generated snapshot: + +- installs `opencode-ai`, `@opencode-ai/plugin`, and `zod` into `/workspace/.openinspect-node` +- installs the Python runtime dependencies directly with `pip` +- copies `packages/sandbox-runtime/src/sandbox_runtime` into `/workspace/app/sandbox_runtime` +- sets `PYTHONPATH`, `NODE_PATH`, `PATH`, and `HOME` for the Open Inspect runtime layout + +## Runtime start + +The control-plane OpenComputer provider launches the runtime with OpenComputer's exec API: + +```bash +python -m sandbox_runtime.entrypoint +``` + +The process runs in `/workspace/app`, and the provider injects the same session env vars used by the +existing Docker and Daytona providers. + +## Local Validation Gotcha + +OpenComputer runs the sandbox in a remote VM. For end-to-end local development, the control-plane +worker must be reachable from that VM over the public internet. In practice that means setting +`WORKER_URL` to a public tunnel URL from a service like `ngrok` or `cloudflared tunnel`. diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts index ced3a3e33..9c7697384 100644 --- a/packages/control-plane/src/router.ts +++ b/packages/control-plane/src/router.ts @@ -182,6 +182,7 @@ const PUBLIC_ROUTES: RegExp[] = [ const SANDBOX_AUTH_ROUTES: RegExp[] = [ /^\/sessions\/[^/]+\/pr$/, // PR creation from sandbox /^\/sessions\/[^/]+\/openai-token-refresh$/, // OpenAI token refresh from sandbox + /^\/sessions\/[^/]+\/sandbox\/error$/, // Fatal sandbox error reporting /^\/sessions\/[^/]+\/media$/, // Media upload from sandbox /^\/sessions\/[^/]+\/children$/, // POST spawn, GET list /^\/sessions\/[^/]+\/children\/[^/]+$/, // GET child detail @@ -474,6 +475,11 @@ const routes: Route[] = [ pattern: parsePattern("/sessions/:id/openai-token-refresh"), handler: handleOpenAITokenRefresh, }, + { + method: "POST", + pattern: parsePattern("/sessions/:id/sandbox/error"), + handler: handleSandboxErrorReport, + }, { method: "POST", pattern: parsePattern("/sessions/:id/ws-token"), @@ -1453,6 +1459,30 @@ async function handleOpenAITokenRefresh( ); } +async function handleSandboxErrorReport( + request: Request, + env: Env, + match: RegExpMatchArray, + ctx: RequestContext +): Promise { + const sessionId = match.groups?.id; + if (!sessionId) return error("Session ID required"); + + const body = await request.text(); + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + return stub.fetch( + internalRequest( + buildSessionInternalUrl(SessionInternalPaths.reportSandboxError), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }, + ctx + ) + ); +} + async function handleSessionWsToken( request: Request, env: Env, diff --git a/packages/control-plane/src/sandbox/opencomputer-client.ts b/packages/control-plane/src/sandbox/opencomputer-client.ts new file mode 100644 index 000000000..6f596aa02 --- /dev/null +++ b/packages/control-plane/src/sandbox/opencomputer-client.ts @@ -0,0 +1,269 @@ +import type { CorrelationContext } from "../logger"; + +export interface OpenComputerClientConfig { + apiUrl: string; + apiKey: string; +} + +export interface OpenComputerSandboxResponse { + sandboxID: string; + status: string; + region?: string; + workerID?: string; +} + +export interface OpenComputerCheckpointResponse { + id: string; + sandboxID: string; + name: string; + status: string; + sizeBytes: number; + createdAt: string; +} + +export interface OpenComputerExecSessionResponse { + sessionID: string; + sandboxID: string; + command: string; + args?: string[]; + running: boolean; + exitCode: number | null; + startedAt: string; + attachedClients: number; +} + +export interface OpenComputerCreateSandboxParams { + templateID?: string; + snapshot?: string; + timeout?: number; + cpuCount?: number; + memoryMB?: number; + envs?: Record; + metadata?: Record; +} + +export interface OpenComputerForkFromCheckpointParams { + timeout?: number; + envs?: Record; +} + +export interface OpenComputerStartExecParams { + cmd: string; + args?: string[]; + envs?: Record; + cwd?: string; + timeout?: number; + maxRunAfterDisconnect?: number; +} + +interface OpenComputerErrorBody { + error?: string; +} + +export class OpenComputerApiError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = "OpenComputerApiError"; + } +} + +export interface OpenComputerClient { + readonly config: OpenComputerClientConfig; + createSandbox( + params: OpenComputerCreateSandboxParams, + correlation?: CorrelationContext + ): Promise; + getSandbox(id: string, correlation?: CorrelationContext): Promise; + hibernateSandbox( + id: string, + correlation?: CorrelationContext + ): Promise; + wakeSandbox( + id: string, + timeout: number | undefined, + correlation?: CorrelationContext + ): Promise; + setSandboxTimeout(id: string, timeout: number, correlation?: CorrelationContext): Promise; + createCheckpoint( + sandboxId: string, + name: string, + correlation?: CorrelationContext + ): Promise; + forkFromCheckpoint( + checkpointId: string, + params: OpenComputerForkFromCheckpointParams, + correlation?: CorrelationContext + ): Promise; + startExecSession( + sandboxId: string, + params: OpenComputerStartExecParams, + correlation?: CorrelationContext + ): Promise; +} + +class OpenComputerRestClient implements OpenComputerClient { + constructor(readonly config: OpenComputerClientConfig) {} + + async createSandbox( + params: OpenComputerCreateSandboxParams, + correlation?: CorrelationContext + ): Promise { + return this.request("/sandboxes", { + method: "POST", + body: JSON.stringify(params), + correlation, + expectedStatuses: [201], + }); + } + + async getSandbox( + id: string, + correlation?: CorrelationContext + ): Promise { + return this.request(`/sandboxes/${encodeURIComponent(id)}`, { + method: "GET", + correlation, + expectedStatuses: [200], + }); + } + + async hibernateSandbox( + id: string, + correlation?: CorrelationContext + ): Promise { + return this.request( + `/sandboxes/${encodeURIComponent(id)}/hibernate`, + { + method: "POST", + correlation, + expectedStatuses: [200], + } + ); + } + + async wakeSandbox( + id: string, + timeout: number | undefined, + correlation?: CorrelationContext + ): Promise { + return this.request(`/sandboxes/${encodeURIComponent(id)}/wake`, { + method: "POST", + body: JSON.stringify(timeout === undefined ? {} : { timeout }), + correlation, + expectedStatuses: [200], + }); + } + + async setSandboxTimeout( + id: string, + timeout: number, + correlation?: CorrelationContext + ): Promise { + await this.request(`/sandboxes/${encodeURIComponent(id)}/timeout`, { + method: "POST", + body: JSON.stringify({ timeout }), + correlation, + expectedStatuses: [204], + }); + } + + async createCheckpoint( + sandboxId: string, + name: string, + correlation?: CorrelationContext + ): Promise { + return this.request( + `/sandboxes/${encodeURIComponent(sandboxId)}/checkpoints`, + { + method: "POST", + body: JSON.stringify({ name }), + correlation, + expectedStatuses: [201], + } + ); + } + + async forkFromCheckpoint( + checkpointId: string, + params: OpenComputerForkFromCheckpointParams, + correlation?: CorrelationContext + ): Promise { + return this.request( + `/sandboxes/from-checkpoint/${encodeURIComponent(checkpointId)}`, + { + method: "POST", + body: JSON.stringify(params), + correlation, + expectedStatuses: [201], + } + ); + } + + async startExecSession( + sandboxId: string, + params: OpenComputerStartExecParams, + correlation?: CorrelationContext + ): Promise { + return this.request( + `/sandboxes/${encodeURIComponent(sandboxId)}/exec`, + { + method: "POST", + body: JSON.stringify(params), + correlation, + expectedStatuses: [201], + } + ); + } + + private async request( + path: string, + options: { + method: string; + body?: string; + correlation?: CorrelationContext; + expectedStatuses: number[]; + } + ): Promise { + const response = await fetch(`${this.config.apiUrl}${path}`, { + method: options.method, + headers: { + "Content-Type": "application/json", + "X-API-Key": this.config.apiKey, + ...(options.correlation?.trace_id ? { "X-Trace-ID": options.correlation.trace_id } : {}), + ...(options.correlation?.request_id + ? { "X-Request-ID": options.correlation.request_id } + : {}), + }, + body: options.body, + }); + + if (!options.expectedStatuses.includes(response.status)) { + let message = `OpenComputer API request failed with HTTP ${response.status}`; + try { + const body = (await response.json()) as OpenComputerErrorBody; + if (body.error) { + message = body.error; + } + } catch { + // Ignore parse failures and keep the generic message. + } + throw new OpenComputerApiError(message, response.status); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } +} + +export function createOpenComputerClient(config: OpenComputerClientConfig): OpenComputerClient { + return new OpenComputerRestClient({ + apiUrl: config.apiUrl.replace(/\/$/, ""), + apiKey: config.apiKey, + }); +} diff --git a/packages/control-plane/src/sandbox/provider-name.test.ts b/packages/control-plane/src/sandbox/provider-name.test.ts index af412c9d1..943095755 100644 --- a/packages/control-plane/src/sandbox/provider-name.test.ts +++ b/packages/control-plane/src/sandbox/provider-name.test.ts @@ -26,17 +26,23 @@ describe("resolveSandboxBackendName", () => { expect(resolveSandboxBackendName("docker")).toBe("docker"); }); + it('returns "opencomputer" for "opencomputer"', () => { + expect(resolveSandboxBackendName("opencomputer")).toBe("opencomputer"); + }); + it("is case-insensitive", () => { expect(resolveSandboxBackendName("MODAL")).toBe("modal"); expect(resolveSandboxBackendName("Daytona")).toBe("daytona"); expect(resolveSandboxBackendName("DAYTONA")).toBe("daytona"); expect(resolveSandboxBackendName("DOCKER")).toBe("docker"); + expect(resolveSandboxBackendName("OPENCOMPUTER")).toBe("opencomputer"); }); it("trims whitespace", () => { expect(resolveSandboxBackendName(" modal ")).toBe("modal"); expect(resolveSandboxBackendName(" daytona ")).toBe("daytona"); expect(resolveSandboxBackendName(" docker ")).toBe("docker"); + expect(resolveSandboxBackendName(" opencomputer ")).toBe("opencomputer"); }); it("throws for unsupported provider", () => { @@ -61,4 +67,8 @@ describe("isModalSandboxBackend", () => { it("returns false for docker", () => { expect(isModalSandboxBackend("docker")).toBe(false); }); + + it("returns false for opencomputer", () => { + expect(isModalSandboxBackend("opencomputer")).toBe(false); + }); }); diff --git a/packages/control-plane/src/sandbox/provider-name.ts b/packages/control-plane/src/sandbox/provider-name.ts index cd43209b1..3c77be633 100644 --- a/packages/control-plane/src/sandbox/provider-name.ts +++ b/packages/control-plane/src/sandbox/provider-name.ts @@ -2,7 +2,7 @@ * Sandbox backend selection utilities. */ -export type SandboxBackendName = "modal" | "daytona" | "docker"; +export type SandboxBackendName = "modal" | "daytona" | "docker" | "opencomputer"; /** * Resolve the configured sandbox backend. @@ -24,6 +24,10 @@ export function resolveSandboxBackendName(value: string | undefined): SandboxBac return "docker"; } + if (normalized === "opencomputer") { + return "opencomputer"; + } + throw new Error(`Unsupported SANDBOX_PROVIDER: ${value}`); } diff --git a/packages/control-plane/src/sandbox/providers/opencomputer-provider.test.ts b/packages/control-plane/src/sandbox/providers/opencomputer-provider.test.ts new file mode 100644 index 000000000..9ae4d40d9 --- /dev/null +++ b/packages/control-plane/src/sandbox/providers/opencomputer-provider.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + OpenComputerApiError, + type OpenComputerCheckpointResponse, + type OpenComputerClient, + type OpenComputerExecSessionResponse, + type OpenComputerSandboxResponse, +} from "../opencomputer-client"; +import type { CreateSandboxConfig, ResumeConfig, SnapshotConfig, StopConfig } from "../provider"; +import { + OpenComputerSandboxProvider, + type OpenComputerProviderConfig, +} from "./opencomputer-provider"; + +const baseSandboxResponse: OpenComputerSandboxResponse = { + sandboxID: "sb-123", + status: "running", + region: "use2", + workerID: "w-123", +}; + +const baseExecResponse: OpenComputerExecSessionResponse = { + sessionID: "es-123", + sandboxID: "sb-123", + command: "python", + args: ["-m", "sandbox_runtime.entrypoint"], + running: true, + exitCode: null, + startedAt: "2025-01-01T00:00:00Z", + attachedClients: 0, +}; + +const baseCheckpointResponse: OpenComputerCheckpointResponse = { + id: "cp-123", + sandboxID: "sb-123", + name: "checkpoint", + status: "processing", + sizeBytes: 0, + createdAt: "2025-01-01T00:00:00Z", +}; + +function createMockClient(overrides: Partial = {}): OpenComputerClient { + return { + config: { + apiUrl: "https://app.opencomputer.dev/api", + apiKey: "oc_test_key", + }, + createSandbox: vi.fn(async () => baseSandboxResponse), + getSandbox: vi.fn(async () => baseSandboxResponse), + hibernateSandbox: vi.fn(async () => ({ ...baseSandboxResponse, status: "hibernated" })), + wakeSandbox: vi.fn(async () => baseSandboxResponse), + setSandboxTimeout: vi.fn(async () => {}), + createCheckpoint: vi.fn(async () => baseCheckpointResponse), + forkFromCheckpoint: vi.fn(async () => ({ ...baseSandboxResponse, sandboxID: "sb-forked" })), + startExecSession: vi.fn(async () => baseExecResponse), + ...overrides, + }; +} + +const providerConfig: OpenComputerProviderConfig = { + scmProvider: "github", + apiKey: "oc_test_key", + snapshot: "open-inspect-runtime", +}; + +const getCloneToken = vi.fn(async () => "ghs_test_clone_token"); + +const baseCreateConfig: CreateSandboxConfig = { + sessionId: "session-123", + sandboxId: "sandbox-456", + repoOwner: "acme", + repoName: "widget", + controlPlaneUrl: "https://control-plane.test", + sandboxAuthToken: "sandbox-auth-token", + provider: "anthropic", + model: "claude-sonnet-4-6", +}; + +const baseResumeConfig: ResumeConfig = { + providerObjectId: "sb-123", + sessionId: "session-123", + sandboxId: "sandbox-456", +}; + +const baseSnapshotConfig: SnapshotConfig = { + providerObjectId: "sb-123", + sessionId: "session-123", + reason: "inactivity_timeout", +}; + +const baseStopConfig: StopConfig = { + providerObjectId: "sb-123", + sessionId: "session-123", + reason: "inactivity_timeout", +}; + +describe("OpenComputerSandboxProvider", () => { + beforeEach(() => { + vi.restoreAllMocks(); + getCloneToken.mockResolvedValue("ghs_test_clone_token"); + }); + + it("reports capabilities", () => { + const provider = new OpenComputerSandboxProvider( + createMockClient(), + providerConfig, + getCloneToken + ); + + expect(provider.name).toBe("opencomputer"); + expect(provider.capabilities).toEqual({ + supportsSnapshots: true, + supportsRestore: true, + supportsWarm: false, + supportsPersistentResume: true, + supportsExplicitStop: true, + }); + }); + + it("creates a sandbox from snapshot and starts the runtime process", async () => { + const client = createMockClient(); + const provider = new OpenComputerSandboxProvider(client, providerConfig, getCloneToken); + + const result = await provider.createSandbox({ + ...baseCreateConfig, + codeServerEnabled: true, + sandboxSettings: { tunnelPorts: [3000, 8080], terminalEnabled: true }, + }); + + expect(client.createSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + snapshot: "open-inspect-runtime", + timeout: 7200, + metadata: expect.objectContaining({ + openinspect_session_id: "session-123", + openinspect_expected_sandbox_id: "sandbox-456", + }), + }), + undefined + ); + + expect(client.startExecSession).toHaveBeenCalledWith( + "sb-123", + expect.objectContaining({ + cmd: "/workspace/.venv/bin/python", + args: ["-m", "sandbox_runtime.entrypoint"], + cwd: "/workspace/app", + envs: expect.objectContaining({ + SANDBOX_ID: "sandbox-456", + CONTROL_PLANE_URL: "https://control-plane.test", + REPO_OWNER: "acme", + REPO_NAME: "widget", + VCS_HOST: "github.com", + VCS_CLONE_TOKEN: "ghs_test_clone_token", + TERMINAL_ENABLED: "1", + PYTHONPATH: "/workspace/app", + }), + }), + undefined + ); + + expect(result.providerObjectId).toBe("sb-123"); + expect(result.codeServerUrl).toBe("https://sb-123-p8080.workers.opencomputer.dev"); + expect(result.codeServerPassword).toHaveLength(32); + expect(result.ttydUrl).toBe("https://sb-123-p7680.workers.opencomputer.dev"); + expect(result.tunnelUrls).toEqual({ + "3000": "https://sb-123-p3000.workers.opencomputer.dev", + }); + }); + + it("restores by forking from checkpoint and starting the runtime", async () => { + const client = createMockClient(); + const provider = new OpenComputerSandboxProvider(client, providerConfig, getCloneToken); + + const result = await provider.restoreFromSnapshot({ + ...baseCreateConfig, + snapshotImageId: "cp-123", + }); + + expect(client.forkFromCheckpoint).toHaveBeenCalledWith( + "cp-123", + expect.objectContaining({ timeout: 7200 }), + undefined + ); + expect(client.startExecSession).toHaveBeenCalledWith( + "sb-forked", + expect.any(Object), + undefined + ); + expect(result).toMatchObject({ + success: true, + providerObjectId: "sb-forked", + sandboxId: "sandbox-456", + }); + }); + + it("resumes hibernated sandboxes with wake", async () => { + const client = createMockClient({ + getSandbox: vi.fn(async () => ({ ...baseSandboxResponse, status: "hibernated" })), + }); + const provider = new OpenComputerSandboxProvider(client, providerConfig, getCloneToken); + + const result = await provider.resumeSandbox({ + ...baseResumeConfig, + timeoutSeconds: 600, + codeServerEnabled: true, + }); + + expect(client.wakeSandbox).toHaveBeenCalledWith("sb-123", 600, undefined); + expect(result.success).toBe(true); + expect(result.codeServerUrl).toBe("https://sb-123-p8080.workers.opencomputer.dev"); + }); + + it("falls back to fresh spawn when the sandbox no longer exists", async () => { + const client = createMockClient({ + getSandbox: vi.fn(async () => { + throw new OpenComputerApiError("not found", 404); + }), + }); + const provider = new OpenComputerSandboxProvider(client, providerConfig, getCloneToken); + + const result = await provider.resumeSandbox(baseResumeConfig); + + expect(result).toEqual({ + success: false, + error: "Sandbox no longer exists in OpenComputer", + shouldSpawnFresh: true, + }); + }); + + it("creates checkpoints for snapshots", async () => { + vi.spyOn(Date, "now").mockReturnValue(1234567890); + const client = createMockClient(); + const provider = new OpenComputerSandboxProvider(client, providerConfig, getCloneToken); + + const result = await provider.takeSnapshot(baseSnapshotConfig); + + expect(client.createCheckpoint).toHaveBeenCalledWith( + "sb-123", + "session-123-inactivity_timeout-1234567890", + undefined + ); + expect(result).toEqual({ success: true, imageId: "cp-123" }); + }); + + it("hibernates sandboxes when stopping", async () => { + const client = createMockClient(); + const provider = new OpenComputerSandboxProvider(client, providerConfig, getCloneToken); + + const result = await provider.stopSandbox(baseStopConfig); + + expect(client.hibernateSandbox).toHaveBeenCalledWith("sb-123", undefined); + expect(result).toEqual({ success: true }); + }); + + it("classifies API failures as SandboxProviderError", async () => { + const client = createMockClient({ + createSandbox: vi.fn(async () => { + throw new OpenComputerApiError("quota exceeded", 429); + }), + }); + const provider = new OpenComputerSandboxProvider(client, providerConfig, getCloneToken); + + await expect(provider.createSandbox(baseCreateConfig)).rejects.toMatchObject({ + errorType: "permanent", + }); + }); + + it("fails fast for non-public control plane URLs", async () => { + const client = createMockClient(); + const provider = new OpenComputerSandboxProvider(client, providerConfig, getCloneToken); + + await expect( + provider.createSandbox({ + ...baseCreateConfig, + controlPlaneUrl: "http://control-plane:8787", + }) + ).rejects.toThrow("OpenComputer sandboxes must reach the control plane over a public URL"); + + expect(client.createSandbox).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/sandbox/providers/opencomputer-provider.ts b/packages/control-plane/src/sandbox/providers/opencomputer-provider.ts new file mode 100644 index 000000000..15a616d54 --- /dev/null +++ b/packages/control-plane/src/sandbox/providers/opencomputer-provider.ts @@ -0,0 +1,450 @@ +import { computeHmacHex, MAX_TUNNEL_PORTS } from "@open-inspect/shared"; +import type { SourceControlProviderName } from "../../source-control"; +import { + OpenComputerApiError, + type OpenComputerClient, + type OpenComputerCreateSandboxParams, +} from "../opencomputer-client"; +import { + DEFAULT_SANDBOX_TIMEOUT_SECONDS, + SandboxProviderError, + type CreateSandboxConfig, + type CreateSandboxResult, + type RestoreConfig, + type RestoreResult, + type ResumeConfig, + type ResumeResult, + type SandboxProvider, + type SandboxProviderCapabilities, + type SnapshotConfig, + type SnapshotResult, + type StopConfig, + type StopResult, +} from "../provider"; + +const CODE_SERVER_PORT = 8080; +const TTYD_PROXY_PORT = 7680; +const DEFAULT_PREVIEW_BASE_DOMAIN = "workers.opencomputer.dev"; +const DEFAULT_RUNTIME_ROOT = "/workspace/app"; +const DEFAULT_RUNTIME_CWD = DEFAULT_RUNTIME_ROOT; +const DEFAULT_RUNTIME_COMMAND = "/workspace/.venv/bin/python"; +const DEFAULT_RUNTIME_ARGS = ["-m", "sandbox_runtime.entrypoint"]; +const DEFAULT_EXEC_GRACE_SECONDS = 31536000; +const DEFAULT_NODE_PATH = "/workspace/.openinspect-node/lib/node_modules"; +const DEFAULT_PATH = + "/workspace/.venv/bin:/workspace/.openinspect-node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +export interface OpenComputerProviderConfig { + scmProvider: SourceControlProviderName; + gitlabAccessToken?: string; + apiKey: string; + snapshot?: string; + templateId?: string; + previewBaseDomain?: string; + runtimeCommand?: string; + runtimeArgs?: string[]; + runtimeCwd?: string; +} + +export class OpenComputerSandboxProvider implements SandboxProvider { + readonly name = "opencomputer"; + + readonly capabilities: SandboxProviderCapabilities = { + supportsSnapshots: true, + supportsRestore: true, + supportsWarm: false, + supportsPersistentResume: true, + supportsExplicitStop: true, + }; + + constructor( + private readonly client: OpenComputerClient, + private readonly providerConfig: OpenComputerProviderConfig, + private readonly getCloneToken: () => Promise + ) {} + + async createSandbox(config: CreateSandboxConfig): Promise { + try { + validatePublicControlPlaneUrl(config.controlPlaneUrl); + + const sandbox = await this.client.createSandbox( + this.buildCreateParams(config), + config.correlation + ); + + await this.startRuntime(sandbox.sandboxID, config); + const access = await this.buildAccessUrls(sandbox.sandboxID, config); + + return { + sandboxId: config.sandboxId, + providerObjectId: sandbox.sandboxID, + status: sandbox.status, + createdAt: Date.now(), + ...access, + }; + } catch (error) { + throw this.classifyError("Failed to create OpenComputer sandbox", error); + } + } + + async restoreFromSnapshot(config: RestoreConfig): Promise { + try { + validatePublicControlPlaneUrl(config.controlPlaneUrl); + + const sandbox = await this.client.forkFromCheckpoint( + config.snapshotImageId, + { + timeout: config.timeoutSeconds ?? DEFAULT_SANDBOX_TIMEOUT_SECONDS, + }, + config.correlation + ); + + await this.startRuntime(sandbox.sandboxID, config); + const access = await this.buildAccessUrls(sandbox.sandboxID, config); + + return { + success: true, + sandboxId: config.sandboxId, + providerObjectId: sandbox.sandboxID, + ...access, + }; + } catch (error) { + throw this.classifyError("Failed to restore OpenComputer sandbox", error); + } + } + + async resumeSandbox(config: ResumeConfig): Promise { + try { + let sandbox; + try { + sandbox = await this.client.getSandbox(config.providerObjectId, config.correlation); + } catch (error) { + if (error instanceof OpenComputerApiError && error.status === 404) { + return { + success: false, + error: "Sandbox no longer exists in OpenComputer", + shouldSpawnFresh: true, + }; + } + throw error; + } + + if (sandbox.status === "hibernated") { + sandbox = await this.client.wakeSandbox( + config.providerObjectId, + config.timeoutSeconds ?? DEFAULT_SANDBOX_TIMEOUT_SECONDS, + config.correlation + ); + } else if (config.timeoutSeconds !== undefined) { + await this.client.setSandboxTimeout( + config.providerObjectId, + config.timeoutSeconds, + config.correlation + ); + } + + const access = await this.buildAccessUrls(sandbox.sandboxID, config); + + return { + success: true, + providerObjectId: sandbox.sandboxID, + ...access, + }; + } catch (error) { + throw this.classifyError("Failed to resume OpenComputer sandbox", error); + } + } + + async takeSnapshot(config: SnapshotConfig): Promise { + try { + const checkpointName = this.buildCheckpointName(config); + const checkpoint = await this.client.createCheckpoint( + config.providerObjectId, + checkpointName, + config.correlation + ); + + return { + success: true, + imageId: checkpoint.id, + }; + } catch (error) { + throw this.classifyError("Failed to create OpenComputer checkpoint", error); + } + } + + async stopSandbox(config: StopConfig): Promise { + try { + try { + await this.client.hibernateSandbox(config.providerObjectId, config.correlation); + } catch (error) { + if (error instanceof OpenComputerApiError && error.status === 404) { + return { success: true }; + } + throw error; + } + + return { success: true }; + } catch (error) { + throw this.classifyError("Failed to stop OpenComputer sandbox", error); + } + } + + private buildCreateParams(config: CreateSandboxConfig): OpenComputerCreateSandboxParams { + if (!this.providerConfig.snapshot && !this.providerConfig.templateId) { + throw new Error( + "OpenComputer provider requires OPENCOMPUTER_SNAPSHOT or OPENCOMPUTER_TEMPLATE_ID" + ); + } + + return { + ...(this.providerConfig.snapshot + ? { snapshot: this.providerConfig.snapshot } + : this.providerConfig.templateId + ? { templateID: this.providerConfig.templateId } + : {}), + timeout: config.timeoutSeconds ?? DEFAULT_SANDBOX_TIMEOUT_SECONDS, + metadata: this.buildMetadata(config), + }; + } + + private buildMetadata(config: CreateSandboxConfig): Record { + return { + openinspect_framework: "open-inspect", + openinspect_session_id: config.sessionId, + openinspect_repo: `${config.repoOwner}/${config.repoName}`, + openinspect_expected_sandbox_id: config.sandboxId, + }; + } + + private async startRuntime( + providerSandboxId: string, + config: Pick< + CreateSandboxConfig, + | "sessionId" + | "sandboxId" + | "repoOwner" + | "repoName" + | "controlPlaneUrl" + | "sandboxAuthToken" + | "provider" + | "model" + | "userEnvVars" + | "timeoutSeconds" + | "branch" + | "codeServerEnabled" + | "sandboxSettings" + | "correlation" + > + ): Promise { + const envs = await this.buildRuntimeEnvVars(config); + + const runtimeCommand = this.providerConfig.runtimeCommand ?? DEFAULT_RUNTIME_COMMAND; + const runtimeArgs = this.providerConfig.runtimeArgs ?? DEFAULT_RUNTIME_ARGS; + + await this.client.startExecSession( + providerSandboxId, + { + cmd: runtimeCommand, + args: runtimeArgs, + envs, + cwd: this.providerConfig.runtimeCwd ?? DEFAULT_RUNTIME_CWD, + maxRunAfterDisconnect: DEFAULT_EXEC_GRACE_SECONDS, + }, + config.correlation + ); + } + + private async buildRuntimeEnvVars( + config: Pick< + CreateSandboxConfig, + | "sessionId" + | "sandboxId" + | "repoOwner" + | "repoName" + | "controlPlaneUrl" + | "sandboxAuthToken" + | "provider" + | "model" + | "userEnvVars" + | "branch" + | "codeServerEnabled" + | "sandboxSettings" + > + ): Promise> { + const cloneToken = await this.getCloneToken(); + const envVars: Record = { ...(config.userEnvVars ?? {}) }; + + const sessionConfig: Record = { + session_id: config.sessionId, + repo_owner: config.repoOwner, + repo_name: config.repoName, + provider: config.provider, + model: config.model, + }; + if (config.branch) { + sessionConfig.branch = config.branch; + } + + Object.assign(envVars, { + PYTHONUNBUFFERED: "1", + SANDBOX_ID: config.sandboxId, + CONTROL_PLANE_URL: config.controlPlaneUrl, + SANDBOX_AUTH_TOKEN: config.sandboxAuthToken, + REPO_OWNER: config.repoOwner, + REPO_NAME: config.repoName, + SESSION_CONFIG: JSON.stringify(sessionConfig), + VCS_HOST: this.providerConfig.scmProvider === "gitlab" ? "gitlab.com" : "github.com", + VCS_CLONE_USERNAME: + this.providerConfig.scmProvider === "gitlab" ? "oauth2" : "x-access-token", + HOME: "/workspace", + NODE_PATH: DEFAULT_NODE_PATH, + PATH: DEFAULT_PATH, + PYTHONPATH: DEFAULT_RUNTIME_ROOT, + }); + + if (config.codeServerEnabled) { + envVars.CODE_SERVER_PASSWORD = await this.deriveCodeServerPassword(config.sandboxId); + } + + if (config.sandboxSettings?.terminalEnabled) { + envVars.TERMINAL_ENABLED = "1"; + } + + if (cloneToken) { + envVars.VCS_CLONE_TOKEN = cloneToken; + if (this.providerConfig.scmProvider === "github") { + envVars.GITHUB_APP_TOKEN = cloneToken; + envVars.GITHUB_TOKEN = cloneToken; + } + } + + if (this.providerConfig.scmProvider === "gitlab" && this.providerConfig.gitlabAccessToken) { + envVars.GITLAB_ACCESS_TOKEN = this.providerConfig.gitlabAccessToken; + } + + return envVars; + } + + private async buildAccessUrls( + providerSandboxId: string, + config: + | Pick + | Pick + | Pick + ): Promise<{ + codeServerUrl?: string; + codeServerPassword?: string; + ttydUrl?: string; + tunnelUrls?: Record; + }> { + const tunnelPorts = resolveTunnelPorts(config.sandboxSettings?.tunnelPorts); + const remainingPorts = config.codeServerEnabled + ? tunnelPorts.filter((port) => port !== CODE_SERVER_PORT) + : tunnelPorts; + + const codeServerPassword = config.codeServerEnabled + ? await this.deriveCodeServerPassword(config.sandboxId) + : undefined; + + return { + codeServerUrl: config.codeServerEnabled + ? this.buildPreviewUrl(providerSandboxId, CODE_SERVER_PORT) + : undefined, + codeServerPassword, + ttydUrl: config.sandboxSettings?.terminalEnabled + ? this.buildPreviewUrl(providerSandboxId, TTYD_PROXY_PORT) + : undefined, + tunnelUrls: + remainingPorts.length > 0 + ? Object.fromEntries( + remainingPorts.map((port) => [ + String(port), + this.buildPreviewUrl(providerSandboxId, port), + ]) + ) + : undefined, + }; + } + + private buildPreviewUrl(providerSandboxId: string, port: number): string { + const baseDomain = this.providerConfig.previewBaseDomain ?? DEFAULT_PREVIEW_BASE_DOMAIN; + return `https://${providerSandboxId}-p${port}.${baseDomain}`; + } + + private async deriveCodeServerPassword(sandboxId: string): Promise { + const digest = await computeHmacHex(`code-server:${sandboxId}`, this.providerConfig.apiKey); + return digest.slice(0, 32); + } + + private buildCheckpointName(config: SnapshotConfig): string { + return `${config.sessionId}-${config.reason}-${Date.now()}`; + } + + private classifyError(message: string, error: unknown): SandboxProviderError { + if (error instanceof OpenComputerApiError) { + return SandboxProviderError.fromFetchError( + `${message}: ${error.message}`, + error, + error.status + ); + } + return SandboxProviderError.fromFetchError( + error instanceof Error ? `${message}: ${error.message}` : message, + error + ); + } +} + +function resolveTunnelPorts(rawPorts: number[] | undefined): number[] { + if (!rawPorts) return []; + const ports: number[] = []; + for (const value of rawPorts) { + if (Number.isInteger(value) && value >= 1 && value <= 65535) { + ports.push(value); + } + if (ports.length >= MAX_TUNNEL_PORTS) break; + } + return ports; +} + +function validatePublicControlPlaneUrl(controlPlaneUrl: string): void { + let url: URL; + try { + url = new URL(controlPlaneUrl); + } catch { + throw new Error( + `OpenComputer requires a valid public WORKER_URL; received invalid CONTROL_PLANE_URL: ${controlPlaneUrl}` + ); + } + + const hostname = url.hostname.toLowerCase(); + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "0.0.0.0" || + hostname === "control-plane" || + hostname.endsWith(".local") || + isPrivateIpv4Host(hostname) + ) { + throw new Error( + `OpenComputer sandboxes must reach the control plane over a public URL; set WORKER_URL to a public tunnel instead of ${controlPlaneUrl}` + ); + } +} + +function isPrivateIpv4Host(hostname: string): boolean { + const match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!match) return false; + + const [a, b] = match.slice(1).map((part) => Number(part)); + return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31); +} + +export function createOpenComputerProvider( + client: OpenComputerClient, + providerConfig: OpenComputerProviderConfig, + getCloneToken: () => Promise +): OpenComputerSandboxProvider { + return new OpenComputerSandboxProvider(client, providerConfig, getCloneToken); +} diff --git a/packages/control-plane/src/session/contracts.ts b/packages/control-plane/src/session/contracts.ts index ef7827cf6..401e6e2d7 100644 --- a/packages/control-plane/src/session/contracts.ts +++ b/packages/control-plane/src/session/contracts.ts @@ -19,6 +19,7 @@ export const SessionInternalPaths = { archive: "/internal/archive", unarchive: "/internal/unarchive", verifySandboxToken: "/internal/verify-sandbox-token", + reportSandboxError: "/internal/report-sandbox-error", openaiTokenRefresh: "/internal/openai-token-refresh", spawnContext: "/internal/spawn-context", childSummary: "/internal/child-summary", diff --git a/packages/control-plane/src/session/durable-object.ts b/packages/control-plane/src/session/durable-object.ts index c54dc0a20..f8c160849 100644 --- a/packages/control-plane/src/session/durable-object.ts +++ b/packages/control-plane/src/session/durable-object.ts @@ -16,9 +16,11 @@ import { getGitHubAppConfig, getCachedInstallationToken } from "../auth/github-a import { createModalClient } from "../sandbox/client"; import { createDaytonaRestClient } from "../sandbox/daytona-rest-client"; import { createDockerSandboxClient } from "../sandbox/docker-client"; +import { createOpenComputerClient } from "../sandbox/opencomputer-client"; import { createModalProvider } from "../sandbox/providers/modal-provider"; import { createDaytonaProvider } from "../sandbox/providers/daytona-provider"; import { createDockerProvider } from "../sandbox/providers/docker-provider"; +import { createOpenComputerProvider } from "../sandbox/providers/opencomputer-provider"; import { resolveSandboxBackendName } from "../sandbox/provider-name"; import { createLogger, parseLogLevel } from "../logger"; import type { Logger } from "../logger"; @@ -167,6 +169,7 @@ export class SessionDO extends DurableObject { archive: (request) => this.sessionLifecycleHandler.archive(request), unarchive: (request) => this.sessionLifecycleHandler.unarchive(request), verifySandboxToken: (request) => this.sandboxHandler.verifySandboxToken(request), + reportSandboxError: (request) => this.handleReportedSandboxError(request), openaiTokenRefresh: () => this.sandboxHandler.openaiTokenRefresh(), spawnContext: () => this.childSessionsHandler.getSpawnContext(), childSummary: () => this.childSessionsHandler.getChildSummary(), @@ -626,19 +629,55 @@ export class SessionDO extends DurableObject { getCloneToken ); })() - : (() => { - if (!this.env.MODAL_API_SECRET || !this.env.MODAL_WORKSPACE) { - throw new Error( - "MODAL_API_SECRET and MODAL_WORKSPACE are required when SANDBOX_PROVIDER=modal" + : sandboxBackend === "opencomputer" + ? (() => { + if (!this.env.OPENCOMPUTER_API_KEY) { + throw new Error( + "OPENCOMPUTER_API_KEY is required when SANDBOX_PROVIDER=opencomputer" + ); + } + + const openComputerClient = createOpenComputerClient({ + apiUrl: this.env.OPENCOMPUTER_API_URL || "https://app.opencomputer.dev/api", + apiKey: this.env.OPENCOMPUTER_API_KEY, + }); + + const scmProvider = resolveScmProviderFromEnv(this.env.SCM_PROVIDER); + const appConfig = getGitHubAppConfig(this.env); + + const getCloneToken: () => Promise = + scmProvider === "gitlab" + ? () => Promise.resolve(this.env.GITLAB_ACCESS_TOKEN ?? null) + : appConfig + ? () => getCachedInstallationToken(appConfig, this.env) + : () => Promise.resolve(null); + + return createOpenComputerProvider( + openComputerClient, + { + scmProvider, + gitlabAccessToken: this.env.GITLAB_ACCESS_TOKEN, + apiKey: this.env.OPENCOMPUTER_API_KEY, + snapshot: this.env.OPENCOMPUTER_SNAPSHOT, + templateId: this.env.OPENCOMPUTER_TEMPLATE_ID, + previewBaseDomain: this.env.OPENCOMPUTER_PREVIEW_BASE_DOMAIN, + }, + getCloneToken ); - } - - const modalClient = createModalClient( - this.env.MODAL_API_SECRET, - this.env.MODAL_WORKSPACE - ); - return createModalProvider(modalClient); - })(); + })() + : (() => { + if (!this.env.MODAL_API_SECRET || !this.env.MODAL_WORKSPACE) { + throw new Error( + "MODAL_API_SECRET and MODAL_WORKSPACE are required when SANDBOX_PROVIDER=modal" + ); + } + + const modalClient = createModalClient( + this.env.MODAL_API_SECRET, + this.env.MODAL_WORKSPACE + ); + return createModalProvider(modalClient); + })(); // Storage adapter const storage: SandboxStorage = { @@ -1779,6 +1818,28 @@ export class SessionDO extends DurableObject { this.repository.updateSandboxStatus(status as SandboxStatus); } + private async handleReportedSandboxError(request: Request): Promise { + const body = (await request.json()) as { error?: unknown; fatal?: unknown }; + const errorMessage = + typeof body.error === "string" && body.error.trim().length > 0 + ? body.error.trim() + : "Sandbox startup failed"; + const fatal = body.fatal === true; + + this.log.error("Sandbox reported fatal error", { + error: errorMessage, + fatal, + }); + + this.repository.updateSandboxSpawnError(errorMessage, Date.now()); + if (fatal) { + this.updateSandboxStatus("failed"); + } + + this.broadcast({ type: "sandbox_error", error: errorMessage }); + return Response.json({ status: "ok" }); + } + // HTTP handlers private parseArtifactMetadata( diff --git a/packages/control-plane/src/session/http/routes.test.ts b/packages/control-plane/src/session/http/routes.test.ts index b4390097f..6934d0dd3 100644 --- a/packages/control-plane/src/session/http/routes.test.ts +++ b/packages/control-plane/src/session/http/routes.test.ts @@ -26,6 +26,7 @@ describe("createSessionInternalRoutes", () => { archive: noopHandler(), unarchive: noopHandler(), verifySandboxToken: noopHandler(), + reportSandboxError: noopHandler(), openaiTokenRefresh: noopHandler(), spawnContext: noopHandler(), childSummary: noopHandler(), @@ -54,6 +55,7 @@ describe("createSessionInternalRoutes", () => { `POST ${SessionInternalPaths.archive}`, `POST ${SessionInternalPaths.unarchive}`, `POST ${SessionInternalPaths.verifySandboxToken}`, + `POST ${SessionInternalPaths.reportSandboxError}`, `POST ${SessionInternalPaths.openaiTokenRefresh}`, `GET ${SessionInternalPaths.spawnContext}`, `GET ${SessionInternalPaths.childSummary}`, diff --git a/packages/control-plane/src/session/http/routes.ts b/packages/control-plane/src/session/http/routes.ts index e14c11885..8d4ce129f 100644 --- a/packages/control-plane/src/session/http/routes.ts +++ b/packages/control-plane/src/session/http/routes.ts @@ -29,6 +29,7 @@ export interface SessionInternalRouteHandlers { archive: SessionInternalRouteHandler; unarchive: SessionInternalRouteHandler; verifySandboxToken: SessionInternalRouteHandler; + reportSandboxError: SessionInternalRouteHandler; openaiTokenRefresh: SessionInternalRouteHandler; spawnContext: SessionInternalRouteHandler; childSummary: SessionInternalRouteHandler; @@ -77,6 +78,11 @@ export function createSessionInternalRoutes( path: SessionInternalPaths.verifySandboxToken, handler: handlers.verifySandboxToken, }, + { + method: "POST", + path: SessionInternalPaths.reportSandboxError, + handler: handlers.reportSandboxError, + }, { method: "POST", path: SessionInternalPaths.openaiTokenRefresh, diff --git a/packages/control-plane/src/types.ts b/packages/control-plane/src/types.ts index f802094a3..4ac470184 100644 --- a/packages/control-plane/src/types.ts +++ b/packages/control-plane/src/types.ts @@ -61,6 +61,7 @@ export interface Env { MODAL_TOKEN_SECRET?: string; MODAL_API_SECRET?: string; // Shared secret for authenticating with Modal endpoints DAYTONA_API_KEY?: string; // Daytona REST API key (Bearer auth + HMAC derivation) + OPENCOMPUTER_API_KEY?: string; // OpenComputer REST API key (X-API-Key auth) INTERNAL_CALLBACK_SECRET?: string; // For signing callbacks to slack-bot // GitHub App secrets (for git operations) @@ -78,7 +79,7 @@ export interface Env { WORKER_URL?: string; // Base URL for the worker (for callbacks) WEB_APP_URL?: string; // Base URL for the web app (for PR links) CF_ACCOUNT_ID?: string; // Cloudflare account ID - SANDBOX_PROVIDER?: string; // "modal" (default), "daytona", or "docker" + SANDBOX_PROVIDER?: string; // "modal" (default), "daytona", "docker", or "opencomputer" MODAL_WORKSPACE?: string; // Modal workspace name (used in Modal endpoint URLs) DOCKER_SANDBOX_API_URL?: string; // Local Docker sandbox manager base URL DOCKER_SANDBOX_API_TOKEN?: string; // Optional bearer token for Docker sandbox manager @@ -87,6 +88,10 @@ export interface Env { DAYTONA_AUTO_STOP_INTERVAL_MINUTES?: string; // Daytona idle stop interval in minutes DAYTONA_AUTO_ARCHIVE_INTERVAL_MINUTES?: string; // Daytona archive interval in minutes DAYTONA_TARGET?: string; // Optional Daytona target name + OPENCOMPUTER_API_URL?: string; // OpenComputer REST API base URL (defaults to hosted API) + OPENCOMPUTER_SNAPSHOT?: string; // Snapshot with Open Inspect runtime installed + OPENCOMPUTER_TEMPLATE_ID?: string; // Optional template fallback when no snapshot is configured + OPENCOMPUTER_PREVIEW_BASE_DOMAIN?: string; // Preview hostname suffix (default: workers.opencomputer.dev) // Sandbox lifecycle configuration SANDBOX_INACTIVITY_TIMEOUT_MS?: string; // Inactivity timeout in ms (default: 600000 = 10 min) diff --git a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py index d8374b87b..7bc392478 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py @@ -162,7 +162,8 @@ def __init__( self.control_plane_url = control_plane_url self.auth_token = auth_token self.opencode_port = opencode_port - self.opencode_base_url = f"http://localhost:{opencode_port}" + # Some sandbox runtimes resolve localhost to IPv6 first, while OpenCode binds on IPv4. + self.opencode_base_url = f"http://127.0.0.1:{opencode_port}" # Logger self.log = get_logger( diff --git a/packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py b/packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py index 2a58f2f50..63823b9ee 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py @@ -602,7 +602,8 @@ async def _forward_opencode_logs(self) -> None: async def _wait_for_health(self) -> None: """Poll health endpoint until server is ready.""" - health_url = f"http://localhost:{self.OPENCODE_PORT}/global/health" + # Some sandbox runtimes resolve localhost to IPv6 first, while OpenCode binds on IPv4. + health_url = f"http://127.0.0.1:{self.OPENCODE_PORT}/global/health" start_time = time.time() async with httpx.AsyncClient() as client: @@ -845,7 +846,7 @@ async def monitor_processes(self) -> None: async def _report_fatal_error(self, message: str) -> None: """Report a fatal error to the control plane.""" - self.log.error("supervisor.fatal", message=message) + self.log.error("supervisor.fatal", detail=message) if not self.control_plane_url: return @@ -853,7 +854,7 @@ async def _report_fatal_error(self, message: str) -> None: try: async with httpx.AsyncClient() as client: await client.post( - f"{self.control_plane_url}/sandbox/{self.sandbox_id}/error", + f"{self.control_plane_url}/sessions/{self.session_config.get('session_id', '')}/sandbox/error", json={"error": message, "fatal": True}, headers={"Authorization": f"Bearer {self.sandbox_token}"}, timeout=5.0, diff --git a/scripts/build-opencomputer-runtime-snapshot.mjs b/scripts/build-opencomputer-runtime-snapshot.mjs new file mode 100644 index 000000000..69dbaea4c --- /dev/null +++ b/scripts/build-opencomputer-runtime-snapshot.mjs @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +async function loadSdk() { + try { + return await import("@opencomputer/sdk/node"); + } catch { + throw new Error( + "Missing @opencomputer/sdk. Run this script with: npm exec --yes --package=@opencomputer/sdk node scripts/build-opencomputer-runtime-snapshot.mjs" + ); + } +} + +async function walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walk(fullPath))); + } else { + files.push(fullPath); + } + } + return files; +} + +async function main() { + if (!process.env.OPENCOMPUTER_API_KEY) { + throw new Error("OPENCOMPUTER_API_KEY is required"); + } + + const { Image, Snapshots } = await loadSdk(); + + const repoRoot = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url))); + const runtimeRoot = path.join(repoRoot, "packages", "sandbox-runtime"); + const srcRoot = path.join(runtimeRoot, "src", "sandbox_runtime"); + const snapshotName = process.argv[2] || `open-inspect-runtime-${Date.now()}`; + const srcFiles = await walk(srcRoot); + + let image = Image.base().runCommands( + "mkdir -p /workspace/.openinspect-node /workspace/app /workspace /tmp/opencode /tmp/miniforge", + "curl -Ls https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh -o /tmp/miniforge/miniforge.sh", + "bash /tmp/miniforge/miniforge.sh -b -p /workspace/.venv", + "npm install -g --prefix /workspace/.openinspect-node opencode-ai@latest @opencode-ai/plugin@latest zod", + "/workspace/.venv/bin/pip install --no-cache-dir httpx websockets pydantic 'PyJWT[crypto]'" + ); + + for (const file of srcFiles) { + const relPath = path.relative(srcRoot, file); + image = image.addLocalFile(file, `/workspace/app/sandbox_runtime/${relPath}`); + } + + image = image + .env({ + HOME: "/workspace", + NODE_ENV: "development", + PYTHONPATH: "/workspace/app", + NODE_PATH: "/workspace/.openinspect-node/lib/node_modules", + PATH: "/workspace/.venv/bin:/workspace/.openinspect-node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + }) + .workdir("/workspace/app"); + + const snapshots = new Snapshots({ apiKey: process.env.OPENCOMPUTER_API_KEY }); + await snapshots.create({ + name: snapshotName, + image, + onBuildLogs: (log) => console.log(log), + }); + + console.log(`SNAPSHOT_NAME=${snapshotName}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +});