diff --git a/docs/deploy/docker.mdx b/docs/deploy/docker.mdx index 28c9f77e..1c64af8e 100644 --- a/docs/deploy/docker.mdx +++ b/docs/deploy/docker.mdx @@ -16,17 +16,11 @@ docker run --rm -p 3000:3000 \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ alpine:latest sh -c "\ - apk add --no-cache curl ca-certificates libstdc++ libgcc bash && \ + apk add --no-cache curl ca-certificates libstdc++ libgcc bash nodejs npm && \ curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && \ - sandbox-agent install-agent claude && \ - sandbox-agent install-agent codex && \ sandbox-agent server --no-token --host 0.0.0.0 --port 3000" ``` - -Alpine is required for some agent binaries that target musl libc. - - ## TypeScript with dockerode ```typescript @@ -37,17 +31,18 @@ const docker = new Docker(); const PORT = 3000; const container = await docker.createContainer({ - Image: "alpine:latest", + Image: "node:22-bookworm-slim", Cmd: ["sh", "-c", [ - "apk add --no-cache curl ca-certificates libstdc++ libgcc bash", + "apt-get update", + "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", + "rm -rf /var/lib/apt/lists/*", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", - "sandbox-agent install-agent claude", - "sandbox-agent install-agent codex", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, ].join(" && ")], Env: [ `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, + `CODEX_API_KEY=${process.env.CODEX_API_KEY}`, ].filter(Boolean), ExposedPorts: { [`${PORT}/tcp`]: {} }, HostConfig: { @@ -61,7 +56,7 @@ await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; const sdk = await SandboxAgent.connect({ baseUrl }); -const session = await sdk.createSession({ agent: "claude" }); +const session = await sdk.createSession({ agent: "codex" }); await session.prompt([{ type: "text", text: "Summarize this repository." }]); ``` diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index 7974c659..53a38f6d 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -39,6 +39,8 @@ const sdk = await SandboxAgent.connect({ }); ``` +`SandboxAgent.connect(...)` now waits for `/v1/health` by default before other SDK requests proceed. To disable that gate, pass `waitForHealth: false`. To keep the default gate but fail after a bounded wait, pass `waitForHealth: { timeoutMs: 120_000 }`. To cancel the startup wait early, pass `signal: abortController.signal`. + With a custom fetch handler (for example, proxying requests inside Workers): ```ts @@ -47,6 +49,19 @@ const sdk = await SandboxAgent.connect({ }); ``` +With an abort signal for the startup health gate: + +```ts +const controller = new AbortController(); + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + signal: controller.signal, +}); + +controller.abort(); +``` + With persistence: ```ts @@ -170,6 +185,8 @@ Parameters: - `token` (optional): Bearer token for authenticated servers - `headers` (optional): Additional request headers - `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls +- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait +- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()` ## Types diff --git a/examples/boxlite/src/index.ts b/examples/boxlite/src/index.ts index e5ce412a..c2401be3 100644 --- a/examples/boxlite/src/index.ts +++ b/examples/boxlite/src/index.ts @@ -1,6 +1,6 @@ import { SimpleBox } from "@boxlite-ai/boxlite"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { setupImage, OCI_DIR } from "./setup-image.ts"; const env: Record = {}; @@ -26,9 +26,7 @@ if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.std const baseUrl = "http://localhost:3000"; -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/computesdk/src/computesdk.ts b/examples/computesdk/src/computesdk.ts index bc2ddc6d..37f413df 100644 --- a/examples/computesdk/src/computesdk.ts +++ b/examples/computesdk/src/computesdk.ts @@ -10,7 +10,7 @@ import { type ProviderName, } from "computesdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; @@ -116,9 +116,6 @@ export async function setupComputeSdkSandboxAgent(): Promise<{ const baseUrl = await sandbox.getUrl({ port: PORT }); - console.log("Waiting for server..."); - await waitForHealth({ baseUrl }); - const cleanup = async () => { try { await sandbox.destroy(); diff --git a/examples/daytona/src/daytona-with-snapshot.ts b/examples/daytona/src/daytona-with-snapshot.ts index 0ec694d2..d6900df2 100644 --- a/examples/daytona/src/daytona-with-snapshot.ts +++ b/examples/daytona/src/daytona-with-snapshot.ts @@ -1,6 +1,6 @@ import { Daytona, Image } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -25,9 +25,7 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/daytona/src/index.ts b/examples/daytona/src/index.ts index ddbd6fbd..bbf9d6e3 100644 --- a/examples/daytona/src/index.ts +++ b/examples/daytona/src/index.ts @@ -1,6 +1,6 @@ import { Daytona } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -30,9 +30,7 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/docker/src/index.ts b/examples/docker/src/index.ts index e31d8ede..593ef313 100644 --- a/examples/docker/src/index.ts +++ b/examples/docker/src/index.ts @@ -1,9 +1,16 @@ import Docker from "dockerode"; +import fs from "node:fs"; +import path from "node:path"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; -const IMAGE = "alpine:latest"; +const IMAGE = "node:22-bookworm-slim"; const PORT = 3000; +const agent = detectAgent(); +const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null; +const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) + ? [`${codexAuthPath}:/root/.codex/auth.json:ro`] + : []; const docker = new Docker({ socketPath: "/var/run/docker.sock" }); @@ -24,29 +31,30 @@ console.log("Starting container..."); const container = await docker.createContainer({ Image: IMAGE, Cmd: ["sh", "-c", [ - "apk add --no-cache curl ca-certificates libstdc++ libgcc bash", + "apt-get update", + "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", + "rm -rf /var/lib/apt/lists/*", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", - "sandbox-agent install-agent claude", - "sandbox-agent install-agent codex", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, ].join(" && ")], Env: [ process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", + process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "", ].filter(Boolean), ExposedPorts: { [`${PORT}/tcp`]: {} }, HostConfig: { AutoRemove: true, PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] }, + Binds: bindMounts, }, }); await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; -await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); diff --git a/examples/e2b/src/index.ts b/examples/e2b/src/index.ts index 48fcc016..b02f2391 100644 --- a/examples/e2b/src/index.ts +++ b/examples/e2b/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@e2b/code-interpreter"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -27,9 +27,7 @@ await sandbox.commands.run("sandbox-agent server --no-token --host 0.0.0.0 --por const baseUrl = `https://${sandbox.getHost(3000)}`; -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/shared/src/docker.ts b/examples/shared/src/docker.ts index 5ec8a8c0..adceecb4 100644 --- a/examples/shared/src/docker.ts +++ b/examples/shared/src/docker.ts @@ -4,7 +4,6 @@ import fs from "node:fs"; import path from "node:path"; import { PassThrough } from "node:stream"; import { fileURLToPath } from "node:url"; -import { waitForHealth } from "./sandbox-agent-client.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const EXAMPLE_IMAGE = "sandbox-agent-examples:latest"; @@ -173,7 +172,7 @@ async function ensureExampleImage(_docker: Docker): Promise { } /** - * Start a Docker container running sandbox-agent and wait for it to be healthy. + * Start a Docker container running sandbox-agent. * Registers SIGINT/SIGTERM handlers for cleanup. */ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { @@ -275,18 +274,8 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { stopStartupLogs(); diff --git a/examples/shared/src/sandbox-agent-client.ts b/examples/shared/src/sandbox-agent-client.ts index df8fa51e..5c7e7cf5 100644 --- a/examples/shared/src/sandbox-agent-client.ts +++ b/examples/shared/src/sandbox-agent-client.ts @@ -3,8 +3,6 @@ * Provides minimal helpers for connecting to and interacting with sandbox-agent servers. */ -import { setTimeout as delay } from "node:timers/promises"; - function normalizeBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/+$/, ""); } @@ -74,41 +72,6 @@ export function buildHeaders({ return headers; } -export async function waitForHealth({ - baseUrl, - token, - extraHeaders, - timeoutMs = 120_000, -}: { - baseUrl: string; - token?: string; - extraHeaders?: Record; - timeoutMs?: number; -}): Promise { - const normalized = normalizeBaseUrl(baseUrl); - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - const headers = buildHeaders({ token, extraHeaders }); - const response = await fetch(`${normalized}/v1/health`, { headers }); - if (response.ok) { - const data = await response.json(); - if (data?.status === "ok") { - return; - } - lastError = new Error(`Unexpected health response: ${JSON.stringify(data)}`); - } else { - lastError = new Error(`Health check failed: ${response.status}`); - } - } catch (error) { - lastError = error; - } - await delay(500); - } - throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error; -} - export function generateSessionId(): string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let id = "session-"; @@ -144,4 +107,3 @@ export function detectAgent(): string { } return "claude"; } - diff --git a/examples/vercel/src/index.ts b/examples/vercel/src/index.ts index 56fbfe8a..258fbe41 100644 --- a/examples/vercel/src/index.ts +++ b/examples/vercel/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@vercel/sandbox"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -38,9 +38,7 @@ await sandbox.runCommand({ const baseUrl = sandbox.domain(3000); -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } }); const sessionId = session.id; diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 35d16917..65b8aa56 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -67,13 +67,23 @@ const DEFAULT_BASE_URL = "http://sandbox-agent"; const DEFAULT_REPLAY_MAX_EVENTS = 50; const DEFAULT_REPLAY_MAX_CHARS = 12_000; const EVENT_INDEX_SCAN_EVENTS_LIMIT = 500; +const HEALTH_WAIT_MIN_DELAY_MS = 500; +const HEALTH_WAIT_MAX_DELAY_MS = 15_000; +const HEALTH_WAIT_LOG_AFTER_MS = 5_000; +const HEALTH_WAIT_LOG_EVERY_MS = 10_000; + +export interface SandboxAgentHealthWaitOptions { + timeoutMs?: number; +} interface SandboxAgentConnectCommonOptions { headers?: HeadersInit; persist?: SessionPersistDriver; replayMaxEvents?: number; replayMaxChars?: number; + signal?: AbortSignal; token?: string; + waitForHealth?: boolean | SandboxAgentHealthWaitOptions; } export type SandboxAgentConnectOptions = @@ -477,12 +487,17 @@ export class SandboxAgent { private readonly token?: string; private readonly fetcher: typeof fetch; private readonly defaultHeaders?: HeadersInit; + private readonly healthWait: NormalizedHealthWaitOptions; + private readonly healthWaitAbortController = new AbortController(); private readonly persist: SessionPersistDriver; private readonly replayMaxEvents: number; private readonly replayMaxChars: number; private spawnHandle?: SandboxAgentSpawnHandle; + private healthPromise?: Promise; + private healthError?: Error; + private disposed = false; private readonly liveConnections = new Map(); private readonly pendingLiveConnections = new Map>(); @@ -504,10 +519,13 @@ export class SandboxAgent { } this.fetcher = resolvedFetch; this.defaultHeaders = options.headers; + this.healthWait = normalizeHealthWaitOptions(options.waitForHealth, options.signal); this.persist = options.persist ?? new InMemorySessionPersistDriver(); this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS); this.replayMaxChars = normalizePositiveInt(options.replayMaxChars, DEFAULT_REPLAY_MAX_CHARS); + + this.startHealthWait(); } static async connect(options: SandboxAgentConnectOptions): Promise { @@ -529,6 +547,7 @@ export class SandboxAgent { token: handle.token, fetch: options.fetch, headers: options.headers, + waitForHealth: false, persist: options.persist, replayMaxEvents: options.replayMaxEvents, replayMaxChars: options.replayMaxChars, @@ -539,6 +558,9 @@ export class SandboxAgent { } async dispose(): Promise { + this.disposed = true; + this.healthWaitAbortController.abort(createAbortError("SandboxAgent was disposed.")); + const connections = [...this.liveConnections.values()]; this.liveConnections.clear(); const pending = [...this.pendingLiveConnections.values()]; @@ -706,7 +728,7 @@ export class SandboxAgent { } async getHealth(): Promise { - return this.requestJson("GET", `${API_PREFIX}/health`); + return this.requestHealth(); } async listAgents(options?: AgentQueryOptions): Promise { @@ -935,6 +957,8 @@ export class SandboxAgent { } private async getLiveConnection(agent: string): Promise { + await this.awaitHealthy(); + const existing = this.liveConnections.get(agent); if (existing) { return existing; @@ -1115,6 +1139,7 @@ export class SandboxAgent { headers: options.headers, accept: options.accept ?? "application/json", signal: options.signal, + skipReadyWait: options.skipReadyWait, }); if (response.status === 204) { @@ -1125,6 +1150,10 @@ export class SandboxAgent { } private async requestRaw(method: string, path: string, options: RequestOptions = {}): Promise { + if (!options.skipReadyWait) { + await this.awaitHealthy(options.signal); + } + const url = this.buildUrl(path, options.query); const headers = this.buildHeaders(options.headers); @@ -1161,6 +1190,79 @@ export class SandboxAgent { return response; } + private startHealthWait(): void { + if (!this.healthWait.enabled || this.healthPromise) { + return; + } + + this.healthPromise = this.runHealthWait().catch((error) => { + this.healthError = error instanceof Error ? error : new Error(String(error)); + }); + } + + private async awaitHealthy(signal?: AbortSignal): Promise { + if (!this.healthPromise) { + throwIfAborted(signal); + return; + } + + await waitForAbortable(this.healthPromise, signal); + throwIfAborted(signal); + if (this.healthError) { + throw this.healthError; + } + } + + private async runHealthWait(): Promise { + const signal = this.healthWait.enabled + ? anyAbortSignal([this.healthWait.signal, this.healthWaitAbortController.signal]) + : undefined; + const startedAt = Date.now(); + const deadline = + typeof this.healthWait.timeoutMs === "number" ? startedAt + this.healthWait.timeoutMs : undefined; + + let delayMs = HEALTH_WAIT_MIN_DELAY_MS; + let nextLogAt = startedAt + HEALTH_WAIT_LOG_AFTER_MS; + let lastError: unknown; + + while (!this.disposed && (deadline === undefined || Date.now() < deadline)) { + throwIfAborted(signal); + + try { + const health = await this.requestHealth({ signal }); + if (health.status === "ok") { + return; + } + lastError = new Error(`Unexpected health response: ${JSON.stringify(health)}`); + } catch (error) { + if (isAbortError(error)) { + throw error; + } + lastError = error; + } + + const now = Date.now(); + if (now >= nextLogAt) { + const details = formatHealthWaitError(lastError); + console.warn( + `sandbox-agent at ${this.baseUrl} is not healthy after ${now - startedAt}ms; still waiting (${details})`, + ); + nextLogAt = now + HEALTH_WAIT_LOG_EVERY_MS; + } + + await sleep(delayMs, signal); + delayMs = Math.min(HEALTH_WAIT_MAX_DELAY_MS, delayMs * 2); + } + + if (this.disposed) { + return; + } + + throw new Error( + `Timed out waiting for sandbox-agent health after ${this.healthWait.timeoutMs}ms (${formatHealthWaitError(lastError)})`, + ); + } + private buildHeaders(extra?: HeadersInit): Headers { const headers = new Headers(this.defaultHeaders ?? undefined); @@ -1190,6 +1292,13 @@ export class SandboxAgent { return url.toString(); } + + private async requestHealth(options: { signal?: AbortSignal } = {}): Promise { + return this.requestJson("GET", `${API_PREFIX}/health`, { + signal: options.signal, + skipReadyWait: true, + }); + } } type QueryValue = string | number | boolean | null | undefined; @@ -1202,8 +1311,13 @@ type RequestOptions = { headers?: HeadersInit; accept?: string; signal?: AbortSignal; + skipReadyWait?: boolean; }; +type NormalizedHealthWaitOptions = + | { enabled: false; timeoutMs?: undefined; signal?: undefined } + | { enabled: true; timeoutMs?: number; signal?: AbortSignal }; + /** * Auto-select and call `authenticate` based on the agent's advertised auth methods. * Prefers env-var-based methods that the server process already has configured. @@ -1375,6 +1489,30 @@ function normalizePositiveInt(value: number | undefined, fallback: number): numb return Math.floor(value as number); } +function normalizeHealthWaitOptions( + value: boolean | SandboxAgentHealthWaitOptions | undefined, + signal: AbortSignal | undefined, +): NormalizedHealthWaitOptions { + if (value === false) { + return { enabled: false }; + } + + if (value === true || value === undefined) { + return { enabled: true, signal }; + } + + const timeoutMs = + typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0 + ? Math.floor(value.timeoutMs) + : undefined; + + return { + enabled: true, + signal, + timeoutMs, + }; +} + function normalizeSpawnOptions( spawn: SandboxAgentSpawnOptions | boolean | undefined, defaultEnabled: boolean, @@ -1405,6 +1543,92 @@ async function readProblem(response: Response): Promise): AbortSignal | undefined { + const active = signals.filter((signal): signal is AbortSignal => Boolean(signal)); + if (active.length === 0) { + return undefined; + } + + if (active.length === 1) { + return active[0]; + } + + const controller = new AbortController(); + const onAbort = (event: Event) => { + cleanup(); + const signal = event.target as AbortSignal; + controller.abort(signal.reason ?? createAbortError()); + }; + const cleanup = () => { + for (const signal of active) { + signal.removeEventListener("abort", onAbort); + } + }; + + for (const signal of active) { + if (signal.aborted) { + controller.abort(signal.reason ?? createAbortError()); + return controller.signal; + } + } + + for (const signal of active) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + return controller.signal; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) { + return; + } + + throw signal.reason instanceof Error ? signal.reason : createAbortError(signal.reason); +} + +async function waitForAbortable(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) { + return promise; + } + + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + const onAbort = () => { + cleanup(); + reject(signal.reason instanceof Error ? signal.reason : createAbortError(signal.reason)); + }; + const cleanup = () => { + signal.removeEventListener("abort", onAbort); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + async function consumeProcessLogSse( body: ReadableStream, listener: ProcessLogListener, @@ -1494,3 +1718,43 @@ function toWebSocketUrl(url: string): string { function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } + +function createAbortError(reason?: unknown): Error { + if (reason instanceof Error) { + return reason; + } + + const message = typeof reason === "string" ? reason : "This operation was aborted."; + if (typeof DOMException !== "undefined") { + return new DOMException(message, "AbortError"); + } + + const error = new Error(message); + error.name = "AbortError"; + return error; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const onAbort = () => { + cleanup(); + reject(signal.reason instanceof Error ? signal.reason : createAbortError(signal.reason)); + }; + const cleanup = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 82b5791e..82738097 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -10,6 +10,7 @@ export { AcpRpcError } from "acp-http-client"; export { buildInspectorUrl } from "./inspector.ts"; export type { + SandboxAgentHealthWaitOptions, AgentQueryOptions, ProcessLogFollowQuery, ProcessLogListener, diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 20ad871e..238c6cb7 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -337,6 +337,111 @@ describe("Integration: TypeScript SDK flat session API", () => { ); }); + it("waits for health before non-ACP HTTP helpers", async () => { + const defaultFetch = globalThis.fetch; + if (!defaultFetch) { + throw new Error("Global fetch is not available in this runtime."); + } + + let healthAttempts = 0; + const seenPaths: string[] = []; + const customFetch: typeof fetch = async (input, init) => { + const outgoing = new Request(input, init); + const parsed = new URL(outgoing.url); + seenPaths.push(parsed.pathname); + + if (parsed.pathname === "/v1/health") { + healthAttempts += 1; + if (healthAttempts < 3) { + return new Response("warming up", { status: 503 }); + } + } + + const forwardedUrl = new URL(`${parsed.pathname}${parsed.search}`, baseUrl); + const forwarded = new Request(forwardedUrl.toString(), outgoing); + return defaultFetch(forwarded); + }; + + const sdk = await SandboxAgent.connect({ + token, + fetch: customFetch, + }); + + const agents = await sdk.listAgents(); + expect(Array.isArray(agents.agents)).toBe(true); + expect(healthAttempts).toBe(3); + + const firstAgentsRequest = seenPaths.indexOf("/v1/agents"); + expect(firstAgentsRequest).toBeGreaterThanOrEqual(0); + expect(seenPaths.slice(0, firstAgentsRequest)).toEqual([ + "/v1/health", + "/v1/health", + "/v1/health", + ]); + + await sdk.dispose(); + }); + + it("surfaces health timeout when a request awaits readiness", async () => { + const customFetch: typeof fetch = async (input, init) => { + const outgoing = new Request(input, init); + const parsed = new URL(outgoing.url); + + if (parsed.pathname === "/v1/health") { + return new Response("warming up", { status: 503 }); + } + + throw new Error(`Unexpected request path during timeout test: ${parsed.pathname}`); + }; + + const sdk = await SandboxAgent.connect({ + token, + fetch: customFetch, + waitForHealth: { timeoutMs: 100 }, + }); + + await expect(sdk.listAgents()).rejects.toThrow("Timed out waiting for sandbox-agent health"); + await sdk.dispose(); + }); + + it("aborts the shared health wait when connect signal is aborted", async () => { + const controller = new AbortController(); + const customFetch: typeof fetch = async (input, init) => { + const outgoing = new Request(input, init); + const parsed = new URL(outgoing.url); + + if (parsed.pathname !== "/v1/health") { + throw new Error(`Unexpected request path during abort test: ${parsed.pathname}`); + } + + return new Promise((_resolve, reject) => { + const onAbort = () => { + outgoing.signal.removeEventListener("abort", onAbort); + reject(outgoing.signal.reason ?? new DOMException("Connect aborted", "AbortError")); + }; + + if (outgoing.signal.aborted) { + onAbort(); + return; + } + + outgoing.signal.addEventListener("abort", onAbort, { once: true }); + }); + }; + + const sdk = await SandboxAgent.connect({ + token, + fetch: customFetch, + signal: controller.signal, + }); + + const pending = sdk.listAgents(); + controller.abort(new DOMException("Connect aborted", "AbortError")); + + await expect(pending).rejects.toThrow("Connect aborted"); + await sdk.dispose(); + }); + it("restores a session on stale connection by recreating and replaying history on first prompt", async () => { const persist = new InMemorySessionPersistDriver({ maxEventsPerSession: 200,