@tangle-network/sandbox 0.9.2 → 0.9.3-develop.20260627012805.bf68a85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4500 @@
1
+ import { c as ServerError, d as ValidationError, f as parseErrorResponse, l as StateError, o as QuotaError, r as NetworkError, s as SandboxError, t as AuthError, u as TimeoutError } from "./errors-DZsfJUuc.js";
2
+ import { addTokenUsage, readTokenCostUsd, readTokenUsage } from "@tangle-network/agent-core";
3
+ //#region src/backend-config.ts
4
+ function parseModelString(model) {
5
+ const parts = model.split("/");
6
+ if (parts.length >= 2) return {
7
+ provider: parts[0],
8
+ model: parts.slice(1).join("/")
9
+ };
10
+ return { model };
11
+ }
12
+ /**
13
+ * Normalize runtime backend config for the wire format.
14
+ *
15
+ * Callers set `backend.profile` (named string or portable AgentProfile).
16
+ * This function generates the native `inlineProfile` shim automatically.
17
+ */
18
+ function normalizeRuntimeBackendConfig(backend, options = {}) {
19
+ if (!backend && !options.model) return void 0;
20
+ const portableProfile = backend?.profile && typeof backend.profile !== "string" ? backend.profile : void 0;
21
+ const inlineProfile = portableProfile ? toBackendProfile(portableProfile) : void 0;
22
+ const callerInlineProfile = backend?.inlineProfile;
23
+ if (callerInlineProfile && !inlineProfile) console.warn("[sandbox-sdk] backend.inlineProfile is deprecated. Use backend.profile (AgentPortableProfile) instead.");
24
+ return {
25
+ ...backend?.type ? { type: backend.type } : {},
26
+ ...typeof backend?.profile === "string" ? { profile: backend.profile } : {},
27
+ ...inlineProfile ? { inlineProfile } : callerInlineProfile ? { inlineProfile: callerInlineProfile } : {},
28
+ ...backend?.model ? { model: backend.model } : options.model ? { model: parseModelString(options.model) } : {},
29
+ ...backend?.server ? { server: backend.server } : {}
30
+ };
31
+ }
32
+ /**
33
+ * Permission keys the sidecar's strict `permission` schema accepts
34
+ * (apps/sidecar/src/schemas/agent-profile-schema.ts → permissionConfigSchema).
35
+ * Any other portable-permission key is a per-tool gate, not a permission, and
36
+ * is routed into `tools` instead of passed through — the sidecar's `.strict()`
37
+ * schema rejects unknown permission keys with `unrecognized_keys`.
38
+ *
39
+ * Name must not carry an UPPERCASE `SIDECAR_`/`ORCHESTRATOR_`-style prefix:
40
+ * obfuscate.sh runs `--rename-globals false`, so a module-global identifier
41
+ * ships verbatim in dist/, and verify-dist.sh bans those prefixes as
42
+ * closed-source leaks.
43
+ */
44
+ const CANONICAL_PERMISSION_KEYS = new Set([
45
+ "edit",
46
+ "bash",
47
+ "webfetch",
48
+ "mcp",
49
+ "doom_loop",
50
+ "external_directory"
51
+ ]);
52
+ function hasAnyProfileResource(resources) {
53
+ return !!(resources.files?.length || resources.tools?.length || resources.skills?.length || resources.agents?.length || resources.commands?.length || resources.instructions);
54
+ }
55
+ function validateProfileResources(profile) {
56
+ if (profile.resources !== void 0 && (profile.resources === null || typeof profile.resources !== "object" || Array.isArray(profile.resources))) throw new Error("backend.profile.resources must be an object when provided");
57
+ if (profile.resources?.plugins?.length) throw new Error("backend.profile.resources.plugins is not supported by the sandbox SDK profile contract; use backend-specific provider profiles for OpenCode plugin resources");
58
+ }
59
+ function toBackendProfile(profile) {
60
+ validateProfileResources(profile);
61
+ const permission = {};
62
+ const derivedTools = {};
63
+ for (const [rawKey, value] of Object.entries(profile.permissions ?? {})) {
64
+ const key = rawKey === "network" ? "webfetch" : rawKey;
65
+ if (CANONICAL_PERMISSION_KEYS.has(key)) permission[key] = value;
66
+ else if (typeof value === "string") derivedTools[key] = value !== "deny";
67
+ else console.warn(`[sandbox-sdk] dropping unsupported permission key "${rawKey}": not a canonical sidecar permission and not a string tool gate`);
68
+ }
69
+ const tools = {
70
+ ...derivedTools,
71
+ ...profile.tools ?? {}
72
+ };
73
+ const mcp = profile.mcp ? Object.fromEntries(Object.entries(profile.mcp).map(([name, config]) => {
74
+ const c = config;
75
+ const transport = c.transport;
76
+ const command = c.command;
77
+ const hasCommand = Array.isArray(command) ? command.length > 0 : typeof command === "string" && command.trim().length > 0;
78
+ const isRemote = transport === "http" || transport === "sse" || typeof c.url === "string" && !hasCommand;
79
+ const enabled = c.enabled;
80
+ const timeout = c.timeout;
81
+ if (isRemote) return [name, {
82
+ type: "remote",
83
+ url: c.url,
84
+ ...c.headers ? { headers: c.headers } : {},
85
+ ...enabled !== void 0 ? { enabled } : {},
86
+ ...timeout !== void 0 ? { timeout } : {}
87
+ }];
88
+ return [name, {
89
+ type: "local",
90
+ command: [...Array.isArray(command) ? command : typeof command === "string" ? command.trim().split(/\s+/).filter(Boolean) : [], ...c.args ?? []],
91
+ ...c.env ? { environment: c.env } : {},
92
+ ...enabled !== void 0 ? { enabled } : {},
93
+ ...timeout !== void 0 ? { timeout } : {}
94
+ }];
95
+ })) : void 0;
96
+ return {
97
+ name: profile.name,
98
+ model: profile.model?.default,
99
+ ...profile.prompt?.systemPrompt ? { systemPrompt: profile.prompt.systemPrompt } : {},
100
+ ...Object.keys(tools).length > 0 ? { tools } : {},
101
+ ...profile.prompt?.instructions?.length ? { instructions: profile.prompt.instructions } : {},
102
+ ...Object.keys(permission).length > 0 ? { permission } : {},
103
+ ...mcp ? { mcp } : {},
104
+ ...profile.connections?.length ? { connections: profile.connections } : {},
105
+ ...profile.subagents ? { subagents: profile.subagents } : {},
106
+ ...profile.resources && hasAnyProfileResource(profile.resources) ? { resources: profile.resources } : {},
107
+ ...profile.hooks ? { hooks: profile.hooks } : {},
108
+ ...profile.modes ? { modes: profile.modes } : {},
109
+ ...profile.extensions ? { extensions: profile.extensions } : {}
110
+ };
111
+ }
112
+ /**
113
+ * Serialize a portable AgentProfile into the sidecar's canonical inline-profile
114
+ * wire shape (validated by inlineProfileSchema in
115
+ * apps/sidecar/src/schemas/agent-profile-schema.ts).
116
+ *
117
+ * Single choke point every product shares: streamPrompt →
118
+ * normalizeRuntimeBackendConfig → toBackendProfile. Exported so callers and the
119
+ * drift-guard test target one contract — the test parses this output with the
120
+ * sidecar's own schema, so any future schema tightening fails here first.
121
+ */
122
+ function serializeForSidecar(profile) {
123
+ return toBackendProfile(profile);
124
+ }
125
+ //#endregion
126
+ //#region src/lib/sse-parser.ts
127
+ /**
128
+ * Parse a streaming SSE response body into an async iterable of
129
+ * structured events.
130
+ *
131
+ * @throws `TypeError` when the response has no body.
132
+ * @throws `DOMException` with `name === "AbortError"` on cancellation.
133
+ */
134
+ async function* parseSSEStream(body, options) {
135
+ if (!body) throw new TypeError("SSE stream has no response body");
136
+ const reader = body.getReader();
137
+ const decoder = new TextDecoder();
138
+ const { signal } = options ?? {};
139
+ let buffer = "";
140
+ let currentEvent = "";
141
+ let dataLines = [];
142
+ let currentId = "";
143
+ const flush = function* () {
144
+ if (dataLines.length === 0) {
145
+ currentEvent = "";
146
+ currentId = "";
147
+ return;
148
+ }
149
+ const effectiveEvent = currentEvent || "message";
150
+ const joined = dataLines.join("\n");
151
+ try {
152
+ yield {
153
+ type: effectiveEvent,
154
+ data: JSON.parse(joined),
155
+ id: currentId || void 0
156
+ };
157
+ } catch {
158
+ console.warn(`[sandbox-sdk] Malformed SSE JSON for event "${effectiveEvent}": ${joined.slice(0, 200)}`);
159
+ }
160
+ currentEvent = "";
161
+ dataLines = [];
162
+ currentId = "";
163
+ };
164
+ const processLine = (line) => {
165
+ if (line.startsWith("event:")) currentEvent = line.slice(6).trim();
166
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
167
+ else if (line.startsWith("id:")) currentId = line.slice(3).trim();
168
+ };
169
+ try {
170
+ while (true) {
171
+ if (signal?.aborted) throw new DOMException("SSE stream aborted", "AbortError");
172
+ const { done, value } = await reader.read();
173
+ if (done) {
174
+ if (signal?.aborted) throw new DOMException("SSE stream aborted", "AbortError");
175
+ break;
176
+ }
177
+ buffer += decoder.decode(value, { stream: true });
178
+ const lines = buffer.split(/\r?\n/);
179
+ buffer = lines.pop() ?? "";
180
+ for (const line of lines) if (line === "") yield* flush();
181
+ else processLine(line);
182
+ }
183
+ buffer += decoder.decode();
184
+ if (buffer.length > 0) processLine(buffer);
185
+ yield* flush();
186
+ } finally {
187
+ reader.releaseLock();
188
+ }
189
+ }
190
+ //#endregion
191
+ //#region src/lib/wire-encoding.ts
192
+ /**
193
+ * Encode a prompt text part for the wire. Server decodes at the route
194
+ * boundary; the LLM only ever sees the original UTF-8. Done
195
+ * unconditionally because readable bodies false-positive on ingress WAF
196
+ * rules that pattern-match shell-injection-shaped substrings (which
197
+ * legitimate prompts routinely contain — e.g. Step-0 dev-server
198
+ * bootstrap).
199
+ */
200
+ function encodeTextForWire(text) {
201
+ if (typeof Buffer !== "undefined") return Buffer.from(text, "utf8").toString("base64");
202
+ if (typeof btoa === "function") return btoa(encodeURIComponent(text).replace(/%([0-9A-F]{2})/g, (_, h) => String.fromCharCode(Number.parseInt(h, 16))));
203
+ throw new Error("encodeTextForWire: no base64 encoder available (Buffer and btoa both undefined)");
204
+ }
205
+ /**
206
+ * Convert a caller-supplied prompt (string or parts array) into the
207
+ * wire-format parts array. Every text part is base64-encoded; non-text
208
+ * parts pass through. A bare string is wrapped in a single text part.
209
+ */
210
+ function encodePromptForWire(message) {
211
+ return (typeof message === "string" ? [{
212
+ type: "text",
213
+ text: message
214
+ }] : message).map((part) => part.type === "text" ? {
215
+ type: "text",
216
+ text: encodeTextForWire(part.text)
217
+ } : part);
218
+ }
219
+ //#endregion
220
+ //#region src/trace-exporter.ts
221
+ function buildTraceExportPayload(bundle, format = "tangle", serviceName = "tangle-sandbox") {
222
+ if (format === "tangle") return bundle;
223
+ if (format === "otel-json") return toOtelJson(bundle, serviceName);
224
+ throw new Error(`Unsupported trace export format: ${String(format)}`);
225
+ }
226
+ async function exportTraceBundle(bundle, sink) {
227
+ if (!sink.url) throw new Error("Trace export requires sink.url");
228
+ const fetchImpl = sink.fetch ?? globalThis.fetch;
229
+ if (!fetchImpl) throw new Error("Trace export requires fetch");
230
+ const controller = new AbortController();
231
+ const timeout = setTimeout(() => controller.abort(), sink.timeoutMs ?? 3e4);
232
+ try {
233
+ const response = await fetchImpl(sink.url, {
234
+ method: "POST",
235
+ headers: {
236
+ "content-type": "application/json",
237
+ ...sink.headers
238
+ },
239
+ body: JSON.stringify(buildTraceExportPayload(bundle, sink.format ?? "tangle", sink.serviceName)),
240
+ signal: controller.signal
241
+ });
242
+ const body = await response.text();
243
+ if (!response.ok) throw new Error(`Trace export failed with ${response.status}: ${body.slice(0, 500)}`);
244
+ return {
245
+ status: response.status,
246
+ ok: true,
247
+ body
248
+ };
249
+ } finally {
250
+ clearTimeout(timeout);
251
+ }
252
+ }
253
+ function toOtelJson(bundle, serviceName = "tangle-sandbox") {
254
+ const traceId = hexId(bundle.trace.traceId, 32);
255
+ const rootSpanId = hexId(`${bundle.trace.traceId}:root`, 16);
256
+ const rootStart = unixNano(rootTimestamp(bundle));
257
+ const rootEnd = rootStart + BigInt(Math.round(traceDurationMs(bundle.trace) * 1e6));
258
+ const rootSpan = {
259
+ traceId,
260
+ spanId: rootSpanId,
261
+ name: traceRootName(bundle),
262
+ kind: 1,
263
+ startTimeUnixNano: rootStart.toString(),
264
+ endTimeUnixNano: rootEnd.toString(),
265
+ attributes: [
266
+ attr("tangle.subject.id", traceSubjectId(bundle)),
267
+ attr("tangle.trace.id", bundle.trace.traceId),
268
+ attr("tangle.trace.schema_version", bundle.trace.schemaVersion),
269
+ ...Object.entries(bundle.trace.timings ?? {}).map(([key, value]) => attr(`tangle.timing.${snakeCase(key)}`, value)),
270
+ ...Object.entries(bundle.trace.criticalPath ?? {}).map(([key, value]) => attr(`tangle.critical_path.${snakeCase(key)}`, value))
271
+ ],
272
+ ...traceHasError(bundle) ? otelErrorStatus("Trace contains failure evidence") : {}
273
+ };
274
+ return { resourceSpans: [{
275
+ resource: { attributes: [
276
+ attr("service.name", serviceName),
277
+ attr("tangle.subject.id", traceSubjectId(bundle)),
278
+ attr("tangle.trace.schema_version", bundle.trace.schemaVersion),
279
+ ...bundle.intelligence ? [
280
+ attr("tangle.intelligence.schema_version", bundle.intelligence.schemaVersion),
281
+ attr("tangle.intelligence.billable", false),
282
+ attr("tangle.intelligence.cost_usd", 0)
283
+ ] : []
284
+ ] },
285
+ scopeSpans: [{
286
+ scope: {
287
+ name: "@tangle-network/sandbox",
288
+ version: "trace.v1"
289
+ },
290
+ spans: [rootSpan, ...bundle.trace.events.map((event, index) => {
291
+ const start = unixNano(event.timestamp);
292
+ const durationMs = Math.max(0, event.durationMs ?? 0);
293
+ const end = start + BigInt(Math.round(durationMs * 1e6));
294
+ return {
295
+ traceId,
296
+ spanId: hexId(`${bundle.trace.traceId}:${index}`, 16),
297
+ parentSpanId: rootSpanId,
298
+ name: event.type,
299
+ kind: 1,
300
+ startTimeUnixNano: start.toString(),
301
+ endTimeUnixNano: end.toString(),
302
+ attributes: [
303
+ attr("tangle.subject.id", eventSubjectId(event)),
304
+ ..."machineId" in event && event.machineId ? [attr("tangle.fleet.machine_id", event.machineId)] : [],
305
+ ...Object.entries(event.attributes).map(([key, value]) => attr(key, value))
306
+ ],
307
+ ...eventHasError(event) ? otelErrorStatus("Event contains failure evidence") : {}
308
+ };
309
+ })]
310
+ }]
311
+ }] };
312
+ }
313
+ function otelTraceIdForTangleTrace(traceId) {
314
+ return hexId(traceId, 32);
315
+ }
316
+ function traceSubjectId(bundle) {
317
+ return "fleetId" in bundle.trace ? bundle.trace.fleetId : bundle.trace.sandboxId;
318
+ }
319
+ function eventSubjectId(event) {
320
+ return "fleetId" in event ? event.fleetId : event.sandboxId;
321
+ }
322
+ function attr(key, value) {
323
+ if (typeof value === "boolean") return {
324
+ key,
325
+ value: { boolValue: value }
326
+ };
327
+ if (typeof value === "number" && Number.isFinite(value)) return Number.isInteger(value) ? {
328
+ key,
329
+ value: { intValue: String(value) }
330
+ } : {
331
+ key,
332
+ value: { doubleValue: value }
333
+ };
334
+ if (typeof value === "string") return {
335
+ key,
336
+ value: { stringValue: value }
337
+ };
338
+ if (Array.isArray(value)) return {
339
+ key,
340
+ value: { arrayValue: { values: value.map((item) => attrValue(item)) } }
341
+ };
342
+ if (value && typeof value === "object") return {
343
+ key,
344
+ value: { kvlistValue: { values: Object.entries(value).map(([entryKey, entryValue]) => ({
345
+ key: entryKey,
346
+ value: attrValue(entryValue)
347
+ })) } }
348
+ };
349
+ return {
350
+ key,
351
+ value: { stringValue: JSON.stringify(value ?? null) }
352
+ };
353
+ }
354
+ function attrValue(value) {
355
+ if (typeof value === "boolean") return { boolValue: value };
356
+ if (typeof value === "number" && Number.isFinite(value)) return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
357
+ if (typeof value === "string") return { stringValue: value };
358
+ if (Array.isArray(value)) return { arrayValue: { values: value.map(attrValue) } };
359
+ if (value && typeof value === "object") return { kvlistValue: { values: Object.entries(value).map(([key, item]) => ({
360
+ key,
361
+ value: attrValue(item)
362
+ })) } };
363
+ return { stringValue: JSON.stringify(value ?? null) };
364
+ }
365
+ function unixNano(timestamp) {
366
+ const millis = Date.parse(timestamp);
367
+ return BigInt(Number.isFinite(millis) ? millis : 0) * 1000000n;
368
+ }
369
+ function hexId(input, length) {
370
+ const bytes = new TextEncoder().encode(input);
371
+ const hashes = [
372
+ 2166136261,
373
+ 2654435769,
374
+ 2246822507,
375
+ 3266489909
376
+ ];
377
+ for (const byte of bytes) for (let lane = 0; lane < hashes.length; lane += 1) {
378
+ const hash = hashes[lane];
379
+ if (hash === void 0) throw new Error(`Missing trace hash lane ${lane}`);
380
+ hashes[lane] = Math.imul(hash ^ byte + lane, 16777619);
381
+ }
382
+ let out = hashes.map((hash) => (hash >>> 0).toString(16).padStart(8, "0")).join("");
383
+ while (out.length < length) {
384
+ const next = hexId(`${input}:${out.length}`, 32);
385
+ out += next;
386
+ }
387
+ return out.slice(0, length);
388
+ }
389
+ function traceRootName(bundle) {
390
+ return "fleetId" in bundle.trace ? "tangle.fleet.trace" : "tangle.sandbox.trace";
391
+ }
392
+ function rootTimestamp(bundle) {
393
+ return bundle.trace.events[0]?.timestamp ?? bundle.trace.exportedAt;
394
+ }
395
+ function traceDurationMs(trace) {
396
+ return Math.max(0, trace.criticalPath?.durationMs ?? 0);
397
+ }
398
+ function traceHasError(bundle) {
399
+ return bundle.trace.events.some(eventHasError);
400
+ }
401
+ function eventHasError(event) {
402
+ const status = event.attributes.status;
403
+ const error = event.attributes.error;
404
+ return event.type.includes("fail") || status === "failed" || status === "error" || Boolean(error);
405
+ }
406
+ function otelErrorStatus(message) {
407
+ return { status: {
408
+ code: 2,
409
+ message
410
+ } };
411
+ }
412
+ function snakeCase(value) {
413
+ return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
414
+ }
415
+ //#endregion
416
+ //#region src/normalize.ts
417
+ /**
418
+ * Normalize a raw `connection` payload from the Sandbox API into the SDK's
419
+ * canonical `SandboxConnection` shape.
420
+ *
421
+ * The current server returns the runtime URL as `connection.sidecarUrl`;
422
+ * the SDK exposes it as `connection.runtimeUrl`. This helper reads either
423
+ * field name so a future server-side rename needs no client change.
424
+ *
425
+ * Returns `undefined` when the raw payload has no usable runtime URL, so
426
+ * callers using `normalizeConnection(data.connection) ?? fallback` can
427
+ * preserve a previously-known connection during transient server states
428
+ * (e.g. a partial payload that only carries an auth token). Without this
429
+ * guard, a partial `{ authToken: "..." }` response would overwrite a
430
+ * previously-valid connection with an object missing its URL.
431
+ */
432
+ function normalizeConnection(raw) {
433
+ if (!raw || typeof raw !== "object") return void 0;
434
+ const c = raw;
435
+ const runtimeUrl = c.runtimeUrl ?? c.sidecarUrl;
436
+ const edgeStatus = normalizeEdgeStatus(c.edgeStatus);
437
+ const ssh = normalizeSshCredentials(c.ssh);
438
+ const webTerminalUrl = c.webTerminalUrl;
439
+ if (!runtimeUrl && !edgeStatus && !ssh && !webTerminalUrl) return void 0;
440
+ return {
441
+ runtimeUrl,
442
+ authToken: c.authToken,
443
+ authTokenExpiresAt: c.authTokenExpiresAt,
444
+ edgeStatus,
445
+ edgeReadyAt: c.edgeReadyAt,
446
+ edgeError: c.edgeError,
447
+ ssh,
448
+ webTerminalUrl
449
+ };
450
+ }
451
+ function normalizeEdgeStatus(raw) {
452
+ return raw === "pending" || raw === "ready" || raw === "failed" || raw === "skipped" ? raw : void 0;
453
+ }
454
+ /**
455
+ * Narrow a raw `ssh` payload into a `SSHCredentials` object. Returns
456
+ * `undefined` if the payload is missing required fields so callers that
457
+ * dereference `connection.ssh.proxyCommand` don't surprise-crash on partial
458
+ * server responses.
459
+ */
460
+ function normalizeSshCredentials(raw) {
461
+ if (!raw || typeof raw !== "object") return void 0;
462
+ const s = raw;
463
+ if (typeof s.port !== "number" || typeof s.username !== "string" || typeof s.proxyCommand !== "string" || s.proxyCommand.length === 0) return;
464
+ return {
465
+ username: s.username,
466
+ port: s.port,
467
+ proxyCommand: s.proxyCommand
468
+ };
469
+ }
470
+ //#endregion
471
+ //#region src/lib/agent-response-text.ts
472
+ /**
473
+ * Canonical extraction of an agent's response text from its sandbox event
474
+ * stream. This is the single source of truth used by both `SandboxInstance.prompt`
475
+ * (streaming accumulation) and any consumer that collects the raw event array
476
+ * first (loop kernels, workflow runners) and needs the final text.
477
+ *
478
+ * The runtime emits agent text across several event shapes — a terminal
479
+ * `result` event, incremental `token` events, and cumulative
480
+ * `message.part.updated` text parts — each carrying the text under a different
481
+ * key. Reading a single hard-coded field (e.g. `data.text`) misses the real
482
+ * payload entirely, so the helpers below understand every shape the runtime
483
+ * actually produces.
484
+ */
485
+ /**
486
+ * The text carried by a single event, or `undefined` when the event carries no
487
+ * text payload. Mirrors the event shapes the sidecar/OpenCode runtime emits:
488
+ * - `result` — the terminal event; final text under `finalText`/`response`/
489
+ * `output`/`content`/`text`.
490
+ * - `token` — an incremental token; text under `value`/`delta`/`text`/
491
+ * `content`.
492
+ * - `message.part.updated` — a streamed assistant message part; the text part
493
+ * lives at `data.part.text` (or `.content`) when
494
+ * `part.type === "text"`.
495
+ */
496
+ function isRecord(v) {
497
+ return typeof v === "object" && v !== null;
498
+ }
499
+ /** The first candidate that is actually a string. Using `typeof` rather than a
500
+ * `??` chain means a non-string in an earlier field (a malformed `finalText: 0`)
501
+ * never shadows a valid string in a later fallback, and the `string | undefined`
502
+ * return contract stays honest for callers. */
503
+ function firstString(...candidates) {
504
+ for (const candidate of candidates) if (typeof candidate === "string") return candidate;
505
+ }
506
+ function getSandboxEventText(event) {
507
+ const data = isRecord(event.data) ? event.data : {};
508
+ if (event.type === "result") return firstString(data.finalText, data.response, data.output, data.content, data.text);
509
+ if (event.type === "token") return firstString(data.value, data.delta, data.text, data.content);
510
+ if (event.type !== "message.part.updated") return;
511
+ const part = isRecord(data.part) ? data.part : void 0;
512
+ if (part?.type !== "text") return;
513
+ return firstString(part.text, part.content);
514
+ }
515
+ /** As {@link getSandboxEventText}, but treats a whitespace-only payload as no
516
+ * text — so blank parts never overwrite real accumulated text. */
517
+ function getMeaningfulSandboxEventText(event) {
518
+ const text = getSandboxEventText(event);
519
+ if (typeof text !== "string" || text.trim().length === 0) return;
520
+ return text;
521
+ }
522
+ /**
523
+ * Fold one event into the running response text. `token` events APPEND their
524
+ * incremental delta/value; every other text-bearing event (`result`,
525
+ * `message.part.updated`) carries the cumulative text, so it REPLACES — unless
526
+ * it is a strict continuation of what we already have, in which case it is
527
+ * appended once. An event with no meaningful text leaves `current` untouched.
528
+ *
529
+ * Whitespace handling differs by kind: an incremental `token` keeps its RAW text
530
+ * (a lone space delta is a word separator — dropping it concatenates words), but
531
+ * a cumulative event uses the meaningful text so a blank payload never overwrites
532
+ * real accumulated text.
533
+ */
534
+ function applySandboxEventText(current, event) {
535
+ const text = event.type === "token" ? getSandboxEventText(event) : getMeaningfulSandboxEventText(event);
536
+ if (text === void 0) return current;
537
+ if (event.type !== "token") return text;
538
+ const delta = event.data.delta;
539
+ if (typeof delta === "string") return `${current ?? ""}${delta}`;
540
+ const value = event.data.value;
541
+ if (typeof value === "string") {
542
+ if (!current || value.startsWith(current)) return value;
543
+ return `${current}${value}`;
544
+ }
545
+ if (!current) return text;
546
+ if (text.startsWith(current)) return text;
547
+ return `${current}${text}`;
548
+ }
549
+ /**
550
+ * Collect an agent's complete response text from an already-buffered event
551
+ * array. Equivalent to the streaming accumulation in `SandboxInstance.prompt`,
552
+ * exposed as a pure function so consumers that collect the raw stream first
553
+ * (e.g. the agent-runtime loop kernel) decode the SAME way the SDK does.
554
+ *
555
+ * Returns `undefined` when the stream carried no meaningful text — the honest
556
+ * "agent produced nothing extractable" signal, distinct from an empty string.
557
+ * Whitespace is preserved DURING accumulation (a lone space token separates
558
+ * words), but a result that is entirely whitespace is "no meaningful text", so
559
+ * the final value is collapsed to `undefined` — a whitespace-only token stream
560
+ * must not read as a truthy response.
561
+ */
562
+ function collectAgentResponseText(events) {
563
+ let responseText;
564
+ for (const event of events) {
565
+ responseText = applySandboxEventText(responseText, event);
566
+ if (event.type === "result" || event.type === "done") break;
567
+ }
568
+ return responseText && responseText.trim().length > 0 ? responseText : void 0;
569
+ }
570
+ //#endregion
571
+ //#region src/interactive.ts
572
+ /**
573
+ * Handle for one session's interactive harness. Obtained via
574
+ * `box.session(id).interactive()`; does not hit the network until a method is
575
+ * called.
576
+ */
577
+ var InteractiveSessionHandle = class {
578
+ constructor(host, sessionId) {
579
+ this.host = host;
580
+ this.sessionId = sessionId;
581
+ }
582
+ /**
583
+ * Spawn the harness's interactive TUI for this session. Returns the
584
+ * `streamUrl` to attach a terminal client to. Throws if the harness has no
585
+ * interactive entrypoint (e.g. opencode), the binary is not installed, or a
586
+ * session is already running for this id.
587
+ */
588
+ start(options) {
589
+ return this.host._startInteractive(this.sessionId, options);
590
+ }
591
+ /** Inject a prompt as keystrokes into the live harness (submitted on send). */
592
+ sendPrompt(prompt) {
593
+ return this.host._sendInteractivePrompt(this.sessionId, prompt);
594
+ }
595
+ /** Stop the interactive harness and reap its PTY. */
596
+ stop() {
597
+ return this.host._stopInteractive(this.sessionId);
598
+ }
599
+ };
600
+ //#endregion
601
+ //#region src/session.ts
602
+ /**
603
+ * SandboxSession — first-class noun for an agent session inside a sandbox.
604
+ *
605
+ * Closes Issue #913 Gap 3: today `sessionId` is a string passed into
606
+ * `prompt()` for resume; there is no way to inspect a session, list
607
+ * active sessions, or query state without re-running the prompt. Apps
608
+ * that fan out parallel research workers (the agent-builder Forge use
609
+ * case) end up shadowing all session state in their own database. This
610
+ * class makes the sandbox the source of truth — apps reduce their own
611
+ * tables to thin denormalizations for cross-sandbox queries.
612
+ *
613
+ * The class is a thin façade over the sidecar's `/agents/sessions/{id}`
614
+ * routes: `status` reads `GET /agents/sessions/{id}`, `events` opens an
615
+ * SSE stream against `/agents/events?sessionId=...` (with `since` for
616
+ * replay), `prompt` continues the session via `/agents/run/stream`,
617
+ * `cancel` calls `/agents/sessions/{id}/abort`. No new sidecar surface
618
+ * is required — every endpoint already exists.
619
+ *
620
+ * Construction is private to the SDK: get a Session from
621
+ * `box.session(id)` (lazy reference, no network) or `box.sessions(...)`
622
+ * (lister, single round-trip).
623
+ */
624
+ /**
625
+ * A single agent session inside a sandbox. Created via
626
+ * `box.session(id)` — does not hit the network until a method is called.
627
+ */
628
+ var SandboxSession = class {
629
+ /**
630
+ * @internal SDK-internal constructor — apps should call `box.session(id)`.
631
+ */
632
+ constructor(box, id) {
633
+ this.box = box;
634
+ this.id = id;
635
+ }
636
+ /**
637
+ * Fetch the current session state from the sandbox. Includes status,
638
+ * model, prompt count, token usage if known, and timing metadata.
639
+ *
640
+ * Throws on transport error; returns `null` if the session id is not
641
+ * known to the sandbox (e.g. it ended and was reaped, or the id is
642
+ * invalid).
643
+ */
644
+ async status() {
645
+ return this.box._sessionStatus(this.id);
646
+ }
647
+ /**
648
+ * Stream events from this session as they arrive. With no `since`,
649
+ * starts at the live tail; with `since`, replays from that event id
650
+ * forward — useful for reconnect-after-disconnect flows.
651
+ *
652
+ * The async iterator terminates when the session reaches a terminal
653
+ * state (`completed`, `failed`, `cancelled`) and the corresponding
654
+ * terminal event has been yielded, OR when the caller's signal aborts.
655
+ */
656
+ events(opts) {
657
+ return this.box._sessionEvents(this.id, opts);
658
+ }
659
+ /**
660
+ * Await the session's terminal result. Polls status + drains events
661
+ * until the session reaches a terminal state, then returns the
662
+ * aggregated `PromptResult`.
663
+ *
664
+ * Use this to wait for a session that was started by another caller
665
+ * (e.g. `dispatchPrompt`).
666
+ */
667
+ async result() {
668
+ return this.box._sessionResult(this.id);
669
+ }
670
+ /**
671
+ * Continue this session with an additional prompt. Equivalent to
672
+ * `box.prompt(message, { ...opts, sessionId: this.id })` but reads
673
+ * naturally on a Session reference.
674
+ */
675
+ async prompt(message, opts) {
676
+ return this.box.prompt(message, {
677
+ ...opts,
678
+ sessionId: this.id
679
+ });
680
+ }
681
+ /**
682
+ * Abort the session's in-flight execution; the session and its messages
683
+ * are preserved (this does not delete the session). Void-returning alias
684
+ * of `interrupt()` — use `interrupt()` when you need to know whether an
685
+ * execution was actually running. Best-effort: an in-flight LLM call may
686
+ * still complete one more token before the abort takes effect. Idempotent —
687
+ * aborting a session with nothing in flight is a no-op.
688
+ */
689
+ async cancel() {
690
+ return this.box._sessionCancel(this.id);
691
+ }
692
+ /**
693
+ * Drive this session's harness in INTERACTIVE mode: spawn its native TUI,
694
+ * stream the live framebuffer, and inject prompts as keystrokes. Distinct
695
+ * from the headless `prompt()`/`events()` surface above. Lazy — does not hit
696
+ * the network until a handle method is called.
697
+ */
698
+ interactive() {
699
+ return new InteractiveSessionHandle(this.box, this.id);
700
+ }
701
+ /**
702
+ * Resolve a pending permission interaction the agent raised mid-turn,
703
+ * forwarding `allow`/`deny` to the blocked agent through the unified
704
+ * interaction channel. Throws if no matching permission is outstanding.
705
+ */
706
+ async respondToPermission(permissionID, options) {
707
+ return this.box._respondToPermission(this.id, permissionID, options);
708
+ }
709
+ /**
710
+ * Interrupt the session's current execution without deleting it. Returns
711
+ * `{ cancelled: false }` when nothing was running — the no-op is reported
712
+ * explicitly rather than masked as success.
713
+ */
714
+ async interrupt() {
715
+ return this.box._interrupt(this.id);
716
+ }
717
+ /**
718
+ * Answer a question the agent asked via a question tool invocation. `answers`
719
+ * maps each question id to the selected option(s). OpenCode-only; other
720
+ * backends reject loudly.
721
+ */
722
+ async answer(answers) {
723
+ return this.box._answerQuestion(this.id, answers);
724
+ }
725
+ };
726
+ //#endregion
727
+ //#region src/sandbox.ts
728
+ /**
729
+ * Sandbox Instance
730
+ *
731
+ * Represents a single sandbox and provides methods to interact with it.
732
+ */
733
+ function normalizeProvisionStep(raw) {
734
+ if (raw === "sidecar-ready") return "runtime-ready";
735
+ return raw;
736
+ }
737
+ /**
738
+ * Normalize the raw `startupDiagnostics` field from a sandbox API
739
+ * response into a {@link StartupDiagnostics}, deriving the
740
+ * operation-name → duration `phases` map. Returns `null` when the
741
+ * payload is absent or carries no operations — diagnostics are emitted
742
+ * only once, on the create response, so most reads have no payload to
743
+ * normalize.
744
+ */
745
+ function normalizeStartupDiagnostics(raw) {
746
+ if (!raw || typeof raw !== "object") return null;
747
+ const rawOperations = raw.operations;
748
+ if (!Array.isArray(rawOperations) || rawOperations.length === 0) return null;
749
+ const operations = [];
750
+ const phases = {};
751
+ for (const entry of rawOperations) {
752
+ if (!entry || typeof entry !== "object") continue;
753
+ const record = entry;
754
+ const operation = record.operation;
755
+ if (typeof operation !== "string") continue;
756
+ const durationMs = typeof record.durationMs === "number" ? record.durationMs : void 0;
757
+ operations.push({
758
+ operation,
759
+ service: typeof record.service === "string" ? record.service : void 0,
760
+ status: typeof record.status === "string" ? record.status : void 0,
761
+ durationMs,
762
+ metadata: record.metadata && typeof record.metadata === "object" ? record.metadata : void 0
763
+ });
764
+ if (durationMs !== void 0) phases[operation] = durationMs;
765
+ }
766
+ if (operations.length === 0) return null;
767
+ return {
768
+ operations,
769
+ phases
770
+ };
771
+ }
772
+ const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
773
+ function assertValidSessionId(value) {
774
+ if (!SESSION_ID_PATTERN.test(value)) throw new ValidationError(`Invalid sessionId ${JSON.stringify(value)}: must match ${SESSION_ID_PATTERN.source}`);
775
+ }
776
+ /**
777
+ * Replace bearer-secret fields on a `SandboxConnection` with a sentinel
778
+ * before serialization. The serialized shape is preserved (so external
779
+ * tooling that switches on `connection.authToken !== undefined` keeps
780
+ * working), but the actual token value never leaves the process.
781
+ */
782
+ function redactConnectionSecrets(connection) {
783
+ if (!connection) return connection;
784
+ if (connection.authToken === void 0) return connection;
785
+ return {
786
+ ...connection,
787
+ authToken: "[REDACTED]"
788
+ };
789
+ }
790
+ function normalizePreviewLink(raw) {
791
+ const sandboxId = raw.sandboxId ?? raw.sidecarId ?? "";
792
+ return {
793
+ previewId: raw.previewId,
794
+ sandboxId,
795
+ port: raw.port,
796
+ protocol: raw.protocol,
797
+ hostname: raw.hostname,
798
+ url: raw.url,
799
+ status: raw.status,
800
+ lastSyncAt: raw.lastSyncAt,
801
+ createdAt: raw.createdAt,
802
+ updatedAt: raw.updatedAt,
803
+ metadata: raw.metadata
804
+ };
805
+ }
806
+ const SNAPSHOT_RESTORE_POLL_INTERVAL_MS = 1e3;
807
+ const SNAPSHOT_RESTORE_TIMEOUT_MS = 600 * 1e3;
808
+ const SNAPSHOT_VISIBILITY_POLL_INTERVAL_MS = 1e3;
809
+ const SNAPSHOT_VISIBILITY_TIMEOUT_MS = 120 * 1e3;
810
+ const SNAPSHOT_CREATE_REQUEST_TIMEOUT_MS = 600 * 1e3;
811
+ function toSnapshotResult(data) {
812
+ return {
813
+ snapshotId: data.snapshotId ?? data.id,
814
+ createdAt: new Date(data.createdAt),
815
+ sizeBytes: data.sizeBytes,
816
+ tags: data.tags ?? []
817
+ };
818
+ }
819
+ function isTransientSnapshotVisibilityError(error) {
820
+ const message = error instanceof Error ? error.message : String(error);
821
+ return message.includes("SNAPSHOT_NOT_FOUND") || message.includes("Failed to list snapshot") || message.includes("Storage agent error (404)");
822
+ }
823
+ /**
824
+ * Single source of truth for a run's outcome, shared by
825
+ * `prompt`/`task`/`_sessionResult`/turn settling. A run succeeds only when it
826
+ * reached a terminal event, raised no run-level error, hit no approval gate,
827
+ * left no unanswered question, and recorded no failed (`isError`) tool. A
828
+ * tool-only turn with no text succeeds; a turn whose tool failed (even if
829
+ * narrated around) fails.
830
+ */
831
+ function deriveOutcome(inputs) {
832
+ if (inputs.question) return {
833
+ status: "awaiting_question",
834
+ success: false,
835
+ error: inputs.runError ?? "Agent is awaiting an answer to a question"
836
+ };
837
+ if (inputs.approval) return {
838
+ status: "blocked_on_approval",
839
+ success: false,
840
+ error: inputs.runError ?? inputs.approval.message ?? "Agent is blocked on a hub approval"
841
+ };
842
+ if (inputs.runError) return {
843
+ status: "failed",
844
+ success: false,
845
+ error: inputs.runError
846
+ };
847
+ const failedTool = inputs.toolInvocations?.find((t) => t.isError === true);
848
+ if (failedTool) return {
849
+ status: "failed",
850
+ success: false,
851
+ error: `Tool "${failedTool.toolName}" failed`
852
+ };
853
+ if (!inputs.terminalReached) return {
854
+ status: "failed",
855
+ success: false,
856
+ error: "Agent stream ended without a terminal event"
857
+ };
858
+ return {
859
+ status: "success",
860
+ success: true
861
+ };
862
+ }
863
+ /** Read tool invocations off a `result` event payload. */
864
+ function readToolInvocations(data) {
865
+ const raw = data.toolInvocations;
866
+ return Array.isArray(raw) ? raw : void 0;
867
+ }
868
+ /** Hub error code surfaced when a write tool hits an approval gate. */
869
+ const HUB_APPROVAL_CODE = "HUB_APPROVAL_REQUIRED";
870
+ /** Best-effort string view of an arbitrary tool result for signal matching. */
871
+ function stringifyToolResult(result) {
872
+ if (typeof result === "string") return result;
873
+ try {
874
+ return JSON.stringify(result) ?? "";
875
+ } catch {
876
+ return String(result);
877
+ }
878
+ }
879
+ /**
880
+ * Walk a tool result for an `approval` object and return its `id`/`connectionId`.
881
+ * The in-sandbox hub bridge carries these in `structuredContent.details.approval`,
882
+ * but the exact nesting depends on how the backend serialized the MCP result, so
883
+ * search defensively. Returns `undefined` when not present — the id is then
884
+ * surfaced honestly as `undefined`, never invented.
885
+ */
886
+ function findApprovalDetails(value, depth = 0) {
887
+ if (depth > 6 || value === null || typeof value !== "object") return void 0;
888
+ const obj = value;
889
+ const approval = obj.approval;
890
+ if (approval && typeof approval === "object") {
891
+ const a = approval;
892
+ return {
893
+ ...typeof a.id === "string" ? { id: a.id } : {},
894
+ ...typeof a.connectionId === "string" ? { connectionId: a.connectionId } : {}
895
+ };
896
+ }
897
+ for (const v of Object.values(obj)) {
898
+ const found = findApprovalDetails(v, depth + 1);
899
+ if (found) return found;
900
+ }
901
+ }
902
+ /** Find a human-readable `message` string anywhere in a tool result. */
903
+ function findResultMessage(value, depth = 0) {
904
+ if (depth > 6 || value === null || typeof value !== "object") return void 0;
905
+ const obj = value;
906
+ if (typeof obj.message === "string" && obj.message.length > 0) return obj.message;
907
+ for (const v of Object.values(obj)) {
908
+ const found = findResultMessage(v, depth + 1);
909
+ if (found) return found;
910
+ }
911
+ }
912
+ /**
913
+ * Detect a hub approval gate among a run's tool invocations: a failed tool whose
914
+ * result carries `HUB_APPROVAL_REQUIRED`. Returns the approval id/connectionId
915
+ * when the structured signal survived serialization, else just the message — so
916
+ * the run can settle as `blocked_on_approval` even when the id is unrecoverable.
917
+ */
918
+ function detectHubApproval(toolInvocations) {
919
+ if (!toolInvocations) return void 0;
920
+ for (const tool of toolInvocations) {
921
+ if (tool.isError !== true) continue;
922
+ if (!stringifyToolResult(tool.result).includes(HUB_APPROVAL_CODE)) continue;
923
+ const approval = findApprovalDetails(tool.result);
924
+ const message = findResultMessage(tool.result) ?? "Hub action requires approval";
925
+ return {
926
+ ...approval?.id ? { approvalId: approval.id } : {},
927
+ ...approval?.connectionId ? { connectionId: approval.connectionId } : {},
928
+ message
929
+ };
930
+ }
931
+ }
932
+ /** Read an `interaction` event (kind `question`) into an awaiting-question requirement. */
933
+ function readQuestionEvent(data) {
934
+ const request = data.request;
935
+ if (!request || request.kind !== "question" || typeof request.id !== "string") return;
936
+ return {
937
+ questionId: request.id,
938
+ questions: questionsFromAnswerSpec(request)
939
+ };
940
+ }
941
+ /** Reconstruct the prompt/options shape from a question interaction's answerSpec. */
942
+ function questionsFromAnswerSpec(request) {
943
+ return request.answerSpec.fields.map((field) => field.type === "select" ? {
944
+ question: field.label,
945
+ options: field.options.map((o) => ({
946
+ label: o.label,
947
+ description: o.description
948
+ })),
949
+ multiSelect: field.multi === true
950
+ } : { question: field.label });
951
+ }
952
+ /**
953
+ * Convert a thrown error into an in-band `error` SandboxEvent so a streaming
954
+ * generator can surface a failure as an event followed by a terminal `done`
955
+ * instead of throwing mid-iteration (which blanks the stream). Structured
956
+ * `SandboxError` fields (code/status/origin/endpoint/retryAfterMs) are carried
957
+ * through so consumers can inspect the failure without re-parsing the message.
958
+ */
959
+ function toStreamErrorEvent(err) {
960
+ if (err instanceof SandboxError) return {
961
+ type: "error",
962
+ data: {
963
+ message: err.message,
964
+ code: err.code,
965
+ name: err.name,
966
+ ...err.status !== void 0 ? { status: err.status } : {},
967
+ ...err.origin ? { origin: err.origin } : {},
968
+ ...err.endpoint ? { endpoint: err.endpoint } : {},
969
+ ...err.retryAfterMs !== void 0 ? { retryAfterMs: err.retryAfterMs } : {}
970
+ }
971
+ };
972
+ return {
973
+ type: "error",
974
+ data: {
975
+ message: err instanceof Error ? err.message : String(err),
976
+ name: err instanceof Error ? err.name : "Error"
977
+ }
978
+ };
979
+ }
980
+ const WRITE_MANY_DEFAULT_PACE_MS = 150;
981
+ const WRITE_MANY_DEFAULT_MAX_RETRIES = 4;
982
+ const WRITE_MANY_RETRY_BASE_MS = 250;
983
+ const WRITE_MANY_RETRY_MAX_MS = 2e3;
984
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
985
+ /** Transient file-write failures worth retrying: rate-limit (quota), 5xx,
986
+ * transport (network), and request timeouts. A non-transient error (bad path,
987
+ * auth, validation) fails loud immediately. */
988
+ function isTransientWriteError(err) {
989
+ return err instanceof QuotaError || err instanceof ServerError || err instanceof NetworkError || err instanceof TimeoutError;
990
+ }
991
+ /**
992
+ * A sandbox instance with methods for interaction.
993
+ */
994
+ var SandboxInstance = class SandboxInstance {
995
+ client;
996
+ info;
997
+ defaultRuntimeBackend;
998
+ constructor(client, info, defaultRuntimeBackend) {
999
+ this.client = client;
1000
+ this.info = info;
1001
+ this.defaultRuntimeBackend = defaultRuntimeBackend;
1002
+ }
1003
+ /** Unique sandbox identifier */
1004
+ get id() {
1005
+ return this.info.id;
1006
+ }
1007
+ /** Human-readable name */
1008
+ get name() {
1009
+ return this.info.name;
1010
+ }
1011
+ /** Current status */
1012
+ get status() {
1013
+ return this.info.status;
1014
+ }
1015
+ /** Connection information */
1016
+ get connection() {
1017
+ return this.info.connection;
1018
+ }
1019
+ /** Custom metadata */
1020
+ get metadata() {
1021
+ return this.info.metadata;
1022
+ }
1023
+ /** When the sandbox was created */
1024
+ get createdAt() {
1025
+ return this.info.createdAt;
1026
+ }
1027
+ /** When the sandbox started running */
1028
+ get startedAt() {
1029
+ return this.info.startedAt;
1030
+ }
1031
+ /** Last activity timestamp */
1032
+ get lastActivityAt() {
1033
+ return this.info.lastActivityAt;
1034
+ }
1035
+ /** When the sandbox will expire */
1036
+ get expiresAt() {
1037
+ return this.info.expiresAt;
1038
+ }
1039
+ /** Error message if status is 'failed' */
1040
+ get error() {
1041
+ return this.info.error;
1042
+ }
1043
+ /** Web terminal URL for browser-based access */
1044
+ get url() {
1045
+ return this.info.connection?.webTerminalUrl;
1046
+ }
1047
+ /**
1048
+ * The 12-phase startup breakdown (storage_provision, host_select, edge_bind,
1049
+ * edge_ready, egress_proxy, docker_pull/create/start, sidecar_boot,
1050
+ * health_check, …) the platform emitted when this sandbox was provisioned.
1051
+ *
1052
+ * Present only on a freshly-created box; `null` for a box resolved by id or
1053
+ * when the runtime did not report diagnostics. Use `.phases` for the
1054
+ * operation-name → durationMs map.
1055
+ *
1056
+ * @example
1057
+ * ```typescript
1058
+ * const d = box.startupDiagnostics();
1059
+ * if (d) console.log(`provision phases: ${JSON.stringify(d.phases)}`);
1060
+ * ```
1061
+ */
1062
+ startupDiagnostics() {
1063
+ return this.info.startupDiagnostics ?? null;
1064
+ }
1065
+ /**
1066
+ * Serialize to the public sandbox shape for logs and structured
1067
+ * output. Secrets in `connection` (currently `authToken`) are
1068
+ * redacted so that `JSON.stringify(box)` is safe to ship to log
1069
+ * sinks. Use {@link toDebugJSON} when the bearer is required (e.g.
1070
+ * one-off CLI commands that print credentials to the user).
1071
+ */
1072
+ toJSON() {
1073
+ return {
1074
+ id: this.id,
1075
+ name: this.name,
1076
+ status: this.status,
1077
+ connection: redactConnectionSecrets(this.connection),
1078
+ metadata: this.metadata,
1079
+ createdAt: this.createdAt,
1080
+ startedAt: this.startedAt,
1081
+ lastActivityAt: this.lastActivityAt,
1082
+ expiresAt: this.expiresAt,
1083
+ error: this.error
1084
+ };
1085
+ }
1086
+ /**
1087
+ * Serialize the sandbox **including secrets** when `includeSecrets`
1088
+ * is true. The default behavior matches {@link toJSON} and redacts
1089
+ * `connection.authToken`.
1090
+ *
1091
+ * Use only when the caller has an explicit need for the bearer
1092
+ * (e.g. presenting it once to the human operator). Never wire the
1093
+ * result of `toDebugJSON({ includeSecrets: true })` into a structured
1094
+ * logger — the bearer will land in any log sink consuming that output.
1095
+ */
1096
+ toDebugJSON(options = {}) {
1097
+ if (options.includeSecrets !== true) return this.toJSON();
1098
+ return {
1099
+ ...this.toJSON(),
1100
+ connection: this.connection
1101
+ };
1102
+ }
1103
+ /**
1104
+ * Create an advanced direct-runtime view of this sandbox.
1105
+ *
1106
+ * Runtime methods on the returned instance talk to the sandbox runtime
1107
+ * directly using `connection.runtimeUrl` and `connection.authToken`.
1108
+ * Lifecycle methods still go through the parent SDK client.
1109
+ */
1110
+ direct() {
1111
+ if (!this.connection?.runtimeUrl) throw new StateError("Sandbox has no direct runtime URL", this.status, "running");
1112
+ let directInstance;
1113
+ directInstance = new SandboxInstance(new DirectRuntimeHttpClient(this.client, this.id, () => directInstance.connection, async () => {
1114
+ await directInstance.refresh();
1115
+ const next = directInstance.connection?.authToken;
1116
+ const client = this.client;
1117
+ if (next && typeof client._emitTokenRefresh === "function") client._emitTokenRefresh(this.id, next);
1118
+ }), { ...this.info }, this.defaultRuntimeBackend);
1119
+ return directInstance;
1120
+ }
1121
+ /**
1122
+ * Get an MCP endpoint for this sandbox. Returns a paste-able config
1123
+ * for any MCP-capable host (Claude Desktop, Cursor, claude-code,
1124
+ * codex, opencode, …) plus a freshly-minted, capability-scoped JWT.
1125
+ *
1126
+ * The token is short-lived and limited to the requested capabilities
1127
+ * — it cannot be used against admin endpoints (`/exec`, `/files`,
1128
+ * etc.) on the sandbox. Call `getMcpEndpoint()` again to rotate.
1129
+ *
1130
+ * Requires the sandbox to have been created with `capabilities`
1131
+ * including the requested capability (default: `computer_use`).
1132
+ *
1133
+ * @example
1134
+ * ```typescript
1135
+ * const ep = await box.getMcpEndpoint();
1136
+ * // Save ep.config to your IDE's mcp.json — that's it.
1137
+ * fs.writeFileSync("mcp.json", JSON.stringify(ep.config, null, 2));
1138
+ * ```
1139
+ */
1140
+ async getMcpEndpoint(options) {
1141
+ const params = new URLSearchParams();
1142
+ if (options?.capabilities && options.capabilities.length > 0) params.set("capabilities", options.capabilities.join(","));
1143
+ if (options?.serverName) params.set("serverName", options.serverName);
1144
+ if (options?.ttlMinutes) params.set("ttlMinutes", String(options.ttlMinutes));
1145
+ const qs = params.toString();
1146
+ const path = `/v1/sandboxes/${this.id}/mcp${qs ? `?${qs}` : ""}`;
1147
+ const response = await this.client.fetch(path);
1148
+ if (!response.ok) {
1149
+ const body = await response.text();
1150
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1151
+ }
1152
+ return await response.json();
1153
+ }
1154
+ /**
1155
+ * Refresh sandbox information from the server.
1156
+ */
1157
+ async refresh() {
1158
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}`);
1159
+ if (!response.ok) {
1160
+ const body = await response.text();
1161
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1162
+ }
1163
+ const data = await response.json();
1164
+ this.info = this.parseInfo(data);
1165
+ }
1166
+ /**
1167
+ * Fetch fresh TEE attestation evidence for this sandbox.
1168
+ *
1169
+ * When `attestationNonce` is supplied, the runtime must return evidence bound
1170
+ * to that challenge or fail closed if the selected TEE backend cannot support
1171
+ * nonce-bound report data.
1172
+ */
1173
+ async getTeeAttestation(options) {
1174
+ await this.ensureRunning();
1175
+ const hasNonce = Boolean(options?.attestationNonce);
1176
+ const response = await this.client.fetch(`/v1/sandboxes/${encodeURIComponent(this.id)}/tee/attestation`, {
1177
+ method: hasNonce ? "POST" : "GET",
1178
+ body: hasNonce ? JSON.stringify({ attestation_nonce: options?.attestationNonce }) : void 0
1179
+ });
1180
+ if (!response.ok) {
1181
+ const body = await response.text();
1182
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1183
+ }
1184
+ const data = await response.json();
1185
+ if (hasNonce) data.attestationNonce = options?.attestationNonce;
1186
+ this.info = {
1187
+ ...this.info,
1188
+ metadata: {
1189
+ ...this.info.metadata ?? {},
1190
+ teeAttestationJson: JSON.stringify(data.attestation),
1191
+ ...data.attestationNonce ? { attestationNonce: data.attestationNonce } : {}
1192
+ }
1193
+ };
1194
+ return data;
1195
+ }
1196
+ /**
1197
+ * Fetch the TEE-bound public key used for sealed-secret encryption.
1198
+ *
1199
+ * The returned key includes an attestation report. Verify that report before
1200
+ * encrypting secrets to the key.
1201
+ */
1202
+ async getTeePublicKey() {
1203
+ await this.ensureRunning();
1204
+ const response = await this.client.fetch(`/v1/sandboxes/${encodeURIComponent(this.id)}/tee/public-key`, { method: "GET" });
1205
+ if (!response.ok) {
1206
+ const body = await response.text();
1207
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1208
+ }
1209
+ const data = await response.json();
1210
+ this.info = {
1211
+ ...this.info,
1212
+ metadata: {
1213
+ ...this.info.metadata ?? {},
1214
+ teePublicKeyJson: JSON.stringify(data.public_key)
1215
+ }
1216
+ };
1217
+ return data;
1218
+ }
1219
+ /**
1220
+ * Bootstrap a real-time collaboration session for a file.
1221
+ * Returns the WebSocket URL and auth token needed to connect a
1222
+ * Hocuspocus/Yjs provider for live multi-user editing.
1223
+ *
1224
+ * @example
1225
+ * ```typescript
1226
+ * const collab = await box.collaborate("src/index.ts")
1227
+ * // Use collab.transport.websocketUrl + collab.transport.token
1228
+ * // with @hocuspocus/provider to connect
1229
+ * ```
1230
+ */
1231
+ async collaborate(path, options) {
1232
+ const response = await this.client.fetch("/v1/collaboration/bootstrap", {
1233
+ method: "POST",
1234
+ headers: { "Content-Type": "application/json" },
1235
+ body: JSON.stringify({
1236
+ sandboxId: this.id,
1237
+ path,
1238
+ access: options?.access ?? "write"
1239
+ })
1240
+ });
1241
+ if (!response.ok) {
1242
+ const body = await response.text();
1243
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1244
+ }
1245
+ return await response.json();
1246
+ }
1247
+ /**
1248
+ * Refresh a collaboration token for an existing document session.
1249
+ */
1250
+ /**
1251
+ * Refresh a collaboration token. Access level is preserved from the original
1252
+ * token — cannot be escalated (read stays read, write stays write).
1253
+ */
1254
+ async refreshCollaborationToken(documentId, currentToken) {
1255
+ const response = await this.client.fetch("/v1/collaboration/token", {
1256
+ method: "POST",
1257
+ headers: { "Content-Type": "application/json" },
1258
+ body: JSON.stringify({
1259
+ sandboxId: this.id,
1260
+ documentId,
1261
+ currentToken
1262
+ })
1263
+ });
1264
+ if (!response.ok) {
1265
+ const body = await response.text();
1266
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1267
+ }
1268
+ return await response.json();
1269
+ }
1270
+ /**
1271
+ * Get SSH credentials for connecting to the sandbox.
1272
+ * Throws if SSH is not enabled or sandbox is not running.
1273
+ */
1274
+ async ssh() {
1275
+ await this.ensureRunning();
1276
+ if (!this.connection?.ssh) throw new StateError("SSH is not enabled for this sandbox", this.status, "running");
1277
+ return this.connection.ssh;
1278
+ }
1279
+ async sshCommand() {
1280
+ const ssh = await this.ssh();
1281
+ const authToken = this.client.getApiKey?.();
1282
+ if (!authToken) throw new AuthError("SSH command requires client API key access to populate proxy auth");
1283
+ const nullKnownHostsFile = process.platform === "win32" ? "NUL" : "/dev/null";
1284
+ return {
1285
+ command: `ssh -o ProxyCommand="${ssh.proxyCommand}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=${nullKnownHostsFile} -o GlobalKnownHostsFile=${nullKnownHostsFile} -o LogLevel=ERROR -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o TCPKeepAlive=yes ${ssh.username}@localhost -p ${ssh.port}`,
1286
+ env: { TANGLE_SSH_PROXY_AUTH_TOKEN: authToken }
1287
+ };
1288
+ }
1289
+ /**
1290
+ * Execute a command in the sandbox.
1291
+ */
1292
+ async exec(command, options) {
1293
+ await this.ensureRunning();
1294
+ const headers = {};
1295
+ if (options?.sessionId) headers["X-Session-Id"] = options.sessionId;
1296
+ const response = await this.runtimeFetch("/terminals/commands", {
1297
+ method: "POST",
1298
+ headers,
1299
+ body: JSON.stringify({
1300
+ command,
1301
+ cwd: options?.cwd,
1302
+ env: options?.env,
1303
+ timeout: options?.timeoutMs
1304
+ })
1305
+ });
1306
+ if (!response.ok) {
1307
+ const body = await response.text();
1308
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1309
+ }
1310
+ const data = await response.json();
1311
+ const result = data.result ?? data;
1312
+ return {
1313
+ exitCode: result.exitCode ?? 0,
1314
+ stdout: result.stdout ?? "",
1315
+ stderr: result.stderr ?? ""
1316
+ };
1317
+ }
1318
+ /**
1319
+ * Run code in a persistent language kernel.
1320
+ *
1321
+ * Each `(sessionId, language)` pair gets its own long-lived kernel that
1322
+ * keeps variable state across calls — like Jupyter cells. Without a
1323
+ * `sessionId`, calls share a process-wide kernel per language.
1324
+ *
1325
+ * Returns typed results: stdout/stderr text plus a `results` array of
1326
+ * structured outputs (matplotlib images as base64 PNG, pandas DataFrames,
1327
+ * explicit `display(value)` calls as JSON/HTML, errors with traceback).
1328
+ *
1329
+ * @example Persistent Python session
1330
+ * ```ts
1331
+ * await box.runCode("python", "import pandas as pd; df = pd.DataFrame({'x': range(5)})", { sessionId: "s1" });
1332
+ * const r = await box.runCode("python", "df.describe()", { sessionId: "s1" });
1333
+ * // r.results[0] is a `dataframe` part with columns + rows from the describe()
1334
+ * ```
1335
+ *
1336
+ * @example Matplotlib chart
1337
+ * ```ts
1338
+ * const r = await box.runCode("python",
1339
+ * "import matplotlib.pyplot as plt; plt.plot([1,2,3,4]); plt.show()",
1340
+ * { sessionId: "s1" });
1341
+ * const png = r.results.find(p => p.type === "image");
1342
+ * // png.data is a base64 PNG ready to render or hand back to an LLM
1343
+ * ```
1344
+ */
1345
+ async runCode(language, source, options) {
1346
+ if (options?.sessionId !== void 0) assertValidSessionId(options.sessionId);
1347
+ await this.ensureRunning();
1348
+ const headers = {};
1349
+ if (options?.sessionId) headers["X-Session-Id"] = options.sessionId;
1350
+ if (options?.idempotencyKey) headers["X-Idempotency-Key"] = options.idempotencyKey;
1351
+ const response = await this.runtimeFetch("/code/exec", {
1352
+ method: "POST",
1353
+ headers,
1354
+ body: JSON.stringify({
1355
+ language,
1356
+ source,
1357
+ timeoutMs: options?.timeoutMs,
1358
+ env: options?.env,
1359
+ cwd: options?.cwd
1360
+ })
1361
+ });
1362
+ if (!response.ok) {
1363
+ const body = await response.text();
1364
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1365
+ }
1366
+ const payload = await response.json();
1367
+ if (!payload.success || !payload.data) throw new Error(payload.error?.message ?? "runCode failed without a typed error");
1368
+ return payload.data;
1369
+ }
1370
+ /**
1371
+ * Read a file from the sandbox.
1372
+ *
1373
+ * @param path - Path to the file. Relative paths resolve from the workspace root.
1374
+ * Absolute paths (e.g., `/tmp/output.json`) access the container filesystem directly.
1375
+ * @returns File content as string
1376
+ *
1377
+ * @example
1378
+ * ```typescript
1379
+ * const content = await box.read("src/index.ts");
1380
+ * const report = await box.read("/output/report.json");
1381
+ * ```
1382
+ */
1383
+ async read(path, options) {
1384
+ await this.ensureRunning();
1385
+ const headers = {};
1386
+ if (options?.sessionId) headers["X-Session-Id"] = options.sessionId;
1387
+ const response = await this.runtimeFetch("/files/read", {
1388
+ method: "POST",
1389
+ headers,
1390
+ body: JSON.stringify({ path })
1391
+ });
1392
+ if (!response.ok) {
1393
+ const body = await response.text();
1394
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1395
+ }
1396
+ return (await response.json()).data?.content ?? "";
1397
+ }
1398
+ /**
1399
+ * Write content to a file in the sandbox.
1400
+ *
1401
+ * @param path - Path to the file. Relative paths resolve from the workspace root.
1402
+ * Absolute paths (e.g., `/tmp/cases.json`) write to the container filesystem directly.
1403
+ * @param content - Content to write
1404
+ *
1405
+ * @example
1406
+ * ```typescript
1407
+ * await box.write("src/fix.ts", "export const fix = () => {}");
1408
+ * await box.write("/tmp/config.json", JSON.stringify(config));
1409
+ * ```
1410
+ */
1411
+ async write(path, content) {
1412
+ await this.ensureRunning();
1413
+ const response = await this.runtimeFetch("/files/write", {
1414
+ method: "POST",
1415
+ body: JSON.stringify({
1416
+ path,
1417
+ content
1418
+ })
1419
+ });
1420
+ if (!response.ok) {
1421
+ const body = await response.text();
1422
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1423
+ }
1424
+ }
1425
+ /**
1426
+ * Write many files in one paced, retry-aware batch — see
1427
+ * {@link FileSystem.writeMany}. Each file goes through {@link write}; a small
1428
+ * pace between writes keeps a large corpus under the file-write rate limit,
1429
+ * and transient failures (quota / server / network / timeout) retry with
1430
+ * exponential backoff (honoring a server `retryAfterMs`). Fail-loud on the
1431
+ * first file that cannot be written after its retries.
1432
+ */
1433
+ async writeMany(files, options = {}) {
1434
+ const paceMs = options.paceMs ?? WRITE_MANY_DEFAULT_PACE_MS;
1435
+ const maxRetries = options.maxRetries ?? WRITE_MANY_DEFAULT_MAX_RETRIES;
1436
+ let started = false;
1437
+ for (const file of files) for (let attempt = 0;; attempt++) {
1438
+ if (started && paceMs > 0) await delay(paceMs);
1439
+ started = true;
1440
+ try {
1441
+ await this.write(file.path, file.content);
1442
+ break;
1443
+ } catch (err) {
1444
+ if (isTransientWriteError(err) && attempt < maxRetries) {
1445
+ const backoff = Math.min(WRITE_MANY_RETRY_BASE_MS * 2 ** attempt, WRITE_MANY_RETRY_MAX_MS);
1446
+ await delay((err instanceof SandboxError ? err.retryAfterMs : void 0) ?? backoff);
1447
+ continue;
1448
+ }
1449
+ throw err;
1450
+ }
1451
+ }
1452
+ }
1453
+ /**
1454
+ * Send a prompt to the agent running in the sandbox.
1455
+ * Returns the complete response after the agent finishes.
1456
+ */
1457
+ async prompt(message, options) {
1458
+ await this.ensureRunning();
1459
+ const startTime = Date.now();
1460
+ let responseText;
1461
+ let runError;
1462
+ let traceId;
1463
+ let usage;
1464
+ let costUsd;
1465
+ let toolInvocations;
1466
+ let question;
1467
+ let terminalReached = false;
1468
+ const controller = new AbortController();
1469
+ try {
1470
+ const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
1471
+ for await (const event of this.streamPrompt(message, {
1472
+ ...options,
1473
+ signal
1474
+ })) {
1475
+ responseText = applySandboxEventText(responseText, event);
1476
+ if (event.type === "result" || event.type === "done") {
1477
+ usage = readTokenUsage(event.data) ?? usage;
1478
+ costUsd = readTokenCostUsd(event.data) ?? costUsd;
1479
+ }
1480
+ if (event.type === "result") toolInvocations = readToolInvocations(event.data) ?? toolInvocations;
1481
+ if (event.type === "result" || event.type === "done") terminalReached = true;
1482
+ if (event.type === "interaction") question = readQuestionEvent(event.data) ?? question;
1483
+ if (event.type === "trace.id") traceId = event.data.traceId;
1484
+ if (event.type === "error") runError = event.data.message;
1485
+ }
1486
+ const approval = detectHubApproval(toolInvocations);
1487
+ const outcome = deriveOutcome({
1488
+ terminalReached,
1489
+ runError,
1490
+ toolInvocations,
1491
+ approval,
1492
+ question
1493
+ });
1494
+ return {
1495
+ success: outcome.success,
1496
+ status: outcome.status,
1497
+ response: responseText,
1498
+ error: outcome.error,
1499
+ toolInvocations,
1500
+ approval,
1501
+ question,
1502
+ traceId,
1503
+ durationMs: Date.now() - startTime,
1504
+ usage,
1505
+ costUsd
1506
+ };
1507
+ } catch (err) {
1508
+ return {
1509
+ success: false,
1510
+ status: "failed",
1511
+ error: err instanceof Error ? err.message : String(err),
1512
+ durationMs: Date.now() - startTime
1513
+ };
1514
+ } finally {
1515
+ controller.abort();
1516
+ }
1517
+ }
1518
+ /**
1519
+ * Stream events from an agent prompt.
1520
+ * Use this for real-time updates during agent execution.
1521
+ *
1522
+ * Guarantees a terminal event on every non-cancelled path: a clean run
1523
+ * ends with the runtime's `result`/`done`; a failure (pre-stream HTTP
1524
+ * error, mid-stream network drop, timeout, or reconnect exhaustion) is
1525
+ * surfaced as an in-band `error` event followed by a synthetic `done`,
1526
+ * never a thrown generator. Caller-initiated cancellation (aborting
1527
+ * `options.signal`) ends the stream silently with no synthetic terminal.
1528
+ *
1529
+ * Automatically reconnects via the runtime event replay endpoint if the
1530
+ * SSE stream drops before a terminal event (`result` or `done`) is
1531
+ * received. Reconnection is transparent — replayed events that were
1532
+ * already yielded (based on event ID tracking) are deduplicated.
1533
+ */
1534
+ async *streamPrompt(message, options) {
1535
+ const isCallerAbort = () => options?.signal?.aborted === true;
1536
+ let receivedTerminal = false;
1537
+ let sessionId = options?.sessionId;
1538
+ let executionId = options?.executionId;
1539
+ try {
1540
+ for await (const event of this.streamPromptInner(message, options)) {
1541
+ if (event.type === "result" || event.type === "done") receivedTerminal = true;
1542
+ if (event.type === "execution.started" && event.data.executionId) executionId = event.data.executionId;
1543
+ const sid = event.data.sessionId;
1544
+ if (typeof sid === "string" && sid.length > 0) sessionId = sid;
1545
+ yield event;
1546
+ }
1547
+ } catch (err) {
1548
+ if (isCallerAbort()) return;
1549
+ yield toStreamErrorEvent(err);
1550
+ } finally {
1551
+ if (!receivedTerminal && !isCallerAbort()) yield {
1552
+ type: "done",
1553
+ data: {
1554
+ status: "failed",
1555
+ ...sessionId ? { sessionId } : {},
1556
+ ...executionId ? { executionId } : {}
1557
+ }
1558
+ };
1559
+ }
1560
+ }
1561
+ /**
1562
+ * Inner prompt stream: opens the SSE connection and reconnects on silent
1563
+ * drops. May throw (pre-stream HTTP error, timeout, reconnect exhausted);
1564
+ * the public `streamPrompt` wrapper converts those throws into a terminal
1565
+ * `error` + `done` so callers never see a thrown generator.
1566
+ */
1567
+ async *streamPromptInner(message, options) {
1568
+ await this.ensureRunning();
1569
+ const parts = encodePromptForWire(message);
1570
+ const timeoutMs = typeof options?.timeoutMs === "number" && options.timeoutMs > 0 ? options.timeoutMs : void 0;
1571
+ const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : void 0;
1572
+ const streamSignal = timeoutSignal ? options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal : options?.signal;
1573
+ const throwIfTimedOut = () => {
1574
+ if (!timeoutSignal?.aborted || !timeoutMs) return;
1575
+ throw new TimeoutError(timeoutMs, `Prompt stream timed out after ${timeoutMs}ms`, {
1576
+ endpoint: "/agents/run/stream",
1577
+ origin: "runtime"
1578
+ });
1579
+ };
1580
+ let response;
1581
+ try {
1582
+ response = await this.runtimeFetch("/agents/run/stream", {
1583
+ method: "POST",
1584
+ signal: streamSignal,
1585
+ headers: {
1586
+ ...options?.executionId ? { "X-Execution-ID": options.executionId } : {},
1587
+ ...options?.lastEventId ? { "Last-Event-ID": options.lastEventId } : {},
1588
+ ...options?.detach ? { "x-agent-detach": "true" } : {}
1589
+ },
1590
+ body: JSON.stringify({
1591
+ identifier: "default",
1592
+ parts,
1593
+ sessionId: options?.sessionId,
1594
+ turnId: options?.turnId,
1595
+ metadata: options?.context,
1596
+ ...options?.detach ? { detach: true } : {},
1597
+ backend: normalizeRuntimeBackendConfig(options?.backend ?? this.defaultRuntimeBackend, { model: options?.model })
1598
+ })
1599
+ });
1600
+ } catch (err) {
1601
+ throwIfTimedOut();
1602
+ throw err;
1603
+ }
1604
+ if (!response.ok) {
1605
+ const body = await response.text();
1606
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1607
+ }
1608
+ let lastEventId = options?.lastEventId;
1609
+ let executionId = options?.executionId;
1610
+ let sessionId = options?.sessionId;
1611
+ let receivedTerminal = false;
1612
+ const RECONNECT_BACKOFF_MS = [
1613
+ 2e3,
1614
+ 4e3,
1615
+ 8e3,
1616
+ 16e3,
1617
+ 3e4,
1618
+ 3e4
1619
+ ];
1620
+ const MAX_RECONNECT_ATTEMPTS = RECONNECT_BACKOFF_MS.length;
1621
+ let lastReplayStatus;
1622
+ let lastExecutionStatus;
1623
+ for await (const event of this.parseSSEStream(response, streamSignal)) {
1624
+ if (event.id) lastEventId = event.id;
1625
+ if (event.type === "execution.started" && event.data?.executionId) executionId = event.data.executionId;
1626
+ const eventSessionId = event.data?.sessionId;
1627
+ if (typeof eventSessionId === "string" && eventSessionId.length > 0) sessionId = eventSessionId;
1628
+ if (event.type === "result" || event.type === "done") receivedTerminal = true;
1629
+ yield event;
1630
+ }
1631
+ if (receivedTerminal) return;
1632
+ throwIfTimedOut();
1633
+ if (options?.signal?.aborted || streamSignal?.aborted) return;
1634
+ if (!executionId) throw new NetworkError("Prompt stream dropped before execution.started; cannot reconnect", {
1635
+ endpoint: "/agents/run/stream",
1636
+ origin: "runtime"
1637
+ });
1638
+ for (let attempt = 1; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
1639
+ throwIfTimedOut();
1640
+ if (options?.signal?.aborted || streamSignal?.aborted) return;
1641
+ console.warn(`[sandbox-sdk] Stream dropped without terminal event, reconnecting (attempt ${attempt}/${MAX_RECONNECT_ATTEMPTS})`);
1642
+ await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS[attempt - 1]));
1643
+ throwIfTimedOut();
1644
+ if (options?.signal?.aborted || streamSignal?.aborted) return;
1645
+ try {
1646
+ const queryParams = lastEventId ? `?lastEventId=${encodeURIComponent(lastEventId)}&format=sse` : "?format=sse";
1647
+ const replayResponse = await this.runtimeFetch(`/agents/run/${encodeURIComponent(executionId)}/events${queryParams}`, {
1648
+ method: "GET",
1649
+ signal: streamSignal
1650
+ });
1651
+ if (!replayResponse.ok) {
1652
+ lastReplayStatus = replayResponse.status;
1653
+ console.warn(`[sandbox-sdk] Replay endpoint returned ${replayResponse.status}, attempt ${attempt}`);
1654
+ if (replayResponse.status === 404 && sessionId) try {
1655
+ const info = await this._sessionStatus(sessionId);
1656
+ if (info) {
1657
+ lastExecutionStatus = info.status;
1658
+ if (info.status === "completed" || info.status === "failed" || info.status === "cancelled") {
1659
+ console.warn(`[sandbox-sdk] Replay 404 but session ${sessionId} is terminal (${info.status}); ending stream gracefully`);
1660
+ yield {
1661
+ type: "done",
1662
+ data: {
1663
+ sessionId,
1664
+ executionId,
1665
+ status: info.status,
1666
+ replayUnavailable: true
1667
+ }
1668
+ };
1669
+ return;
1670
+ }
1671
+ }
1672
+ } catch (statusErr) {
1673
+ console.warn(`[sandbox-sdk] Session status poll failed during replay 404: ${statusErr instanceof Error ? statusErr.message : String(statusErr)}`);
1674
+ }
1675
+ continue;
1676
+ }
1677
+ for await (const event of this.parseSSEStream(replayResponse, streamSignal)) {
1678
+ if (event.type === "history.replay.start" || event.type === "history.replay.end") continue;
1679
+ if (event.id) lastEventId = event.id;
1680
+ const eventSessionId = event.data?.sessionId;
1681
+ if (typeof eventSessionId === "string" && eventSessionId.length > 0) sessionId = eventSessionId;
1682
+ if (event.type === "result" || event.type === "done") receivedTerminal = true;
1683
+ yield event;
1684
+ }
1685
+ if (receivedTerminal) return;
1686
+ throwIfTimedOut();
1687
+ if (options?.signal?.aborted || streamSignal?.aborted) return;
1688
+ } catch (err) {
1689
+ throwIfTimedOut();
1690
+ console.warn(`[sandbox-sdk] Reconnection attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
1691
+ }
1692
+ }
1693
+ if (!receivedTerminal) {
1694
+ console.warn(`[sandbox-sdk] Exhausted ${MAX_RECONNECT_ATTEMPTS} reconnection attempts without receiving terminal event`);
1695
+ const replayStatusText = lastReplayStatus !== void 0 ? `; last replay status ${lastReplayStatus}` : "";
1696
+ const execStatusText = lastExecutionStatus ? `; last known execution status ${lastExecutionStatus}` : "";
1697
+ throw new NetworkError(`Prompt stream ended before a terminal event for execution ${executionId}${replayStatusText}${execStatusText}`, {
1698
+ endpoint: `/agents/run/${executionId}/events`,
1699
+ origin: "runtime"
1700
+ });
1701
+ }
1702
+ }
1703
+ /**
1704
+ * Stream sandbox lifecycle and activity events.
1705
+ */
1706
+ async *events(options) {
1707
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/events`, { signal: options?.signal });
1708
+ if (!response.ok) {
1709
+ const body = await response.text();
1710
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1711
+ }
1712
+ for await (const event of this.parseSSEStream(response, options?.signal)) {
1713
+ if (options?.eventTypes && !options.eventTypes.includes(event.type)) continue;
1714
+ yield event;
1715
+ }
1716
+ }
1717
+ async trace(options = {}) {
1718
+ const query = new URLSearchParams();
1719
+ if (options.includeIntelligence === true) query.set("includeIntelligence", "true");
1720
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/trace${query.size ? `?${query}` : ""}`);
1721
+ if (!response.ok) {
1722
+ const body = await response.text();
1723
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1724
+ }
1725
+ return await response.json();
1726
+ }
1727
+ async intelligence() {
1728
+ const intelligence = (await this.trace({ includeIntelligence: true })).intelligence;
1729
+ if (!intelligence) throw new Error("Sandbox intelligence was not returned");
1730
+ return intelligence;
1731
+ }
1732
+ async createIntelligenceReport(options = {}) {
1733
+ const { window, compareTo, ...rest } = options;
1734
+ const response = await this.client.fetch("/v1/intelligence/reports", {
1735
+ method: "POST",
1736
+ body: JSON.stringify({
1737
+ subject: {
1738
+ type: "sandbox",
1739
+ id: this.id,
1740
+ window,
1741
+ compareTo
1742
+ },
1743
+ ...rest
1744
+ })
1745
+ });
1746
+ const body = await response.text();
1747
+ if (!response.ok) throw parseErrorResponse(response.status, body, void 0, response.headers);
1748
+ return JSON.parse(body).report;
1749
+ }
1750
+ async createAgenticIntelligenceReport(options) {
1751
+ return this.createIntelligenceReport({
1752
+ mode: "agentic",
1753
+ budget: {
1754
+ billTo: "customer",
1755
+ maxUsd: options.maxUsd
1756
+ },
1757
+ metadata: options.metadata
1758
+ });
1759
+ }
1760
+ async exportTrace(sink) {
1761
+ return exportTraceBundle(await this.trace(), sink);
1762
+ }
1763
+ /**
1764
+ * Stream real-time provisioning progress events.
1765
+ *
1766
+ * Connects to the SSE events stream and yields typed `ProvisionEvent` objects
1767
+ * for each provisioning step. The generator completes when provisioning
1768
+ * finishes (success or failure) and returns the terminal result.
1769
+ *
1770
+ * @returns AsyncGenerator of ProvisionEvent, with a ProvisionResult return value
1771
+ *
1772
+ * @example
1773
+ * ```typescript
1774
+ * const box = await client.create({ image: "ethereum" });
1775
+ *
1776
+ * const stream = box.watchProvisioning();
1777
+ * for await (const event of stream) {
1778
+ * console.log(`[${event.step}] ${event.status} — ${event.message}`);
1779
+ * }
1780
+ * // stream.return value contains the terminal result
1781
+ * ```
1782
+ */
1783
+ async *watchProvisioning(options) {
1784
+ const abortController = new AbortController();
1785
+ const signal = options?.signal;
1786
+ if (signal) if (signal.aborted) abortController.abort();
1787
+ else signal.addEventListener("abort", () => abortController.abort(), { once: true });
1788
+ let result;
1789
+ try {
1790
+ for await (const event of this.events({
1791
+ signal: abortController.signal,
1792
+ eventTypes: [
1793
+ "provision_progress",
1794
+ "provision_complete",
1795
+ "provision_failed"
1796
+ ]
1797
+ })) if (event.type === "provision_progress") {
1798
+ const d = event.data;
1799
+ yield {
1800
+ step: normalizeProvisionStep(d.step),
1801
+ status: d.status,
1802
+ message: d.message,
1803
+ percent: d.progress,
1804
+ detail: d.detail,
1805
+ timestamp: d.timestamp ?? (/* @__PURE__ */ new Date()).toISOString()
1806
+ };
1807
+ } else if (event.type === "provision_complete") {
1808
+ result = {
1809
+ type: "complete",
1810
+ containerId: event.data.containerId,
1811
+ timestamp: event.data.timestamp ?? (/* @__PURE__ */ new Date()).toISOString()
1812
+ };
1813
+ break;
1814
+ } else if (event.type === "provision_failed") {
1815
+ result = {
1816
+ type: "failed",
1817
+ error: event.data.error ?? "Provisioning failed",
1818
+ timestamp: event.data.timestamp ?? (/* @__PURE__ */ new Date()).toISOString()
1819
+ };
1820
+ break;
1821
+ }
1822
+ } finally {
1823
+ abortController.abort();
1824
+ }
1825
+ try {
1826
+ await this.refresh();
1827
+ } catch {}
1828
+ return result;
1829
+ }
1830
+ /**
1831
+ * Run an agentic task until completion.
1832
+ *
1833
+ * Unlike prompt(), task() is designed for autonomous agent work:
1834
+ * - The agent works until it completes the task or hits an error
1835
+ * - Session state is maintained for context continuity
1836
+ * - Token usage is aggregated across the execution
1837
+ *
1838
+ * Note: The agent (OpenCode/Claude) handles multi-turn execution internally.
1839
+ * Most tasks complete in a single call. The maxTurns option is for edge cases
1840
+ * where the agent explicitly signals it needs additional input.
1841
+ *
1842
+ * @param prompt - Task description for the agent
1843
+ * @param options - Task options
1844
+ * @returns Task result with response and execution metadata
1845
+ */
1846
+ async task(prompt, options) {
1847
+ const startTime = Date.now();
1848
+ const sessionId = options?.sessionId ?? `task-${crypto.randomUUID()}`;
1849
+ let responseText;
1850
+ let runError;
1851
+ let traceId;
1852
+ let usage;
1853
+ let costUsd;
1854
+ let toolInvocations;
1855
+ let question;
1856
+ let terminalReached = false;
1857
+ try {
1858
+ for await (const event of this.streamPrompt(prompt, {
1859
+ ...options,
1860
+ sessionId
1861
+ })) {
1862
+ responseText = applySandboxEventText(responseText, event);
1863
+ if (event.type === "result") {
1864
+ const eventUsage = readTokenUsage(event.data);
1865
+ if (eventUsage) usage = addTokenUsage(usage, eventUsage);
1866
+ const eventCostUsd = readTokenCostUsd(event.data);
1867
+ if (eventCostUsd !== void 0) costUsd = (costUsd ?? 0) + eventCostUsd;
1868
+ toolInvocations = readToolInvocations(event.data) ?? toolInvocations;
1869
+ }
1870
+ if (event.type === "done" && costUsd === void 0) costUsd = readTokenCostUsd(event.data);
1871
+ if (event.type === "result" || event.type === "done") terminalReached = true;
1872
+ if (event.type === "interaction") question = readQuestionEvent(event.data) ?? question;
1873
+ if (event.type === "trace.id") traceId = event.data.traceId;
1874
+ if (event.type === "error") runError = event.data.message;
1875
+ }
1876
+ const approval = detectHubApproval(toolInvocations);
1877
+ const outcome = deriveOutcome({
1878
+ terminalReached,
1879
+ runError,
1880
+ toolInvocations,
1881
+ approval,
1882
+ question
1883
+ });
1884
+ return {
1885
+ success: outcome.success,
1886
+ status: outcome.status,
1887
+ response: responseText,
1888
+ error: outcome.error,
1889
+ toolInvocations,
1890
+ approval,
1891
+ question,
1892
+ traceId,
1893
+ durationMs: Date.now() - startTime,
1894
+ usage,
1895
+ costUsd,
1896
+ turnsUsed: 1,
1897
+ sessionId
1898
+ };
1899
+ } catch (err) {
1900
+ return {
1901
+ success: false,
1902
+ status: "failed",
1903
+ error: err instanceof Error ? err.message : String(err),
1904
+ durationMs: Date.now() - startTime,
1905
+ turnsUsed: 1,
1906
+ sessionId
1907
+ };
1908
+ }
1909
+ }
1910
+ /**
1911
+ * Stream events from a task execution.
1912
+ *
1913
+ * Use this for real-time updates as the agent works:
1914
+ * - Tool calls and results
1915
+ * - Thinking/reasoning steps
1916
+ * - File operations
1917
+ * - Final response
1918
+ *
1919
+ * @param prompt - Task description for the agent
1920
+ * @param options - Task options
1921
+ */
1922
+ async *streamTask(prompt, options) {
1923
+ const sessionId = options?.sessionId ?? `task-${crypto.randomUUID()}`;
1924
+ yield {
1925
+ type: "task.start",
1926
+ data: {
1927
+ sessionId,
1928
+ prompt
1929
+ }
1930
+ };
1931
+ try {
1932
+ for await (const event of this.streamPrompt(prompt, {
1933
+ ...options,
1934
+ sessionId
1935
+ })) yield event;
1936
+ } finally {
1937
+ yield {
1938
+ type: "task.complete",
1939
+ data: { sessionId }
1940
+ };
1941
+ }
1942
+ }
1943
+ /**
1944
+ * Search for text patterns in files using ripgrep.
1945
+ *
1946
+ * This is a first-class code search capability, not a shell wrapper.
1947
+ * Ripgrep is pre-installed in all managed sandboxes.
1948
+ *
1949
+ * @param pattern - Regular expression pattern to search for
1950
+ * @param options - Search options
1951
+ * @returns Async iterator of search matches
1952
+ *
1953
+ * @example Search for task-marker comments
1954
+ * ```typescript
1955
+ * for await (const match of box.search("TASK:", { glob: "**\/*.ts" })) {
1956
+ * console.log(`${match.path}:${match.line}: ${match.text}`);
1957
+ * }
1958
+ * ```
1959
+ *
1960
+ * @example Collect all matches
1961
+ * ```typescript
1962
+ * const matches = [];
1963
+ * for await (const match of box.search("function.*async")) {
1964
+ * matches.push(match);
1965
+ * }
1966
+ * ```
1967
+ */
1968
+ async *search(pattern, options) {
1969
+ await this.ensureRunning();
1970
+ const response = await this.runtimeFetch("/search", {
1971
+ method: "POST",
1972
+ body: JSON.stringify({
1973
+ pattern,
1974
+ glob: options?.glob,
1975
+ cwd: options?.cwd,
1976
+ maxResults: options?.maxResults,
1977
+ ignoreCase: options?.ignoreCase,
1978
+ context: options?.context
1979
+ })
1980
+ });
1981
+ if (!response.ok) {
1982
+ const body = await response.text();
1983
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
1984
+ }
1985
+ const matches = (await response.json()).matches ?? [];
1986
+ for (const match of matches) yield {
1987
+ path: match.path,
1988
+ line: match.line,
1989
+ column: match.column ?? 1,
1990
+ text: match.text,
1991
+ before: match.before,
1992
+ after: match.after
1993
+ };
1994
+ }
1995
+ /**
1996
+ * Live whole-sandbox resource usage (memory + CPU) from the container cgroup.
1997
+ *
1998
+ * Returns `null` when cgroup stats are unavailable (non-Linux host). Memory is
1999
+ * in MB; `memoryPeakMb` is the high-water mark since the sandbox started. CPU
2000
+ * is a cumulative microsecond counter — sample twice and compute
2001
+ * `cpu% = ΔcpuUsageUsec / (ΔsampledAtMs * 10)` for utilization.
2002
+ *
2003
+ * @example
2004
+ * ```typescript
2005
+ * const r = await box.resourceUsage();
2006
+ * if (r) console.log(`peak ${r.memoryPeakMb} MB`);
2007
+ * ```
2008
+ */
2009
+ async resourceUsage() {
2010
+ await this.ensureRunning();
2011
+ const response = await this.runtimeFetch("/health/detailed", { method: "GET" });
2012
+ if (!response.ok) {
2013
+ const body = await response.text();
2014
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2015
+ }
2016
+ return (await response.json()).resources ?? null;
2017
+ }
2018
+ /**
2019
+ * Git capability object for repository operations.
2020
+ *
2021
+ * All git operations are executed in the sandbox workspace.
2022
+ *
2023
+ * @example Check status and commit
2024
+ * ```typescript
2025
+ * const status = await box.git.status();
2026
+ * if (status.isDirty) {
2027
+ * await box.git.add(["."]);
2028
+ * await box.git.commit("Update files");
2029
+ * await box.git.push();
2030
+ * }
2031
+ * ```
2032
+ */
2033
+ get git() {
2034
+ return {
2035
+ status: () => this.gitStatus(),
2036
+ log: (limit) => this.gitLog(limit),
2037
+ diff: (ref) => this.gitDiff(ref),
2038
+ add: (paths) => this.gitAdd(paths),
2039
+ commit: (message, options) => this.gitCommit(message, options),
2040
+ push: (options) => this.gitPush(options),
2041
+ pull: (options) => this.gitPull(options),
2042
+ branches: () => this.gitBranches(),
2043
+ checkout: (ref, options) => this.gitCheckout(ref, options)
2044
+ };
2045
+ }
2046
+ async gitStatus() {
2047
+ await this.ensureRunning();
2048
+ const response = await this.runtimeFetch("/git/status", { method: "GET" });
2049
+ if (!response.ok) {
2050
+ const body = await response.text();
2051
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2052
+ }
2053
+ const data = await response.json();
2054
+ return {
2055
+ branch: data.branch ?? "main",
2056
+ head: data.head ?? "",
2057
+ isDirty: data.isDirty ?? false,
2058
+ ahead: data.ahead ?? 0,
2059
+ behind: data.behind ?? 0,
2060
+ staged: data.staged ?? [],
2061
+ modified: data.modified ?? [],
2062
+ untracked: data.untracked ?? []
2063
+ };
2064
+ }
2065
+ async gitLog(limit = 10) {
2066
+ await this.ensureRunning();
2067
+ const response = await this.runtimeFetch(`/git/log?limit=${limit}`, { method: "GET" });
2068
+ if (!response.ok) {
2069
+ const body = await response.text();
2070
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2071
+ }
2072
+ return ((await response.json()).commits ?? []).map((c) => ({
2073
+ sha: c.sha,
2074
+ shortSha: c.sha.slice(0, 7),
2075
+ message: c.message,
2076
+ author: c.author,
2077
+ email: c.email,
2078
+ date: new Date(c.date)
2079
+ }));
2080
+ }
2081
+ async gitDiff(ref) {
2082
+ await this.ensureRunning();
2083
+ const url = ref ? `/git/diff?ref=${encodeURIComponent(ref)}` : "/git/diff";
2084
+ const response = await this.runtimeFetch(url, { method: "GET" });
2085
+ if (!response.ok) {
2086
+ const body = await response.text();
2087
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2088
+ }
2089
+ const data = await response.json();
2090
+ return {
2091
+ files: data.files ?? [],
2092
+ additions: data.additions ?? 0,
2093
+ deletions: data.deletions ?? 0,
2094
+ raw: data.raw ?? ""
2095
+ };
2096
+ }
2097
+ async gitAdd(paths) {
2098
+ await this.ensureRunning();
2099
+ const response = await this.runtimeFetch("/git/add", {
2100
+ method: "POST",
2101
+ body: JSON.stringify({ paths })
2102
+ });
2103
+ if (!response.ok) {
2104
+ const body = await response.text();
2105
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2106
+ }
2107
+ }
2108
+ async gitCommit(message, options) {
2109
+ await this.ensureRunning();
2110
+ const response = await this.runtimeFetch("/git/commit", {
2111
+ method: "POST",
2112
+ body: JSON.stringify({
2113
+ message,
2114
+ amend: options?.amend
2115
+ })
2116
+ });
2117
+ if (!response.ok) {
2118
+ const body = await response.text();
2119
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2120
+ }
2121
+ const data = await response.json();
2122
+ return {
2123
+ sha: data.sha,
2124
+ shortSha: data.sha.slice(0, 7),
2125
+ message: data.message,
2126
+ author: data.author,
2127
+ email: data.email,
2128
+ date: new Date(data.date)
2129
+ };
2130
+ }
2131
+ async gitPush(options) {
2132
+ await this.ensureRunning();
2133
+ const response = await this.runtimeFetch("/git/push", {
2134
+ method: "POST",
2135
+ body: JSON.stringify({ force: options?.force })
2136
+ });
2137
+ if (!response.ok) {
2138
+ const body = await response.text();
2139
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2140
+ }
2141
+ }
2142
+ async gitPull(options) {
2143
+ await this.ensureRunning();
2144
+ const response = await this.runtimeFetch("/git/pull", {
2145
+ method: "POST",
2146
+ body: JSON.stringify({ rebase: options?.rebase })
2147
+ });
2148
+ if (!response.ok) {
2149
+ const body = await response.text();
2150
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2151
+ }
2152
+ }
2153
+ async gitBranches() {
2154
+ await this.ensureRunning();
2155
+ const response = await this.runtimeFetch("/git/branches", { method: "GET" });
2156
+ if (!response.ok) {
2157
+ const body = await response.text();
2158
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2159
+ }
2160
+ return (await response.json()).branches ?? [];
2161
+ }
2162
+ async gitCheckout(ref, options) {
2163
+ await this.ensureRunning();
2164
+ const response = await this.runtimeFetch("/git/checkout", {
2165
+ method: "POST",
2166
+ body: JSON.stringify({
2167
+ ref,
2168
+ create: options?.create
2169
+ })
2170
+ });
2171
+ if (!response.ok) {
2172
+ const body = await response.text();
2173
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2174
+ }
2175
+ }
2176
+ /**
2177
+ * Tools capability object for managing language runtimes.
2178
+ *
2179
+ * Uses mise (polyglot version manager) to install and manage tools.
2180
+ *
2181
+ * @example Install and use Node.js
2182
+ * ```typescript
2183
+ * await box.tools.install("node", "22");
2184
+ * await box.tools.use("node", "22");
2185
+ * const list = await box.tools.list();
2186
+ * ```
2187
+ */
2188
+ get tools() {
2189
+ return {
2190
+ install: (tool, version) => this.toolsInstall(tool, version),
2191
+ use: (tool, version) => this.toolsUse(tool, version),
2192
+ list: () => this.toolsList(),
2193
+ run: (tool, args) => this.toolsRun(tool, args)
2194
+ };
2195
+ }
2196
+ /**
2197
+ * File system operations beyond basic read/write:
2198
+ * - Binary upload/download
2199
+ * - Directory ops (uploadDir, downloadDir, list, mkdir)
2200
+ * - Metadata (stat, exists)
2201
+ * - Progress reporting for large files
2202
+ *
2203
+ * @example Upload and download
2204
+ * ```typescript
2205
+ * await box.fs.upload("./model.bin", "/workspace/models/model.bin");
2206
+ * await box.fs.download("/workspace/results.zip", "./results.zip");
2207
+ * ```
2208
+ *
2209
+ * @example Directory operations
2210
+ * ```typescript
2211
+ * await box.fs.uploadDir("./project", "/workspace/project");
2212
+ * const files = await box.fs.list("/workspace");
2213
+ * ```
2214
+ *
2215
+ * @example File management
2216
+ * ```typescript
2217
+ * if (await box.fs.exists("/workspace/config.json")) {
2218
+ * const info = await box.fs.stat("/workspace/config.json");
2219
+ * console.log(`Size: ${info.size}`);
2220
+ * }
2221
+ * await box.fs.mkdir("/workspace/output", { recursive: true });
2222
+ * await box.fs.delete("/workspace/temp", { recursive: true });
2223
+ * ```
2224
+ */
2225
+ get fs() {
2226
+ return {
2227
+ read: (path) => this.read(path),
2228
+ write: (path, content) => this.write(path, content),
2229
+ writeMany: (files, options) => this.writeMany(files, options),
2230
+ search: (query, options) => this.search(query, options),
2231
+ upload: (localPath, remotePath, options) => this.fsUpload(localPath, remotePath, options),
2232
+ download: (remotePath, localPath, options) => this.fsDownload(remotePath, localPath, options),
2233
+ uploadDir: (localDir, remoteDir) => this.fsUploadDir(localDir, remoteDir),
2234
+ downloadDir: (remoteDir, localDir) => this.fsDownloadDir(remoteDir, localDir),
2235
+ list: (path, options) => this.fsList(path, options),
2236
+ stat: (path) => this.fsStat(path),
2237
+ delete: (path, options) => this.fsDelete(path, options),
2238
+ mkdir: (path, options) => this.fsMkdir(path, options),
2239
+ exists: (path) => this.fsExists(path)
2240
+ };
2241
+ }
2242
+ async fsUpload(localPath, remotePath, options) {
2243
+ await this.ensureRunning();
2244
+ const fs = await import("node:fs/promises");
2245
+ const path = await import("node:path");
2246
+ const fileBuffer = await fs.readFile(localPath);
2247
+ const fileName = path.basename(localPath);
2248
+ if (options?.onProgress) options.onProgress({
2249
+ bytesUploaded: 0,
2250
+ totalBytes: fileBuffer.length,
2251
+ percentage: 0
2252
+ });
2253
+ const formData = new FormData();
2254
+ formData.append("file", new Blob([new Uint8Array(fileBuffer)]), fileName);
2255
+ formData.append("path", remotePath);
2256
+ const response = await this.runtimeFetch("/fs/upload", {
2257
+ method: "POST",
2258
+ body: formData,
2259
+ headers: {}
2260
+ });
2261
+ if (!response.ok) {
2262
+ const body = await response.text();
2263
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2264
+ }
2265
+ if (options?.onProgress) options.onProgress({
2266
+ bytesUploaded: fileBuffer.length,
2267
+ totalBytes: fileBuffer.length,
2268
+ percentage: 100
2269
+ });
2270
+ }
2271
+ async fsDownload(remotePath, localPath, options) {
2272
+ await this.ensureRunning();
2273
+ const fs = await import("node:fs/promises");
2274
+ const path = await import("node:path");
2275
+ await fs.mkdir(path.dirname(localPath), { recursive: true });
2276
+ const response = await this.runtimeFetch(`/fs/download${remotePath}`, { method: "GET" });
2277
+ if (!response.ok) {
2278
+ const body = await response.text();
2279
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2280
+ }
2281
+ const contentLength = response.headers.get("content-length");
2282
+ const totalBytes = contentLength ? Number.parseInt(contentLength, 10) : 0;
2283
+ if (options?.onProgress && totalBytes > 0) options.onProgress({
2284
+ bytesDownloaded: 0,
2285
+ totalBytes,
2286
+ percentage: 0
2287
+ });
2288
+ const buffer = await response.arrayBuffer();
2289
+ await fs.writeFile(localPath, Buffer.from(buffer));
2290
+ if (options?.onProgress) options.onProgress({
2291
+ bytesDownloaded: buffer.byteLength,
2292
+ totalBytes: buffer.byteLength,
2293
+ percentage: 100
2294
+ });
2295
+ }
2296
+ async fsUploadDir(localDir, remoteDir) {
2297
+ await this.ensureRunning();
2298
+ const fs = await import("node:fs/promises");
2299
+ const path = await import("node:path");
2300
+ const { execSync } = await import("node:child_process");
2301
+ const tempTarPath = path.join(await fs.mkdtemp(path.join((await import("node:os")).tmpdir(), "upload-")), "archive.tar.gz");
2302
+ try {
2303
+ execSync(`tar -czf "${tempTarPath}" -C "${localDir}" .`, { stdio: "pipe" });
2304
+ const tarBuffer = await fs.readFile(tempTarPath);
2305
+ const formData = new FormData();
2306
+ formData.append("archive", new Blob([new Uint8Array(tarBuffer)]), "archive.tar.gz");
2307
+ formData.append("path", remoteDir);
2308
+ const response = await this.runtimeFetch("/fs/upload-dir", {
2309
+ method: "POST",
2310
+ body: formData,
2311
+ headers: {}
2312
+ });
2313
+ if (!response.ok) {
2314
+ const body = await response.text();
2315
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2316
+ }
2317
+ } finally {
2318
+ await fs.unlink(tempTarPath).catch(() => {});
2319
+ }
2320
+ }
2321
+ async fsDownloadDir(remoteDir, localDir) {
2322
+ await this.ensureRunning();
2323
+ const fs = await import("node:fs/promises");
2324
+ const path = await import("node:path");
2325
+ const { execSync } = await import("node:child_process");
2326
+ const response = await this.runtimeFetch(`/fs/download-dir${remoteDir}`, { method: "GET" });
2327
+ if (!response.ok) {
2328
+ const body = await response.text();
2329
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2330
+ }
2331
+ const tempDir = await fs.mkdtemp(path.join((await import("node:os")).tmpdir(), "download-"));
2332
+ const tempTarPath = path.join(tempDir, "archive.tar.gz");
2333
+ try {
2334
+ const buffer = await response.arrayBuffer();
2335
+ await fs.writeFile(tempTarPath, Buffer.from(buffer));
2336
+ await fs.mkdir(localDir, { recursive: true });
2337
+ execSync(`tar -xzf "${tempTarPath}" -C "${localDir}"`, { stdio: "pipe" });
2338
+ } finally {
2339
+ await fs.rm(tempDir, { recursive: true }).catch(() => {});
2340
+ }
2341
+ }
2342
+ async fsList(path, options) {
2343
+ await this.ensureRunning();
2344
+ const params = new URLSearchParams();
2345
+ if (options?.all) params.set("all", "true");
2346
+ if (options?.long) params.set("long", "true");
2347
+ const query = params.toString();
2348
+ const url = query ? `/fs/list${path}?${query}` : `/fs/list${path}`;
2349
+ const response = await this.runtimeFetch(url, { method: "GET" });
2350
+ if (!response.ok) {
2351
+ const body = await response.text();
2352
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2353
+ }
2354
+ return ((await response.json()).data?.entries ?? []).map((e) => ({
2355
+ name: e.name,
2356
+ path: e.path,
2357
+ size: e.size,
2358
+ isDir: e.isDir,
2359
+ isFile: e.isFile,
2360
+ isSymlink: e.isSymlink,
2361
+ permissions: e.permissions,
2362
+ owner: e.owner,
2363
+ group: e.group,
2364
+ modTime: new Date(e.modTime),
2365
+ accessTime: new Date(e.accessTime)
2366
+ }));
2367
+ }
2368
+ async fsStat(path) {
2369
+ await this.ensureRunning();
2370
+ const response = await this.runtimeFetch(`/fs/stat${path}`, { method: "GET" });
2371
+ if (!response.ok) {
2372
+ const body = await response.text();
2373
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2374
+ }
2375
+ const e = (await response.json()).data;
2376
+ return {
2377
+ name: e.name,
2378
+ path: e.path,
2379
+ size: e.size,
2380
+ isDir: e.isDir,
2381
+ isFile: e.isFile,
2382
+ isSymlink: e.isSymlink,
2383
+ permissions: e.permissions,
2384
+ owner: e.owner,
2385
+ group: e.group,
2386
+ modTime: new Date(e.modTime),
2387
+ accessTime: new Date(e.accessTime)
2388
+ };
2389
+ }
2390
+ async fsDelete(path, options) {
2391
+ await this.ensureRunning();
2392
+ const params = new URLSearchParams();
2393
+ if (options?.recursive) params.set("recursive", "true");
2394
+ const query = params.toString();
2395
+ const url = query ? `/fs${path}?${query}` : `/fs${path}`;
2396
+ const response = await this.runtimeFetch(url, { method: "DELETE" });
2397
+ if (!response.ok) {
2398
+ const body = await response.text();
2399
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2400
+ }
2401
+ }
2402
+ async fsMkdir(path, options) {
2403
+ await this.ensureRunning();
2404
+ const params = new URLSearchParams();
2405
+ if (options?.recursive) params.set("recursive", "true");
2406
+ const query = params.toString();
2407
+ const url = query ? `/fs/mkdir${path}?${query}` : `/fs/mkdir${path}`;
2408
+ const response = await this.runtimeFetch(url, { method: "POST" });
2409
+ if (!response.ok) {
2410
+ const body = await response.text();
2411
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2412
+ }
2413
+ }
2414
+ async fsExists(path) {
2415
+ await this.ensureRunning();
2416
+ const response = await this.runtimeFetch(`/fs/exists${path}`, { method: "GET" });
2417
+ if (!response.ok) {
2418
+ const body = await response.text();
2419
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2420
+ }
2421
+ return (await response.json()).data?.exists === true;
2422
+ }
2423
+ /**
2424
+ * Permissions manager for multi-user access control.
2425
+ *
2426
+ * @example List users
2427
+ * ```typescript
2428
+ * const users = await box.permissions.list();
2429
+ * for (const user of users) {
2430
+ * console.log(`${user.username}: ${user.role}`);
2431
+ * }
2432
+ * ```
2433
+ *
2434
+ * @example Add a developer
2435
+ * ```typescript
2436
+ * await box.permissions.add({
2437
+ * userId: "user_abc",
2438
+ * role: "developer",
2439
+ * sshKeys: ["ssh-ed25519 AAAA..."],
2440
+ * });
2441
+ * ```
2442
+ */
2443
+ get permissions() {
2444
+ return {
2445
+ list: () => this.permissionsList(),
2446
+ get: (userId) => this.permissionsGet(userId),
2447
+ add: (options) => this.permissionsAdd(options),
2448
+ update: (userId, options) => this.permissionsUpdate(userId, options),
2449
+ remove: (userId, options) => this.permissionsRemove(userId, options),
2450
+ setAccessPolicies: (userId, rules) => this.permissionsSetAccessPolicies(userId, rules),
2451
+ getAccessPolicies: (userId) => this.permissionsGetAccessPolicies(userId),
2452
+ checkAccess: (userId, path, action) => this.permissionsCheckAccess(userId, path, action)
2453
+ };
2454
+ }
2455
+ async permissionsList() {
2456
+ await this.ensureRunning();
2457
+ const response = await this.runtimeFetch("/permissions/users", { method: "GET" });
2458
+ if (!response.ok) {
2459
+ const body = await response.text();
2460
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2461
+ }
2462
+ return ((await response.json()).users ?? []).map((u) => ({
2463
+ userId: u.userId,
2464
+ username: u.username,
2465
+ homeDir: u.homeDir,
2466
+ role: u.role,
2467
+ sshKeys: u.sshKeys ?? [],
2468
+ directoryPermissions: u.directoryPermissions,
2469
+ accessPolicies: u.accessPolicies,
2470
+ createdAt: new Date(u.createdAt)
2471
+ }));
2472
+ }
2473
+ async permissionsGet(userId) {
2474
+ await this.ensureRunning();
2475
+ const response = await this.runtimeFetch(`/permissions/users/${encodeURIComponent(userId)}`, { method: "GET" });
2476
+ if (response.status === 404) return null;
2477
+ if (!response.ok) {
2478
+ const body = await response.text();
2479
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2480
+ }
2481
+ const u = await response.json();
2482
+ return {
2483
+ userId: u.userId,
2484
+ username: u.username,
2485
+ homeDir: u.homeDir,
2486
+ role: u.role,
2487
+ sshKeys: u.sshKeys ?? [],
2488
+ directoryPermissions: u.directoryPermissions,
2489
+ accessPolicies: u.accessPolicies,
2490
+ createdAt: new Date(u.createdAt)
2491
+ };
2492
+ }
2493
+ async permissionsAdd(options) {
2494
+ await this.ensureRunning();
2495
+ const response = await this.runtimeFetch("/permissions/users", {
2496
+ method: "POST",
2497
+ body: JSON.stringify(options)
2498
+ });
2499
+ if (!response.ok) {
2500
+ const body = await response.text();
2501
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2502
+ }
2503
+ const u = await response.json();
2504
+ return {
2505
+ userId: u.userId,
2506
+ username: u.username,
2507
+ homeDir: u.homeDir,
2508
+ role: u.role,
2509
+ sshKeys: u.sshKeys ?? [],
2510
+ directoryPermissions: u.directoryPermissions,
2511
+ accessPolicies: u.accessPolicies,
2512
+ createdAt: new Date(u.createdAt)
2513
+ };
2514
+ }
2515
+ async permissionsUpdate(userId, options) {
2516
+ await this.ensureRunning();
2517
+ const response = await this.runtimeFetch(`/permissions/users/${encodeURIComponent(userId)}`, {
2518
+ method: "PATCH",
2519
+ body: JSON.stringify(options)
2520
+ });
2521
+ if (!response.ok) {
2522
+ const body = await response.text();
2523
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2524
+ }
2525
+ const u = await response.json();
2526
+ return {
2527
+ userId: u.userId,
2528
+ username: u.username,
2529
+ homeDir: u.homeDir,
2530
+ role: u.role,
2531
+ sshKeys: u.sshKeys ?? [],
2532
+ directoryPermissions: u.directoryPermissions,
2533
+ accessPolicies: u.accessPolicies,
2534
+ createdAt: new Date(u.createdAt)
2535
+ };
2536
+ }
2537
+ async permissionsRemove(userId, options) {
2538
+ await this.ensureRunning();
2539
+ const params = new URLSearchParams();
2540
+ if (options?.preserveHomeDir) params.set("preserveHomeDir", "true");
2541
+ const query = params.toString();
2542
+ const path = query ? `/permissions/users/${encodeURIComponent(userId)}?${query}` : `/permissions/users/${encodeURIComponent(userId)}`;
2543
+ const response = await this.runtimeFetch(path, { method: "DELETE" });
2544
+ if (!response.ok) {
2545
+ const body = await response.text();
2546
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2547
+ }
2548
+ }
2549
+ async permissionsSetAccessPolicies(userId, rules) {
2550
+ await this.ensureRunning();
2551
+ const response = await this.runtimeFetch(`/permissions/users/${encodeURIComponent(userId)}/policies`, {
2552
+ method: "PUT",
2553
+ body: JSON.stringify({ rules })
2554
+ });
2555
+ if (!response.ok) {
2556
+ const body = await response.text();
2557
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2558
+ }
2559
+ }
2560
+ async permissionsGetAccessPolicies(userId) {
2561
+ await this.ensureRunning();
2562
+ const response = await this.runtimeFetch(`/permissions/users/${encodeURIComponent(userId)}/policies`, { method: "GET" });
2563
+ if (!response.ok) {
2564
+ const body = await response.text();
2565
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2566
+ }
2567
+ return (await response.json()).rules ?? [];
2568
+ }
2569
+ async permissionsCheckAccess(userId, path, action) {
2570
+ await this.ensureRunning();
2571
+ const response = await this.runtimeFetch(`/permissions/users/${encodeURIComponent(userId)}/check`, {
2572
+ method: "POST",
2573
+ body: JSON.stringify({
2574
+ path,
2575
+ action
2576
+ })
2577
+ });
2578
+ if (!response.ok) {
2579
+ const body = await response.text();
2580
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2581
+ }
2582
+ return (await response.json()).allowed === true;
2583
+ }
2584
+ /**
2585
+ * Backend manager for runtime agent configuration.
2586
+ *
2587
+ * @example Check backend status
2588
+ * ```typescript
2589
+ * const status = await box.backend.status();
2590
+ * console.log(`Backend: ${status.type}, Status: ${status.status}`);
2591
+ * ```
2592
+ *
2593
+ * @example Add MCP server at runtime
2594
+ * ```typescript
2595
+ * await box.backend.addMcp("web-search", {
2596
+ * command: "npx",
2597
+ * args: ["-y", "@anthropic/web-search"],
2598
+ * });
2599
+ * ```
2600
+ *
2601
+ * @example Read provider-native Cursor metadata
2602
+ * ```typescript
2603
+ * const models = await box.backend.models();
2604
+ * const agents = await box.backend.agents({ limit: 20 });
2605
+ * const runs = await box.backend.runs(agents.items[0].agentId);
2606
+ * ```
2607
+ */
2608
+ get backend() {
2609
+ return {
2610
+ status: () => this.backendStatus(),
2611
+ capabilities: () => this.backendCapabilities(),
2612
+ addMcp: (name, config) => this.backendAddMcp(name, config),
2613
+ getMcpStatus: () => this.backendGetMcpStatus(),
2614
+ updateConfig: (config) => this.backendUpdateConfig(config),
2615
+ account: () => this.backendAccount(),
2616
+ models: () => this.backendModels(),
2617
+ repositories: () => this.backendRepositories(),
2618
+ agents: (options) => this.backendAgents(options),
2619
+ agent: (agentId) => this.backendAgent(agentId),
2620
+ archiveAgent: (agentId) => this.backendArchiveAgent(agentId),
2621
+ unarchiveAgent: (agentId) => this.backendUnarchiveAgent(agentId),
2622
+ deleteAgent: (agentId) => this.backendDeleteAgent(agentId),
2623
+ runs: (agentId, options) => this.backendRuns(agentId, options),
2624
+ run: (runId, options) => this.backendRun(runId, options),
2625
+ agentMessages: (agentId, options) => this.backendAgentMessages(agentId, options),
2626
+ artifacts: (sessionId) => this.backendArtifacts(sessionId),
2627
+ downloadArtifact: (sessionId, path) => this.backendDownloadArtifact(sessionId, path),
2628
+ restart: () => this.backendRestart()
2629
+ };
2630
+ }
2631
+ async backendStatus() {
2632
+ await this.ensureRunning();
2633
+ const response = await this.runtimeFetch("/backend/status", { method: "GET" });
2634
+ if (!response.ok) {
2635
+ const body = await response.text();
2636
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2637
+ }
2638
+ return await response.json();
2639
+ }
2640
+ async backendCapabilities() {
2641
+ await this.ensureRunning();
2642
+ const response = await this.runtimeFetch("/backend/capabilities", { method: "GET" });
2643
+ if (!response.ok) {
2644
+ const body = await response.text();
2645
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2646
+ }
2647
+ return await response.json();
2648
+ }
2649
+ async backendAddMcp(name, config) {
2650
+ await this.ensureRunning();
2651
+ const response = await this.runtimeFetch("/backend/mcp", {
2652
+ method: "POST",
2653
+ body: JSON.stringify({
2654
+ name,
2655
+ config
2656
+ })
2657
+ });
2658
+ if (!response.ok) {
2659
+ const body = await response.text();
2660
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2661
+ }
2662
+ }
2663
+ async backendGetMcpStatus() {
2664
+ await this.ensureRunning();
2665
+ const response = await this.runtimeFetch("/backend/mcp", { method: "GET" });
2666
+ if (!response.ok) {
2667
+ const body = await response.text();
2668
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2669
+ }
2670
+ return (await response.json()).servers ?? {};
2671
+ }
2672
+ async backendUpdateConfig(config) {
2673
+ await this.ensureRunning();
2674
+ const response = await this.runtimeFetch("/backend/config", {
2675
+ method: "PATCH",
2676
+ body: JSON.stringify(config)
2677
+ });
2678
+ if (!response.ok) {
2679
+ const body = await response.text();
2680
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2681
+ }
2682
+ }
2683
+ async backendControlData(path) {
2684
+ await this.ensureRunning();
2685
+ const response = await this.runtimeFetch(path, { method: "GET" });
2686
+ if (!response.ok) {
2687
+ const body = await response.text();
2688
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2689
+ }
2690
+ const payload = await response.json();
2691
+ if (!payload || typeof payload !== "object" || !("data" in payload)) throw new ServerError("Backend control response missing data", 502, {
2692
+ endpoint: path,
2693
+ origin: "runtime"
2694
+ });
2695
+ return payload.data;
2696
+ }
2697
+ async backendControlAction(path, method) {
2698
+ await this.ensureRunning();
2699
+ const response = await this.runtimeFetch(path, { method });
2700
+ if (!response.ok) {
2701
+ const body = await response.text();
2702
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2703
+ }
2704
+ }
2705
+ backendListSearch(options) {
2706
+ const search = new URLSearchParams();
2707
+ if (options?.limit !== void 0) search.set("limit", String(options.limit));
2708
+ if (options?.cursor) search.set("cursor", options.cursor);
2709
+ const query = search.toString();
2710
+ return query ? `?${query}` : "";
2711
+ }
2712
+ async backendAccount() {
2713
+ return this.backendControlData("/config/account");
2714
+ }
2715
+ async backendModels() {
2716
+ return this.backendControlData("/config/models");
2717
+ }
2718
+ async backendRepositories() {
2719
+ return this.backendControlData("/config/repositories");
2720
+ }
2721
+ async backendAgents(options) {
2722
+ return this.backendControlData(`/config/backend-agents${this.backendListSearch(options)}`);
2723
+ }
2724
+ async backendAgent(agentId) {
2725
+ return this.backendControlData(`/config/backend-agents/${encodeURIComponent(agentId)}`);
2726
+ }
2727
+ async backendArchiveAgent(agentId) {
2728
+ await this.backendControlAction(`/config/backend-agents/${encodeURIComponent(agentId)}/archive`, "POST");
2729
+ }
2730
+ async backendUnarchiveAgent(agentId) {
2731
+ await this.backendControlAction(`/config/backend-agents/${encodeURIComponent(agentId)}/unarchive`, "POST");
2732
+ }
2733
+ async backendDeleteAgent(agentId) {
2734
+ await this.backendControlAction(`/config/backend-agents/${encodeURIComponent(agentId)}`, "DELETE");
2735
+ }
2736
+ async backendRuns(agentId, options) {
2737
+ return this.backendControlData(`/config/backend-agents/${encodeURIComponent(agentId)}/runs${this.backendListSearch(options)}`);
2738
+ }
2739
+ async backendRun(runId, options) {
2740
+ const search = new URLSearchParams();
2741
+ if (options?.agentId) search.set("agentId", options.agentId);
2742
+ const query = search.toString();
2743
+ return this.backendControlData(`/config/backend-runs/${encodeURIComponent(runId)}${query ? `?${query}` : ""}`);
2744
+ }
2745
+ async backendAgentMessages(agentId, options) {
2746
+ return this.backendControlData(`/config/backend-agents/${encodeURIComponent(agentId)}/messages${this.backendListSearch(options)}`);
2747
+ }
2748
+ async backendArtifacts(sessionId) {
2749
+ return this.backendControlData(`/config/artifacts/${encodeURIComponent(sessionId)}`);
2750
+ }
2751
+ async backendDownloadArtifact(sessionId, path) {
2752
+ await this.ensureRunning();
2753
+ const search = new URLSearchParams({ path });
2754
+ const response = await this.runtimeFetch(`/config/artifacts/${encodeURIComponent(sessionId)}/download?${search.toString()}`, { method: "GET" });
2755
+ if (!response.ok) {
2756
+ const body = await response.text();
2757
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2758
+ }
2759
+ const payload = await response.json();
2760
+ if (!payload || typeof payload.base64 !== "string") throw new ServerError("Backend artifact download response missing base64", 502, {
2761
+ endpoint: `/config/artifacts/${encodeURIComponent(sessionId)}/download`,
2762
+ origin: "runtime"
2763
+ });
2764
+ return Uint8Array.from(Buffer.from(payload.base64, "base64"));
2765
+ }
2766
+ async backendRestart() {
2767
+ await this.ensureRunning();
2768
+ const response = await this.runtimeFetch("/backend/restart", { method: "POST" });
2769
+ if (!response.ok) {
2770
+ const body = await response.text();
2771
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2772
+ }
2773
+ }
2774
+ /**
2775
+ * Process manager for spawning and controlling processes.
2776
+ *
2777
+ * Provides non-blocking process execution with real-time log streaming,
2778
+ * ideal for long-running tasks like ML training or dev servers.
2779
+ *
2780
+ * @example Non-blocking process
2781
+ * ```typescript
2782
+ * const proc = await box.process.spawn("python train.py", {
2783
+ * cwd: "/workspace",
2784
+ * env: { "CUDA_VISIBLE_DEVICES": "0" }
2785
+ * });
2786
+ *
2787
+ * // Stream logs
2788
+ * for await (const entry of proc.logs()) {
2789
+ * console.log(`[${entry.type}] ${entry.data}`);
2790
+ * }
2791
+ *
2792
+ * // Check status
2793
+ * const status = await proc.status();
2794
+ * console.log(`Running: ${status.running}`);
2795
+ *
2796
+ * // Kill if needed
2797
+ * await proc.kill();
2798
+ * ```
2799
+ *
2800
+ * @example Run Python code directly
2801
+ * ```typescript
2802
+ * const result = await box.process.runCode(`
2803
+ * import numpy as np
2804
+ * print(np.random.rand(10).mean())
2805
+ * `);
2806
+ * console.log(result.stdout);
2807
+ * ```
2808
+ */
2809
+ get process() {
2810
+ return {
2811
+ spawn: (command, options) => this.processSpawn(command, options),
2812
+ runCode: (code, options) => this.processRunCode(code, options),
2813
+ list: () => this.processList(),
2814
+ get: (pid) => this.processGet(pid)
2815
+ };
2816
+ }
2817
+ async processSpawn(command, options) {
2818
+ await this.ensureRunning();
2819
+ const response = await this.runtimeFetch("/process/spawn", {
2820
+ method: "POST",
2821
+ body: JSON.stringify({
2822
+ command,
2823
+ cwd: options?.cwd,
2824
+ env: options?.env,
2825
+ timeoutMs: options?.timeoutMs,
2826
+ blocking: false
2827
+ })
2828
+ });
2829
+ if (!response.ok) {
2830
+ const body = await response.text();
2831
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2832
+ }
2833
+ const data = await response.json();
2834
+ return this.createProcessHandle(data.data.pid, command);
2835
+ }
2836
+ async processRunCode(code, options) {
2837
+ await this.ensureRunning();
2838
+ const response = await this.runtimeFetch("/process/run-code", {
2839
+ method: "POST",
2840
+ body: JSON.stringify({
2841
+ code,
2842
+ cwd: options?.cwd,
2843
+ env: options?.env,
2844
+ timeoutMs: options?.timeoutMs,
2845
+ blocking: true
2846
+ })
2847
+ });
2848
+ if (!response.ok) {
2849
+ const body = await response.text();
2850
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2851
+ }
2852
+ return (await response.json()).data;
2853
+ }
2854
+ async processList() {
2855
+ await this.ensureRunning();
2856
+ const response = await this.runtimeFetch("/process", { method: "GET" });
2857
+ if (!response.ok) {
2858
+ const body = await response.text();
2859
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2860
+ }
2861
+ return ((await response.json()).data ?? []).map((p) => ({
2862
+ pid: p.pid,
2863
+ command: p.command,
2864
+ cwd: p.cwd,
2865
+ running: p.running,
2866
+ exitCode: p.exitCode,
2867
+ exitSignal: p.exitSignal,
2868
+ startedAt: new Date(p.startedAt),
2869
+ exitedAt: p.exitedAt ? new Date(p.exitedAt) : void 0
2870
+ }));
2871
+ }
2872
+ async processGet(pid) {
2873
+ await this.ensureRunning();
2874
+ const response = await this.runtimeFetch(`/process/${pid}`, { method: "GET" });
2875
+ if (response.status === 404) return null;
2876
+ if (!response.ok) {
2877
+ const body = await response.text();
2878
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2879
+ }
2880
+ const data = await response.json();
2881
+ return this.createProcessHandle(pid, data.data.command);
2882
+ }
2883
+ createProcessHandle(pid, command) {
2884
+ const self = this;
2885
+ return {
2886
+ pid,
2887
+ command,
2888
+ async status() {
2889
+ const response = await self.runtimeFetch(`/process/${pid}`, { method: "GET" });
2890
+ if (!response.ok) {
2891
+ const body = await response.text();
2892
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2893
+ }
2894
+ const p = (await response.json()).data;
2895
+ return {
2896
+ pid: p.pid,
2897
+ command: p.command,
2898
+ cwd: p.cwd,
2899
+ running: p.running,
2900
+ exitCode: p.exitCode,
2901
+ exitSignal: p.exitSignal,
2902
+ startedAt: new Date(p.startedAt),
2903
+ exitedAt: p.exitedAt ? new Date(p.exitedAt) : void 0
2904
+ };
2905
+ },
2906
+ async wait() {
2907
+ while (true) {
2908
+ const s = await this.status();
2909
+ if (!s.running) return s.exitCode;
2910
+ await new Promise((resolve) => setTimeout(resolve, 500));
2911
+ }
2912
+ },
2913
+ async kill(signal = "SIGTERM", options) {
2914
+ const response = await self.runtimeFetch(`/process/${pid}`, {
2915
+ method: "DELETE",
2916
+ body: JSON.stringify({
2917
+ signal,
2918
+ ...options?.tree === true ? { tree: true } : {}
2919
+ })
2920
+ });
2921
+ if (!response.ok && response.status !== 404) {
2922
+ const body = await response.text();
2923
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2924
+ }
2925
+ },
2926
+ async *logs() {
2927
+ const response = await self.runtimeFetch(`/process/${pid}/logs`, { method: "GET" });
2928
+ if (!response.ok) {
2929
+ const body = await response.text();
2930
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
2931
+ }
2932
+ yield* self.parseProcessLogStream(response);
2933
+ },
2934
+ async *stdout() {
2935
+ for await (const entry of this.logs()) if (entry.type === "stdout") yield entry.data;
2936
+ },
2937
+ async *stderr() {
2938
+ for await (const entry of this.logs()) if (entry.type === "stderr") yield entry.data;
2939
+ }
2940
+ };
2941
+ }
2942
+ async *parseProcessLogStream(response) {
2943
+ const reader = response.body?.getReader();
2944
+ if (!reader) throw new NetworkError("No response body");
2945
+ const decoder = new TextDecoder();
2946
+ let buffer = "";
2947
+ let currentEvent = "";
2948
+ let currentData = "";
2949
+ try {
2950
+ while (true) {
2951
+ const { done, value } = await reader.read();
2952
+ if (done) break;
2953
+ buffer += decoder.decode(value, { stream: true });
2954
+ const lines = buffer.split("\n");
2955
+ buffer = lines.pop() ?? "";
2956
+ for (const line of lines) if (line.startsWith("event:")) currentEvent = line.slice(6).trim();
2957
+ else if (line.startsWith("data:")) currentData = line.slice(5).trim();
2958
+ else if (line === "" && currentEvent && currentData) {
2959
+ if (currentEvent === "stdout" || currentEvent === "stderr") try {
2960
+ const parsed = JSON.parse(currentData);
2961
+ yield {
2962
+ type: currentEvent,
2963
+ data: parsed.data,
2964
+ timestamp: parsed.timestamp
2965
+ };
2966
+ } catch {}
2967
+ else if (currentEvent === "exit") return;
2968
+ currentEvent = "";
2969
+ currentData = "";
2970
+ }
2971
+ }
2972
+ } finally {
2973
+ reader.releaseLock();
2974
+ }
2975
+ }
2976
+ /**
2977
+ * Network manager for runtime network configuration.
2978
+ *
2979
+ * @example Update network restrictions
2980
+ * ```typescript
2981
+ * // Block all outbound traffic
2982
+ * await box.network.update({ blockOutbound: true });
2983
+ *
2984
+ * // Or switch to allowlist mode
2985
+ * await box.network.update({
2986
+ * allowList: ["192.168.1.0/24", "8.8.8.8/32"]
2987
+ * });
2988
+ * ```
2989
+ *
2990
+ * @example Expose ports dynamically
2991
+ * ```typescript
2992
+ * const url = await box.network.exposePort(8000);
2993
+ * console.log(`Service available at: ${url}`);
2994
+ * ```
2995
+ */
2996
+ get network() {
2997
+ return {
2998
+ update: (config) => this.networkUpdate(config),
2999
+ exposePort: (port) => this.networkExposePort(port),
3000
+ listUrls: () => this.networkListUrls(),
3001
+ getConfig: () => this.networkGetConfig()
3002
+ };
3003
+ }
3004
+ /**
3005
+ * Egress policy manager for controlling outbound internet access.
3006
+ *
3007
+ * @example Read current egress policy
3008
+ * ```typescript
3009
+ * const { policy, source } = await box.egress.get();
3010
+ * console.log(policy.mode, source);
3011
+ * ```
3012
+ *
3013
+ * @example Update egress policy at runtime
3014
+ * ```typescript
3015
+ * await box.egress.update({ mode: "strict", allowDomains: ["api.github.com"] });
3016
+ * ```
3017
+ */
3018
+ get egress() {
3019
+ return {
3020
+ get: () => this.egressGet(),
3021
+ update: (policy) => this.egressUpdate(policy)
3022
+ };
3023
+ }
3024
+ async networkUpdate(config) {
3025
+ if (config.blockOutbound !== void 0 && config.allowList !== void 0) {
3026
+ if (config.blockOutbound && config.allowList.length > 0) throw new Error("blockOutbound and allowList are mutually exclusive");
3027
+ }
3028
+ if (config.allowList) {
3029
+ if (config.allowList.length > 10) throw new Error("allowList cannot exceed 10 entries");
3030
+ for (const cidr of config.allowList) if (!this.isValidCidr(cidr)) throw new Error(`Invalid CIDR format: ${cidr}`);
3031
+ }
3032
+ const response = await this.runtimeFetch("/network", {
3033
+ method: "PATCH",
3034
+ body: JSON.stringify(config)
3035
+ });
3036
+ if (!response.ok) {
3037
+ const body = await response.text();
3038
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3039
+ }
3040
+ }
3041
+ async networkExposePort(port) {
3042
+ if (port < 1 || port > 65535) throw new Error("Port must be between 1 and 65535");
3043
+ const response = await this.runtimeFetch("/network/expose", {
3044
+ method: "POST",
3045
+ body: JSON.stringify({ port })
3046
+ });
3047
+ if (!response.ok) {
3048
+ const body = await response.text();
3049
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3050
+ }
3051
+ return (await response.json()).url;
3052
+ }
3053
+ async networkListUrls() {
3054
+ const response = await this.runtimeFetch("/network/urls");
3055
+ if (!response.ok) {
3056
+ const body = await response.text();
3057
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3058
+ }
3059
+ return (await response.json()).urls ?? {};
3060
+ }
3061
+ async networkGetConfig() {
3062
+ const response = await this.runtimeFetch("/network");
3063
+ if (!response.ok) {
3064
+ const body = await response.text();
3065
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3066
+ }
3067
+ return await response.json();
3068
+ }
3069
+ async egressGet() {
3070
+ const response = await this.client.fetch(`/v1/sandboxes/${encodeURIComponent(this.id)}`);
3071
+ if (!response.ok) {
3072
+ const body = await response.text();
3073
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3074
+ }
3075
+ const data = await response.json();
3076
+ return {
3077
+ policy: data.egressPolicy ?? { mode: "open" },
3078
+ source: data.egressPolicySource ?? "platform"
3079
+ };
3080
+ }
3081
+ async egressUpdate(policy) {
3082
+ const response = await this.client.fetch(`/v1/sandboxes/${encodeURIComponent(this.id)}/egress`, {
3083
+ method: "PATCH",
3084
+ body: JSON.stringify(policy)
3085
+ });
3086
+ if (!response.ok) {
3087
+ const body = await response.text();
3088
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3089
+ }
3090
+ const data = await response.json();
3091
+ this.info = {
3092
+ ...this.info,
3093
+ egressPolicy: data.egressPolicy,
3094
+ egressPolicySource: data.source
3095
+ };
3096
+ return {
3097
+ policy: data.egressPolicy,
3098
+ source: data.source
3099
+ };
3100
+ }
3101
+ /**
3102
+ * Validate CIDR notation (IPv4 and IPv6)
3103
+ */
3104
+ isValidCidr(cidr) {
3105
+ const ipv4Pattern = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\/(?:[0-9]|[12]\d|3[0-2])$/;
3106
+ const ipv6Pattern = /^([a-fA-F0-9:]+)\/(\d{1,3})$/;
3107
+ if (ipv4Pattern.test(cidr)) return true;
3108
+ const ipv6Match = cidr.match(ipv6Pattern);
3109
+ if (ipv6Match) {
3110
+ const ipPart = ipv6Match[1];
3111
+ const prefixPart = ipv6Match[2];
3112
+ if (ipPart === void 0 || prefixPart === void 0) return false;
3113
+ const prefix = Number.parseInt(prefixPart, 10);
3114
+ if (prefix >= 0 && prefix <= 128) {
3115
+ const groups = ipPart.split(":");
3116
+ if (groups.length <= 8 && ipPart.includes(":")) return groups.every((g) => g === "" || /^[a-fA-F0-9]{1,4}$/.test(g));
3117
+ }
3118
+ }
3119
+ return false;
3120
+ }
3121
+ /**
3122
+ * Preview link management.
3123
+ *
3124
+ * Create publicly accessible HTTPS URLs for TCP ports inside the sandbox.
3125
+ *
3126
+ * @example
3127
+ * ```typescript
3128
+ * const link = await box.previewLinks.create(3000);
3129
+ * console.log(link.url);
3130
+ *
3131
+ * const links = await box.previewLinks.list();
3132
+ * await box.previewLinks.remove(link.previewId);
3133
+ * ```
3134
+ */
3135
+ get previewLinks() {
3136
+ return {
3137
+ create: (port, options) => this.previewLinkCreate(port, options),
3138
+ list: () => this.previewLinkList(),
3139
+ remove: (previewId) => this.previewLinkRemove(previewId)
3140
+ };
3141
+ }
3142
+ async previewLinkCreate(port, options) {
3143
+ if (port < 1 || port > 65535) throw new Error("Port must be between 1 and 65535");
3144
+ const response = await this.runtimeFetch("/preview-links", {
3145
+ method: "POST",
3146
+ body: JSON.stringify({
3147
+ port,
3148
+ protocol: options?.protocol ?? "tcp",
3149
+ metadata: options?.metadata
3150
+ })
3151
+ });
3152
+ if (!response.ok) {
3153
+ const body = await response.text();
3154
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3155
+ }
3156
+ return normalizePreviewLink((await response.json()).previewLink);
3157
+ }
3158
+ async previewLinkList() {
3159
+ const response = await this.runtimeFetch("/preview-links");
3160
+ if (!response.ok) {
3161
+ const body = await response.text();
3162
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3163
+ }
3164
+ return ((await response.json()).previewLinks ?? []).map(normalizePreviewLink);
3165
+ }
3166
+ async previewLinkRemove(previewId) {
3167
+ const response = await this.runtimeFetch(`/preview-links/${encodeURIComponent(previewId)}`, { method: "DELETE" });
3168
+ if (!response.ok) {
3169
+ const body = await response.text();
3170
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3171
+ }
3172
+ }
3173
+ /**
3174
+ * Get information about the infrastructure driver for this sandbox.
3175
+ *
3176
+ * @example
3177
+ * ```typescript
3178
+ * const info = await box.getDriverInfo();
3179
+ * console.log(`Driver: ${info.type}, CRIU: ${info.capabilities.criu}`);
3180
+ * ```
3181
+ */
3182
+ async getDriverInfo() {
3183
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/driver`);
3184
+ if (!response.ok) {
3185
+ const body = await response.text();
3186
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3187
+ }
3188
+ return await response.json();
3189
+ }
3190
+ async toolsInstall(tool, version) {
3191
+ await this.ensureRunning();
3192
+ const response = await this.runtimeFetch("/tools/install", {
3193
+ method: "POST",
3194
+ body: JSON.stringify({
3195
+ tool,
3196
+ version
3197
+ })
3198
+ });
3199
+ if (!response.ok) {
3200
+ const body = await response.text();
3201
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3202
+ }
3203
+ }
3204
+ async toolsUse(tool, version) {
3205
+ await this.ensureRunning();
3206
+ const response = await this.runtimeFetch("/tools/use", {
3207
+ method: "POST",
3208
+ body: JSON.stringify({
3209
+ tool,
3210
+ version
3211
+ })
3212
+ });
3213
+ if (!response.ok) {
3214
+ const body = await response.text();
3215
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3216
+ }
3217
+ }
3218
+ async toolsList() {
3219
+ await this.ensureRunning();
3220
+ const response = await this.runtimeFetch("/tools", { method: "GET" });
3221
+ if (!response.ok) {
3222
+ const body = await response.text();
3223
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3224
+ }
3225
+ return (await response.json()).tools ?? [];
3226
+ }
3227
+ async toolsRun(tool, args) {
3228
+ await this.ensureRunning();
3229
+ const response = await this.runtimeFetch("/tools/run", {
3230
+ method: "POST",
3231
+ body: JSON.stringify({
3232
+ tool,
3233
+ args
3234
+ })
3235
+ });
3236
+ if (!response.ok) {
3237
+ const body = await response.text();
3238
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3239
+ }
3240
+ const data = await response.json();
3241
+ return {
3242
+ exitCode: data.exitCode ?? 0,
3243
+ stdout: data.stdout ?? "",
3244
+ stderr: data.stderr ?? ""
3245
+ };
3246
+ }
3247
+ /**
3248
+ * Create a snapshot of the sandbox state.
3249
+ * Snapshots can be used to save workspace state for later restoration.
3250
+ *
3251
+ * If `storage` is provided (BYOS3), the snapshot is created directly
3252
+ * directly on the sandbox and uploaded to customer-provided S3 storage.
3253
+ *
3254
+ * @param options - Snapshot options (tags, paths, storage)
3255
+ * @returns Snapshot result with ID and metadata
3256
+ *
3257
+ * @example Standard snapshot (our storage)
3258
+ * ```typescript
3259
+ * const snap = await box.snapshot({
3260
+ * tags: ["v1.0", "stable"],
3261
+ * });
3262
+ * console.log(`Snapshot: ${snap.snapshotId}`);
3263
+ * ```
3264
+ *
3265
+ * @example BYOS3 snapshot (customer storage)
3266
+ * ```typescript
3267
+ * const snap = await box.snapshot({
3268
+ * tags: ["production"],
3269
+ * storage: {
3270
+ * type: "s3",
3271
+ * bucket: "my-snapshots",
3272
+ * credentials: { accessKeyId: "...", secretAccessKey: "..." },
3273
+ * },
3274
+ * });
3275
+ * ```
3276
+ */
3277
+ async snapshot(options) {
3278
+ await this.ensureRunning();
3279
+ if (options?.storage) {
3280
+ const response = await this.runtimeFetch("/snapshots", {
3281
+ method: "POST",
3282
+ body: JSON.stringify({
3283
+ projectId: this.id,
3284
+ storage: options.storage,
3285
+ tags: options.tags,
3286
+ paths: options.paths
3287
+ })
3288
+ });
3289
+ if (!response.ok) {
3290
+ const body = await response.text();
3291
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3292
+ }
3293
+ const data = await response.json();
3294
+ return {
3295
+ snapshotId: data.snapshot?.id ?? data.snapshotId,
3296
+ createdAt: new Date(data.snapshot?.createdAt ?? data.createdAt ?? Date.now()),
3297
+ sizeBytes: data.snapshot?.sizeBytes ?? data.sizeBytes,
3298
+ tags: data.snapshot?.tags ?? data.tags ?? []
3299
+ };
3300
+ }
3301
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/snapshots`, {
3302
+ method: "POST",
3303
+ body: JSON.stringify({
3304
+ tags: options?.tags,
3305
+ paths: options?.paths
3306
+ })
3307
+ }, { timeoutMs: SNAPSHOT_CREATE_REQUEST_TIMEOUT_MS });
3308
+ if (!response.ok) {
3309
+ const body = await response.text();
3310
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3311
+ }
3312
+ const data = await response.json();
3313
+ const snapshotId = data.snapshotId ?? data.id;
3314
+ if (typeof snapshotId !== "string" || snapshotId.length === 0) throw new ServerError(`Snapshot create returned 200 but body had no snapshot ID: ${JSON.stringify(data).slice(0, 200)}`, response.status);
3315
+ const snapshot = {
3316
+ snapshotId,
3317
+ createdAt: new Date(data.createdAt ?? Date.now()),
3318
+ sizeBytes: data.sizeBytes,
3319
+ tags: data.tags ?? []
3320
+ };
3321
+ await this.waitForSnapshotVisible(snapshot.snapshotId, {
3322
+ createdAt: snapshot.createdAt,
3323
+ sizeBytes: snapshot.sizeBytes,
3324
+ tags: snapshot.tags
3325
+ });
3326
+ return snapshot;
3327
+ }
3328
+ /**
3329
+ * List all snapshots for this sandbox.
3330
+ *
3331
+ * If `storage` is provided (BYOS3), lists snapshots from customer-provided
3332
+ * S3 storage.
3333
+ *
3334
+ * @param storage - Optional customer storage config for BYOS3
3335
+ * @returns Array of snapshot metadata
3336
+ *
3337
+ * @example List from our storage
3338
+ * ```typescript
3339
+ * const snapshots = await box.listSnapshots();
3340
+ * for (const snap of snapshots) {
3341
+ * console.log(`${snap.snapshotId}: ${snap.createdAt}`);
3342
+ * }
3343
+ * ```
3344
+ *
3345
+ * @example List from customer S3 (BYOS3)
3346
+ * ```typescript
3347
+ * const snapshots = await box.listSnapshots({
3348
+ * type: "s3",
3349
+ * bucket: "my-snapshots",
3350
+ * credentials: { accessKeyId: "...", secretAccessKey: "..." },
3351
+ * });
3352
+ * ```
3353
+ */
3354
+ async listSnapshots(storage) {
3355
+ if (storage) {
3356
+ await this.ensureRunning();
3357
+ const response = await this.runtimeFetch("/snapshots/list", {
3358
+ method: "POST",
3359
+ body: JSON.stringify({
3360
+ projectId: this.id,
3361
+ storage
3362
+ })
3363
+ });
3364
+ if (!response.ok) {
3365
+ const body = await response.text();
3366
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3367
+ }
3368
+ return ((await response.json()).snapshots ?? []).map((s) => ({
3369
+ snapshotId: s.id ?? s.snapshotId,
3370
+ sandboxId: this.id,
3371
+ createdAt: new Date(s.createdAt),
3372
+ tags: s.tags ?? [],
3373
+ paths: [],
3374
+ sizeBytes: s.sizeBytes
3375
+ }));
3376
+ }
3377
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/snapshots`);
3378
+ if (!response.ok) {
3379
+ const body = await response.text();
3380
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3381
+ }
3382
+ const data = await response.json();
3383
+ return (Array.isArray(data) ? data : data.snapshots ?? []).map((s) => ({
3384
+ snapshotId: s.snapshotId ?? s.id,
3385
+ sandboxId: s.sandboxId ?? s.projectRef ?? this.id,
3386
+ createdAt: new Date(s.createdAt),
3387
+ tags: s.tags ?? [],
3388
+ paths: s.paths ?? [],
3389
+ sizeBytes: s.sizeBytes
3390
+ }));
3391
+ }
3392
+ async revertToSnapshot(snapshotId) {
3393
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/snapshots/${encodeURIComponent(snapshotId)}/restore`, { method: "POST" });
3394
+ if (!response.ok) {
3395
+ const body = await response.text();
3396
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3397
+ }
3398
+ const data = await response.json();
3399
+ if (response.status === 202) return this.waitForSnapshotRestore(snapshotId, data);
3400
+ return toSnapshotResult(data);
3401
+ }
3402
+ async waitForSnapshotRestore(snapshotId, accepted) {
3403
+ const deadline = Date.now() + SNAPSHOT_RESTORE_TIMEOUT_MS;
3404
+ while (Date.now() < deadline) {
3405
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/snapshots/jobs/${encodeURIComponent(accepted.jobId)}`);
3406
+ if (!response.ok) {
3407
+ const body = await response.text();
3408
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3409
+ }
3410
+ const status = await response.json();
3411
+ if (status.status === "failed" || status.result?.success === false) throw new ServerError(status.result?.error ?? `Snapshot restore job ${accepted.jobId} failed`);
3412
+ if (status.status === "completed") return await this.waitForSnapshotVisible(snapshotId, status.completedAt ? { createdAt: new Date(status.completedAt) } : void 0);
3413
+ await this.sleep(SNAPSHOT_RESTORE_POLL_INTERVAL_MS);
3414
+ }
3415
+ throw new TimeoutError(SNAPSHOT_RESTORE_TIMEOUT_MS);
3416
+ }
3417
+ async waitForSnapshotVisible(snapshotId, fallback) {
3418
+ const deadline = Date.now() + SNAPSHOT_VISIBILITY_TIMEOUT_MS;
3419
+ let lastError;
3420
+ while (Date.now() < deadline) {
3421
+ try {
3422
+ const snapshot = (await this.listSnapshots()).find((item) => item.snapshotId === snapshotId);
3423
+ if (snapshot) return {
3424
+ snapshotId: snapshot.snapshotId,
3425
+ createdAt: snapshot.createdAt,
3426
+ sizeBytes: snapshot.sizeBytes,
3427
+ tags: snapshot.tags
3428
+ };
3429
+ lastError = /* @__PURE__ */ new Error(`Snapshot ${snapshotId} not yet visible in list`);
3430
+ } catch (error) {
3431
+ if (!isTransientSnapshotVisibilityError(error)) throw error;
3432
+ lastError = error;
3433
+ }
3434
+ await this.sleep(SNAPSHOT_VISIBILITY_POLL_INTERVAL_MS);
3435
+ }
3436
+ if (fallback) return {
3437
+ snapshotId,
3438
+ createdAt: fallback.createdAt ?? /* @__PURE__ */ new Date(),
3439
+ sizeBytes: fallback.sizeBytes,
3440
+ tags: fallback.tags ?? []
3441
+ };
3442
+ throw lastError instanceof Error ? lastError : new TimeoutError(SNAPSHOT_VISIBILITY_TIMEOUT_MS);
3443
+ }
3444
+ async deleteSnapshot(snapshotId) {
3445
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/snapshots/${encodeURIComponent(snapshotId)}`, { method: "DELETE" });
3446
+ if (!response.ok) {
3447
+ const body = await response.text();
3448
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3449
+ }
3450
+ }
3451
+ /**
3452
+ * Restore from the latest snapshot in customer-provided storage.
3453
+ * Only available when using BYOS3 (calls the runtime directly).
3454
+ *
3455
+ * @param storage - Customer storage config (required)
3456
+ * @param destinationPath - Optional path to restore to
3457
+ * @returns Snapshot info if restored, null if no snapshot found
3458
+ *
3459
+ * @example Restore from customer S3
3460
+ * ```typescript
3461
+ * const result = await box.restoreFromStorage({
3462
+ * type: "s3",
3463
+ * bucket: "my-snapshots",
3464
+ * credentials: { accessKeyId: "...", secretAccessKey: "..." },
3465
+ * });
3466
+ *
3467
+ * if (result) {
3468
+ * console.log(`Restored from ${result.snapshotId}`);
3469
+ * } else {
3470
+ * console.log("No snapshot found");
3471
+ * }
3472
+ * ```
3473
+ */
3474
+ async restoreFromStorage(storage, options) {
3475
+ if (!storage) throw new Error("Storage config is required for restoreFromStorage");
3476
+ await this.ensureRunning();
3477
+ const response = await this.runtimeFetch("/snapshots/restore", {
3478
+ method: "POST",
3479
+ body: JSON.stringify({
3480
+ projectId: this.id,
3481
+ storage,
3482
+ destinationPath: options?.destinationPath,
3483
+ snapshotId: options?.snapshotId
3484
+ })
3485
+ });
3486
+ if (response.status === 404) return null;
3487
+ if (!response.ok) {
3488
+ const body = await response.text();
3489
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3490
+ }
3491
+ const data = await response.json();
3492
+ if (!data.snapshot) return null;
3493
+ return {
3494
+ snapshotId: data.snapshot.id,
3495
+ createdAt: new Date(data.snapshot.createdAt),
3496
+ sizeBytes: data.snapshot.sizeBytes,
3497
+ tags: data.snapshot.tags ?? []
3498
+ };
3499
+ }
3500
+ /**
3501
+ * Create a CRIU checkpoint of the sandbox's memory state.
3502
+ *
3503
+ * Checkpoints capture the complete memory state of the running sandbox,
3504
+ * enabling true pause/resume and fork operations. Unlike snapshots which
3505
+ * only preserve filesystem state, checkpoints preserve process memory,
3506
+ * open file descriptors, and execution state.
3507
+ *
3508
+ * **Requirements:** CRIU must be available on the host. Check availability
3509
+ * with `client.criuStatus()` before calling.
3510
+ *
3511
+ * **Note:** By default, checkpoint stops the sandbox. Use `leaveRunning: true`
3512
+ * to keep it running (creates a copy-on-write checkpoint).
3513
+ *
3514
+ * @param options - Checkpoint options
3515
+ * @returns Checkpoint result with ID and metadata
3516
+ *
3517
+ * @example Basic checkpoint (stops sandbox)
3518
+ * ```typescript
3519
+ * const checkpoint = await box.checkpoint();
3520
+ * console.log(`Checkpoint: ${checkpoint.checkpointId}`);
3521
+ * // Sandbox is now stopped, resume with box.resume()
3522
+ * ```
3523
+ *
3524
+ * @example Checkpoint without stopping
3525
+ * ```typescript
3526
+ * const checkpoint = await box.checkpoint({
3527
+ * tags: ["before-deploy"],
3528
+ * leaveRunning: true,
3529
+ * });
3530
+ * // Sandbox continues running
3531
+ * ```
3532
+ */
3533
+ async checkpoint(options) {
3534
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/checkpoints`, {
3535
+ method: "POST",
3536
+ body: JSON.stringify({
3537
+ tags: options?.tags,
3538
+ leaveRunning: options?.leaveRunning,
3539
+ includeSnapshot: options?.includeSnapshot
3540
+ })
3541
+ });
3542
+ if (!response.ok) {
3543
+ const body = await response.text();
3544
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3545
+ }
3546
+ const data = await response.json();
3547
+ return {
3548
+ checkpointId: data.checkpointId ?? data.id,
3549
+ createdAt: new Date(data.createdAt ?? ""),
3550
+ sizeBytes: data.sizeBytes,
3551
+ tags: data.tags ?? []
3552
+ };
3553
+ }
3554
+ /**
3555
+ * List all checkpoints for this sandbox.
3556
+ *
3557
+ * @returns Array of checkpoint metadata
3558
+ *
3559
+ * @example
3560
+ * ```typescript
3561
+ * const checkpoints = await box.listCheckpoints();
3562
+ * for (const cp of checkpoints) {
3563
+ * console.log(`${cp.checkpointId}: ${cp.createdAt}`);
3564
+ * }
3565
+ * ```
3566
+ */
3567
+ async listCheckpoints() {
3568
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/checkpoints`);
3569
+ if (!response.ok) {
3570
+ const body = await response.text();
3571
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3572
+ }
3573
+ const data = await response.json();
3574
+ return (Array.isArray(data) ? data : data.checkpoints ?? []).map((cp) => this.parseCheckpointInfo(cp));
3575
+ }
3576
+ /**
3577
+ * Delete a checkpoint.
3578
+ *
3579
+ * @param checkpointId - ID of the checkpoint to delete
3580
+ */
3581
+ async deleteCheckpoint(checkpointId) {
3582
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/checkpoints/${encodeURIComponent(checkpointId)}`, { method: "DELETE" });
3583
+ if (!response.ok) {
3584
+ const body = await response.text();
3585
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3586
+ }
3587
+ }
3588
+ /**
3589
+ * Fork a new sandbox from a checkpoint.
3590
+ *
3591
+ * Creates a new sandbox with the same memory state as this sandbox
3592
+ * at the time of the checkpoint. The fork has a new identity but
3593
+ * preserves the execution state.
3594
+ *
3595
+ * **Use cases:**
3596
+ * - Branch workflows: Create parallel execution paths
3597
+ * - A/B testing: Run same state with different configurations
3598
+ * - Debugging: Fork at a specific point to investigate
3599
+ *
3600
+ * @param checkpointId - ID of the checkpoint to fork from
3601
+ * @param options - Fork configuration
3602
+ * @returns The new sandbox instance
3603
+ *
3604
+ * @example Basic fork
3605
+ * ```typescript
3606
+ * const checkpoint = await box.checkpoint({ leaveRunning: true });
3607
+ * const forked = await box.fork(checkpoint.checkpointId);
3608
+ * // forked has same memory state as box at checkpoint time
3609
+ * ```
3610
+ *
3611
+ * @example Fork with custom config
3612
+ * ```typescript
3613
+ * const forked = await box.fork(checkpointId, {
3614
+ * name: "experiment-branch",
3615
+ * env: { EXPERIMENT: "true" },
3616
+ * });
3617
+ * ```
3618
+ */
3619
+ async fork(checkpointId, options) {
3620
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/fork`, {
3621
+ method: "POST",
3622
+ body: JSON.stringify({
3623
+ checkpointId,
3624
+ name: options?.name,
3625
+ env: options?.env,
3626
+ resources: options?.resources,
3627
+ metadata: options?.metadata
3628
+ })
3629
+ });
3630
+ if (!response.ok) {
3631
+ const body = await response.text();
3632
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3633
+ }
3634
+ const data = await response.json();
3635
+ return new SandboxInstance(this.client, this.parseInfo(data.sandbox ?? data), this.defaultRuntimeBackend);
3636
+ }
3637
+ /**
3638
+ * Stop the sandbox (keeps state for resume).
3639
+ */
3640
+ async stop() {
3641
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/stop`, { method: "POST" });
3642
+ if (!response.ok) {
3643
+ const body = await response.text();
3644
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3645
+ }
3646
+ await this.refresh();
3647
+ }
3648
+ /**
3649
+ * Resume a stopped sandbox.
3650
+ */
3651
+ async resume(options = {}) {
3652
+ if (options.timeoutMs !== void 0 && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) throw new ValidationError("resume timeoutMs must be a positive number");
3653
+ const timeoutSignal = options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : void 0;
3654
+ const signal = timeoutSignal ? options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal : options.signal;
3655
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}/resume`, {
3656
+ method: "POST",
3657
+ ...signal ? { signal } : {}
3658
+ });
3659
+ if (!response.ok) {
3660
+ const body = await response.text();
3661
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3662
+ }
3663
+ await this.refresh();
3664
+ }
3665
+ /**
3666
+ * Delete the sandbox permanently.
3667
+ */
3668
+ async delete() {
3669
+ const response = await this.client.fetch(`/v1/sandboxes/${this.id}`, { method: "DELETE" });
3670
+ if (!response.ok) {
3671
+ const body = await response.text();
3672
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3673
+ }
3674
+ }
3675
+ /**
3676
+ * keepAlive is intentionally unavailable until the API exposes timeout updates.
3677
+ * @param seconds - Reserved for future support
3678
+ */
3679
+ async keepAlive(_seconds = 3600) {
3680
+ throw new Error("Sandbox keepAlive() is not supported by the current API. Set idleTimeoutSeconds when creating the sandbox instead.");
3681
+ }
3682
+ /**
3683
+ * Upload a local directory to the sandbox via tar.
3684
+ * @param localPath - Local directory path to upload
3685
+ * @param remotePath - Destination path in the sandbox (default: /home/user)
3686
+ */
3687
+ async uploadDirectory(localPath, remotePath = "/home/user") {
3688
+ const { stat } = await import("node:fs/promises");
3689
+ const { resolve } = await import("node:path");
3690
+ const { spawn } = await import("node:child_process");
3691
+ const absPath = resolve(localPath);
3692
+ if (!(await stat(absPath).catch(() => null))?.isDirectory()) throw new Error(`Local path is not a directory: ${localPath}`);
3693
+ if (!remotePath || remotePath.trim() === "") throw new Error("Remote path cannot be empty");
3694
+ const tarBase64 = (await new Promise((resolveTar, rejectTar) => {
3695
+ const tar = spawn("tar", [
3696
+ "-cf",
3697
+ "-",
3698
+ "--exclude=.git",
3699
+ "--exclude=.hg",
3700
+ "--exclude=.svn",
3701
+ "--exclude=node_modules",
3702
+ "--exclude=.next",
3703
+ "--exclude=.nuxt",
3704
+ "--exclude=.svelte-kit",
3705
+ "--exclude=.turbo",
3706
+ "--exclude=.parcel-cache",
3707
+ "--exclude=.cache",
3708
+ "--exclude=dist",
3709
+ "--exclude=build",
3710
+ "--exclude=out",
3711
+ "--exclude=coverage",
3712
+ "--exclude=target",
3713
+ "--exclude=__pycache__",
3714
+ "--exclude=.venv",
3715
+ "--exclude=venv",
3716
+ "--exclude=.tox",
3717
+ "--exclude=.pytest_cache",
3718
+ "--exclude=.mypy_cache",
3719
+ "--exclude=.ruff_cache",
3720
+ "--exclude=*.pyc",
3721
+ "--exclude=*.egg-info",
3722
+ "--exclude=CMakeFiles",
3723
+ "--exclude=cmake-build-debug",
3724
+ "--exclude=cmake-build-release",
3725
+ "--exclude=*.o",
3726
+ "--exclude=*.obj",
3727
+ "--exclude=.gradle",
3728
+ "--exclude=*.class",
3729
+ "--exclude=.DS_Store",
3730
+ "-C",
3731
+ absPath,
3732
+ "."
3733
+ ], { stdio: [
3734
+ "ignore",
3735
+ "pipe",
3736
+ "pipe"
3737
+ ] });
3738
+ const chunks = [];
3739
+ const stderrChunks = [];
3740
+ let totalSize = 0;
3741
+ let settled = false;
3742
+ const maxTarBytes = 100 * 1024 * 1024;
3743
+ const rejectOnce = (error) => {
3744
+ if (settled) return;
3745
+ settled = true;
3746
+ rejectTar(error);
3747
+ };
3748
+ tar.stdout.on("data", (chunk) => {
3749
+ totalSize += chunk.length;
3750
+ if (totalSize > maxTarBytes) {
3751
+ tar.kill("SIGKILL");
3752
+ rejectOnce(/* @__PURE__ */ new Error("uploadDirectory archive exceeds 100MB limit"));
3753
+ return;
3754
+ }
3755
+ chunks.push(chunk);
3756
+ });
3757
+ tar.stderr.on("data", (chunk) => {
3758
+ stderrChunks.push(chunk);
3759
+ });
3760
+ tar.on("error", (error) => {
3761
+ rejectOnce(/* @__PURE__ */ new Error(`Failed to create tar archive: ${error.message}`));
3762
+ });
3763
+ tar.on("close", (code) => {
3764
+ if (settled) return;
3765
+ if (code !== 0) {
3766
+ const stderr = Buffer.concat(stderrChunks).toString().trim();
3767
+ rejectOnce(/* @__PURE__ */ new Error(`tar failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
3768
+ return;
3769
+ }
3770
+ settled = true;
3771
+ resolveTar(Buffer.concat(chunks));
3772
+ });
3773
+ })).toString("base64");
3774
+ const quotedRemotePath = quoteForShell(remotePath);
3775
+ await this.exec(`mkdir -p ${quotedRemotePath}`);
3776
+ await this.exec(`printf %s ${quoteForShell(tarBase64)} | base64 -d | tar -xf - -C ${quotedRemotePath}`);
3777
+ }
3778
+ /**
3779
+ * Wait for the sandbox to reach a specific status.
3780
+ *
3781
+ * When `onProgress` is provided and the sandbox is still provisioning,
3782
+ * uses SSE events for real-time progress instead of polling. Falls back
3783
+ * to polling if the SSE connection fails or is unavailable.
3784
+ *
3785
+ * @example Basic wait
3786
+ * ```typescript
3787
+ * await box.waitFor("running");
3788
+ * ```
3789
+ *
3790
+ * @example Wait with progress tracking
3791
+ * ```typescript
3792
+ * await box.waitFor("running", {
3793
+ * onProgress: (event) => {
3794
+ * console.log(`[${event.step}] ${event.status} — ${event.message}`);
3795
+ * if (event.percent !== undefined) {
3796
+ * updateProgressBar(event.percent);
3797
+ * }
3798
+ * },
3799
+ * });
3800
+ * ```
3801
+ */
3802
+ async waitFor(status, options) {
3803
+ const statuses = Array.isArray(status) ? status : [status];
3804
+ const timeoutMs = options?.timeoutMs ?? 12e4;
3805
+ const pollIntervalMs = options?.pollIntervalMs ?? 2e3;
3806
+ if (options?.onProgress && (this.status === "provisioning" || this.status === "pending")) try {
3807
+ await this.waitForWithSSE(statuses, timeoutMs, options.onProgress, options.signal);
3808
+ return;
3809
+ } catch (err) {
3810
+ if (err instanceof StateError || err instanceof TimeoutError) throw err;
3811
+ }
3812
+ const startTime = Date.now();
3813
+ while (true) {
3814
+ if (options?.signal?.aborted) throw new TimeoutError(0, "Aborted");
3815
+ await this.refresh();
3816
+ if (statuses.includes(this.status)) return;
3817
+ if (this.status === "failed") throw new StateError(this.error ?? "Sandbox failed", this.status);
3818
+ if (Date.now() - startTime > timeoutMs) throw new TimeoutError(timeoutMs, `Timed out waiting for sandbox to reach ${statuses.join(" or ")}`);
3819
+ await this.sleep(pollIntervalMs);
3820
+ }
3821
+ }
3822
+ /**
3823
+ * SSE-based wait implementation. Subscribes to provisioning events and
3824
+ * delivers progress callbacks while waiting for a target status.
3825
+ */
3826
+ async waitForWithSSE(statuses, timeoutMs, onProgress, signal) {
3827
+ const abortController = new AbortController();
3828
+ const timeout = setTimeout(() => abortController.abort(), timeoutMs);
3829
+ if (signal) {
3830
+ if (signal.aborted) {
3831
+ clearTimeout(timeout);
3832
+ throw new TimeoutError(0, "Aborted");
3833
+ }
3834
+ signal.addEventListener("abort", () => abortController.abort(), { once: true });
3835
+ }
3836
+ try {
3837
+ for await (const event of this.events({
3838
+ signal: abortController.signal,
3839
+ eventTypes: [
3840
+ "provision_progress",
3841
+ "provision_complete",
3842
+ "provision_failed",
3843
+ "status_change"
3844
+ ]
3845
+ })) if (event.type === "provision_progress") {
3846
+ const d = event.data;
3847
+ onProgress({
3848
+ step: normalizeProvisionStep(d.step),
3849
+ status: d.status,
3850
+ message: d.message,
3851
+ percent: d.progress,
3852
+ detail: d.detail,
3853
+ timestamp: d.timestamp ?? (/* @__PURE__ */ new Date()).toISOString()
3854
+ });
3855
+ } else if (event.type === "provision_failed") {
3856
+ await this.refresh();
3857
+ throw new StateError(event.data.error ?? "Provisioning failed", this.status);
3858
+ } else if (event.type === "provision_complete" || event.type === "status_change") {
3859
+ await this.refresh();
3860
+ if (statuses.includes(this.status)) return;
3861
+ if (this.status === "failed") throw new StateError(this.error ?? "Sandbox failed", this.status);
3862
+ }
3863
+ await this.refresh();
3864
+ if (statuses.includes(this.status)) return;
3865
+ if (this.status === "failed") throw new StateError(this.error ?? "Sandbox failed", this.status);
3866
+ throw new TimeoutError(timeoutMs, `SSE stream ended before sandbox reached ${statuses.join(" or ")}`);
3867
+ } finally {
3868
+ clearTimeout(timeout);
3869
+ abortController.abort();
3870
+ }
3871
+ }
3872
+ parseCheckpointInfo(cp) {
3873
+ return {
3874
+ checkpointId: cp.checkpointId ?? cp.id,
3875
+ sandboxId: cp.sandboxId ?? cp.projectRef ?? this.id,
3876
+ createdAt: new Date(cp.createdAt),
3877
+ tags: cp.tags ?? [],
3878
+ sizeBytes: cp.sizeBytes,
3879
+ hasMemoryState: cp.hasMemoryState ?? true,
3880
+ hasFilesystemSnapshot: cp.hasFilesystemSnapshot ?? false
3881
+ };
3882
+ }
3883
+ async ensureRunning() {
3884
+ await this.refresh();
3885
+ if (this.status !== "running") throw new StateError(`Sandbox is not running (status: ${this.status})`, this.status, "running");
3886
+ }
3887
+ async runtimeFetch(path, options) {
3888
+ const url = `/v1/sandboxes/${encodeURIComponent(this.id)}/runtime${path}`;
3889
+ try {
3890
+ return options ? await this.client.fetch(url, options) : await this.client.fetch(url);
3891
+ } catch (err) {
3892
+ throw new NetworkError(`Failed to connect to sandbox: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0, {
3893
+ endpoint: path,
3894
+ origin: "runtime"
3895
+ });
3896
+ }
3897
+ }
3898
+ /**
3899
+ * Delegates to the shared `parseSSEStream` in `lib/sse-parser.ts`
3900
+ * — one canonical SSE implementation for the whole SDK. The
3901
+ * shared parser throws `AbortError` on cancellation (so consumers
3902
+ * can distinguish cancel from clean EOF), but agent/task streaming
3903
+ * callers of this method historically relied on a silent-return
3904
+ * abort: they check `options?.signal?.aborted` AFTER the loop and
3905
+ * decide whether to reconnect. The try/catch below preserves that
3906
+ * legacy contract by swallowing AbortError at the delegate layer.
3907
+ */
3908
+ async *parseSSEStream(response, signal) {
3909
+ if (!response.body) throw new NetworkError("No response body");
3910
+ try {
3911
+ for await (const event of parseSSEStream(response.body, { signal })) yield event;
3912
+ } catch (err) {
3913
+ if (err instanceof Error && err.name === "AbortError") return;
3914
+ throw err;
3915
+ }
3916
+ }
3917
+ /**
3918
+ * Associate a session ID with a user ID so the Sandbox API can route
3919
+ * subsequent WebSocket connections to this sandbox.
3920
+ *
3921
+ * @param opts - Session and user identifiers
3922
+ *
3923
+ * @example
3924
+ * ```typescript
3925
+ * await box.registerSessionMapping({
3926
+ * sessionId: "sess_abc",
3927
+ * userId: "user_123",
3928
+ * });
3929
+ * ```
3930
+ */
3931
+ async registerSessionMapping(opts) {
3932
+ const response = await this.client.fetch(`/v1/session/${encodeURIComponent(opts.sessionId)}/mapping`, {
3933
+ method: "PUT",
3934
+ body: JSON.stringify({
3935
+ userId: opts.userId,
3936
+ sandboxId: this.id
3937
+ })
3938
+ });
3939
+ if (!response.ok) {
3940
+ const body = await response.text();
3941
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3942
+ }
3943
+ }
3944
+ parseInfo(data) {
3945
+ return {
3946
+ id: data.id ?? this.id,
3947
+ name: data.name,
3948
+ status: data.status ?? "pending",
3949
+ connection: normalizeConnection(data.connection) ?? this.info.connection,
3950
+ metadata: data.metadata,
3951
+ createdAt: data.createdAt ? new Date(data.createdAt) : this.info.createdAt,
3952
+ startedAt: data.startedAt ? new Date(data.startedAt) : void 0,
3953
+ lastActivityAt: data.lastActivityAt ? new Date(data.lastActivityAt) : void 0,
3954
+ expiresAt: data.expiresAt ? new Date(data.expiresAt) : void 0,
3955
+ error: data.error,
3956
+ startupDiagnostics: normalizeStartupDiagnostics(data.startupDiagnostics) ?? this.info?.startupDiagnostics
3957
+ };
3958
+ }
3959
+ sleep(ms) {
3960
+ return new Promise((resolve) => setTimeout(resolve, ms));
3961
+ }
3962
+ /**
3963
+ * Get a session reference bound to this sandbox. Lazy: does not hit the
3964
+ * network until you call a method on the returned `SandboxSession`.
3965
+ * Use {@link sessions} to discover existing session ids.
3966
+ */
3967
+ session(id) {
3968
+ return new SandboxSession(this, id);
3969
+ }
3970
+ /**
3971
+ * List sessions on this sandbox, optionally filtering by status. Returns
3972
+ * `SandboxSession` instances paired with their last-known
3973
+ * {@link SessionInfo} so callers can avoid an extra round-trip per
3974
+ * session for status.
3975
+ */
3976
+ async sessions(opts) {
3977
+ await this.ensureRunning();
3978
+ const search = new URLSearchParams();
3979
+ if (opts?.backend) search.set("backend", opts.backend);
3980
+ const qs = search.toString();
3981
+ const response = await this.runtimeFetch(`/agents/sessions${qs ? `?${qs}` : ""}`);
3982
+ if (!response.ok) {
3983
+ const body = await response.text();
3984
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
3985
+ }
3986
+ const list = await response.json();
3987
+ const out = [];
3988
+ for (const raw of list) {
3989
+ const info = normalizeSessionInfo(raw);
3990
+ if (!info) continue;
3991
+ if (opts?.status && info.status !== opts.status) continue;
3992
+ out.push({
3993
+ session: this.session(info.id),
3994
+ info
3995
+ });
3996
+ }
3997
+ return out;
3998
+ }
3999
+ /**
4000
+ * Dispatch a prompt and return immediately with the session id (Issue
4001
+ * #913 Gap 2). The sandbox keeps running the prompt after this call
4002
+ * returns; reconnect via `box.session(id).events()` or wait for
4003
+ * completion with `box.session(id).result()`.
4004
+ *
4005
+ * Idempotent on `opts.sessionId`: re-dispatching with the same id when
4006
+ * the session is already running is a lookup, not a re-create. This
4007
+ * lets queue retries and reconnect-after-Worker-restart be safe by
4008
+ * construction.
4009
+ */
4010
+ async dispatchPrompt(message, opts) {
4011
+ await this.ensureRunning();
4012
+ if (opts?.sessionId) {
4013
+ const existing = await this._sessionStatus(opts.sessionId);
4014
+ if (existing) return {
4015
+ sessionId: existing.id,
4016
+ status: existing.status,
4017
+ alreadyExisted: true
4018
+ };
4019
+ }
4020
+ const ctrl = new AbortController();
4021
+ const stream = this.streamPrompt(message, {
4022
+ ...opts,
4023
+ detach: true,
4024
+ signal: ctrl.signal
4025
+ });
4026
+ let resolvedSessionId = opts?.sessionId;
4027
+ try {
4028
+ for await (const event of stream) {
4029
+ const id = event.data?.sessionId;
4030
+ if (typeof id === "string" && id.length > 0) {
4031
+ resolvedSessionId = id;
4032
+ break;
4033
+ }
4034
+ }
4035
+ } finally {
4036
+ ctrl.abort();
4037
+ }
4038
+ if (!resolvedSessionId) throw new ServerError("dispatchPrompt did not receive a session id from the sandbox");
4039
+ return {
4040
+ sessionId: resolvedSessionId,
4041
+ status: "running",
4042
+ alreadyExisted: false
4043
+ };
4044
+ }
4045
+ /**
4046
+ * List messages for a session, including in-flight assistant content
4047
+ * the agent is still streaming. Each entry's `metadata` carries the
4048
+ * durability marker — `status: "streaming" | "completed" | "interrupted"`,
4049
+ * `completed/interrupted` booleans, and the caller-supplied `turnId`
4050
+ * when one was set. See `SessionMessage` for the full contract.
4051
+ *
4052
+ * Polling this is the right way to detect "did the sidecar die mid-
4053
+ * turn?" — a SIGKILL leaves the assistant message with `status:
4054
+ * "streaming"` and no `completed`/`interrupted` marker; a graceful
4055
+ * abort stamps `interrupted: true` explicitly.
4056
+ */
4057
+ async messages(opts) {
4058
+ await this.ensureRunning();
4059
+ const params = new URLSearchParams();
4060
+ if (opts.limit !== void 0) params.set("limit", String(opts.limit));
4061
+ if (opts.offset !== void 0) params.set("offset", String(opts.offset));
4062
+ if (opts.since !== void 0) params.set("since", String(opts.since));
4063
+ const query = params.toString() ? `?${params.toString()}` : "";
4064
+ const response = await this.client.fetch(`/v1/sessions/${encodeURIComponent(opts.sessionId)}/messages${query}`, { method: "GET" });
4065
+ if (!response.ok) {
4066
+ const body = await response.text();
4067
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4068
+ }
4069
+ return (await response.json()).map((m) => ({
4070
+ id: m.info.id,
4071
+ role: m.info.role,
4072
+ timestamp: m.info.timestamp,
4073
+ parts: m.parts,
4074
+ metadata: m.info.metadata
4075
+ }));
4076
+ }
4077
+ /**
4078
+ * Look up a cached turn result by idempotency key. Returns the cached
4079
+ * payload if a turn with this `turnId` previously completed on the
4080
+ * given session; returns `null` if no such turn has finished yet
4081
+ * (either it never started, or it interrupted before completion).
4082
+ *
4083
+ * Call this before re-issuing a `streamPrompt` / `prompt` / `task`
4084
+ * that you might be retrying — a non-null result means the original
4085
+ * attempt finished and you can return that to your caller instead of
4086
+ * running the agent a second time. Only turns that reach the
4087
+ * `completed` terminal state are cached; interrupted turns are not.
4088
+ */
4089
+ async findCompletedTurn(turnId, opts) {
4090
+ await this.ensureRunning();
4091
+ const response = await this.client.fetch(`/v1/sessions/${encodeURIComponent(opts.sessionId)}/turns/${encodeURIComponent(turnId)}`, { method: "GET" });
4092
+ if (response.status === 404) return null;
4093
+ if (!response.ok) {
4094
+ const body = await response.text();
4095
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4096
+ }
4097
+ return await response.json();
4098
+ }
4099
+ /**
4100
+ * Drive a detached turn forward by exactly one settle → poll → dispatch
4101
+ * pass and report where it stands. Built for tick-based callers —
4102
+ * Cloudflare Workflows steps, queue consumers, crons — that re-invoke
4103
+ * on their own schedule instead of holding a stream open. One
4104
+ * invocation never loops, never sleeps, and never keeps a connection
4105
+ * alive past the pass.
4106
+ *
4107
+ * The pass resolves to the first of:
4108
+ * 1. The completed-turn cache has `turnId` → `completed`, or `failed`
4109
+ * when the cached payload carries no text — that result is final,
4110
+ * so a retry cannot improve it.
4111
+ * 2. The session is queued/running → `running`, after enforcing
4112
+ * `wallCapMs`: a session past the cap is cancelled and reported
4113
+ * `failed`.
4114
+ * 3. The session is terminal without a cached turn → settle from the
4115
+ * session result; an unsuccessful result is `failed`.
4116
+ * 4. No session exists → dispatch fire-and-detach (idempotent on
4117
+ * `sessionId`, exactly like `dispatchPrompt`) → `running`.
4118
+ *
4119
+ * `failed` is always deterministic: re-invoking with the same ids
4120
+ * returns the same outcome rather than starting a second agent run, so
4121
+ * callers can treat it as terminal without their own retry bookkeeping.
4122
+ */
4123
+ async driveTurn(message, opts) {
4124
+ const turnId = opts.turnId ?? opts.sessionId;
4125
+ const turn = await this.findCompletedTurn(turnId, { sessionId: opts.sessionId });
4126
+ if (turn) return settleTurnDrive(turn.result);
4127
+ const info = await this._sessionStatus(opts.sessionId);
4128
+ if (info && (info.status === "running" || info.status === "queued")) {
4129
+ const startedAt = info.startedAt ?? info.createdAt;
4130
+ const elapsedMs = startedAt ? Date.now() - startedAt.getTime() : void 0;
4131
+ if (opts.wallCapMs !== void 0 && elapsedMs !== void 0 && elapsedMs > opts.wallCapMs) {
4132
+ await this._sessionCancel(opts.sessionId);
4133
+ return {
4134
+ state: "failed",
4135
+ error: `session ${opts.sessionId} exceeded the ${opts.wallCapMs}ms wall-clock cap and was cancelled`
4136
+ };
4137
+ }
4138
+ return {
4139
+ state: "running",
4140
+ ...startedAt ? { startedAt } : {},
4141
+ ...elapsedMs !== void 0 ? { elapsedMs } : {}
4142
+ };
4143
+ }
4144
+ if (info) {
4145
+ const result = await this._sessionResult(opts.sessionId);
4146
+ if (!result.success) return {
4147
+ state: "failed",
4148
+ error: `session ${opts.sessionId} ${info.status}: ${result.error ?? "no error detail"}`
4149
+ };
4150
+ return settleTurnDrive({ ...result });
4151
+ }
4152
+ await this.dispatchPrompt(message, {
4153
+ ...opts,
4154
+ turnId
4155
+ });
4156
+ return { state: "running" };
4157
+ }
4158
+ /**
4159
+ * Mint a scoped, time-bounded JWT for direct browser access to this
4160
+ * sandbox (Issue #913 Gap 1). Authority is the caller's
4161
+ * `TANGLE_API_KEY` (sk-tan-*) — the Sandbox API mints the token;
4162
+ * signing secrets stay server-side.
4163
+ *
4164
+ * Use this to give a browser direct read access to the sandbox without
4165
+ * leaking the full bearer (`box.connection.authToken`). The returned
4166
+ * token verifies against the same sidecar middleware that already
4167
+ * gates ProductTokenIssuer-issued JWTs — no new sidecar surface.
4168
+ */
4169
+ async mintScopedToken(opts) {
4170
+ const response = await this.client.fetch(`/v1/sandboxes/${encodeURIComponent(this.id)}/scoped-token`, {
4171
+ method: "POST",
4172
+ headers: { "Content-Type": "application/json" },
4173
+ body: JSON.stringify({
4174
+ scope: opts.scope,
4175
+ ...opts.sessionId ? { sessionId: opts.sessionId } : {},
4176
+ ...opts.ttlMinutes ? { ttlMinutes: opts.ttlMinutes } : {}
4177
+ })
4178
+ });
4179
+ if (!response.ok) {
4180
+ const body = await response.text();
4181
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4182
+ }
4183
+ const data = await response.json();
4184
+ if (typeof data.sidecarUrl !== "string" || typeof data.sidecarProxyUrl !== "string") throw new Error("Scoped token response missing runtime URLs.");
4185
+ return {
4186
+ token: data.token,
4187
+ expiresAt: /* @__PURE__ */ new Date(data.expiresAt * 1e3),
4188
+ runtimeUrl: data.sidecarUrl,
4189
+ sidecarProxyUrl: data.sidecarProxyUrl,
4190
+ scope: data.scope
4191
+ };
4192
+ }
4193
+ /** @internal — invoked by SandboxSession.status(). */
4194
+ async _sessionStatus(id) {
4195
+ await this.ensureRunning();
4196
+ const response = await this.runtimeFetch(`/agents/sessions/${encodeURIComponent(id)}`);
4197
+ if (response.status === 404) return null;
4198
+ if (!response.ok) {
4199
+ const body = await response.text();
4200
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4201
+ }
4202
+ return normalizeSessionInfo(await response.json());
4203
+ }
4204
+ /** @internal — invoked by SandboxSession.events(). */
4205
+ async *_sessionEvents(id, opts) {
4206
+ await this.ensureRunning();
4207
+ const search = new URLSearchParams();
4208
+ search.set("sessionId", id);
4209
+ if (opts?.since) search.set("since", opts.since);
4210
+ const response = await this.runtimeFetch(`/agents/events/?${search.toString()}`, { signal: opts?.signal });
4211
+ if (!response.ok) {
4212
+ const body = await response.text();
4213
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4214
+ }
4215
+ yield* this.parseSSEStream(response, opts?.signal);
4216
+ }
4217
+ /** @internal — invoked by SandboxSession.result(). */
4218
+ async _sessionResult(id) {
4219
+ const startTime = Date.now();
4220
+ let response;
4221
+ let runError;
4222
+ let traceId;
4223
+ let usage;
4224
+ let costUsd;
4225
+ let toolInvocations;
4226
+ let question;
4227
+ let terminalReached = false;
4228
+ for await (const event of this._sessionEvents(id)) {
4229
+ response = applySandboxEventText(response, event);
4230
+ if (event.type === "result" || event.type === "done") {
4231
+ const data = event.data;
4232
+ usage = readTokenUsage(data) ?? usage;
4233
+ costUsd = readTokenCostUsd(data) ?? costUsd;
4234
+ }
4235
+ if (event.type === "result") {
4236
+ const data = event.data;
4237
+ toolInvocations = readToolInvocations(data) ?? toolInvocations;
4238
+ }
4239
+ if (event.type === "interaction") question = readQuestionEvent(event.data) ?? question;
4240
+ if (event.type === "trace.id") traceId = event.data.traceId;
4241
+ if (event.type === "error") runError = event.data.message;
4242
+ if (event.type === "done" || event.type === "result") {
4243
+ terminalReached = true;
4244
+ break;
4245
+ }
4246
+ }
4247
+ const approval = detectHubApproval(toolInvocations);
4248
+ const outcome = deriveOutcome({
4249
+ terminalReached,
4250
+ runError,
4251
+ toolInvocations,
4252
+ approval,
4253
+ question
4254
+ });
4255
+ return {
4256
+ success: outcome.success,
4257
+ status: outcome.status,
4258
+ response,
4259
+ error: outcome.error,
4260
+ toolInvocations,
4261
+ approval,
4262
+ question,
4263
+ traceId,
4264
+ durationMs: Date.now() - startTime,
4265
+ usage,
4266
+ costUsd
4267
+ };
4268
+ }
4269
+ /**
4270
+ * @internal — invoked by SandboxSession.cancel(). Void-returning alias of
4271
+ * `_interrupt`: both abort the in-flight execution via `POST /{id}/abort`
4272
+ * (the session and its messages are preserved). `cancel()` discards the
4273
+ * `cancelled` flag; callers that need to distinguish a real interruption
4274
+ * from a no-op should use `interrupt()`.
4275
+ */
4276
+ async _sessionCancel(id) {
4277
+ await this._interrupt(id);
4278
+ }
4279
+ /** @internal — invoked by InteractiveSessionHandle.start(). */
4280
+ async _startInteractive(id, options) {
4281
+ await this.ensureRunning();
4282
+ const response = await this.runtimeFetch(`/agents/sessions/${encodeURIComponent(id)}/interactive`, {
4283
+ method: "POST",
4284
+ body: JSON.stringify(options)
4285
+ });
4286
+ if (!response.ok) {
4287
+ const body = await response.text();
4288
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4289
+ }
4290
+ const data = (await response.json())?.data;
4291
+ if (!data || typeof data.sessionId !== "string" || typeof data.streamUrl !== "string") throw new ServerError("Interactive session response missing data", 502, {
4292
+ endpoint: `/agents/sessions/${encodeURIComponent(id)}/interactive`,
4293
+ origin: "runtime"
4294
+ });
4295
+ return data;
4296
+ }
4297
+ /** @internal — invoked by InteractiveSessionHandle.sendPrompt(). */
4298
+ async _sendInteractivePrompt(id, prompt) {
4299
+ await this.ensureRunning();
4300
+ const response = await this.runtimeFetch(`/agents/sessions/${encodeURIComponent(id)}/interactive/prompt`, {
4301
+ method: "POST",
4302
+ body: JSON.stringify({ prompt })
4303
+ });
4304
+ if (!response.ok) {
4305
+ const body = await response.text();
4306
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4307
+ }
4308
+ }
4309
+ /** @internal — invoked by InteractiveSessionHandle.stop(). */
4310
+ async _stopInteractive(id) {
4311
+ await this.ensureRunning();
4312
+ const response = await this.runtimeFetch(`/agents/sessions/${encodeURIComponent(id)}/interactive`, { method: "DELETE" });
4313
+ if (!response.ok && response.status !== 404) {
4314
+ const body = await response.text();
4315
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4316
+ }
4317
+ }
4318
+ /** @internal — invoked by SandboxSession.respondToPermission(). */
4319
+ async _respondToPermission(id, permissionID, options) {
4320
+ await this.ensureRunning();
4321
+ const body = options.response === "allow" ? {
4322
+ id: permissionID,
4323
+ outcome: "accepted",
4324
+ data: { grant: "allow_once" }
4325
+ } : {
4326
+ id: permissionID,
4327
+ outcome: "declined"
4328
+ };
4329
+ const response = await this.runtimeFetch(`/agents/sessions/${encodeURIComponent(id)}/interactions`, {
4330
+ method: "POST",
4331
+ body: JSON.stringify(body)
4332
+ });
4333
+ if (!response.ok) {
4334
+ const errBody = await response.text();
4335
+ throw parseErrorResponse(response.status, errBody, void 0, response.headers);
4336
+ }
4337
+ }
4338
+ /** @internal — invoked by SandboxSession.interrupt(). */
4339
+ async _interrupt(id) {
4340
+ await this.ensureRunning();
4341
+ const response = await this.runtimeFetch(`/agents/sessions/${encodeURIComponent(id)}/abort`, { method: "POST" });
4342
+ if (response.status === 404) return { cancelled: false };
4343
+ if (!response.ok) {
4344
+ const body = await response.text();
4345
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4346
+ }
4347
+ return { cancelled: (await response.json()).data?.cancelled === true };
4348
+ }
4349
+ /**
4350
+ * @internal — invoked by SandboxSession.answer(). Resolves the session's
4351
+ * outstanding question interaction and posts the answer through the
4352
+ * interaction response route. The positional answer arrays map onto the
4353
+ * question's `answerSpec` fields in order.
4354
+ */
4355
+ async _answerQuestion(id, answers) {
4356
+ await this.ensureRunning();
4357
+ const listResp = await this.runtimeFetch(`/agents/sessions/${encodeURIComponent(id)}/interactions`, { method: "GET" });
4358
+ if (!listResp.ok) {
4359
+ const body = await listResp.text();
4360
+ throw parseErrorResponse(listResp.status, body, void 0, listResp.headers);
4361
+ }
4362
+ const outstanding = ((await listResp.json()).data?.interactions ?? []).find((r) => r.kind === "question");
4363
+ if (!outstanding) throw new Error("No outstanding question to answer for this session");
4364
+ const positional = Object.values(answers);
4365
+ const data = {};
4366
+ outstanding.answerSpec.fields.forEach((field, index) => {
4367
+ const raw = answers[field.name] ?? answers[String(index)] ?? positional[index] ?? [];
4368
+ switch (field.type) {
4369
+ case "select":
4370
+ data[field.name] = raw;
4371
+ break;
4372
+ case "number":
4373
+ data[field.name] = Number(raw[0] ?? 0);
4374
+ break;
4375
+ case "boolean":
4376
+ data[field.name] = raw[0] === "true";
4377
+ break;
4378
+ default: data[field.name] = raw[0] ?? "";
4379
+ }
4380
+ });
4381
+ const response = await this.runtimeFetch(`/agents/sessions/${encodeURIComponent(id)}/interactions`, {
4382
+ method: "POST",
4383
+ body: JSON.stringify({
4384
+ id: outstanding.id,
4385
+ outcome: "accepted",
4386
+ data
4387
+ })
4388
+ });
4389
+ if (!response.ok) {
4390
+ const body = await response.text();
4391
+ throw parseErrorResponse(response.status, body, void 0, response.headers);
4392
+ }
4393
+ }
4394
+ };
4395
+ function settleTurnDrive(result) {
4396
+ const text = typeof result.text === "string" ? result.text : typeof result.response === "string" ? result.response : void 0;
4397
+ const status = typeof result.status === "string" ? result.status : deriveOutcome({
4398
+ terminalReached: true,
4399
+ runError: typeof result.error === "string" ? result.error : void 0,
4400
+ toolInvocations: Array.isArray(result.toolInvocations) ? result.toolInvocations : void 0
4401
+ }).status;
4402
+ if (status !== "success") return {
4403
+ state: "failed",
4404
+ error: typeof result.error === "string" && result.error.length > 0 ? result.error : `turn ${status}`
4405
+ };
4406
+ return {
4407
+ state: "completed",
4408
+ text: text ?? "",
4409
+ result
4410
+ };
4411
+ }
4412
+ function normalizeSessionInfo(raw) {
4413
+ const id = raw.id;
4414
+ if (typeof id !== "string") return null;
4415
+ const status = raw.status || "running";
4416
+ return {
4417
+ id,
4418
+ status: [
4419
+ "queued",
4420
+ "running",
4421
+ "completed",
4422
+ "failed",
4423
+ "cancelled"
4424
+ ].includes(status) ? status : "running",
4425
+ backend: typeof raw.backendType === "string" ? raw.backendType : typeof raw.backend === "string" ? raw.backend : void 0,
4426
+ model: typeof raw.model === "string" ? raw.model : void 0,
4427
+ promptCount: typeof raw.promptCount === "number" ? raw.promptCount : void 0,
4428
+ createdAt: raw.createdAt ? new Date(raw.createdAt) : void 0,
4429
+ startedAt: raw.startedAt ? new Date(raw.startedAt) : void 0,
4430
+ endedAt: raw.endedAt ? new Date(raw.endedAt) : void 0,
4431
+ raw
4432
+ };
4433
+ }
4434
+ var DirectRuntimeHttpClient = class {
4435
+ baseClient;
4436
+ sandboxId;
4437
+ getConnection;
4438
+ onUnauthorized;
4439
+ constructor(baseClient, sandboxId, getConnection, onUnauthorized) {
4440
+ this.baseClient = baseClient;
4441
+ this.sandboxId = sandboxId;
4442
+ this.getConnection = getConnection;
4443
+ this.onUnauthorized = onUnauthorized;
4444
+ }
4445
+ async fetch(path, options, fetchOptions) {
4446
+ const runtimePrefix = `/v1/sandboxes/${encodeURIComponent(this.sandboxId)}/runtime`;
4447
+ if (!path.startsWith(runtimePrefix)) return this.baseClient.fetch(path, options, fetchOptions);
4448
+ const runtimePath = path.slice(runtimePrefix.length);
4449
+ if (runtimePath === "/network" || runtimePath.startsWith("/network/") || runtimePath === "/preview-links" || runtimePath.startsWith("/preview-links/")) return this.baseClient.fetch(path, options, fetchOptions);
4450
+ const connection = this.getConnection();
4451
+ const runtimeUrl = connection?.runtimeUrl;
4452
+ if (!runtimeUrl) throw new NetworkError("Sandbox has no direct runtime URL");
4453
+ const targetUrl = `${runtimeUrl.replace(/\/$/, "")}${runtimePath}`;
4454
+ const headers = new Headers(options?.headers);
4455
+ if (connection?.authToken) headers.set("Authorization", `Bearer ${connection.authToken}`);
4456
+ const isFormDataBody = typeof FormData !== "undefined" && options?.body instanceof FormData;
4457
+ if (!headers.has("Content-Type") && !isFormDataBody) headers.set("Content-Type", "application/json");
4458
+ const init = {
4459
+ ...options,
4460
+ headers
4461
+ };
4462
+ if (options?.body && typeof options.body === "object" && !isFormDataBody) init.duplex = "half";
4463
+ const directTimeoutMs = fetchOptions?.timeoutMs;
4464
+ if (!init.signal && typeof directTimeoutMs === "number" && directTimeoutMs > 0) init.signal = AbortSignal.timeout(directTimeoutMs);
4465
+ let response = await fetch(targetUrl, init);
4466
+ if (response.status === 401 && this.onUnauthorized && !headers.has("x-no-retry")) {
4467
+ await response.arrayBuffer().catch(() => void 0);
4468
+ try {
4469
+ await this.onUnauthorized();
4470
+ } catch {
4471
+ return new Response("", {
4472
+ status: 401,
4473
+ statusText: response.statusText
4474
+ });
4475
+ }
4476
+ const refreshedConnection = this.getConnection();
4477
+ if (refreshedConnection?.authToken) {
4478
+ headers.set("Authorization", `Bearer ${refreshedConnection.authToken}`);
4479
+ response = await fetch(targetUrl, {
4480
+ ...init,
4481
+ headers
4482
+ });
4483
+ }
4484
+ }
4485
+ const responseHeaders = new Headers(response.headers);
4486
+ responseHeaders.set("x-tangle-request-path", path);
4487
+ if (options?.method) responseHeaders.set("x-tangle-request-method", options.method);
4488
+ return new Response(response.body, {
4489
+ status: response.status,
4490
+ statusText: response.statusText,
4491
+ headers: responseHeaders
4492
+ });
4493
+ }
4494
+ };
4495
+ function quoteForShell(value) {
4496
+ if (value.includes("\0")) throw new Error("Shell argument contains NUL byte");
4497
+ return `'${value.replace(/'/g, "'\\''")}'`;
4498
+ }
4499
+ //#endregion
4500
+ export { collectAgentResponseText as a, buildTraceExportPayload as c, toOtelJson as d, encodePromptForWire as f, serializeForSidecar as h, applySandboxEventText as i, exportTraceBundle as l, normalizeRuntimeBackendConfig as m, SandboxSession as n, getSandboxEventText as o, parseSSEStream as p, InteractiveSessionHandle as r, normalizeConnection as s, SandboxInstance as t, otelTraceIdForTangleTrace as u };