Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/opencomputer-provider.md
Original file line number Diff line number Diff line change
@@ -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-<timestamp>
```

`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`.
30 changes: 30 additions & 0 deletions packages/control-plane/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -1453,6 +1459,30 @@ async function handleOpenAITokenRefresh(
);
}

async function handleSandboxErrorReport(
request: Request,
env: Env,
match: RegExpMatchArray,
ctx: RequestContext
): Promise<Response> {
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,
Expand Down
269 changes: 269 additions & 0 deletions packages/control-plane/src/sandbox/opencomputer-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
metadata?: Record<string, string>;
}

export interface OpenComputerForkFromCheckpointParams {
timeout?: number;
envs?: Record<string, string>;
}

export interface OpenComputerStartExecParams {
cmd: string;
args?: string[];
envs?: Record<string, string>;
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<OpenComputerSandboxResponse>;
getSandbox(id: string, correlation?: CorrelationContext): Promise<OpenComputerSandboxResponse>;
hibernateSandbox(
id: string,
correlation?: CorrelationContext
): Promise<OpenComputerSandboxResponse>;
wakeSandbox(
id: string,
timeout: number | undefined,
correlation?: CorrelationContext
): Promise<OpenComputerSandboxResponse>;
setSandboxTimeout(id: string, timeout: number, correlation?: CorrelationContext): Promise<void>;
createCheckpoint(
sandboxId: string,
name: string,
correlation?: CorrelationContext
): Promise<OpenComputerCheckpointResponse>;
forkFromCheckpoint(
checkpointId: string,
params: OpenComputerForkFromCheckpointParams,
correlation?: CorrelationContext
): Promise<OpenComputerSandboxResponse>;
startExecSession(
sandboxId: string,
params: OpenComputerStartExecParams,
correlation?: CorrelationContext
): Promise<OpenComputerExecSessionResponse>;
}

class OpenComputerRestClient implements OpenComputerClient {
constructor(readonly config: OpenComputerClientConfig) {}

async createSandbox(
params: OpenComputerCreateSandboxParams,
correlation?: CorrelationContext
): Promise<OpenComputerSandboxResponse> {
return this.request<OpenComputerSandboxResponse>("/sandboxes", {
method: "POST",
body: JSON.stringify(params),
correlation,
expectedStatuses: [201],
});
}

async getSandbox(
id: string,
correlation?: CorrelationContext
): Promise<OpenComputerSandboxResponse> {
return this.request<OpenComputerSandboxResponse>(`/sandboxes/${encodeURIComponent(id)}`, {
method: "GET",
correlation,
expectedStatuses: [200],
});
}

async hibernateSandbox(
id: string,
correlation?: CorrelationContext
): Promise<OpenComputerSandboxResponse> {
return this.request<OpenComputerSandboxResponse>(
`/sandboxes/${encodeURIComponent(id)}/hibernate`,
{
method: "POST",
correlation,
expectedStatuses: [200],
}
);
}

async wakeSandbox(
id: string,
timeout: number | undefined,
correlation?: CorrelationContext
): Promise<OpenComputerSandboxResponse> {
return this.request<OpenComputerSandboxResponse>(`/sandboxes/${encodeURIComponent(id)}/wake`, {
method: "POST",
body: JSON.stringify(timeout === undefined ? {} : { timeout }),
correlation,
expectedStatuses: [200],
});
}

async setSandboxTimeout(
id: string,
timeout: number,
correlation?: CorrelationContext
): Promise<void> {
await this.request<void>(`/sandboxes/${encodeURIComponent(id)}/timeout`, {
method: "POST",
body: JSON.stringify({ timeout }),
correlation,
expectedStatuses: [204],
});
}

async createCheckpoint(
sandboxId: string,
name: string,
correlation?: CorrelationContext
): Promise<OpenComputerCheckpointResponse> {
return this.request<OpenComputerCheckpointResponse>(
`/sandboxes/${encodeURIComponent(sandboxId)}/checkpoints`,
{
method: "POST",
body: JSON.stringify({ name }),
correlation,
expectedStatuses: [201],
}
);
}

async forkFromCheckpoint(
checkpointId: string,
params: OpenComputerForkFromCheckpointParams,
correlation?: CorrelationContext
): Promise<OpenComputerSandboxResponse> {
return this.request<OpenComputerSandboxResponse>(
`/sandboxes/from-checkpoint/${encodeURIComponent(checkpointId)}`,
{
method: "POST",
body: JSON.stringify(params),
correlation,
expectedStatuses: [201],
}
);
}

async startExecSession(
sandboxId: string,
params: OpenComputerStartExecParams,
correlation?: CorrelationContext
): Promise<OpenComputerExecSessionResponse> {
return this.request<OpenComputerExecSessionResponse>(
`/sandboxes/${encodeURIComponent(sandboxId)}/exec`,
{
method: "POST",
body: JSON.stringify(params),
correlation,
expectedStatuses: [201],
}
);
}

private async request<T>(
path: string,
options: {
method: string;
body?: string;
correlation?: CorrelationContext;
expectedStatuses: number[];
}
): Promise<T> {
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,
});
}
Loading