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,