mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 03:00:48 +00:00
Add Agent Computer provider
Add an Agent Computer provider to the TypeScript SDK, wire in provider-specific inspector URLs, and document the new deploy flow with an example package. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
bf484e7c96
commit
e7d31de44f
17 changed files with 729 additions and 6 deletions
|
|
@ -32,7 +32,7 @@ Sandbox Agent solves three problems:
|
|||
|
||||
- **Universal Agent API**: Single interface to control Claude Code, Codex, OpenCode, Cursor, Amp, and Pi with full feature coverage
|
||||
- **Universal Session Schema**: Standardized schema that normalizes all agent event formats for storage and replay
|
||||
- **Runs Inside Any Sandbox**: Lightweight static Rust binary. One curl command to install inside E2B, Daytona, Vercel Sandboxes, or Docker
|
||||
- **Runs Inside Any Sandbox**: Lightweight static Rust binary. One curl command to install inside Agent Computer, E2B, Daytona, Vercel Sandboxes, or Docker
|
||||
- **Server or SDK Mode**: Run as an HTTP server or embed with the TypeScript SDK
|
||||
- **OpenAPI Spec**: [Well documented](https://sandboxagent.dev/docs/api-reference) and easy to integrate from any language
|
||||
- **OpenCode SDK & UI Support** *(Experimental)*: [Connect OpenCode CLI, SDK, or web UI](https://sandboxagent.dev/docs/opencode-compatibility) to control agents through familiar OpenCode tooling
|
||||
|
|
|
|||
60
docs/deploy/agentcomputer.mdx
Normal file
60
docs/deploy/agentcomputer.mdx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
---
|
||||
title: "Agent Computer"
|
||||
description: "Run Sandbox Agent on Agent Computer managed workers."
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `COMPUTER_API_KEY` or `AGENTCOMPUTER_API_KEY`
|
||||
- An Agent Computer managed-worker image, or default machine source, that already has your coding agent authenticated
|
||||
|
||||
The platform image already includes Sandbox Agent, Claude Code, and Codex. Agent credentials still live inside the machine, so make sure your image or workspace bootstrap has already handled agent login.
|
||||
|
||||
## TypeScript example
|
||||
|
||||
```bash
|
||||
npm install sandbox-agent@0.4.x
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { agentcomputer } from "sandbox-agent/agentcomputer";
|
||||
|
||||
const sdk = await SandboxAgent.start({
|
||||
sandbox: agentcomputer(),
|
||||
});
|
||||
|
||||
try {
|
||||
const health = await sdk.getHealth();
|
||||
console.log(health.status);
|
||||
console.log(sdk.inspectorUrl);
|
||||
|
||||
const agents = await sdk.listAgents();
|
||||
console.log(agents.agents.map((agent) => agent.id));
|
||||
} finally {
|
||||
await sdk.destroySandbox();
|
||||
}
|
||||
```
|
||||
|
||||
The `agentcomputer` provider creates a managed-worker computer, waits until browser access is ready, and routes SDK requests through the machine's authenticated web host. `sdk.inspectorUrl` is already a browser-ready URL, so it opens the built-in Inspector directly.
|
||||
|
||||
By default, the provider creates:
|
||||
|
||||
- `runtimeFamily: "managed-worker"`
|
||||
- `usePlatformDefault: true`
|
||||
|
||||
Pass `create` when you want to override the handle, workspace name, or other machine-create settings:
|
||||
|
||||
```typescript
|
||||
const sdk = await SandboxAgent.start({
|
||||
sandbox: agentcomputer({
|
||||
create: {
|
||||
handle: "sandbox-agent-demo",
|
||||
workspaceName: "sandbox-agent-demo",
|
||||
usePlatformDefault: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Set `apiUrl` when you are targeting a self-hosted or local Agent Computer API instead of `https://api.computer.agentcomputer.ai`.
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
"deploy/local",
|
||||
"deploy/e2b",
|
||||
"deploy/daytona",
|
||||
"deploy/agentcomputer",
|
||||
"deploy/vercel",
|
||||
"deploy/cloudflare",
|
||||
"deploy/docker",
|
||||
|
|
@ -122,9 +123,9 @@
|
|||
]
|
||||
},
|
||||
"__removed": [
|
||||
{
|
||||
"group": "Orchestration",
|
||||
"pages": ["orchestration-architecture", "session-persistence", "observability", "multiplayer", "security"]
|
||||
}
|
||||
{
|
||||
"group": "Orchestration",
|
||||
"pages": ["orchestration-architecture", "session-persistence", "observability", "multiplayer", "security"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ await sdk.destroySandbox(); // provider-defined cleanup + disposes client
|
|||
| `sandbox-agent/docker` | Docker container |
|
||||
| `sandbox-agent/e2b` | E2B sandbox |
|
||||
| `sandbox-agent/daytona` | Daytona workspace |
|
||||
| `sandbox-agent/agentcomputer` | Agent Computer managed worker |
|
||||
| `sandbox-agent/vercel` | Vercel Sandbox |
|
||||
| `sandbox-agent/cloudflare` | Cloudflare Sandbox |
|
||||
|
||||
|
|
@ -250,6 +251,8 @@ try {
|
|||
|
||||
## Inspector URL
|
||||
|
||||
When you call `SandboxAgent.start(...)`, prefer `sdk.inspectorUrl`. Some hosted providers return a browser-ready Inspector URL there.
|
||||
|
||||
```ts
|
||||
import { buildInspectorUrl } from "sandbox-agent";
|
||||
|
||||
|
|
|
|||
19
examples/agentcomputer/package.json
Normal file
19
examples/agentcomputer/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@sandbox-agent/example-agentcomputer",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sandbox-agent/example-shared": "workspace:*",
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
"tsx": "latest",
|
||||
"typescript": "latest",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
19
examples/agentcomputer/src/index.ts
Normal file
19
examples/agentcomputer/src/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { agentcomputer } from "sandbox-agent/agentcomputer";
|
||||
|
||||
const client = await SandboxAgent.start({
|
||||
sandbox: agentcomputer(),
|
||||
});
|
||||
|
||||
console.log(`UI: ${client.inspectorUrl}`);
|
||||
|
||||
const health = await client.getHealth();
|
||||
console.log(`Health: ${health.status}`);
|
||||
|
||||
const agents = await client.listAgents();
|
||||
console.log("Agents:", agents.agents.map((agent) => agent.id).join(", "));
|
||||
|
||||
process.once("SIGINT", async () => {
|
||||
await client.destroySandbox();
|
||||
process.exit(0);
|
||||
});
|
||||
27
examples/agentcomputer/tests/agentcomputer.test.ts
Normal file
27
examples/agentcomputer/tests/agentcomputer.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { agentcomputer } from "sandbox-agent/agentcomputer";
|
||||
|
||||
const shouldRun = Boolean(process.env.COMPUTER_API_KEY || process.env.AGENTCOMPUTER_API_KEY);
|
||||
const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000;
|
||||
|
||||
const testFn = shouldRun ? it : it.skip;
|
||||
|
||||
describe("agentcomputer provider", () => {
|
||||
testFn(
|
||||
"starts sandbox-agent and responds to /v1/health",
|
||||
async () => {
|
||||
const sdk = await SandboxAgent.start({
|
||||
sandbox: agentcomputer(),
|
||||
});
|
||||
|
||||
try {
|
||||
const health = await sdk.getHealth();
|
||||
expect(health.status).toBe("ok");
|
||||
} finally {
|
||||
await sdk.destroySandbox();
|
||||
}
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
17
examples/agentcomputer/tsconfig.json
Normal file
17
examples/agentcomputer/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
|
|
@ -31,6 +31,28 @@ importers:
|
|||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
examples/agentcomputer:
|
||||
dependencies:
|
||||
'@sandbox-agent/example-shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
sandbox-agent:
|
||||
specifier: workspace:*
|
||||
version: link:../../sdks/typescript
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: latest
|
||||
version: 25.5.0
|
||||
tsx:
|
||||
specifier: latest
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: latest
|
||||
version: 6.0.2
|
||||
vitest:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
examples/boxlite:
|
||||
dependencies:
|
||||
'@boxlite-ai/boxlite':
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const VERSION_REFERENCE_FILES = [
|
|||
"docs/deploy/cloudflare.mdx",
|
||||
"docs/deploy/vercel.mdx",
|
||||
"docs/deploy/daytona.mdx",
|
||||
"docs/deploy/agentcomputer.mdx",
|
||||
"docs/deploy/e2b.mdx",
|
||||
"docs/deploy/docker.mdx",
|
||||
"docs/deploy/boxlite.mdx",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@
|
|||
"types": "./dist/providers/daytona.d.ts",
|
||||
"import": "./dist/providers/daytona.js"
|
||||
},
|
||||
"./agentcomputer": {
|
||||
"types": "./dist/providers/agentcomputer.d.ts",
|
||||
"import": "./dist/providers/agentcomputer.js"
|
||||
},
|
||||
"./docker": {
|
||||
"types": "./dist/providers/docker.d.ts",
|
||||
"import": "./dist/providers/docker.js"
|
||||
|
|
|
|||
|
|
@ -888,6 +888,7 @@ export class SandboxAgent {
|
|||
private sandboxProvider?: SandboxProvider;
|
||||
private sandboxProviderId?: string;
|
||||
private sandboxProviderRawId?: string;
|
||||
private sandboxInspectorUrl?: string;
|
||||
|
||||
private readonly persist: SessionPersistDriver;
|
||||
private readonly replayMaxEvents: number;
|
||||
|
|
@ -959,6 +960,7 @@ export class SandboxAgent {
|
|||
try {
|
||||
const fetcher = await resolveProviderFetch(provider, rawSandboxId);
|
||||
const baseUrl = provider.getUrl ? await provider.getUrl(rawSandboxId) : undefined;
|
||||
const inspectorUrl = provider.getInspectorUrl ? await provider.getInspectorUrl(rawSandboxId, baseUrl) : undefined;
|
||||
const providerFetch = options.fetch ?? fetcher;
|
||||
const commonConnectOptions = {
|
||||
headers: options.headers,
|
||||
|
|
@ -984,6 +986,7 @@ export class SandboxAgent {
|
|||
client.sandboxProvider = provider;
|
||||
client.sandboxProviderId = prefixedSandboxId;
|
||||
client.sandboxProviderRawId = rawSandboxId;
|
||||
client.sandboxInspectorUrl = inspectorUrl;
|
||||
return client;
|
||||
} catch (error) {
|
||||
if (createdSandbox) {
|
||||
|
|
@ -1006,7 +1009,7 @@ export class SandboxAgent {
|
|||
}
|
||||
|
||||
get inspectorUrl(): string {
|
||||
return `${this.baseUrl.replace(/\/+$/, "")}/ui/`;
|
||||
return this.sandboxInspectorUrl ?? `${this.baseUrl.replace(/\/+$/, "")}/ui/`;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
|
|
|
|||
412
sdks/typescript/src/providers/agentcomputer.ts
Normal file
412
sdks/typescript/src/providers/agentcomputer.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import { SandboxDestroyedError } from "../client.ts";
|
||||
import type { SandboxProvider } from "./types.ts";
|
||||
|
||||
const DEFAULT_API_URL = process.env.COMPUTER_API_URL ?? process.env.AGENTCOMPUTER_API_URL ?? "https://api.computer.agentcomputer.ai";
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1_500;
|
||||
const DEFAULT_START_TIMEOUT_MS = 300_000;
|
||||
const DEFAULT_CWD = "/home/node";
|
||||
const BROWSER_ACCESS_REFRESH_SKEW_MS = 30_000;
|
||||
const BROWSER_SESSION_COOKIE = "agentcomputer_access_session";
|
||||
|
||||
const READY_STATUSES = new Set(["starting", "running"]);
|
||||
const FAILED_STATUSES = new Set(["deleted", "error", "stopped", "stopping"]);
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export interface AgentComputerCreateOverrides {
|
||||
handle?: string;
|
||||
displayName?: string;
|
||||
runtimeFamily?: string;
|
||||
sourceKind?: string;
|
||||
imageFamily?: string;
|
||||
imageRef?: string;
|
||||
sourceRepoUrl?: string;
|
||||
sourceRef?: string;
|
||||
sourceCommitSha?: string;
|
||||
sourceSubpath?: string;
|
||||
primaryPort?: number;
|
||||
primaryPath?: string;
|
||||
healthcheckType?: string;
|
||||
healthcheckValue?: string;
|
||||
sshEnabled?: boolean;
|
||||
vncEnabled?: boolean;
|
||||
workspaceName?: string;
|
||||
usePlatformDefault?: boolean;
|
||||
idea?: string;
|
||||
initialPrompt?: string;
|
||||
}
|
||||
|
||||
export interface AgentComputerProviderOptions {
|
||||
apiKey?: string | (() => MaybePromise<string>);
|
||||
apiUrl?: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
create?: AgentComputerCreateOverrides | (() => MaybePromise<AgentComputerCreateOverrides>);
|
||||
pollIntervalMs?: number;
|
||||
startTimeoutMs?: number;
|
||||
defaultCwd?: string;
|
||||
}
|
||||
|
||||
interface AgentComputerComputer {
|
||||
id: string;
|
||||
status?: string;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
interface AgentComputerConnectionResponse {
|
||||
connection?: {
|
||||
web_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentComputerBrowserAccessResponse {
|
||||
access_url?: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
interface BrowserAccessState {
|
||||
accessToken: string;
|
||||
inspectorUrl: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class AgentComputerApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "AgentComputerApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApiKey(value: AgentComputerProviderOptions["apiKey"]): Promise<string> {
|
||||
const raw = typeof value === "function" ? await value() : (value ?? process.env.COMPUTER_API_KEY ?? process.env.AGENTCOMPUTER_API_KEY ?? "");
|
||||
const apiKey = raw.trim();
|
||||
if (!apiKey) {
|
||||
throw new Error("agentcomputer provider requires an API key. Set COMPUTER_API_KEY (or AGENTCOMPUTER_API_KEY) or pass `apiKey`.");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async function resolveCreateOptions(value: AgentComputerProviderOptions["create"]): Promise<AgentComputerCreateOverrides> {
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
return typeof value === "function" ? await value() : value;
|
||||
}
|
||||
|
||||
function resolveFetch(fetcher: AgentComputerProviderOptions["fetch"]): typeof globalThis.fetch {
|
||||
const resolved = fetcher ?? globalThis.fetch?.bind(globalThis);
|
||||
if (!resolved) {
|
||||
throw new Error("Fetch API is not available; provide a fetch implementation.");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function normalizeApiUrl(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function serializeCreateOptions(options: AgentComputerCreateOverrides): Record<string, unknown> {
|
||||
return {
|
||||
handle: options.handle,
|
||||
display_name: options.displayName,
|
||||
runtime_family: options.runtimeFamily,
|
||||
source_kind: options.sourceKind,
|
||||
image_family: options.imageFamily,
|
||||
image_ref: options.imageRef,
|
||||
source_repo_url: options.sourceRepoUrl,
|
||||
source_ref: options.sourceRef,
|
||||
source_commit_sha: options.sourceCommitSha,
|
||||
source_subpath: options.sourceSubpath,
|
||||
primary_port: options.primaryPort,
|
||||
primary_path: options.primaryPath,
|
||||
healthcheck_type: options.healthcheckType,
|
||||
healthcheck_value: options.healthcheckValue,
|
||||
ssh_enabled: options.sshEnabled,
|
||||
vnc_enabled: options.vncEnabled,
|
||||
workspace_name: options.workspaceName,
|
||||
use_platform_default: options.usePlatformDefault,
|
||||
idea: options.idea,
|
||||
initial_prompt: options.initialPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (payload.error) {
|
||||
return payload.error;
|
||||
}
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return response.statusText || "request failed";
|
||||
}
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
return body || response.statusText || "request failed";
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): error is AgentComputerApiError {
|
||||
return error instanceof AgentComputerApiError && error.status === 404;
|
||||
}
|
||||
|
||||
function isFailedStatus(status: string | undefined): boolean {
|
||||
return !!status && FAILED_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function isReadyStatus(status: string | undefined): boolean {
|
||||
return !!status && READY_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function formatComputerStatusError(sandboxId: string, computer: AgentComputerComputer): Error {
|
||||
const status = computer.status ?? "unknown";
|
||||
if (status === "deleted") {
|
||||
return new SandboxDestroyedError(sandboxId, "agentcomputer");
|
||||
}
|
||||
const suffix = computer.last_error ? `: ${computer.last_error}` : "";
|
||||
return new Error(`agentcomputer computer '${sandboxId}' is not available (status '${status}'${suffix}).`);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
if (ms <= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeCookieHeader(existingCookie: string | null, name: string, value: string): string {
|
||||
if (!existingCookie?.trim()) {
|
||||
return `${name}=${value}`;
|
||||
}
|
||||
return `${existingCookie}; ${name}=${value}`;
|
||||
}
|
||||
|
||||
function isAuthRedirect(response: Response): boolean {
|
||||
if (response.status < 300 || response.status >= 400) {
|
||||
return false;
|
||||
}
|
||||
const location = response.headers.get("location") ?? "";
|
||||
return location.includes("/login") || location.includes("auth_required") || location.includes("machine_unauthorized");
|
||||
}
|
||||
|
||||
function parseBrowserAccess(accessUrl: string, expiresAtRaw: string | undefined): BrowserAccessState {
|
||||
const url = new URL(accessUrl);
|
||||
const accessToken = url.searchParams.get("access_token") ?? url.searchParams.get("token") ?? "";
|
||||
if (!accessToken) {
|
||||
throw new Error("agentcomputer browser access response did not include an access token.");
|
||||
}
|
||||
|
||||
const inspectorUrl = new URL(accessUrl);
|
||||
inspectorUrl.pathname = "/ui/";
|
||||
|
||||
const expiresAt = Date.parse(expiresAtRaw ?? "");
|
||||
return {
|
||||
accessToken,
|
||||
inspectorUrl: inspectorUrl.toString(),
|
||||
expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRefreshBrowserAccess(state: BrowserAccessState | undefined): boolean {
|
||||
if (!state) {
|
||||
return true;
|
||||
}
|
||||
return state.expiresAt <= Date.now() + BROWSER_ACCESS_REFRESH_SKEW_MS;
|
||||
}
|
||||
|
||||
export function agentcomputer(options: AgentComputerProviderOptions = {}): SandboxProvider {
|
||||
const apiUrl = normalizeApiUrl(options.apiUrl ?? DEFAULT_API_URL);
|
||||
const fetcher = resolveFetch(options.fetch);
|
||||
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
const startTimeoutMs = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
|
||||
const defaultCwd = options.defaultCwd ?? DEFAULT_CWD;
|
||||
const connectionUrlBySandbox = new Map<string, string>();
|
||||
const browserAccessBySandbox = new Map<string, BrowserAccessState>();
|
||||
const readySandboxes = new Set<string>();
|
||||
|
||||
async function apiRequest<T>(path: string, init?: RequestInit, allowNotFound = false): Promise<T | undefined> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("accept")) {
|
||||
headers.set("accept", "application/json");
|
||||
}
|
||||
if (init?.body !== undefined && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
headers.set("authorization", `Bearer ${await resolveApiKey(options.apiKey)}`);
|
||||
|
||||
const response = await fetcher(`${apiUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (allowNotFound && response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
throw new AgentComputerApiError(response.status, await readErrorMessage(response));
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function getComputer(sandboxId: string, allowNotFound = false): Promise<AgentComputerComputer | undefined> {
|
||||
return await apiRequest<AgentComputerComputer>(`/v1/computers/${encodeURIComponent(sandboxId)}`, undefined, allowNotFound);
|
||||
}
|
||||
|
||||
async function waitUntilBrowserReady(sandboxId: string): Promise<AgentComputerComputer> {
|
||||
if (readySandboxes.has(sandboxId)) {
|
||||
return { id: sandboxId, status: "running" };
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (true) {
|
||||
const computer = await getComputer(sandboxId, true);
|
||||
if (!computer) {
|
||||
throw new SandboxDestroyedError(sandboxId, "agentcomputer");
|
||||
}
|
||||
if (isReadyStatus(computer.status)) {
|
||||
readySandboxes.add(sandboxId);
|
||||
return computer;
|
||||
}
|
||||
if (isFailedStatus(computer.status)) {
|
||||
readySandboxes.delete(sandboxId);
|
||||
throw formatComputerStatusError(sandboxId, computer);
|
||||
}
|
||||
if (Date.now() - startedAt >= startTimeoutMs) {
|
||||
throw new Error(`agentcomputer computer '${sandboxId}' did not become browser-ready within ${startTimeoutMs}ms.`);
|
||||
}
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function getConnectionUrl(sandboxId: string): Promise<string> {
|
||||
const cached = connectionUrlBySandbox.get(sandboxId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await apiRequest<AgentComputerConnectionResponse>(`/v1/computers/${encodeURIComponent(sandboxId)}/connection`);
|
||||
const webUrl = response?.connection?.web_url?.trim();
|
||||
if (!webUrl) {
|
||||
throw new Error(`agentcomputer connection info did not return a web_url for '${sandboxId}'.`);
|
||||
}
|
||||
|
||||
connectionUrlBySandbox.set(sandboxId, webUrl);
|
||||
return webUrl;
|
||||
}
|
||||
|
||||
async function mintBrowserAccess(sandboxId: string): Promise<BrowserAccessState> {
|
||||
await waitUntilBrowserReady(sandboxId);
|
||||
const response = await apiRequest<AgentComputerBrowserAccessResponse>(`/v1/computers/${encodeURIComponent(sandboxId)}/access/browser`, {
|
||||
method: "POST",
|
||||
});
|
||||
const accessUrl = response?.access_url?.trim();
|
||||
if (!accessUrl) {
|
||||
throw new Error(`agentcomputer browser access did not return an access_url for '${sandboxId}'.`);
|
||||
}
|
||||
const state = parseBrowserAccess(accessUrl, response?.expires_at);
|
||||
browserAccessBySandbox.set(sandboxId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function ensureBrowserAccess(sandboxId: string): Promise<BrowserAccessState> {
|
||||
const cached = browserAccessBySandbox.get(sandboxId);
|
||||
if (!shouldRefreshBrowserAccess(cached)) {
|
||||
return cached!;
|
||||
}
|
||||
return await mintBrowserAccess(sandboxId);
|
||||
}
|
||||
|
||||
return {
|
||||
name: "agentcomputer",
|
||||
defaultCwd,
|
||||
async create(): Promise<string> {
|
||||
const createOptions = await resolveCreateOptions(options.create);
|
||||
const computer = await apiRequest<AgentComputerComputer>("/v1/computers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
serializeCreateOptions({
|
||||
runtimeFamily: "managed-worker",
|
||||
usePlatformDefault: true,
|
||||
...createOptions,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
if (!computer?.id) {
|
||||
throw new Error("agentcomputer create response did not return a computer id.");
|
||||
}
|
||||
|
||||
await waitUntilBrowserReady(computer.id);
|
||||
return computer.id;
|
||||
},
|
||||
async destroy(sandboxId: string): Promise<void> {
|
||||
browserAccessBySandbox.delete(sandboxId);
|
||||
connectionUrlBySandbox.delete(sandboxId);
|
||||
readySandboxes.delete(sandboxId);
|
||||
await apiRequest<void>(`/v1/computers/${encodeURIComponent(sandboxId)}`, { method: "DELETE" }, true);
|
||||
},
|
||||
async reconnect(sandboxId: string): Promise<void> {
|
||||
try {
|
||||
const computer = await getComputer(sandboxId, true);
|
||||
if (!computer) {
|
||||
throw new SandboxDestroyedError(sandboxId, "agentcomputer");
|
||||
}
|
||||
if (isReadyStatus(computer.status)) {
|
||||
readySandboxes.add(sandboxId);
|
||||
return;
|
||||
}
|
||||
if (isFailedStatus(computer.status)) {
|
||||
readySandboxes.delete(sandboxId);
|
||||
throw formatComputerStatusError(sandboxId, computer);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
throw new SandboxDestroyedError(sandboxId, "agentcomputer", { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async getUrl(sandboxId: string): Promise<string> {
|
||||
return await getConnectionUrl(sandboxId);
|
||||
},
|
||||
async getFetch(sandboxId: string): Promise<typeof globalThis.fetch> {
|
||||
return async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
const sandboxOrigin = new URL(await getConnectionUrl(sandboxId)).origin;
|
||||
const requestOrigin = new URL(request.url, sandboxOrigin).origin;
|
||||
if (requestOrigin !== sandboxOrigin) {
|
||||
return await fetcher(request);
|
||||
}
|
||||
|
||||
const browserAccess = await ensureBrowserAccess(sandboxId);
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("cookie", mergeCookieHeader(headers.get("cookie"), BROWSER_SESSION_COOKIE, browserAccess.accessToken));
|
||||
|
||||
const response = await fetcher(new Request(request, { headers, redirect: "manual" }));
|
||||
if (response.status === 401 || isAuthRedirect(response)) {
|
||||
browserAccessBySandbox.delete(sandboxId);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
},
|
||||
async getInspectorUrl(sandboxId: string): Promise<string> {
|
||||
const browserAccess = await ensureBrowserAccess(sandboxId);
|
||||
return browserAccess.inspectorUrl;
|
||||
},
|
||||
async ensureServer(): Promise<void> {
|
||||
// Managed-worker images already boot sandbox-agent and expose health on /v1/health.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -39,6 +39,12 @@ export interface SandboxProvider {
|
|||
*/
|
||||
getFetch?(sandboxId: string): Promise<typeof globalThis.fetch>;
|
||||
|
||||
/**
|
||||
* Return a browser-ready Inspector URL for this sandbox.
|
||||
* When omitted, the SDK falls back to `${getUrl()}/ui/`.
|
||||
*/
|
||||
getInspectorUrl?(sandboxId: string, baseUrl?: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Ensure the sandbox-agent server process is running inside the sandbox.
|
||||
* Called during health-wait after consecutive failures, and before
|
||||
|
|
|
|||
|
|
@ -83,12 +83,24 @@ vi.mock("@fly/sprites", () => ({
|
|||
import { e2b } from "../src/providers/e2b.ts";
|
||||
import { modal } from "../src/providers/modal.ts";
|
||||
import { computesdk } from "../src/providers/computesdk.ts";
|
||||
import { agentcomputer } from "../src/providers/agentcomputer.ts";
|
||||
import { sprites } from "../src/providers/sprites.ts";
|
||||
|
||||
function createFetch(): typeof fetch {
|
||||
return async () => new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createBaseProvider(overrides: Partial<SandboxProvider> = {}): SandboxProvider {
|
||||
return {
|
||||
name: "mock",
|
||||
|
|
@ -203,6 +215,27 @@ describe("SandboxAgent provider lifecycle", () => {
|
|||
await killed.killSandbox();
|
||||
expect(kill).toHaveBeenCalledWith("created");
|
||||
});
|
||||
|
||||
it("uses provider-specific inspector URLs when provided", async () => {
|
||||
const provider = createBaseProvider({
|
||||
async getUrl(): Promise<string> {
|
||||
return "https://sandbox.example";
|
||||
},
|
||||
async getInspectorUrl(): Promise<string> {
|
||||
return "https://sandbox.example/ui/?access_token=test-token";
|
||||
},
|
||||
});
|
||||
|
||||
const sdk = await SandboxAgent.start({
|
||||
sandbox: provider,
|
||||
skipHealthCheck: true,
|
||||
fetch: createFetch(),
|
||||
});
|
||||
|
||||
expect(sdk.inspectorUrl).toBe("https://sandbox.example/ui/?access_token=test-token");
|
||||
|
||||
await sdk.killSandbox();
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2b provider", () => {
|
||||
|
|
@ -374,6 +407,85 @@ describe("computesdk provider", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("agentcomputer provider", () => {
|
||||
it("creates managed-worker computers with platform defaults and exposes an auth-ready inspector URL", async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "cmp_123", status: "pending" }, { status: 201 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "cmp_123", status: "pending" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "cmp_123", status: "starting" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ connection: { web_url: "https://box.agentcomputer.example" } }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
access_url: "https://box.agentcomputer.example?access_token=browser-token",
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ status: "ok" }), { status: 200, headers: { "content-type": "application/json" } }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ status: "ok" }), { status: 200, headers: { "content-type": "application/json" } }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
|
||||
const sdk = await SandboxAgent.start({
|
||||
sandbox: agentcomputer({
|
||||
apiKey: "ac_live_test",
|
||||
fetch: fetchMock,
|
||||
pollIntervalMs: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(sdk.sandboxId).toBe("agentcomputer/cmp_123");
|
||||
expect(sdk.inspectorUrl).toBe("https://box.agentcomputer.example/ui/?access_token=browser-token");
|
||||
|
||||
const health = await sdk.getHealth();
|
||||
expect(health.status).toBe("ok");
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://api.computer.agentcomputer.ai/v1/computers",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
runtime_family: "managed-worker",
|
||||
use_platform_default: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"https://api.computer.agentcomputer.ai/v1/computers/cmp_123/connection",
|
||||
expect.objectContaining({
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
);
|
||||
|
||||
const browserAccessRequest = fetchMock.mock.calls[4];
|
||||
expect(browserAccessRequest?.[0]).toBe("https://api.computer.agentcomputer.ai/v1/computers/cmp_123/access/browser");
|
||||
|
||||
const sandboxHealthRequest = fetchMock.mock.calls[5]?.[0];
|
||||
expect(sandboxHealthRequest).toBeInstanceOf(Request);
|
||||
const sandboxHealthHeaders = new Headers((sandboxHealthRequest as Request).headers);
|
||||
expect(sandboxHealthHeaders.get("cookie")).toContain("agentcomputer_access_session=browser-token");
|
||||
|
||||
await sdk.killSandbox();
|
||||
});
|
||||
|
||||
it("maps missing computers to SandboxDestroyedError during reconnect", async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "not found" }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = agentcomputer({
|
||||
apiKey: "ac_live_test",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
await expect(provider.reconnect?.("cmp_missing")).rejects.toBeInstanceOf(SandboxDestroyedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sprites provider", () => {
|
||||
it("creates a sprite, installs sandbox-agent, and configures the managed service", async () => {
|
||||
const sprite = {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { local } from "../src/providers/local.ts";
|
|||
import { docker } from "../src/providers/docker.ts";
|
||||
import { e2b } from "../src/providers/e2b.ts";
|
||||
import { daytona } from "../src/providers/daytona.ts";
|
||||
import { agentcomputer } from "../src/providers/agentcomputer.ts";
|
||||
import { vercel } from "../src/providers/vercel.ts";
|
||||
import { modal } from "../src/providers/modal.ts";
|
||||
import { computesdk } from "../src/providers/computesdk.ts";
|
||||
|
|
@ -212,6 +213,21 @@ function buildProviders(): ProviderEntry[] {
|
|||
});
|
||||
}
|
||||
|
||||
// --- agentcomputer ---
|
||||
{
|
||||
entries.push({
|
||||
name: "agentcomputer",
|
||||
skipReasons: missingAnyEnvVars("COMPUTER_API_KEY", "AGENTCOMPUTER_API_KEY"),
|
||||
agent: "claude",
|
||||
startTimeoutMs: 300_000,
|
||||
canVerifyDestroyedSandbox: false,
|
||||
sessionTestsEnabled: false,
|
||||
createProvider() {
|
||||
return agentcomputer();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- vercel ---
|
||||
{
|
||||
entries.push({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
|||
"src/providers/local.ts",
|
||||
"src/providers/e2b.ts",
|
||||
"src/providers/daytona.ts",
|
||||
"src/providers/agentcomputer.ts",
|
||||
"src/providers/docker.ts",
|
||||
"src/providers/vercel.ts",
|
||||
"src/providers/cloudflare.ts",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue