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:
Harivansh Rathi 2026-03-26 14:59:01 -04:00
parent bf484e7c96
commit e7d31de44f
17 changed files with 729 additions and 6 deletions

View file

@ -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"

View file

@ -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> {

View 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.
},
};
}

View file

@ -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

View file

@ -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 = {

View file

@ -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({

View file

@ -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",