@springbrand/agent-runtime 0.1.3-alpha.4 → 0.1.3-alpha.6

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.
Files changed (45) hide show
  1. package/package.json +11 -3
  2. package/src/adapter/cloudflare/index.ts +55 -0
  3. package/src/adapter/cloudflare/resources/runtime-resources.ts +89 -0
  4. package/src/adapter/cloudflare/sandbox/adapter.ts +1509 -0
  5. package/src/adapter/cloudflare/sandbox/id.ts +23 -0
  6. package/src/adapter/cloudflare/sandbox/policy.ts +15 -0
  7. package/src/adapter/cloudflare/subagent/definition.ts +574 -0
  8. package/src/adapter/cloudflare/subagent/runner.ts +175 -0
  9. package/src/adapter/cloudflare/subagent/tools.ts +254 -0
  10. package/src/adapter/cloudflare/universal-agent/hooks.ts +35 -0
  11. package/src/adapter/cloudflare/universal-agent/preparation.ts +273 -0
  12. package/src/adapter/cloudflare/universal-agent/tools.ts +74 -0
  13. package/src/adapter/cloudflare/workspace/publisher.ts +31 -0
  14. package/src/adapter/cloudflare/workspace/scoped-workspace.ts +376 -0
  15. package/src/agent-tool-runtime.ts +152 -0
  16. package/src/index.ts +49 -7
  17. package/src/kernel/bindings.ts +6 -6
  18. package/src/kernel/recoverable-chat-agent.ts +12 -0
  19. package/src/kernel/runtime-load.ts +89 -0
  20. package/src/layers/orchestration/temporary-agent/core.ts +12 -1
  21. package/src/layers/orchestration/temporary-agent/runner.ts +1 -2
  22. package/src/lib/mcp.ts +7 -3
  23. package/src/pi/assembly/context.ts +3 -3
  24. package/src/pi/assembly/extensions.ts +11 -22
  25. package/src/pi/assembly/snapshot.ts +1 -1
  26. package/src/pi/message/contract.ts +7 -0
  27. package/src/pi/message/conversion.ts +9 -1
  28. package/src/pi/runtime-adapter/assembly.ts +4 -10
  29. package/src/pi/runtime-adapter/index.ts +6 -2
  30. package/src/pi/tool/base.ts +17 -2
  31. package/src/pi/tool/compiler.ts +0 -1
  32. package/src/pi/tool/core.ts +13 -3
  33. package/src/pi/tool/mcp.ts +3 -4
  34. package/src/pi/tool/schedule.ts +11 -1
  35. package/src/pi/tool/skill.ts +55 -41
  36. package/src/pi/tool/subagent.ts +14 -2
  37. package/src/pi/tool/web-fetch.ts +0 -1
  38. package/src/pi/tool/web-search/web-search.ts +0 -1
  39. package/src/pi/tool/workspace-sandbox.ts +15 -7
  40. package/src/runtime-agent-context.ts +112 -0
  41. package/src/runtime-agent.ts +442 -328
  42. package/src/runtime-assembler.ts +255 -103
  43. package/src/runtime-definition.ts +173 -0
  44. package/src/runtime.ts +185 -25
  45. package/src/tool-registry.ts +143 -0
@@ -0,0 +1,1509 @@
1
+ import {
2
+ ContainerUnavailableError,
3
+ getSandbox,
4
+ OperationInterruptedError,
5
+ parseSSEStream,
6
+ RPCTransportError,
7
+ type ExecEvent,
8
+ type ExecOptions,
9
+ type ExecResult,
10
+ type ProcessOptions,
11
+ type Sandbox,
12
+ type SessionOptions,
13
+ type StreamOptions,
14
+ } from "@cloudflare/sandbox";
15
+ import {
16
+ SandboxPortError,
17
+ type RuntimeSandboxPort,
18
+ type SandboxErrorCode,
19
+ type SandboxExecInput,
20
+ type SandboxExecResult,
21
+ type SandboxProcessLogs,
22
+ type SandboxProcessSummary,
23
+ type SandboxStartProcessInput,
24
+ type SandboxSyncFailure,
25
+ type SandboxSyncResult,
26
+ type WorkspacePort,
27
+ } from "../../../kernel/bindings";
28
+ import type {
29
+ WorkspaceFileVersion,
30
+ WorkspacePublishPort,
31
+ } from "../workspace/publisher";
32
+ import { SANDBOX_HOST_POLICY } from "./policy";
33
+
34
+ const EXEC_SESSION_ID = "agent";
35
+ const PROCESS_SESSION_ID = "agent-processes";
36
+ const BACKGROUND_PROCESS_PREFIX = "ua-bg-";
37
+ const HYDRATION_MARKER =
38
+ "/tmp/.universal-agent/workspace-hydrated.json";
39
+ const WORKSPACE_PAGE_SIZE = 256;
40
+ const OUTPUT_BYTES = SANDBOX_HOST_POLICY.maxOutputBytes;
41
+ const MAX_PUBLISH_FILE_BYTES =
42
+ SANDBOX_HOST_POLICY.maxPublishFileBytes;
43
+ const MAX_HYDRATION_MANIFEST_BYTES =
44
+ SANDBOX_HOST_POLICY.maxHydrationManifestBytes;
45
+ const MAX_HYDRATION_MANIFEST_FILES =
46
+ SANDBOX_HOST_POLICY.maxHydrationFiles;
47
+ const DEFAULT_EXEC_TIMEOUT_MS =
48
+ SANDBOX_HOST_POLICY.defaultExecTimeoutMs;
49
+ const MAX_EXEC_TIMEOUT_MS =
50
+ SANDBOX_HOST_POLICY.maxExecTimeoutMs;
51
+ const EXEC_SESSION_CLEANUP_TIMEOUT_MS = 5_000;
52
+ const EXCLUDED_PUBLISH_SEGMENTS = new Set([
53
+ ".git",
54
+ ".venv",
55
+ "__pycache__",
56
+ "node_modules",
57
+ ]);
58
+
59
+ type SandboxEvent =
60
+ | "sandbox.container.cold_start"
61
+ | "sandbox.workspace.pulled"
62
+ | "sandbox.workspace.published"
63
+ | "sandbox.process.started"
64
+ | "sandbox.process.stopped";
65
+
66
+ export interface SandboxObserver {
67
+ emit(
68
+ event: SandboxEvent,
69
+ fields: Readonly<Record<string, string | number | boolean>>,
70
+ ): void;
71
+ }
72
+
73
+ export interface SandboxAdmission {
74
+ begin(operationId: string): Promise<{
75
+ granted: boolean;
76
+ reason?: "rate_limited" | "concurrency_limited";
77
+ }>;
78
+ end(operationId: string): Promise<void>;
79
+ }
80
+
81
+ interface SandboxFileEntry {
82
+ name: string;
83
+ absolutePath: string;
84
+ relativePath: string;
85
+ type: "file" | "directory" | "symlink" | "other";
86
+ size: number;
87
+ }
88
+
89
+ interface SandboxProcessLike {
90
+ id: string;
91
+ command: string;
92
+ status: string;
93
+ exitCode?: number;
94
+ }
95
+
96
+ interface HydrationManifest {
97
+ files: Map<string, WorkspaceFileVersion>;
98
+ }
99
+
100
+ interface SandboxSessionLike {
101
+ exec(command: string, options?: ExecOptions): Promise<ExecResult>;
102
+ execStream?(
103
+ command: string,
104
+ options?: StreamOptions,
105
+ ): Promise<ReadableStream<Uint8Array>>;
106
+ startProcess(
107
+ command: string,
108
+ options?: ProcessOptions,
109
+ ): Promise<SandboxProcessLike>;
110
+ }
111
+
112
+ interface SandboxClientLike {
113
+ createSession(options: SessionOptions): Promise<SandboxSessionLike>;
114
+ getSession(id: string): Promise<SandboxSessionLike>;
115
+ deleteSession(id: string): Promise<void>;
116
+ exists(path: string): Promise<boolean>;
117
+ mkdir(path: string): Promise<void>;
118
+ writeFile(
119
+ path: string,
120
+ content: string | ReadableStream<Uint8Array>,
121
+ ): Promise<void>;
122
+ readFileBytes(path: string): Promise<{
123
+ content: ReadableStream<Uint8Array>;
124
+ size: number;
125
+ mimeType: string;
126
+ }>;
127
+ deleteFile(path: string): Promise<void>;
128
+ listFiles(
129
+ path: string,
130
+ options?: { recursive?: boolean; includeHidden?: boolean },
131
+ ): Promise<readonly SandboxFileEntry[]>;
132
+ listProcesses(
133
+ sessionId: string,
134
+ ): Promise<readonly SandboxProcessLike[]>;
135
+ getProcess(
136
+ id: string,
137
+ sessionId: string,
138
+ ): Promise<SandboxProcessLike | null>;
139
+ getProcessLogs(id: string, sessionId: string): Promise<{
140
+ stdout: string;
141
+ stderr: string;
142
+ }>;
143
+ killProcess(id: string, sessionId: string): Promise<void>;
144
+ cleanupCompletedProcesses(sessionId: string): Promise<number>;
145
+ destroy(): Promise<void>;
146
+ }
147
+
148
+ type SandboxClientFactory = (
149
+ namespace: DurableObjectNamespace<Sandbox>,
150
+ sandboxId: string,
151
+ ) => SandboxClientLike;
152
+
153
+ export interface CloudflareSandboxAdapterOptions {
154
+ sandboxNamespace: DurableObjectNamespace<Sandbox>;
155
+ sandboxId: string;
156
+ workspace: WorkspacePort;
157
+ publisher: WorkspacePublishPort;
158
+ admission?: SandboxAdmission;
159
+ observer?: SandboxObserver;
160
+ clientFactory?: SandboxClientFactory;
161
+ }
162
+
163
+ function sdkClientFactory(
164
+ namespace: DurableObjectNamespace<Sandbox>,
165
+ sandboxId: string,
166
+ ): SandboxClientLike {
167
+ const sandbox = getSandbox(namespace, sandboxId, {
168
+ transport: "rpc",
169
+ enableDefaultSession: false,
170
+ keepAlive: false,
171
+ normalizeId: true,
172
+ });
173
+
174
+ const wrapProcess = (process: {
175
+ id: string;
176
+ command: string;
177
+ status: string;
178
+ exitCode?: number;
179
+ }): SandboxProcessLike => ({
180
+ id: process.id,
181
+ command: process.command,
182
+ status: process.status,
183
+ ...(process.exitCode === undefined
184
+ ? {}
185
+ : { exitCode: process.exitCode }),
186
+ });
187
+
188
+ const wrapSession = (session: {
189
+ exec(command: string, options?: ExecOptions): Promise<ExecResult>;
190
+ execStream(
191
+ command: string,
192
+ options?: StreamOptions,
193
+ ): Promise<ReadableStream<Uint8Array>>;
194
+ startProcess(
195
+ command: string,
196
+ options?: ProcessOptions,
197
+ ): Promise<{
198
+ id: string;
199
+ command: string;
200
+ status: string;
201
+ exitCode?: number;
202
+ }>;
203
+ }): SandboxSessionLike => ({
204
+ exec: (command, options) => session.exec(command, options),
205
+ execStream: (command, options) =>
206
+ session.execStream(command, options),
207
+ startProcess: async (command, options) =>
208
+ wrapProcess(await session.startProcess(command, options)),
209
+ });
210
+
211
+ return {
212
+ createSession: async (options) =>
213
+ wrapSession(await sandbox.createSession(options)),
214
+ getSession: async (id) =>
215
+ wrapSession(await sandbox.getSession(id)),
216
+ deleteSession: async (id) => {
217
+ await sandbox.deleteSession(id);
218
+ },
219
+ exists: async (path) => (await sandbox.exists(path)).exists,
220
+ mkdir: async (path) => {
221
+ await sandbox.mkdir(path, { recursive: true });
222
+ },
223
+ writeFile: async (path, content) => {
224
+ await sandbox.writeFile(path, content);
225
+ },
226
+ readFileBytes: async (path) => {
227
+ const result = await sandbox.readFile(path, {
228
+ encoding: "none",
229
+ });
230
+ return {
231
+ content: result.content,
232
+ size: result.size,
233
+ mimeType: result.mimeType,
234
+ };
235
+ },
236
+ deleteFile: async (path) => {
237
+ await sandbox.deleteFile(path);
238
+ },
239
+ listFiles: async (path, options) => {
240
+ const result = await sandbox.listFiles(path, options);
241
+ return result.files.map((entry) => ({
242
+ name: entry.name,
243
+ absolutePath: entry.absolutePath,
244
+ relativePath: entry.relativePath,
245
+ type: entry.type,
246
+ size: entry.size,
247
+ }));
248
+ },
249
+ listProcesses: async (sessionId) =>
250
+ (await sandbox.listProcesses(sessionId)).map(wrapProcess),
251
+ getProcess: async (id, sessionId) => {
252
+ const process = await sandbox.getProcess(id, sessionId);
253
+ return process ? wrapProcess(process) : null;
254
+ },
255
+ getProcessLogs: async (id, sessionId) => {
256
+ const logs = await sandbox.getProcessLogs(id, sessionId);
257
+ return {
258
+ stdout: logs.stdout,
259
+ stderr: logs.stderr,
260
+ };
261
+ },
262
+ killProcess: async (id, sessionId) => {
263
+ await sandbox.killProcess(id, "SIGTERM", sessionId);
264
+ },
265
+ cleanupCompletedProcesses: (sessionId) =>
266
+ sandbox.cleanupCompletedProcesses(sessionId),
267
+ destroy: () => sandbox.destroy(),
268
+ };
269
+ }
270
+
271
+ function streamBytes(bytes: Uint8Array): ReadableStream<Uint8Array> {
272
+ return new ReadableStream({
273
+ start(controller) {
274
+ controller.enqueue(bytes);
275
+ controller.close();
276
+ },
277
+ });
278
+ }
279
+
280
+ function normalizedPath(
281
+ value: string | undefined,
282
+ roots: readonly string[],
283
+ defaultPath: string,
284
+ ): string {
285
+ if (value === undefined || !value.trim()) return defaultPath;
286
+ if (value.includes("\0")) {
287
+ throw new SandboxPortError("invalid_path", "path contains a NUL byte");
288
+ }
289
+ const portable = value.trim().replaceAll("\\", "/");
290
+ const segments = portable.split("/").filter(Boolean);
291
+ if (segments.some((segment) => segment === "." || segment === "..")) {
292
+ throw new SandboxPortError(
293
+ "invalid_path",
294
+ "path traversal is not allowed",
295
+ );
296
+ }
297
+ const absolute = portable.startsWith("/")
298
+ ? `/${segments.join("/")}`
299
+ : `${defaultPath}/${segments.join("/")}`;
300
+ if (
301
+ !roots.some(
302
+ (root) => pathInside(root, absolute),
303
+ )
304
+ ) {
305
+ throw new SandboxPortError(
306
+ "invalid_path",
307
+ `path must be inside ${roots.join(" or ")}`,
308
+ );
309
+ }
310
+ return absolute;
311
+ }
312
+
313
+ function pathInside(root: string, path: string): boolean {
314
+ if (root === "/") return path.startsWith("/");
315
+ return path === root || path.startsWith(`${root}/`);
316
+ }
317
+
318
+ function canonicalPathInside(
319
+ path: string,
320
+ root: string,
321
+ ): string {
322
+ const normalized = normalizedPath(path, [root], root);
323
+ if (normalized !== path) {
324
+ throw new SandboxPortError(
325
+ "invalid_path",
326
+ `non-canonical path is not allowed: ${path}`,
327
+ );
328
+ }
329
+ return normalized;
330
+ }
331
+
332
+ function outputTail(value: string, limit = OUTPUT_BYTES): {
333
+ value: string;
334
+ truncated: boolean;
335
+ } {
336
+ const encoded = new TextEncoder().encode(value);
337
+ if (encoded.byteLength <= limit) {
338
+ return { value, truncated: false };
339
+ }
340
+ const tail = new TextDecoder().decode(
341
+ encoded.slice(encoded.byteLength - limit),
342
+ );
343
+ return {
344
+ value: tail.startsWith("\uFFFD") ? tail.slice(1) : tail,
345
+ truncated: true,
346
+ };
347
+ }
348
+
349
+ function appendOutputTail(
350
+ current: string,
351
+ chunk: string,
352
+ ): { value: string; truncated: boolean } {
353
+ return outputTail(`${current}${chunk}`);
354
+ }
355
+
356
+ async function readBoundedStream(
357
+ stream: ReadableStream<Uint8Array>,
358
+ expectedBytes: number,
359
+ limit = MAX_PUBLISH_FILE_BYTES,
360
+ errorCode: SandboxErrorCode = "publish_too_large",
361
+ ): Promise<Uint8Array> {
362
+ if (expectedBytes > limit) {
363
+ throw new SandboxPortError(
364
+ errorCode,
365
+ `stream exceeds the ${limit}-byte transport limit`,
366
+ );
367
+ }
368
+ const chunks: Uint8Array[] = [];
369
+ let total = 0;
370
+ const reader = stream.getReader();
371
+ try {
372
+ while (true) {
373
+ const { done, value } = await reader.read();
374
+ if (done) break;
375
+ total += value.byteLength;
376
+ if (total > limit) {
377
+ await reader.cancel("stream transport limit exceeded");
378
+ throw new SandboxPortError(
379
+ errorCode,
380
+ `stream exceeds the ${limit}-byte transport limit`,
381
+ );
382
+ }
383
+ chunks.push(value);
384
+ }
385
+ } finally {
386
+ reader.releaseLock();
387
+ }
388
+ const bytes = new Uint8Array(total);
389
+ let offset = 0;
390
+ for (const chunk of chunks) {
391
+ bytes.set(chunk, offset);
392
+ offset += chunk.byteLength;
393
+ }
394
+ return bytes;
395
+ }
396
+
397
+ function errorMessage(error: unknown): string {
398
+ return error instanceof Error ? error.message : String(error);
399
+ }
400
+
401
+ function alreadyExists(error: unknown): boolean {
402
+ const message = errorMessage(error).toLowerCase();
403
+ return (
404
+ message.includes("session_already_exists") ||
405
+ message.includes("already exists")
406
+ );
407
+ }
408
+
409
+ function timedOut(error: unknown): boolean {
410
+ const message = errorMessage(error).toLowerCase();
411
+ return message.includes("timeout") || message.includes("timed out");
412
+ }
413
+
414
+ function mappedError(error: unknown): SandboxPortError {
415
+ if (error instanceof SandboxPortError) return error;
416
+ const message = errorMessage(error).toLowerCase();
417
+ let code: SandboxErrorCode = "operation_failed";
418
+ if (
419
+ error instanceof ContainerUnavailableError ||
420
+ message.includes("container unavailable")
421
+ ) {
422
+ code = message.includes("capacity")
423
+ ? "capacity_unavailable"
424
+ : "startup_failed";
425
+ } else if (
426
+ error instanceof RPCTransportError ||
427
+ error instanceof OperationInterruptedError
428
+ ) {
429
+ code = "startup_failed";
430
+ } else if (timedOut(error)) {
431
+ code = "timeout";
432
+ }
433
+ return new SandboxPortError(code, "Sandbox operation failed");
434
+ }
435
+
436
+ function workspaceToSandboxPath(
437
+ path: string,
438
+ root: "/" | "/shared",
439
+ ): string {
440
+ if (root === "/shared") {
441
+ return path === "/shared" ? "/shared" : path;
442
+ }
443
+ return path === "/" ? "/workspace" : `/workspace${path}`;
444
+ }
445
+
446
+ function sandboxToWorkspacePath(path: string): string {
447
+ if (path === "/workspace") return "/";
448
+ if (path.startsWith("/workspace/")) {
449
+ return path.slice("/workspace".length);
450
+ }
451
+ return path;
452
+ }
453
+
454
+ function parentPath(path: string): string {
455
+ const index = path.lastIndexOf("/");
456
+ return index <= 0 ? "/" : path.slice(0, index);
457
+ }
458
+
459
+ function syncFailure(
460
+ path: string,
461
+ error: unknown,
462
+ ): SandboxSyncFailure {
463
+ return {
464
+ path,
465
+ errorCode:
466
+ error instanceof SandboxPortError
467
+ ? error.code
468
+ : "operation_failed",
469
+ };
470
+ }
471
+
472
+ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
473
+ private readonly client: SandboxClientLike;
474
+ private readonly sessions = new Map<string, SandboxSessionLike>();
475
+ private hydration?: Promise<SandboxSyncResult>;
476
+ private manifest?: HydrationManifest;
477
+ private serial: Promise<void> = Promise.resolve();
478
+
479
+ constructor(private readonly options: CloudflareSandboxAdapterOptions) {
480
+ this.client = (
481
+ options.clientFactory ?? sdkClientFactory
482
+ )(options.sandboxNamespace, options.sandboxId);
483
+ }
484
+
485
+ async exec(
486
+ input: SandboxExecInput,
487
+ signal?: AbortSignal,
488
+ ): Promise<SandboxExecResult> {
489
+ signal?.throwIfAborted();
490
+ return this.withAdmission(() =>
491
+ this.enqueue(async () => {
492
+ await this.ensureHydrated();
493
+ const cwd = normalizedPath(
494
+ input.cwd,
495
+ ["/workspace", "/shared", "/tmp"],
496
+ "/workspace",
497
+ );
498
+ const timeout = Math.min(
499
+ Math.max(input.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS, 1),
500
+ MAX_EXEC_TIMEOUT_MS,
501
+ );
502
+ const session = await this.ensureSession(
503
+ EXEC_SESSION_ID,
504
+ "/workspace",
505
+ MAX_EXEC_TIMEOUT_MS,
506
+ );
507
+ let prepared:
508
+ | { command: string; env?: Record<string, string>; stdinPath?: string }
509
+ | undefined;
510
+ try {
511
+ prepared = await this.prepareCommand(input.command, input.stdin);
512
+ const command = prepared.command;
513
+ const execOptions = {
514
+ cwd,
515
+ timeout,
516
+ ...(prepared.env ? { env: prepared.env } : {}),
517
+ };
518
+ return await this.runForeground(signal, timeout, async () => {
519
+ if (session.execStream) {
520
+ return await this.execBounded(
521
+ session,
522
+ command,
523
+ execOptions,
524
+ );
525
+ }
526
+ const result = await session.exec(
527
+ command,
528
+ execOptions,
529
+ );
530
+ const stdout = outputTail(result.stdout);
531
+ const stderr = outputTail(result.stderr);
532
+ return {
533
+ success: result.success,
534
+ stdout: stdout.value,
535
+ stderr: stderr.value,
536
+ exitCode: result.exitCode,
537
+ stdoutTruncated: stdout.truncated,
538
+ stderrTruncated: stderr.truncated,
539
+ };
540
+ });
541
+ } catch (error) {
542
+ if (timedOut(error) || signal?.aborted) {
543
+ await this.deleteExecutionSession();
544
+ }
545
+ signal?.throwIfAborted();
546
+ throw mappedError(error);
547
+ } finally {
548
+ if (prepared?.stdinPath) {
549
+ await this.cleanupWithin(
550
+ this.client.deleteFile(prepared.stdinPath),
551
+ );
552
+ }
553
+ }
554
+ }),
555
+ );
556
+ }
557
+
558
+ async startProcess(
559
+ input: SandboxStartProcessInput,
560
+ ): Promise<SandboxProcessSummary> {
561
+ return this.withAdmission(() =>
562
+ this.enqueue(async () => {
563
+ await this.ensureHydrated();
564
+ const cwd = normalizedPath(
565
+ input.cwd,
566
+ ["/workspace", "/shared", "/tmp"],
567
+ "/workspace",
568
+ );
569
+ const session = await this.ensureSession(
570
+ PROCESS_SESSION_ID,
571
+ "/workspace",
572
+ );
573
+ let prepared:
574
+ | { command: string; env?: Record<string, string>; stdinPath?: string }
575
+ | undefined;
576
+ try {
577
+ const running = await this.pruneProcessesForStart();
578
+ if (
579
+ running.length >=
580
+ SANDBOX_HOST_POLICY.maxBackgroundProcesses
581
+ ) {
582
+ throw new SandboxPortError(
583
+ "capacity_unavailable",
584
+ "background process limit reached",
585
+ );
586
+ }
587
+ prepared = await this.prepareCommand(
588
+ input.command,
589
+ input.stdin,
590
+ );
591
+ const process = await session.startProcess(prepared.command, {
592
+ cwd,
593
+ autoCleanup: false,
594
+ processId:
595
+ `${BACKGROUND_PROCESS_PREFIX}${Date.now().toString(36)}-` +
596
+ crypto.randomUUID(),
597
+ ...(prepared.env ? { env: prepared.env } : {}),
598
+ });
599
+ this.emit("sandbox.process.started", { success: true });
600
+ return this.processSummary(process);
601
+ } catch (error) {
602
+ throw mappedError(error);
603
+ } finally {
604
+ if (prepared?.stdinPath) {
605
+ await this.client
606
+ .deleteFile(prepared.stdinPath)
607
+ .catch(() => undefined);
608
+ }
609
+ }
610
+ }),
611
+ );
612
+ }
613
+
614
+ async getProcessLogs(id: string): Promise<SandboxProcessLogs> {
615
+ return this.withAdmission(async () => {
616
+ await this.ensureHydrated();
617
+ this.assertProcessId(id);
618
+ try {
619
+ const process = await this.client.getProcess(
620
+ id,
621
+ PROCESS_SESSION_ID,
622
+ );
623
+ if (!process) {
624
+ throw new SandboxPortError(
625
+ "not_found",
626
+ "background process was not found",
627
+ );
628
+ }
629
+ if (this.processExpired(id)) {
630
+ await this.client
631
+ .killProcess(id, PROCESS_SESSION_ID)
632
+ .catch(() => undefined);
633
+ throw new SandboxPortError(
634
+ "not_found",
635
+ "background process lease expired",
636
+ );
637
+ }
638
+ const logs = await this.client.getProcessLogs(
639
+ id,
640
+ PROCESS_SESSION_ID,
641
+ );
642
+ const stdout = outputTail(logs.stdout);
643
+ const stderr = outputTail(logs.stderr);
644
+ return {
645
+ id,
646
+ stdout: stdout.value,
647
+ stderr: stderr.value,
648
+ stdoutTruncated: stdout.truncated,
649
+ stderrTruncated: stderr.truncated,
650
+ };
651
+ } catch (error) {
652
+ throw mappedError(error);
653
+ }
654
+ });
655
+ }
656
+
657
+ async stopProcess(id: string): Promise<{ stopped: boolean }> {
658
+ return this.withAdmission(async () => {
659
+ await this.ensureHydrated();
660
+ this.assertProcessId(id);
661
+ try {
662
+ const process = await this.client.getProcess(
663
+ id,
664
+ PROCESS_SESSION_ID,
665
+ );
666
+ if (!process) {
667
+ throw new SandboxPortError(
668
+ "not_found",
669
+ "background process was not found",
670
+ );
671
+ }
672
+ if (this.processExpired(id)) {
673
+ await this.client
674
+ .killProcess(id, PROCESS_SESSION_ID)
675
+ .catch(() => undefined);
676
+ throw new SandboxPortError(
677
+ "not_found",
678
+ "background process lease expired",
679
+ );
680
+ }
681
+ await this.client.killProcess(id, PROCESS_SESSION_ID);
682
+ this.emit("sandbox.process.stopped", { success: true });
683
+ return { stopped: true };
684
+ } catch (error) {
685
+ throw mappedError(error);
686
+ }
687
+ });
688
+ }
689
+
690
+ async publishFiles(
691
+ paths: readonly string[],
692
+ ): Promise<SandboxSyncResult> {
693
+ return this.withAdmission(() =>
694
+ this.enqueue(async () => {
695
+ await this.ensureHydrated();
696
+ const manifest = this.manifest;
697
+ if (!manifest) {
698
+ throw new SandboxPortError(
699
+ "workspace_hydration_failed",
700
+ "Sandbox hydration snapshot is unavailable",
701
+ );
702
+ }
703
+ const result: {
704
+ files: string[];
705
+ skipped: string[];
706
+ failed: SandboxSyncFailure[];
707
+ bytes: number;
708
+ } = {
709
+ files: [],
710
+ skipped: [],
711
+ failed: [],
712
+ bytes: 0,
713
+ };
714
+
715
+ const entries = new Map<string, SandboxFileEntry>();
716
+ for (const input of paths) {
717
+ const path = normalizedPath(
718
+ input,
719
+ ["/workspace"],
720
+ "/workspace",
721
+ );
722
+ let selected: readonly SandboxFileEntry[];
723
+ try {
724
+ await this.assertNoSymlinkComponents(path);
725
+ selected = await this.collectPublishEntries(path);
726
+ } catch (error) {
727
+ if (
728
+ error instanceof SandboxPortError &&
729
+ error.code === "invalid_path"
730
+ ) {
731
+ throw error;
732
+ }
733
+ result.failed.push(syncFailure(path, error));
734
+ continue;
735
+ }
736
+ for (const entry of selected) {
737
+ this.assertPublishEntry(path, entry);
738
+ if (entry.type === "symlink") {
739
+ throw new SandboxPortError(
740
+ "invalid_path",
741
+ `symbolic links cannot be published: ${entry.absolutePath}`,
742
+ );
743
+ }
744
+ if (this.isExcludedPublishPath(entry.absolutePath)) {
745
+ result.skipped.push(entry.absolutePath);
746
+ } else if (entry.type === "file") {
747
+ entries.set(entry.absolutePath, entry);
748
+ } else if (entry.type === "directory") {
749
+ // Parent directories are created by the persistent Workspace
750
+ // write. Empty directories are intentionally not persisted.
751
+ } else {
752
+ result.skipped.push(entry.absolutePath);
753
+ }
754
+ }
755
+ }
756
+
757
+ if (
758
+ entries.size > SANDBOX_HOST_POLICY.maxPublishFiles
759
+ ) {
760
+ throw new SandboxPortError(
761
+ "publish_limit_exceeded",
762
+ "publish selection exceeds the file-count limit",
763
+ );
764
+ }
765
+ const ordered = [...entries.values()].sort((left, right) =>
766
+ left.absolutePath.localeCompare(right.absolutePath),
767
+ );
768
+ let selectedBytes = 0;
769
+ for (const entry of ordered) {
770
+ if (entry.size > MAX_PUBLISH_FILE_BYTES) {
771
+ throw new SandboxPortError(
772
+ "publish_too_large",
773
+ `publish file exceeds the byte limit: ${entry.absolutePath}`,
774
+ );
775
+ }
776
+ selectedBytes += entry.size;
777
+ if (
778
+ selectedBytes >
779
+ SANDBOX_HOST_POLICY.maxPublishBytes
780
+ ) {
781
+ throw new SandboxPortError(
782
+ "publish_limit_exceeded",
783
+ "publish selection exceeds the total byte limit",
784
+ );
785
+ }
786
+ }
787
+
788
+ let manifestChanged = false;
789
+ for (const entry of ordered) {
790
+ const workspacePath = sandboxToWorkspacePath(
791
+ entry.absolutePath,
792
+ );
793
+ try {
794
+ const file = await this.client.readFileBytes(
795
+ entry.absolutePath,
796
+ );
797
+ const bytes = await readBoundedStream(
798
+ file.content,
799
+ file.size,
800
+ );
801
+ const written =
802
+ await this.options.publisher.writeFileBytesIfUnchanged(
803
+ workspacePath,
804
+ bytes,
805
+ file.mimeType,
806
+ manifest.files.get(entry.absolutePath) ?? null,
807
+ );
808
+ if (!written.written) {
809
+ result.failed.push({
810
+ path: entry.absolutePath,
811
+ errorCode: "workspace_conflict",
812
+ });
813
+ continue;
814
+ }
815
+ manifest.files.set(
816
+ entry.absolutePath,
817
+ written.version,
818
+ );
819
+ manifestChanged = true;
820
+ result.files.push(entry.absolutePath);
821
+ result.bytes += bytes.byteLength;
822
+ } catch (error) {
823
+ result.failed.push(
824
+ syncFailure(entry.absolutePath, error),
825
+ );
826
+ }
827
+ }
828
+ if (manifestChanged) {
829
+ await this.writeHydrationManifest(manifest);
830
+ }
831
+ const frozen = this.freezeSyncResult(result);
832
+ this.emit("sandbox.workspace.published", {
833
+ success: frozen.failed.length === 0,
834
+ files: frozen.files.length,
835
+ failed: frozen.failed.length,
836
+ bytes: frozen.bytes,
837
+ });
838
+ return frozen;
839
+ }),
840
+ );
841
+ }
842
+
843
+ async destroy(): Promise<void> {
844
+ await this.client.destroy();
845
+ }
846
+
847
+ private async withAdmission<T>(
848
+ operation: () => Promise<T>,
849
+ ): Promise<T> {
850
+ const operationId = crypto.randomUUID();
851
+ const admission = this.options.admission;
852
+ if (admission) {
853
+ const decision = await admission.begin(operationId);
854
+ if (!decision.granted) {
855
+ throw new SandboxPortError(
856
+ decision.reason ?? "rate_limited",
857
+ "Sandbox operation admission limit reached",
858
+ );
859
+ }
860
+ }
861
+ try {
862
+ return await operation();
863
+ } finally {
864
+ if (admission) {
865
+ await admission.end(operationId).catch(() => undefined);
866
+ }
867
+ }
868
+ }
869
+
870
+ private enqueue<T>(operation: () => Promise<T>): Promise<T> {
871
+ const next = this.serial.then(operation, operation);
872
+ this.serial = next.then(
873
+ () => undefined,
874
+ () => undefined,
875
+ );
876
+ return next;
877
+ }
878
+
879
+ private async ensureHydrated(): Promise<SandboxSyncResult> {
880
+ if (!this.hydration) {
881
+ this.hydration = (async () => {
882
+ try {
883
+ if (this.manifest) {
884
+ return this.freezeSyncResult({
885
+ files: [],
886
+ skipped: [],
887
+ failed: [],
888
+ bytes: 0,
889
+ });
890
+ }
891
+ const existing = await this.readHydrationManifest();
892
+ if (existing) {
893
+ this.manifest = existing;
894
+ return this.freezeSyncResult({
895
+ files: [],
896
+ skipped: [],
897
+ failed: [],
898
+ bytes: 0,
899
+ });
900
+ }
901
+ this.emit("sandbox.container.cold_start", {
902
+ markerMissing: true,
903
+ });
904
+ const result = await this.pullWorkspaceInternal();
905
+ if (result.failed.length > 0) {
906
+ throw new Error("Workspace hydration was partial");
907
+ }
908
+ this.emit("sandbox.workspace.pulled", {
909
+ success: true,
910
+ files: result.files.length,
911
+ skipped: result.skipped.length,
912
+ bytes: result.bytes,
913
+ });
914
+ return result;
915
+ } catch (error) {
916
+ throw new SandboxPortError(
917
+ "workspace_hydration_failed",
918
+ "persistent Workspace could not be copied into the Sandbox",
919
+ );
920
+ }
921
+ })().finally(() => {
922
+ this.hydration = undefined;
923
+ });
924
+ }
925
+ return this.hydration;
926
+ }
927
+
928
+ private async pullWorkspaceInternal(): Promise<SandboxSyncResult> {
929
+ const result: {
930
+ files: string[];
931
+ skipped: string[];
932
+ failed: SandboxSyncFailure[];
933
+ bytes: number;
934
+ } = {
935
+ files: [],
936
+ skipped: [],
937
+ failed: [],
938
+ bytes: 0,
939
+ };
940
+ const currentManifest: HydrationManifest = {
941
+ files: new Map(),
942
+ };
943
+ let visitedEntries = 0;
944
+ await this.client.mkdir("/workspace");
945
+ await this.client.mkdir("/shared");
946
+
947
+ for (const root of ["/", "/shared"] as const) {
948
+ const pending: string[] = [root];
949
+ while (pending.length > 0) {
950
+ const directory = pending.pop()!;
951
+ let offset = 0;
952
+ while (true) {
953
+ const entries = await this.options.workspace.readDir(
954
+ directory,
955
+ {
956
+ limit: WORKSPACE_PAGE_SIZE,
957
+ offset,
958
+ },
959
+ );
960
+ for (const entry of entries) {
961
+ visitedEntries += 1;
962
+ if (
963
+ visitedEntries >
964
+ SANDBOX_HOST_POLICY.maxHydrationEntries
965
+ ) {
966
+ throw new SandboxPortError(
967
+ "capacity_unavailable",
968
+ "Workspace hydration exceeds the traversal limit",
969
+ );
970
+ }
971
+ const sourcePath = canonicalPathInside(
972
+ entry.path,
973
+ root,
974
+ );
975
+ const destination = workspaceToSandboxPath(
976
+ sourcePath,
977
+ root,
978
+ );
979
+ if (entry.type === "symlink") {
980
+ result.skipped.push(destination);
981
+ continue;
982
+ }
983
+ if (entry.type === "directory") {
984
+ await this.client.mkdir(destination);
985
+ pending.push(sourcePath);
986
+ continue;
987
+ }
988
+ if (entry.size > MAX_PUBLISH_FILE_BYTES) {
989
+ result.skipped.push(destination);
990
+ continue;
991
+ }
992
+ if (
993
+ currentManifest.files.size >=
994
+ SANDBOX_HOST_POLICY.maxHydrationFiles
995
+ ) {
996
+ throw new SandboxPortError(
997
+ "capacity_unavailable",
998
+ "Workspace hydration exceeds the file-count limit",
999
+ );
1000
+ }
1001
+ if (
1002
+ result.bytes + entry.size >
1003
+ SANDBOX_HOST_POLICY.maxHydrationBytes
1004
+ ) {
1005
+ throw new SandboxPortError(
1006
+ "capacity_unavailable",
1007
+ "Workspace hydration exceeds the total byte limit",
1008
+ );
1009
+ }
1010
+ const bytes =
1011
+ await this.options.workspace.readFileBytes(sourcePath);
1012
+ if (!bytes) {
1013
+ result.failed.push({
1014
+ path: destination,
1015
+ errorCode: "operation_failed",
1016
+ });
1017
+ continue;
1018
+ }
1019
+ if (
1020
+ bytes.byteLength > MAX_PUBLISH_FILE_BYTES ||
1021
+ result.bytes + bytes.byteLength >
1022
+ SANDBOX_HOST_POLICY.maxHydrationBytes
1023
+ ) {
1024
+ throw new SandboxPortError(
1025
+ "capacity_unavailable",
1026
+ "Workspace hydration exceeds the byte limit",
1027
+ );
1028
+ }
1029
+ await this.client.mkdir(parentPath(destination));
1030
+ await this.client.writeFile(
1031
+ destination,
1032
+ streamBytes(bytes),
1033
+ );
1034
+ currentManifest.files.set(destination, {
1035
+ updatedAt: entry.updatedAt,
1036
+ size: entry.size,
1037
+ });
1038
+ result.files.push(destination);
1039
+ result.bytes += bytes.byteLength;
1040
+ }
1041
+ if (entries.length < WORKSPACE_PAGE_SIZE) break;
1042
+ offset += entries.length;
1043
+ }
1044
+ }
1045
+ }
1046
+ if (result.failed.length === 0) {
1047
+ await this.writeHydrationManifest(currentManifest);
1048
+ }
1049
+ return this.freezeSyncResult(result);
1050
+ }
1051
+
1052
+ private async readHydrationManifest(): Promise<HydrationManifest | null> {
1053
+ try {
1054
+ if (!(await this.client.exists(HYDRATION_MARKER))) {
1055
+ return null;
1056
+ }
1057
+ const file = await this.client.readFileBytes(HYDRATION_MARKER);
1058
+ const bytes = await readBoundedStream(
1059
+ file.content,
1060
+ file.size,
1061
+ MAX_HYDRATION_MANIFEST_BYTES,
1062
+ "operation_failed",
1063
+ );
1064
+ const parsed: unknown = JSON.parse(
1065
+ new TextDecoder().decode(bytes),
1066
+ );
1067
+ if (
1068
+ typeof parsed !== "object" ||
1069
+ parsed === null ||
1070
+ !("complete" in parsed) ||
1071
+ parsed.complete !== true ||
1072
+ !("files" in parsed) ||
1073
+ !Array.isArray(parsed.files) ||
1074
+ parsed.files.length > MAX_HYDRATION_MANIFEST_FILES
1075
+ ) {
1076
+ return null;
1077
+ }
1078
+ const files = new Map<string, WorkspaceFileVersion>();
1079
+ for (const item of parsed.files) {
1080
+ if (
1081
+ typeof item !== "object" ||
1082
+ item === null ||
1083
+ !("path" in item) ||
1084
+ typeof item.path !== "string" ||
1085
+ !("updatedAt" in item) ||
1086
+ typeof item.updatedAt !== "number" ||
1087
+ !Number.isFinite(item.updatedAt) ||
1088
+ !("size" in item) ||
1089
+ typeof item.size !== "number" ||
1090
+ !Number.isFinite(item.size) ||
1091
+ item.size < 0
1092
+ ) {
1093
+ return null;
1094
+ }
1095
+ const root = pathInside("/shared", item.path)
1096
+ ? "/shared"
1097
+ : "/workspace";
1098
+ files.set(canonicalPathInside(item.path, root), {
1099
+ updatedAt: item.updatedAt,
1100
+ size: item.size,
1101
+ });
1102
+ }
1103
+ return { files };
1104
+ } catch {
1105
+ // A missing, invalid, oversized, or user-modified marker forces a fresh
1106
+ // hydrate. It must never authorize broader file access.
1107
+ return null;
1108
+ }
1109
+ }
1110
+
1111
+ private async writeHydrationManifest(
1112
+ manifest: HydrationManifest,
1113
+ ): Promise<void> {
1114
+ const serialized = JSON.stringify({
1115
+ complete: true,
1116
+ files: [...manifest.files]
1117
+ .sort(([left], [right]) => left.localeCompare(right))
1118
+ .map(([path, version]) => ({ path, ...version })),
1119
+ });
1120
+ if (
1121
+ new TextEncoder().encode(serialized).byteLength >
1122
+ MAX_HYDRATION_MANIFEST_BYTES
1123
+ ) {
1124
+ throw new SandboxPortError(
1125
+ "capacity_unavailable",
1126
+ "Workspace hydration manifest exceeds the byte limit",
1127
+ );
1128
+ }
1129
+ await this.client.mkdir(parentPath(HYDRATION_MARKER));
1130
+ await this.client.writeFile(HYDRATION_MARKER, serialized);
1131
+ this.manifest = { files: new Map(manifest.files) };
1132
+ }
1133
+
1134
+ private async ensureSession(
1135
+ id: string,
1136
+ cwd: string,
1137
+ commandTimeoutMs?: number,
1138
+ ): Promise<SandboxSessionLike> {
1139
+ const existing = this.sessions.get(id);
1140
+ if (existing) return existing;
1141
+ try {
1142
+ const created = await this.client.createSession({
1143
+ id,
1144
+ cwd,
1145
+ ...(commandTimeoutMs === undefined
1146
+ ? {}
1147
+ : { commandTimeoutMs }),
1148
+ });
1149
+ this.sessions.set(id, created);
1150
+ return created;
1151
+ } catch (error) {
1152
+ if (!alreadyExists(error)) throw mappedError(error);
1153
+ const session = await this.client.getSession(id);
1154
+ this.sessions.set(id, session);
1155
+ return session;
1156
+ }
1157
+ }
1158
+
1159
+ private async runForeground<T>(
1160
+ signal: AbortSignal | undefined,
1161
+ timeoutMs: number,
1162
+ operation: () => Promise<T>,
1163
+ ): Promise<T> {
1164
+ signal?.throwIfAborted();
1165
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1166
+ let onAbort: (() => void) | undefined;
1167
+ // The SDK timeout cannot settle a stalled SSE transport, so the Host
1168
+ // independently bounds how long a Runtime turn can wait for it.
1169
+ const deadline = new Promise<never>((_resolve, reject) => {
1170
+ timeout = setTimeout(
1171
+ () =>
1172
+ reject(
1173
+ new SandboxPortError(
1174
+ "timeout",
1175
+ "Sandbox execution exceeded its deadline",
1176
+ ),
1177
+ ),
1178
+ timeoutMs,
1179
+ );
1180
+ });
1181
+ const interrupted = new Promise<never>((_resolve, reject) => {
1182
+ if (!signal) return;
1183
+ onAbort = () => {
1184
+ reject(
1185
+ signal.reason ?? new DOMException("Aborted", "AbortError"),
1186
+ );
1187
+ };
1188
+ signal.addEventListener("abort", onAbort, { once: true });
1189
+ });
1190
+ try {
1191
+ return await Promise.race([
1192
+ operation(),
1193
+ deadline,
1194
+ interrupted,
1195
+ ]);
1196
+ } finally {
1197
+ if (timeout !== undefined) clearTimeout(timeout);
1198
+ if (signal && onAbort) {
1199
+ signal.removeEventListener("abort", onAbort);
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ private async deleteExecutionSession(): Promise<void> {
1205
+ this.sessions.delete(EXEC_SESSION_ID);
1206
+ await this.cleanupWithin(
1207
+ this.client.deleteSession(EXEC_SESSION_ID),
1208
+ );
1209
+ }
1210
+
1211
+ private async cleanupWithin(operation: Promise<unknown>): Promise<void> {
1212
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1213
+ try {
1214
+ await Promise.race([
1215
+ operation.catch(() => undefined),
1216
+ new Promise<void>((resolve) => {
1217
+ timeout = setTimeout(
1218
+ resolve,
1219
+ EXEC_SESSION_CLEANUP_TIMEOUT_MS,
1220
+ );
1221
+ }),
1222
+ ]);
1223
+ } finally {
1224
+ if (timeout !== undefined) clearTimeout(timeout);
1225
+ }
1226
+ }
1227
+
1228
+ private async execBounded(
1229
+ session: SandboxSessionLike,
1230
+ command: string,
1231
+ options: StreamOptions,
1232
+ ): Promise<SandboxExecResult> {
1233
+ const stream = await session.execStream!(command, options);
1234
+ let stdout = "";
1235
+ let stderr = "";
1236
+ let stdoutTruncated = false;
1237
+ let stderrTruncated = false;
1238
+ let exitCode: number | undefined;
1239
+ for await (const event of parseSSEStream<ExecEvent>(stream)) {
1240
+ if (event.type === "stdout" && event.data) {
1241
+ const next = appendOutputTail(stdout, event.data);
1242
+ stdout = next.value;
1243
+ stdoutTruncated ||= next.truncated;
1244
+ } else if (event.type === "stderr" && event.data) {
1245
+ const next = appendOutputTail(stderr, event.data);
1246
+ stderr = next.value;
1247
+ stderrTruncated ||= next.truncated;
1248
+ } else if (event.type === "complete") {
1249
+ exitCode = event.exitCode ?? event.result?.exitCode;
1250
+ } else if (event.type === "error") {
1251
+ throw new Error(event.error ?? event.data ?? "command failed");
1252
+ }
1253
+ }
1254
+ if (exitCode === undefined) {
1255
+ throw new Error("streaming command ended without an exit code");
1256
+ }
1257
+ return {
1258
+ success: exitCode === 0,
1259
+ stdout,
1260
+ stderr,
1261
+ exitCode,
1262
+ stdoutTruncated,
1263
+ stderrTruncated,
1264
+ };
1265
+ }
1266
+
1267
+ private async prepareCommand(
1268
+ command: string,
1269
+ stdin?: string,
1270
+ ): Promise<{
1271
+ command: string;
1272
+ env?: Record<string, string>;
1273
+ stdinPath?: string;
1274
+ }> {
1275
+ if (stdin === undefined) return { command };
1276
+ const stdinPath =
1277
+ `/tmp/.universal-agent/stdin-${crypto.randomUUID()}`;
1278
+ await this.client.mkdir(parentPath(stdinPath));
1279
+ await this.client.writeFile(
1280
+ stdinPath,
1281
+ streamBytes(new TextEncoder().encode(stdin)),
1282
+ );
1283
+ return {
1284
+ command:
1285
+ `sh -lc "$UA_SANDBOX_COMMAND" < ${stdinPath}`,
1286
+ env: { UA_SANDBOX_COMMAND: command },
1287
+ stdinPath,
1288
+ };
1289
+ }
1290
+
1291
+ private processSummary(
1292
+ process: SandboxProcessLike,
1293
+ ): SandboxProcessSummary {
1294
+ return {
1295
+ id: process.id,
1296
+ command: process.command,
1297
+ status: process.status,
1298
+ ...(process.exitCode === undefined
1299
+ ? {}
1300
+ : { exitCode: process.exitCode }),
1301
+ };
1302
+ }
1303
+
1304
+ private assertProcessId(id: string): void {
1305
+ if (this.processStartedAt(id) === null) {
1306
+ throw new SandboxPortError(
1307
+ "not_found",
1308
+ "background process was not found",
1309
+ );
1310
+ }
1311
+ }
1312
+
1313
+ private processStartedAt(id: string): number | null {
1314
+ if (!id.startsWith(BACKGROUND_PROCESS_PREFIX)) return null;
1315
+ const token = id
1316
+ .slice(BACKGROUND_PROCESS_PREFIX.length)
1317
+ .split("-", 1)[0];
1318
+ if (!token) return null;
1319
+ const startedAt = Number.parseInt(token, 36);
1320
+ return Number.isSafeInteger(startedAt) && startedAt > 0
1321
+ ? startedAt
1322
+ : null;
1323
+ }
1324
+
1325
+ private processExpired(id: string): boolean {
1326
+ const startedAt = this.processStartedAt(id);
1327
+ return (
1328
+ startedAt === null ||
1329
+ Date.now() - startedAt >
1330
+ SANDBOX_HOST_POLICY.maxBackgroundProcessAgeMs
1331
+ );
1332
+ }
1333
+
1334
+ private async pruneProcessesForStart(): Promise<
1335
+ readonly SandboxProcessLike[]
1336
+ > {
1337
+ await this.client
1338
+ .cleanupCompletedProcesses(PROCESS_SESSION_ID)
1339
+ .catch(() => undefined);
1340
+ const now = Date.now();
1341
+ const running: SandboxProcessLike[] = [];
1342
+ for (const process of await this.client.listProcesses(
1343
+ PROCESS_SESSION_ID,
1344
+ )) {
1345
+ const startedAt = this.processStartedAt(process.id);
1346
+ if (startedAt === null || process.status !== "running") continue;
1347
+ if (
1348
+ now - startedAt >
1349
+ SANDBOX_HOST_POLICY.maxBackgroundProcessAgeMs
1350
+ ) {
1351
+ await this.client
1352
+ .killProcess(process.id, PROCESS_SESSION_ID)
1353
+ .catch(() => undefined);
1354
+ continue;
1355
+ }
1356
+ running.push(process);
1357
+ }
1358
+ return running;
1359
+ }
1360
+
1361
+ private async assertNoSymlinkComponents(
1362
+ path: string,
1363
+ ): Promise<void> {
1364
+ const root = pathInside("/shared", path)
1365
+ ? "/shared"
1366
+ : "/workspace";
1367
+ if (path === root) return;
1368
+
1369
+ const segments = path.slice(root.length + 1).split("/");
1370
+ let parent = root;
1371
+ for (const segment of segments) {
1372
+ const current = `${parent}/${segment}`;
1373
+ const siblings = await this.client.listFiles(parent, {
1374
+ includeHidden: true,
1375
+ });
1376
+ const entry = siblings.find(
1377
+ (candidate) => candidate.absolutePath === current,
1378
+ );
1379
+ if (!entry) return;
1380
+ this.assertPublishEntry(current, entry);
1381
+ if (entry.type === "symlink") {
1382
+ throw new SandboxPortError(
1383
+ "invalid_path",
1384
+ `symbolic links cannot be published: ${current}`,
1385
+ );
1386
+ }
1387
+ if (current !== path && entry.type !== "directory") {
1388
+ throw new SandboxPortError(
1389
+ "not_found",
1390
+ `Sandbox path was not found: ${path}`,
1391
+ );
1392
+ }
1393
+ parent = current;
1394
+ }
1395
+ }
1396
+
1397
+ private assertPublishEntry(
1398
+ selectedPath: string,
1399
+ entry: SandboxFileEntry,
1400
+ ): void {
1401
+ const root = pathInside("/shared", selectedPath)
1402
+ ? "/shared"
1403
+ : "/workspace";
1404
+ const absolutePath = canonicalPathInside(
1405
+ entry.absolutePath,
1406
+ root,
1407
+ );
1408
+ if (!pathInside(selectedPath, absolutePath)) {
1409
+ throw new SandboxPortError(
1410
+ "invalid_path",
1411
+ `Sandbox listed a path outside the explicit publish selection: ${entry.absolutePath}`,
1412
+ );
1413
+ }
1414
+ }
1415
+
1416
+ private async collectPublishEntries(
1417
+ path: string,
1418
+ ): Promise<readonly SandboxFileEntry[]> {
1419
+ let selected: SandboxFileEntry | undefined;
1420
+ if (path !== "/workspace") {
1421
+ selected = (
1422
+ await this.client.listFiles(parentPath(path), {
1423
+ includeHidden: true,
1424
+ })
1425
+ ).find((candidate) => candidate.absolutePath === path);
1426
+ if (!selected) {
1427
+ throw new SandboxPortError(
1428
+ "not_found",
1429
+ `Sandbox path was not found: ${path}`,
1430
+ );
1431
+ }
1432
+ if (selected.type !== "directory") return [selected];
1433
+ }
1434
+
1435
+ const files: SandboxFileEntry[] = [];
1436
+ const pending = [path];
1437
+ let visitedEntries = 0;
1438
+ while (pending.length > 0) {
1439
+ const directory = pending.pop()!;
1440
+ const children = await this.client.listFiles(directory, {
1441
+ recursive: false,
1442
+ includeHidden: true,
1443
+ });
1444
+ for (const child of children) {
1445
+ this.assertPublishEntry(path, child);
1446
+ visitedEntries += 1;
1447
+ if (
1448
+ visitedEntries >
1449
+ SANDBOX_HOST_POLICY.maxPublishEntries
1450
+ ) {
1451
+ throw new SandboxPortError(
1452
+ "publish_limit_exceeded",
1453
+ "publish selection exceeds the traversal limit",
1454
+ );
1455
+ }
1456
+ if (child.type === "directory") {
1457
+ if (!this.isExcludedPublishPath(child.absolutePath)) {
1458
+ pending.push(child.absolutePath);
1459
+ }
1460
+ files.push(child);
1461
+ continue;
1462
+ }
1463
+ files.push(child);
1464
+ }
1465
+ }
1466
+ return selected ? [selected, ...files] : files;
1467
+ }
1468
+
1469
+ private isExcludedPublishPath(path: string): boolean {
1470
+ return path
1471
+ .slice("/workspace".length)
1472
+ .split("/")
1473
+ .filter(Boolean)
1474
+ .some((segment) => EXCLUDED_PUBLISH_SEGMENTS.has(segment));
1475
+ }
1476
+
1477
+ private freezeSyncResult(input: {
1478
+ files: readonly string[];
1479
+ skipped: readonly string[];
1480
+ failed: readonly SandboxSyncFailure[];
1481
+ bytes: number;
1482
+ }): SandboxSyncResult {
1483
+ return Object.freeze({
1484
+ files: Object.freeze([...input.files]),
1485
+ skipped: Object.freeze([...input.skipped]),
1486
+ failed: Object.freeze(
1487
+ input.failed.map((failure) => Object.freeze({ ...failure })),
1488
+ ),
1489
+ bytes: input.bytes,
1490
+ });
1491
+ }
1492
+
1493
+ private emit(
1494
+ event: SandboxEvent,
1495
+ fields: Readonly<Record<string, string | number | boolean>>,
1496
+ ): void {
1497
+ try {
1498
+ this.options.observer?.emit(event, fields);
1499
+ } catch {
1500
+ // Observability must never overturn a completed Sandbox operation.
1501
+ }
1502
+ }
1503
+ }
1504
+
1505
+ export function createCloudflareSandboxAdapter(
1506
+ options: Omit<CloudflareSandboxAdapterOptions, "clientFactory">,
1507
+ ): CloudflareSandboxAdapter {
1508
+ return new CloudflareSandboxAdapter(options);
1509
+ }