@anvia/sandbox 0.6.0 → 1.0.0-rc.10

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.
package/dist/index.js CHANGED
@@ -1,31 +1,13 @@
1
1
  import {
2
- SandboxDockerCommandError,
3
- SandboxDockerUnavailableError,
4
- SandboxError,
5
- SandboxFileSizeError,
6
- SandboxPathError,
7
- SandboxPortError,
8
- SandboxProcessError,
9
- SandboxSessionDestroyedError,
10
- SandboxTimeoutError,
11
- SandboxToolPolicyError,
2
+ DockerSandboxError,
12
3
  assertDockerCli,
4
+ decodeUtf8,
13
5
  runDockerCli
14
- } from "./chunk-FTNNCT6S.js";
15
-
16
- // src/capabilities.ts
17
- function isSandboxPortSession(session) {
18
- const candidate = session;
19
- return Array.isArray(candidate.publishedPorts) && typeof candidate.waitForPort === "function";
20
- }
21
- function isSandboxProcessSession(session) {
22
- const candidate = session;
23
- return typeof candidate.startProcess === "function" && typeof candidate.listProcesses === "function" && typeof candidate.readProcessLogs === "function" && typeof candidate.stopProcess === "function";
24
- }
6
+ } from "./chunk-4D2GWLEH.js";
25
7
 
26
8
  // src/docker-sandbox.ts
27
9
  import { randomUUID as randomUUID2 } from "crypto";
28
- import { mkdtemp, rm, writeFile } from "fs/promises";
10
+ import { lstat, mkdtemp, readFile, realpath, rm, writeFile } from "fs/promises";
29
11
  import os from "os";
30
12
  import path2 from "path";
31
13
 
@@ -37,21 +19,25 @@ import { randomUUID } from "crypto";
37
19
  import path from "path";
38
20
  function normalizeSandboxPath(input, options = {}) {
39
21
  if (input.length === 0) {
40
- throw new SandboxPathError("Sandbox path cannot be empty.");
22
+ throw new DockerSandboxError("Sandbox path cannot be empty.", "invalid_path");
41
23
  }
42
24
  if (input.includes("\0")) {
43
- throw new SandboxPathError("Sandbox path cannot contain null bytes.");
25
+ throw new DockerSandboxError("Sandbox path cannot contain null bytes.", "invalid_path");
44
26
  }
45
27
  const normalized = path.posix.normalize(input.replaceAll("\\", "/"));
46
28
  if (path.posix.isAbsolute(normalized)) {
47
- throw new SandboxPathError(`Sandbox path must be relative: ${input}`);
29
+ throw new DockerSandboxError(`Sandbox path must be relative: ${input}`, "invalid_path");
48
30
  }
49
31
  if (normalized === ".." || normalized.startsWith("../")) {
50
- throw new SandboxPathError(`Sandbox path cannot leave the workspace: ${input}`);
32
+ throw new DockerSandboxError(
33
+ `Sandbox path cannot leave the workspace: ${input}`,
34
+ "invalid_path"
35
+ );
51
36
  }
52
37
  if (normalized === "." && options.allowRoot !== true) {
53
- throw new SandboxPathError(
54
- "Sandbox path must refer to a file or directory inside the workspace."
38
+ throw new DockerSandboxError(
39
+ "Sandbox path must refer to a file or directory inside the workspace.",
40
+ "invalid_path"
55
41
  );
56
42
  }
57
43
  return normalized;
@@ -69,19 +55,25 @@ function parentSandboxPath(relativePath) {
69
55
 
70
56
  // src/docker-process.ts
71
57
  var processMarkerPrefix = "ANVIA_PROCESS";
58
+ var processLauncher = [
59
+ 'wrapper="$1"',
60
+ "shift",
61
+ // GNU setsid forks when it is already a process-group leader. `-w` keeps that launcher alive
62
+ // until the new session exits, so Docker does not detach from a still-running managed process.
63
+ "if command -v setsid >/dev/null 2>&1 && setsid -w true >/dev/null 2>&1; then",
64
+ ' exec setsid -w sh -c "$wrapper" "$@"',
65
+ "fi",
66
+ 'exec sh -c "$wrapper" "$@"'
67
+ ].join("\n");
72
68
  var processWrapper = [
73
69
  'marker="$1"',
74
70
  "shift",
75
- "if command -v setsid >/dev/null 2>&1; then",
76
- ' setsid "$@" &',
77
- " child=$!",
78
- " group=$child",
79
- "else",
80
- ' "$@" &',
81
- " child=$!",
82
- " group=$$",
83
- "fi",
84
- `printf '\\036%s:%s:%s:%s\\036' "$marker" "$$" "$child" "$group"`,
71
+ "group=$$",
72
+ `printf '\\036%s:%s:%s\\036' "$marker" "$$" "$group"`,
73
+ "IFS= read -r ready || exit 125",
74
+ '[ "$ready" = "start" ] || exit 125',
75
+ '"$@" &',
76
+ "child=$!",
85
77
  "terminate() {",
86
78
  ' wait "$child" 2>/dev/null || true',
87
79
  " exit 143",
@@ -94,13 +86,13 @@ var DockerProcessManager = class {
94
86
  constructor(options) {
95
87
  this.options = options;
96
88
  if (!Number.isInteger(options.maxProcesses) || options.maxProcesses < 0) {
97
- throw new SandboxProcessError("Sandbox maxProcesses must be a non-negative integer.");
89
+ throw processError("Sandbox maxProcesses must be a non-negative integer.");
98
90
  }
99
91
  if (!Number.isInteger(options.maxOutputBytes) || options.maxOutputBytes < 0) {
100
- throw new SandboxProcessError("Sandbox maxOutputBytes must be a non-negative integer.");
92
+ throw processError("Sandbox maxOutputBytes must be a non-negative integer.");
101
93
  }
102
94
  if (!Number.isInteger(options.startupTimeoutMs) || options.startupTimeoutMs <= 0) {
103
- throw new SandboxProcessError("Sandbox process startup timeout must be a positive integer.");
95
+ throw processError("Sandbox process startup timeout must be a positive integer.");
104
96
  }
105
97
  }
106
98
  options;
@@ -109,10 +101,11 @@ var DockerProcessManager = class {
109
101
  async start(options) {
110
102
  this.assertActive();
111
103
  assertStartOptions(options);
112
- await this.pruneCompletedRecords();
104
+ options.abortSignal?.throwIfAborted();
105
+ await this.pruneCompletedRecords(options.abortSignal);
113
106
  const trackedCount = this.records.size;
114
107
  if (trackedCount >= this.options.maxProcesses) {
115
- throw new SandboxProcessError(
108
+ throw processError(
116
109
  `Sandbox process limit reached (${trackedCount} >= ${this.options.maxProcesses}).`
117
110
  );
118
111
  }
@@ -122,7 +115,6 @@ var DockerProcessManager = class {
122
115
  const child = spawn(this.options.dockerPath, dockerArgs, {
123
116
  stdio: ["pipe", "pipe", "pipe"]
124
117
  });
125
- child.stdin.end();
126
118
  let resolveStarted;
127
119
  let rejectStarted;
128
120
  const started = new Promise((resolve, reject) => {
@@ -143,7 +135,6 @@ var DockerProcessManager = class {
143
135
  if (options.cwd !== void 0) info.cwd = options.cwd;
144
136
  const record = {
145
137
  info,
146
- startedAtMs: Date.now(),
147
138
  child,
148
139
  stdout: new TailOutputCollector(this.options.maxOutputBytes),
149
140
  stderr: new TailOutputCollector(this.options.maxOutputBytes),
@@ -152,7 +143,6 @@ var DockerProcessManager = class {
152
143
  spawnFailed: false,
153
144
  stopRequested: false,
154
145
  startResolved: false,
155
- exitNotified: false,
156
146
  resolveStarted,
157
147
  rejectStarted,
158
148
  started,
@@ -162,10 +152,8 @@ var DockerProcessManager = class {
162
152
  this.records.set(id, record);
163
153
  this.observe(record);
164
154
  try {
165
- await this.waitForStart(record);
166
- await this.options.onStart?.(copyProcessInfo(record.info));
155
+ await this.waitForStart(record, options.abortSignal);
167
156
  record.startResolved = true;
168
- if (record.info.status !== "running") this.notifyExit(record);
169
157
  return copyProcessInfo(record.info);
170
158
  } catch (error) {
171
159
  if (await this.cleanupFailedStart(record)) this.records.delete(id);
@@ -176,28 +164,27 @@ var DockerProcessManager = class {
176
164
  this.assertActive();
177
165
  return [...this.records.values()].map((record) => copyProcessInfo(record.info));
178
166
  }
179
- logs(processId, options = {}) {
167
+ logs(processId, tailBytes) {
180
168
  this.assertActive();
181
169
  const record = this.getRecord(processId);
182
- const stdout = record.stdout.snapshot(options.tailBytes);
183
- const stderr = record.stderr.snapshot(options.tailBytes);
170
+ const stdout = record.stdout.snapshot(tailBytes);
171
+ const stderr = record.stderr.snapshot(tailBytes);
184
172
  return {
185
- stdout: stdout.text,
186
- stderr: stderr.text,
173
+ stdout: stdout.bytes,
174
+ stderr: stderr.bytes,
187
175
  stdoutTruncated: stdout.truncated,
188
176
  stderrTruncated: stderr.truncated
189
177
  };
190
178
  }
191
- async stop(processId, options = {}) {
179
+ async stop(processId, gracePeriodMs = 5e3, abortSignal) {
192
180
  this.assertActive();
193
181
  const record = this.getRecord(processId);
194
- const gracePeriodMs = options.gracePeriodMs ?? 5e3;
195
182
  if (!Number.isInteger(gracePeriodMs) || gracePeriodMs < 0) {
196
- throw new SandboxProcessError("Process gracePeriodMs must be a non-negative integer.");
183
+ throw processError("Process gracePeriodMs must be a non-negative integer.");
197
184
  }
198
185
  try {
199
- if (!await this.terminateRecord(record, gracePeriodMs)) {
200
- throw new SandboxProcessError(`Sandbox process did not stop: ${processId}`);
186
+ if (!await this.terminateRecord(record, gracePeriodMs, abortSignal)) {
187
+ throw processError(`Sandbox process did not stop: ${processId}`);
201
188
  }
202
189
  } catch (error) {
203
190
  if (record.info.status === "running") record.stopRequested = false;
@@ -205,17 +192,20 @@ var DockerProcessManager = class {
205
192
  }
206
193
  return copyProcessInfo(record.info);
207
194
  }
208
- async dispose() {
195
+ async dispose(abortSignal) {
209
196
  if (this.disposed) return;
210
- this.disposed = true;
197
+ abortSignal?.throwIfAborted();
211
198
  await Promise.all(
212
199
  [...this.records.values()].map(async (record) => {
213
- await this.terminateRecord(record, 1e3).catch(() => false);
200
+ if (!await this.terminateRecord(record, 1e3, abortSignal)) {
201
+ throw processError(`Sandbox process did not stop: ${record.info.id}`);
202
+ }
214
203
  })
215
204
  );
205
+ this.disposed = true;
216
206
  }
217
207
  createExecArgs(options, marker) {
218
- const args = ["exec", "-w", containerPath(this.options.workdir, options.cwd ?? ".")];
208
+ const args = ["exec", "-i", "-w", containerPath(this.options.workdir, options.cwd ?? ".")];
219
209
  for (const [key, value] of Object.entries({ ...this.options.env, ...options.env })) {
220
210
  args.push("-e", `${key}=${value}`);
221
211
  }
@@ -223,6 +213,8 @@ var DockerProcessManager = class {
223
213
  this.options.containerName,
224
214
  "sh",
225
215
  "-c",
216
+ processLauncher,
217
+ "anvia-process-launcher",
226
218
  processWrapper,
227
219
  "anvia-managed-process",
228
220
  marker,
@@ -236,7 +228,9 @@ var DockerProcessManager = class {
236
228
  record.child.stderr.on("data", (chunk) => record.stderr.accept(chunk));
237
229
  record.child.on("error", (error) => {
238
230
  record.spawnFailed = true;
239
- const normalized = error.code === "ENOENT" ? new SandboxDockerUnavailableError("Docker CLI was not found.", error) : error;
231
+ const normalized = error.code === "ENOENT" ? new DockerSandboxError("Docker CLI was not found.", "docker_unavailable", void 0, {
232
+ cause: error
233
+ }) : error;
240
234
  record.rejectStarted(normalized);
241
235
  });
242
236
  record.child.on("close", (code) => {
@@ -248,12 +242,9 @@ var DockerProcessManager = class {
248
242
  record.info.exitCode = code ?? 1;
249
243
  record.info.endedAt = (/* @__PURE__ */ new Date()).toISOString();
250
244
  record.rejectStarted(
251
- new SandboxProcessError(
252
- `Sandbox process exited before startup completed: ${record.info.id}`
253
- )
245
+ processError(`Sandbox process exited before startup completed: ${record.info.id}`)
254
246
  );
255
247
  record.resolveClosed();
256
- if (record.startResolved) this.notifyExit(record);
257
248
  });
258
249
  }
259
250
  acceptStdout(record, chunk) {
@@ -280,14 +271,13 @@ var DockerProcessManager = class {
280
271
  }
281
272
  return;
282
273
  }
283
- const rawPids = record.markerBuffer.subarray(start + record.markerStart.length, end).toString("utf8").split(":");
274
+ const rawPids = decodeUtf8(
275
+ record.markerBuffer.subarray(start + record.markerStart.length, end)
276
+ ).split(":");
284
277
  const supervisorPid = Number(rawPids[0]);
285
- const childPid = Number(rawPids[1]);
286
- const processGroupId = Number(rawPids[2]);
287
- if (!isProcessId(supervisorPid) || !isProcessId(childPid) || !isProcessId(processGroupId)) {
288
- record.rejectStarted(
289
- new SandboxProcessError("Sandbox process returned invalid process IDs.")
290
- );
278
+ const processGroupId = Number(rawPids[1]);
279
+ if (!isProcessId(supervisorPid) || !isProcessId(processGroupId) || rawPids.length !== 2) {
280
+ record.rejectStarted(processError("Sandbox process returned invalid process IDs."));
291
281
  return;
292
282
  }
293
283
  if (start > 0) record.stdout.accept(record.markerBuffer.subarray(0, start));
@@ -296,28 +286,38 @@ var DockerProcessManager = class {
296
286
  }
297
287
  record.markerBuffer = Buffer.alloc(0);
298
288
  record.supervisorPid = supervisorPid;
299
- record.childPid = childPid;
300
289
  record.processGroupId = processGroupId;
290
+ record.child.stdin.write("start\n");
301
291
  record.resolveStarted();
302
292
  }
303
- async waitForStart(record) {
293
+ async waitForStart(record, abortSignal) {
304
294
  let timeout;
295
+ let abort;
305
296
  const timeoutPromise = new Promise((_, reject) => {
306
297
  timeout = setTimeout(() => {
307
- reject(new SandboxTimeoutError("Starting sandbox process timed out."));
298
+ reject(new DockerSandboxError("Starting sandbox process timed out.", "timeout"));
308
299
  }, this.options.startupTimeoutMs);
309
300
  timeout.unref?.();
310
301
  });
311
302
  try {
312
- await Promise.race([record.started, timeoutPromise]);
303
+ const abortPromise = new Promise((_, reject) => {
304
+ if (abortSignal === void 0) return;
305
+ abort = () => reject(abortSignal.reason ?? new DOMException("Aborted", "AbortError"));
306
+ if (abortSignal.aborted) abort();
307
+ else abortSignal.addEventListener("abort", abort, { once: true });
308
+ });
309
+ await Promise.race([record.started, timeoutPromise, abortPromise]);
313
310
  } finally {
314
311
  if (timeout !== void 0) clearTimeout(timeout);
312
+ if (abort !== void 0) abortSignal?.removeEventListener("abort", abort);
315
313
  }
316
314
  }
317
315
  async cleanupFailedStart(record) {
318
316
  record.stopRequested = true;
319
317
  if (record.processGroupId === void 0 && record.info.status === "running") {
320
- await waitForPromise(record.closed, 250);
318
+ record.child.stdin.destroy();
319
+ record.child.kill("SIGKILL");
320
+ await waitForPromise(record.closed, 1e3);
321
321
  }
322
322
  try {
323
323
  return await this.terminateRecord(record, 1e3);
@@ -325,47 +325,49 @@ var DockerProcessManager = class {
325
325
  return false;
326
326
  }
327
327
  }
328
- async terminateRecord(record, gracePeriodMs) {
328
+ async terminateRecord(record, gracePeriodMs, abortSignal) {
329
+ abortSignal?.throwIfAborted();
329
330
  record.stopRequested = true;
330
331
  if (record.processGroupId === void 0) {
331
332
  if (record.info.status === "running") {
332
- await waitForPromise(record.closed, Math.min(gracePeriodMs, 250));
333
+ await waitForPromise(record.closed, Math.min(gracePeriodMs, 250), abortSignal);
333
334
  }
334
335
  if (record.processGroupId === void 0) {
335
- return record.spawnFailed && record.info.status !== "running";
336
+ return record.info.status !== "running";
336
337
  }
337
338
  }
338
- if (!await this.isProcessGroupRunning(record)) {
339
- return this.finishAfterGroupExit(record);
339
+ if (!await this.isProcessGroupRunning(record, abortSignal)) {
340
+ return this.finishAfterGroupExit(record, abortSignal);
340
341
  }
341
- await this.signal(record, "TERM");
342
- if (await this.waitForRecordExit(record, gracePeriodMs)) return true;
343
- await this.signal(record, "KILL");
344
- if (await this.waitForRecordExit(record, 1e3)) return true;
342
+ await this.signal(record, "TERM", abortSignal);
343
+ if (await this.waitForRecordExit(record, gracePeriodMs, abortSignal)) return true;
344
+ await this.signal(record, "KILL", abortSignal);
345
+ if (await this.waitForRecordExit(record, 1e3, abortSignal)) return true;
345
346
  return false;
346
347
  }
347
- async waitForRecordExit(record, timeoutMs) {
348
+ async waitForRecordExit(record, timeoutMs, abortSignal) {
348
349
  const deadline = Date.now() + timeoutMs;
349
350
  while (true) {
350
- if (!await this.isProcessGroupRunning(record)) {
351
- return this.finishAfterGroupExit(record);
351
+ abortSignal?.throwIfAborted();
352
+ if (!await this.isProcessGroupRunning(record, abortSignal)) {
353
+ return this.finishAfterGroupExit(record, abortSignal);
352
354
  }
353
355
  const remainingMs = deadline - Date.now();
354
356
  if (remainingMs <= 0) return false;
355
357
  const intervalMs = Math.min(50, remainingMs);
356
358
  if (record.info.status === "running") {
357
- await waitForPromise(record.closed, intervalMs);
359
+ await waitForPromise(record.closed, intervalMs, abortSignal);
358
360
  } else {
359
- await waitForDelay(intervalMs);
361
+ await waitForDelay(intervalMs, abortSignal);
360
362
  }
361
363
  }
362
364
  }
363
- async finishAfterGroupExit(record) {
365
+ async finishAfterGroupExit(record, abortSignal) {
364
366
  if (record.info.status !== "running") return true;
365
367
  record.child.kill("SIGKILL");
366
- return waitForPromise(record.closed, 1e3);
368
+ return waitForPromise(record.closed, 1e3, abortSignal);
367
369
  }
368
- async isProcessGroupRunning(record) {
370
+ async isProcessGroupRunning(record, abortSignal) {
369
371
  if (record.processGroupId === void 0) return false;
370
372
  const result = await runDockerCli(
371
373
  [
@@ -375,17 +377,18 @@ var DockerProcessManager = class {
375
377
  "-c",
376
378
  'kill -0 "-$1" 2>/dev/null',
377
379
  "anvia-process-group-check",
378
- String(record.processGroupId)
380
+ `${record.processGroupId}`
379
381
  ],
380
382
  {
381
383
  dockerPath: this.options.dockerPath,
382
384
  timeoutMs: 5e3,
383
- maxOutputBytes: this.options.maxOutputBytes
385
+ maxOutputBytes: this.options.maxOutputBytes,
386
+ signal: abortSignal
384
387
  }
385
388
  );
386
389
  return result.exitCode === 0;
387
390
  }
388
- async signal(record, signal) {
391
+ async signal(record, dockerSignal, abortSignal) {
389
392
  if (record.processGroupId === void 0) return;
390
393
  const result = await runDockerCli(
391
394
  [
@@ -395,18 +398,20 @@ var DockerProcessManager = class {
395
398
  "-c",
396
399
  'kill "-$2" "-$1" 2>/dev/null || ! kill -0 "-$1" 2>/dev/null',
397
400
  "anvia-process-signal",
398
- String(record.processGroupId),
399
- signal
401
+ `${record.processGroupId}`,
402
+ dockerSignal
400
403
  ],
401
404
  {
402
405
  dockerPath: this.options.dockerPath,
403
406
  timeoutMs: 5e3,
404
- maxOutputBytes: this.options.maxOutputBytes
407
+ maxOutputBytes: this.options.maxOutputBytes,
408
+ signal: abortSignal
405
409
  }
406
410
  );
407
411
  if (result.exitCode !== 0) {
408
- throw new SandboxDockerCommandError(
412
+ throw new DockerSandboxError(
409
413
  `Unable to stop sandbox process: ${record.info.id}`,
414
+ "docker_command_failed",
410
415
  result
411
416
  );
412
417
  }
@@ -414,39 +419,22 @@ var DockerProcessManager = class {
414
419
  getRecord(processId) {
415
420
  const record = this.records.get(processId);
416
421
  if (record === void 0) {
417
- throw new SandboxProcessError(`Unknown sandbox process: ${processId}`);
422
+ throw processError(`Unknown sandbox process: ${processId}`);
418
423
  }
419
424
  return record;
420
425
  }
421
- async pruneCompletedRecords() {
426
+ async pruneCompletedRecords(abortSignal) {
422
427
  for (const [id, record] of this.records) {
423
428
  if (this.records.size < this.options.maxProcesses) return;
424
- const cleanupConfirmed = record.processGroupId === void 0 ? record.spawnFailed : !await this.isProcessGroupRunning(record);
429
+ const cleanupConfirmed = record.processGroupId === void 0 ? record.spawnFailed : !await this.isProcessGroupRunning(record, abortSignal);
425
430
  if (record.info.status !== "running" && cleanupConfirmed) {
426
431
  this.records.delete(id);
427
432
  }
428
433
  }
429
434
  }
430
- logsUnsafe(record) {
431
- const stdout = record.stdout.snapshot();
432
- const stderr = record.stderr.snapshot();
433
- return {
434
- stdout: stdout.text,
435
- stderr: stderr.text,
436
- stdoutTruncated: stdout.truncated,
437
- stderrTruncated: stderr.truncated
438
- };
439
- }
440
- notifyExit(record) {
441
- if (record.exitNotified) return;
442
- record.exitNotified = true;
443
- const durationMs = Date.now() - record.startedAtMs;
444
- const notify = async () => this.options.onExit?.(copyProcessInfo(record.info), this.logsUnsafe(record), durationMs);
445
- void notify().catch(() => void 0);
446
- }
447
435
  assertActive() {
448
436
  if (this.disposed) {
449
- throw new SandboxProcessError("Sandbox process manager has been disposed.");
437
+ throw processError("Sandbox process manager has been disposed.");
450
438
  }
451
439
  }
452
440
  };
@@ -488,56 +476,72 @@ var TailOutputCollector = class {
488
476
  }
489
477
  snapshot(tailBytes) {
490
478
  if (tailBytes !== void 0 && (!Number.isInteger(tailBytes) || tailBytes < 0)) {
491
- throw new SandboxProcessError("Process tailBytes must be a non-negative integer.");
479
+ throw processError("Process tailBytes must be a non-negative integer.");
492
480
  }
493
481
  const bytes = Buffer.concat(this.chunks, this.length);
494
482
  const selected = tailBytes === 0 ? Buffer.alloc(0) : tailBytes === void 0 || bytes.length <= tailBytes ? bytes : bytes.subarray(bytes.length - tailBytes);
495
483
  return {
496
- text: selected.toString("utf8"),
484
+ bytes: new Uint8Array(selected.buffer, selected.byteOffset, selected.byteLength).slice(),
497
485
  truncated: this.didTruncate || selected.length < bytes.length
498
486
  };
499
487
  }
500
488
  };
489
+ function processError(message) {
490
+ return new DockerSandboxError(message, "process");
491
+ }
501
492
  function assertStartOptions(options) {
502
493
  if (options.command.trim().length === 0) {
503
- throw new SandboxProcessError("Sandbox process command cannot be empty.");
494
+ throw processError("Sandbox process command cannot be empty.");
504
495
  }
505
496
  }
506
497
  function isProcessId(value) {
507
498
  return Number.isSafeInteger(value) && value > 0;
508
499
  }
509
500
  function copyProcessInfo(info) {
510
- const copy = {
501
+ let copy = {
511
502
  id: info.id,
512
503
  command: info.command,
513
504
  args: [...info.args],
514
505
  status: info.status,
515
506
  startedAt: info.startedAt
516
507
  };
517
- if (info.cwd !== void 0) copy.cwd = info.cwd;
518
- if (info.exitCode !== void 0) copy.exitCode = info.exitCode;
519
- if (info.endedAt !== void 0) copy.endedAt = info.endedAt;
508
+ if (info.cwd !== void 0) copy = { ...copy, cwd: info.cwd };
509
+ if (info.exitCode !== void 0) copy = { ...copy, exitCode: info.exitCode };
510
+ if (info.endedAt !== void 0) copy = { ...copy, endedAt: info.endedAt };
520
511
  return copy;
521
512
  }
522
- async function waitForPromise(promise, timeoutMs) {
513
+ async function waitForPromise(promise, timeoutMs, abortSignal) {
514
+ abortSignal?.throwIfAborted();
523
515
  let timeout;
516
+ let abort;
524
517
  try {
525
518
  return await Promise.race([
526
519
  promise.then(() => true),
527
520
  new Promise((resolve) => {
528
521
  timeout = setTimeout(() => resolve(false), timeoutMs);
529
522
  timeout.unref?.();
523
+ }),
524
+ new Promise((_, reject) => {
525
+ if (abortSignal === void 0) return;
526
+ abort = () => reject(abortSignal.reason ?? new DOMException("Aborted", "AbortError"));
527
+ if (abortSignal.aborted) abort();
528
+ else abortSignal.addEventListener("abort", abort, { once: true });
530
529
  })
531
530
  ]);
532
531
  } finally {
533
532
  if (timeout !== void 0) clearTimeout(timeout);
533
+ if (abort !== void 0) abortSignal?.removeEventListener("abort", abort);
534
534
  }
535
535
  }
536
- async function waitForDelay(timeoutMs) {
537
- await new Promise((resolve) => {
538
- const timeout = setTimeout(resolve, timeoutMs);
539
- timeout.unref?.();
540
- });
536
+ async function waitForDelay(timeoutMs, abortSignal) {
537
+ await waitForPromise(
538
+ new Promise((resolve) => {
539
+ const timeout = setTimeout(resolve, timeoutMs);
540
+ timeout.unref?.();
541
+ }),
542
+ timeoutMs,
543
+ abortSignal
544
+ );
541
545
  }
542
546
 
543
547
  // src/text-file.ts
@@ -586,13 +590,31 @@ function decodeCompleteUtf8(bytes) {
586
590
  }
587
591
 
588
592
  // src/docker-sandbox.ts
589
- var defaultImage = "node:22-bookworm";
593
+ var schemaVersion = "1";
594
+ var labelPrefix = "anvia.sandbox.";
590
595
  var defaultWorkdir = "/workspace";
591
- var defaultTimeoutMs = 3e4;
596
+ var defaultCommandTimeoutMs = 3e4;
592
597
  var defaultMaxOutputBytes = 1024 * 1024;
598
+ var defaultMaxFileBytes = 10 * 1024 * 1024;
593
599
  var defaultMaxProcesses = 4;
594
600
  var defaultTextFilePageLines = 500;
595
601
  var defaultTextFilePageBytes = 64 * 1024;
602
+ var defaultPortWaitTimeoutMs = 3e4;
603
+ var defaultPortWaitIntervalMs = 100;
604
+ var idPattern = /^[a-z0-9](?:[a-z0-9_.-]{0,62})$/;
605
+ var envKeyPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
606
+ var labels = {
607
+ schema: `${labelPrefix}schema`,
608
+ id: `${labelPrefix}id`,
609
+ workdir: `${labelPrefix}workdir`,
610
+ workspaceType: `${labelPrefix}workspace.type`,
611
+ workspaceVolume: `${labelPrefix}workspace.volume`,
612
+ networkMode: `${labelPrefix}network.mode`,
613
+ commandTimeoutMs: `${labelPrefix}runtime.command-timeout-ms`,
614
+ maxOutputBytes: `${labelPrefix}runtime.max-output-bytes`,
615
+ maxFileBytes: `${labelPrefix}runtime.max-file-bytes`,
616
+ maxProcesses: `${labelPrefix}runtime.max-processes`
617
+ };
596
618
  var portProbeScript = [
597
619
  `port="$(printf '%04X' "$1")"`,
598
620
  "for table in /proc/net/tcp /proc/net/tcp6; do",
@@ -607,1238 +629,1851 @@ var portProbeScript = [
607
629
  "done",
608
630
  "exit 1"
609
631
  ].join("\n");
610
- var textFilePageScript = [
611
- 'start="$1"',
612
- 'count="$2"',
613
- 'max_bytes="$3"',
614
- 'file="$4"',
615
- 'end="$((start + count))"',
616
- '[ -f "$file" ] || { echo "Not a readable file: $file" >&2; exit 66; }',
617
- 'sed -n "$start,$end p;$end q" "$file" | head -c "$max_bytes"'
618
- ].join("\n");
619
- var DockerSandbox = class _DockerSandbox {
620
- provider = "docker";
621
- image;
622
- pull;
623
- workdir;
624
- workspace;
625
- lifecycle;
626
- network;
632
+ var DockerSandboxClient = class {
627
633
  dockerPath;
628
- labels;
629
- limits;
630
- security;
631
- hooks;
632
- user;
633
634
  constructor(options = {}) {
634
- this.image = options.image ?? defaultImage;
635
- this.pull = options.pull ?? "missing";
636
- this.workdir = options.workdir ?? defaultWorkdir;
637
- this.workspace = options.workspace ?? { mode: "ephemeral" };
638
- const lifecycle = {
639
- autoDestroy: options.lifecycle?.autoDestroy ?? true
640
- };
641
- if (options.lifecycle?.ttlMs !== void 0) lifecycle.ttlMs = options.lifecycle.ttlMs;
642
- if (options.lifecycle?.idleTimeoutMs !== void 0) {
643
- lifecycle.idleTimeoutMs = options.lifecycle.idleTimeoutMs;
644
- }
645
- this.lifecycle = lifecycle;
646
- this.network = options.network ?? false;
635
+ if (!isRecord(options)) throw new TypeError("options must be an object.");
647
636
  this.dockerPath = options.dockerPath ?? "docker";
648
- this.labels = options.labels ?? {};
649
- this.limits = options.limits ?? {};
650
- this.security = {
651
- readonlyRootfs: options.security?.readonlyRootfs ?? false,
652
- noNewPrivileges: options.security?.noNewPrivileges ?? true,
653
- dropCapabilities: options.security?.dropCapabilities ?? ["ALL"]
654
- };
655
- this.hooks = options.hooks ?? {};
656
- this.user = options.user;
657
- }
658
- static node(options = {}) {
659
- return new _DockerSandbox({ ...options, image: options.image ?? "node:22-bookworm" });
660
- }
661
- static python(options = {}) {
662
- return new _DockerSandbox({ ...options, image: options.image ?? "python:3.13-bookworm" });
663
- }
664
- static deno(options = {}) {
665
- return new _DockerSandbox({ ...options, image: options.image ?? "denoland/deno:debian" });
666
- }
667
- async createSession(options = {}) {
668
- const ports = validatePublishedPorts(options.ports ?? []);
669
- this.assertPortNetworkCompatible(ports);
670
- await this.ensureImage();
671
- const id = sanitizeResourceId(options.id ?? randomUUID2());
672
- const workspace = options.workspace ?? this.workspace;
673
- const workspaceId = getWorkspaceId(workspace, id);
674
- const containerName = `anvia-sandbox-${id}`;
675
- const volumeName = `anvia-sandbox-${workspaceId}-workspace`;
676
- const removeVolumeOnDestroy = shouldDestroyWorkspace(workspace);
677
- await assertDockerCli(["volume", "create", volumeName], this.cliOptions());
637
+ assertNonEmptyString(this.dockerPath, "dockerPath");
638
+ }
639
+ async pullImage(options) {
640
+ if (!isRecord(options)) throw new TypeError("options must be an object.");
641
+ assertNonEmptyString(options.image, "image");
642
+ options.abortSignal?.throwIfAborted();
643
+ await assertDockerCli(["pull", options.image], this.cliOptions(options.abortSignal));
644
+ }
645
+ async createSandbox(options) {
646
+ validateCreateOptions(options);
647
+ options = snapshotCreateOptions(options);
648
+ options.abortSignal?.throwIfAborted();
649
+ const id = options.id ?? randomUUID2();
650
+ assertSandboxId(id);
651
+ const containerName = containerNameFor(id);
652
+ const workdir = options.workdir ?? defaultWorkdir;
653
+ const runtime = resolveRuntimeLimits(options.runtime);
654
+ const workspace = copyWorkspace(options.workspace);
655
+ const network = copyNetwork(options.network);
656
+ const env = copyStringRecord(options.env, "env");
657
+ const userLabels = copyStringRecord(options.labels, "labels");
658
+ const volumeName = workspace.type === "ephemeral" ? `${containerName}-workspace-${randomUUID2()}` : workspace.name;
659
+ const ownsVolume = workspace.type === "ephemeral";
660
+ await this.assertContainerDoesNotExist(containerName, options.abortSignal);
661
+ await this.assertImageExists(options.image, options.abortSignal);
662
+ if (workspace.type === "docker-volume") {
663
+ await this.assertVolumeExists(workspace.name, options.abortSignal);
664
+ }
665
+ let containerCreated = false;
666
+ let volumeCreated = false;
678
667
  try {
668
+ if (workspace.type === "ephemeral") {
669
+ await assertDockerCli(
670
+ ["volume", "create", "--label", `${labels.id}=${id}`, volumeName],
671
+ this.cliOptions(options.abortSignal)
672
+ );
673
+ volumeCreated = true;
674
+ }
679
675
  await assertDockerCli(
680
- this.createRunArgs(containerName, volumeName, workspace, options.metadata, ports),
676
+ createRunArgs({
677
+ id,
678
+ containerName,
679
+ image: options.image,
680
+ workdir,
681
+ workspace,
682
+ volumeName,
683
+ env,
684
+ user: options.user,
685
+ userLabels,
686
+ resources: options.resources,
687
+ runtime,
688
+ security: options.security,
689
+ network
690
+ }),
681
691
  {
682
- ...this.cliOptions(),
683
- timeoutMs: this.limits.timeoutMs ?? defaultTimeoutMs
692
+ ...this.cliOptions(options.abortSignal),
693
+ timeoutMs: runtime.commandTimeoutMs
684
694
  }
685
695
  );
686
- const publishedPorts = await this.inspectPublishedPorts(containerName, ports);
687
- const session = new DockerSandboxSessionImpl({
696
+ containerCreated = true;
697
+ const publishedPorts = await inspectPublishedPorts({
698
+ dockerPath: this.dockerPath,
699
+ containerName,
700
+ ports: network.mode === "bridge" ? [...network.ports ?? []] : [],
701
+ abortSignal: options.abortSignal
702
+ });
703
+ const sandbox = this.createHandle({
688
704
  id,
689
705
  containerName,
706
+ workdir,
707
+ workspace,
690
708
  volumeName,
691
- workdir: this.workdir,
692
- dockerPath: this.dockerPath,
693
- limits: this.limits,
694
- lifecycle: this.lifecycle,
695
- removeVolumeOnDestroy,
696
- env: options.manifest?.env ?? {},
697
- hooks: this.hooks,
709
+ ownsVolume,
710
+ env,
711
+ runtime,
698
712
  publishedPorts
699
713
  });
700
- await session.applyManifest(options.manifest);
701
- await this.hooks.onSessionCreate?.(session.event());
702
- return session;
714
+ await applyInitialContent(sandbox.runtime, options);
715
+ return sandbox;
703
716
  } catch (error) {
704
- await this.cleanup(containerName, removeVolumeOnDestroy ? volumeName : void 0);
717
+ const cleanupErrors = [];
718
+ if (containerCreated) {
719
+ try {
720
+ await removeContainer(this.dockerPath, containerName);
721
+ } catch (cleanupError) {
722
+ cleanupErrors.push(cleanupError);
723
+ }
724
+ }
725
+ if (ownsVolume && volumeCreated) {
726
+ try {
727
+ await removeVolume(this.dockerPath, volumeName);
728
+ } catch (cleanupError) {
729
+ cleanupErrors.push(cleanupError);
730
+ }
731
+ }
732
+ if (cleanupErrors.length > 0) {
733
+ throw new AggregateError([error, ...cleanupErrors], "Sandbox creation and rollback failed");
734
+ }
705
735
  throw error;
706
736
  }
707
737
  }
708
- async ensureImage() {
709
- if (this.pull === "always") {
710
- await assertDockerCli(["pull", this.image], this.cliOptions());
711
- return;
738
+ async resumeSandbox(options) {
739
+ if (!isRecord(options)) throw new TypeError("options must be an object.");
740
+ assertSandboxId(options.id);
741
+ options.abortSignal?.throwIfAborted();
742
+ const containerName = containerNameFor(options.id);
743
+ const inspection = await inspectContainer(
744
+ this.dockerPath,
745
+ containerName,
746
+ options.abortSignal,
747
+ true
748
+ );
749
+ const configuration = configurationFromInspection(options.id, containerName, inspection);
750
+ if (inspection.State.Paused === true || inspection.State.Dead === true) {
751
+ throw new DockerSandboxError(
752
+ `Sandbox cannot be resumed from Docker state: ${inspection.State.Status ?? "unknown"}`,
753
+ "invalid_state"
754
+ );
712
755
  }
713
- if (this.pull === "missing") {
714
- const inspect = await runDockerCli(["image", "inspect", this.image], this.cliOptions());
715
- if (inspect.exitCode !== 0) {
716
- await assertDockerCli(["pull", this.image], this.cliOptions());
717
- }
756
+ if (inspection.State.Running === true) {
757
+ await assertDockerCli(["stop", containerName], this.cliOptions(options.abortSignal));
718
758
  }
719
- }
720
- createRunArgs(containerName, volumeName, workspace, metadata, ports) {
721
- const args = [
722
- "run",
723
- "-d",
724
- "--name",
759
+ await assertDockerCli(["start", containerName], this.cliOptions(options.abortSignal));
760
+ configuration.publishedPorts = await inspectPublishedPorts({
761
+ dockerPath: this.dockerPath,
725
762
  containerName,
726
- "-v",
727
- `${volumeName}:${this.workdir}`,
728
- "-w",
729
- this.workdir,
730
- "--label",
731
- "anvia.sandbox=true",
732
- "--label",
733
- `anvia.sandbox.workspace.mode=${workspace.mode ?? "ephemeral"}`,
734
- "--label",
735
- `anvia.sandbox.workspace.volume=${volumeName}`
736
- ];
737
- for (const [key, value] of Object.entries(this.labels)) {
738
- args.push("--label", `${key}=${value}`);
739
- }
740
- if (metadata !== void 0) {
741
- for (const [key, value] of Object.entries(metadata)) {
742
- args.push("--label", `anvia.sandbox.metadata.${key}=${value}`);
743
- }
744
- }
745
- this.appendNetworkArgs(args);
746
- for (const port of ports) {
747
- args.push("--publish", `127.0.0.1::${port}/tcp`);
763
+ ports: configuredContainerPorts(inspection),
764
+ abortSignal: options.abortSignal
765
+ });
766
+ return this.createHandle(configuration);
767
+ }
768
+ createHandle(configuration) {
769
+ return new DockerSandboxHandle({
770
+ dockerPath: this.dockerPath,
771
+ configuration
772
+ });
773
+ }
774
+ async assertImageExists(image, abortSignal) {
775
+ const result = await runDockerCli(["image", "inspect", image], this.cliOptions(abortSignal));
776
+ if (result.exitCode === 0) return;
777
+ const message = safeDecode(result.stderr);
778
+ if (message.toLowerCase().includes("no such image")) {
779
+ throw new DockerSandboxError(
780
+ `Docker image is not available locally: ${image}`,
781
+ "image_not_found"
782
+ );
748
783
  }
749
- this.appendLimitArgs(args);
750
- this.appendSecurityArgs(args);
751
- if (this.user !== void 0) {
752
- args.push("-u", this.user);
784
+ throw new DockerSandboxError(
785
+ "Unable to inspect Docker image.",
786
+ "docker_command_failed",
787
+ result
788
+ );
789
+ }
790
+ async assertContainerDoesNotExist(containerName, abortSignal) {
791
+ const result = await runDockerCli(["container", "inspect", containerName], {
792
+ ...this.cliOptions(abortSignal),
793
+ maxOutputBytes: defaultMaxOutputBytes
794
+ });
795
+ if (result.exitCode === 0) {
796
+ throw new DockerSandboxError(
797
+ `A Docker container already exists for sandbox: ${containerName}`,
798
+ "invalid_state"
799
+ );
753
800
  }
754
- args.push(
755
- this.image,
756
- "sh",
757
- "-c",
758
- "trap 'exit 0' TERM INT; while :; do sleep 3600 & wait $!; done"
801
+ if (safeDecode(result.stderr).toLowerCase().includes("no such")) return;
802
+ throw new DockerSandboxError(
803
+ "Unable to check whether the Docker sandbox already exists.",
804
+ "docker_command_failed",
805
+ result
759
806
  );
760
- return args;
761
807
  }
762
- appendNetworkArgs(args) {
763
- const mode = typeof this.network === "object" ? this.network.mode : this.network;
764
- if (mode === false || mode === "none") {
765
- args.push("--network", "none");
766
- return;
808
+ async assertVolumeExists(name, abortSignal) {
809
+ const result = await runDockerCli(["volume", "inspect", name], this.cliOptions(abortSignal));
810
+ if (result.exitCode === 0) return;
811
+ const message = safeDecode(result.stderr);
812
+ if (message.toLowerCase().includes("no such volume")) {
813
+ throw new DockerSandboxError(`Docker volume does not exist: ${name}`, "volume_not_found");
814
+ }
815
+ throw new DockerSandboxError(
816
+ "Unable to inspect Docker volume.",
817
+ "docker_command_failed",
818
+ result
819
+ );
820
+ }
821
+ cliOptions(abortSignal) {
822
+ return { dockerPath: this.dockerPath, signal: abortSignal };
823
+ }
824
+ };
825
+ var DockerSandboxHandle = class {
826
+ id;
827
+ runtime;
828
+ currentState = "running";
829
+ dockerPath;
830
+ configuration;
831
+ runtimeImpl;
832
+ stopPromise;
833
+ destroyPromise;
834
+ constructor(options) {
835
+ this.dockerPath = options.dockerPath;
836
+ this.configuration = options.configuration;
837
+ this.id = options.configuration.id;
838
+ this.runtimeImpl = new DockerSandboxRuntimeImpl({
839
+ configuration: options.configuration,
840
+ dockerPath: options.dockerPath,
841
+ state: () => this.currentState
842
+ });
843
+ this.runtime = Object.freeze(this.runtimeImpl.publicRuntime());
844
+ }
845
+ get state() {
846
+ return this.currentState;
847
+ }
848
+ inspector(options) {
849
+ if (!isRecord(options)) throw new TypeError("options must be an object.");
850
+ if (options.files !== true && options.ports !== true && options.processes !== true) {
851
+ throw new TypeError("Sandbox inspector must enable at least one capability.");
852
+ }
853
+ let inspector = {
854
+ id: this.id,
855
+ provider: "docker",
856
+ workdir: this.configuration.workdir
857
+ };
858
+ if (options.files === true) {
859
+ inspector = {
860
+ ...inspector,
861
+ listFiles: this.runtime.listFiles.bind(this.runtime),
862
+ readFile: this.runtime.readFile.bind(this.runtime)
863
+ };
767
864
  }
768
- if (mode !== true) {
769
- args.push("--network", mode);
865
+ if (options.ports === true) {
866
+ inspector = { ...inspector, publishedPorts: this.runtime.publishedPorts };
770
867
  }
868
+ if (options.processes === true) {
869
+ inspector = {
870
+ ...inspector,
871
+ listProcesses: this.runtime.listProcesses.bind(this.runtime),
872
+ readProcessLogs: this.runtime.readProcessLogs.bind(this.runtime)
873
+ };
874
+ }
875
+ return Object.freeze(inspector);
771
876
  }
772
- assertPortNetworkCompatible(ports) {
773
- if (ports.length === 0) return;
774
- const mode = typeof this.network === "object" ? this.network.mode : this.network;
775
- if (mode === false || mode === "none" || mode === "host" || typeof mode === "string" && mode.startsWith("container:")) {
776
- throw new SandboxPortError(
777
- "Published sandbox ports require network: true or a bridge-capable Docker network."
778
- );
877
+ async stop(options = {}) {
878
+ if (!isRecord(options)) throw new TypeError("options must be an object.");
879
+ if (this.currentState === "stopped") return;
880
+ if (this.currentState === "destroyed" || this.currentState === "destroying") {
881
+ throw invalidState(this.id, this.currentState);
882
+ }
883
+ if (this.stopPromise !== void 0) return this.stopPromise;
884
+ this.stopPromise = this.performStop(options.abortSignal);
885
+ try {
886
+ await this.stopPromise;
887
+ } finally {
888
+ this.stopPromise = void 0;
779
889
  }
780
890
  }
781
- async inspectPublishedPorts(containerName, ports) {
782
- if (ports.length === 0) return [];
783
- const raw = await assertDockerCli(
784
- ["container", "inspect", "--format", "{{json .NetworkSettings.Ports}}", containerName],
785
- this.cliOptions()
786
- );
787
- let mappings;
891
+ async performStop(abortSignal) {
892
+ abortSignal?.throwIfAborted();
893
+ this.currentState = "stopping";
788
894
  try {
789
- mappings = JSON.parse(raw);
895
+ await this.runtimeImpl.closeActiveOperations("Sandbox is stopping.");
896
+ await this.runtimeImpl.disposeProcesses(abortSignal);
897
+ await assertDockerCli(["stop", this.configuration.containerName], {
898
+ dockerPath: this.dockerPath,
899
+ signal: abortSignal
900
+ });
901
+ this.currentState = "stopped";
790
902
  } catch (error) {
791
- throw new SandboxPortError("Docker returned invalid published port metadata.", error);
792
- }
793
- if (!isRecord(mappings)) {
794
- throw new SandboxPortError("Docker returned invalid published port metadata.");
903
+ this.currentState = "error";
904
+ throw error;
795
905
  }
796
- return ports.map((containerPort) => {
797
- const entries = mappings[`${containerPort}/tcp`];
798
- if (!Array.isArray(entries)) {
799
- throw new SandboxPortError(`Docker did not publish sandbox port ${containerPort}/tcp.`);
800
- }
801
- const entry = entries.find(
802
- (candidate) => isRecord(candidate) && candidate.HostIp === "127.0.0.1" && typeof candidate.HostPort === "string"
803
- );
804
- if (!isRecord(entry) || typeof entry.HostPort !== "string") {
805
- throw new SandboxPortError(
806
- `Docker did not bind sandbox port ${containerPort}/tcp to 127.0.0.1.`
807
- );
808
- }
809
- const hostPort = Number(entry.HostPort);
810
- if (!isValidPort(hostPort)) {
811
- throw new SandboxPortError(
812
- `Docker returned an invalid host port for ${containerPort}/tcp.`
813
- );
814
- }
815
- return { containerPort, host: "127.0.0.1", hostPort, protocol: "tcp" };
816
- });
817
906
  }
818
- appendLimitArgs(args) {
819
- if (this.limits.memoryMb !== void 0) {
820
- args.push("--memory", `${this.limits.memoryMb}m`);
907
+ async destroy() {
908
+ if (this.currentState === "destroyed") return;
909
+ if (this.destroyPromise !== void 0) return this.destroyPromise;
910
+ this.destroyPromise = this.performDestroy();
911
+ try {
912
+ await this.destroyPromise;
913
+ } finally {
914
+ this.destroyPromise = void 0;
821
915
  }
822
- if (this.limits.cpus !== void 0) {
823
- args.push("--cpus", String(this.limits.cpus));
916
+ }
917
+ async performDestroy() {
918
+ if (this.stopPromise !== void 0) await this.stopPromise.catch(() => void 0);
919
+ this.currentState = "destroying";
920
+ const failures = [];
921
+ try {
922
+ await this.runtimeImpl.closeActiveOperations("Sandbox is being destroyed.");
923
+ } catch (error) {
924
+ failures.push(error);
824
925
  }
825
- if (this.limits.pidsLimit !== void 0) {
826
- args.push("--pids-limit", String(this.limits.pidsLimit));
926
+ try {
927
+ await this.runtimeImpl.disposeProcesses();
928
+ } catch (error) {
929
+ failures.push(error);
827
930
  }
828
- }
829
- appendSecurityArgs(args) {
830
- if (this.security.readonlyRootfs) {
831
- args.push("--read-only");
931
+ try {
932
+ await removeContainer(this.dockerPath, this.configuration.containerName);
933
+ } catch (error) {
934
+ failures.push(error);
832
935
  }
833
- if (this.security.noNewPrivileges) {
834
- args.push("--security-opt", "no-new-privileges");
936
+ if (this.configuration.ownsVolume) {
937
+ try {
938
+ await removeVolume(this.dockerPath, this.configuration.volumeName);
939
+ } catch (error) {
940
+ failures.push(error);
941
+ }
835
942
  }
836
- for (const capability of this.security.dropCapabilities) {
837
- args.push("--cap-drop", capability);
943
+ if (failures.length > 0) {
944
+ this.currentState = "error";
945
+ throw new AggregateError(failures, `Unable to destroy sandbox: ${this.id}`);
838
946
  }
947
+ this.currentState = "destroyed";
839
948
  }
840
- cliOptions() {
841
- return {
842
- dockerPath: this.dockerPath,
843
- maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes
844
- };
845
- }
846
- async cleanup(containerName, volumeName) {
847
- await runDockerCli(["rm", "-f", containerName], this.cliOptions()).catch(() => void 0);
848
- if (volumeName !== void 0) {
849
- await runDockerCli(["volume", "rm", "-f", volumeName], this.cliOptions()).catch(
850
- () => void 0
851
- );
852
- }
949
+ async [Symbol.asyncDispose]() {
950
+ await this.destroy();
853
951
  }
854
952
  };
855
- var DockerSandboxSessionImpl = class {
856
- provider = "docker";
857
- id;
858
- workdir;
859
- publishedPorts;
860
- containerName;
861
- volumeName;
953
+ var DockerSandboxRuntimeImpl = class {
954
+ configuration;
862
955
  dockerPath;
863
- limits;
864
- lifecycle;
865
- removeVolumeOnDestroy;
866
- env;
867
- hooks;
956
+ state;
868
957
  processManager;
869
- ttlTimer;
870
- idleTimer;
871
- activeOperations = 0;
872
- destroyed = false;
958
+ activeControllers = /* @__PURE__ */ new Set();
959
+ activeOperations = /* @__PURE__ */ new Set();
960
+ processDisposed = false;
873
961
  constructor(options) {
874
- this.id = options.id;
875
- this.containerName = options.containerName;
876
- this.volumeName = options.volumeName;
877
- this.workdir = options.workdir;
962
+ this.configuration = options.configuration;
878
963
  this.dockerPath = options.dockerPath;
879
- this.limits = options.limits;
880
- this.lifecycle = options.lifecycle;
881
- this.removeVolumeOnDestroy = options.removeVolumeOnDestroy;
882
- this.env = options.env;
883
- this.hooks = options.hooks;
884
- this.publishedPorts = options.publishedPorts;
964
+ this.state = options.state;
885
965
  this.processManager = new DockerProcessManager({
886
- containerName: this.containerName,
887
- dockerPath: this.dockerPath,
888
- workdir: this.workdir,
889
- env: this.env,
890
- maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes,
891
- maxProcesses: this.limits.maxProcesses ?? defaultMaxProcesses,
892
- startupTimeoutMs: this.limits.timeoutMs ?? defaultTimeoutMs,
893
- onStart: async (process) => {
894
- const event = {
895
- ...this.event(),
896
- command: process.command,
897
- args: process.args
898
- };
899
- if (process.cwd !== void 0) event.cwd = process.cwd;
900
- await this.hooks.onExecStart?.(event);
901
- },
902
- onExit: async (process, logs, durationMs) => {
903
- const event = {
904
- ...this.event(),
905
- command: process.command,
906
- args: process.args,
907
- result: {
908
- stdout: logs.stdout,
909
- stderr: logs.stderr,
910
- exitCode: process.exitCode ?? 1,
911
- durationMs,
912
- timedOut: false,
913
- aborted: process.status === "stopped",
914
- stdoutTruncated: logs.stdoutTruncated,
915
- stderrTruncated: logs.stderrTruncated
916
- }
917
- };
918
- if (process.cwd !== void 0) event.cwd = process.cwd;
919
- await this.hooks.onExecEnd?.(event);
920
- }
966
+ containerName: options.configuration.containerName,
967
+ dockerPath: options.dockerPath,
968
+ workdir: options.configuration.workdir,
969
+ env: options.configuration.env,
970
+ maxOutputBytes: options.configuration.runtime.maxOutputBytes,
971
+ maxProcesses: options.configuration.runtime.maxProcesses,
972
+ startupTimeoutMs: options.configuration.runtime.commandTimeoutMs
921
973
  });
922
- this.startLifecycleTimers();
923
974
  }
924
- async applyManifest(manifest) {
925
- await this.runOperation(async () => {
926
- for (const directory of manifest?.directories ?? []) {
927
- await this.mkdir(directory);
928
- }
929
- for (const [filePath, content] of Object.entries(manifest?.files ?? {})) {
930
- await this.writeFile(filePath, content);
931
- }
932
- });
975
+ publicRuntime() {
976
+ return {
977
+ id: this.configuration.id,
978
+ provider: "docker",
979
+ workdir: this.configuration.workdir,
980
+ publishedPorts: Object.freeze(
981
+ this.configuration.publishedPorts.map((port) => Object.freeze({ ...port }))
982
+ ),
983
+ exec: (options) => this.exec(options),
984
+ execStream: (options) => this.execStream(options),
985
+ readFile: (options) => this.readFile(options),
986
+ readTextFile: (options) => this.readTextFile(options),
987
+ readTextFilePage: (options) => this.readTextFilePage(options),
988
+ writeFile: (options) => this.writeFile(options),
989
+ writeTextFile: (options) => this.writeTextFile(options),
990
+ listFiles: (options) => this.listFiles(options),
991
+ startProcess: (options) => this.startProcess(options),
992
+ listProcesses: (options) => this.listProcesses(options),
993
+ readProcessLogs: (options) => this.readProcessLogs(options),
994
+ stopProcess: (options) => this.stopProcess(options),
995
+ waitForPort: (options) => this.waitForPort(options)
996
+ };
933
997
  }
934
998
  async exec(options) {
935
- return this.runOperation(async () => {
936
- const startEvent = {
937
- ...this.event(),
938
- command: options.command,
939
- args: options.args ?? []
940
- };
941
- if (options.cwd !== void 0) startEvent.cwd = options.cwd;
942
- await this.hooks.onExecStart?.(startEvent);
943
- const normalizedResult = await this.execCommand(options);
944
- const endEvent = {
945
- ...this.event(),
946
- command: options.command,
947
- args: options.args ?? [],
948
- result: normalizedResult
949
- };
950
- if (options.cwd !== void 0) endEvent.cwd = options.cwd;
951
- await this.hooks.onExecEnd?.(endEvent);
952
- return normalizedResult;
999
+ validateExecOptions(options);
1000
+ return this.runOperation(options.abortSignal, async (abortSignal) => {
1001
+ const result = await runDockerCli(this.execArgs(options), {
1002
+ dockerPath: this.dockerPath,
1003
+ timeoutMs: options.timeoutMs ?? this.configuration.runtime.commandTimeoutMs,
1004
+ maxOutputBytes: this.configuration.runtime.maxOutputBytes,
1005
+ input: options.input,
1006
+ signal: abortSignal
1007
+ });
1008
+ return toExecResult(result);
953
1009
  });
954
1010
  }
955
1011
  async *execStream(options) {
956
- const events = [];
957
- let notify;
958
- let done = false;
959
- let error;
1012
+ validateExecOptions(options);
1013
+ this.assertRunning();
1014
+ const streamAbort = new AbortController();
1015
+ const combined = combineSignals(options.abortSignal, streamAbort.signal);
1016
+ const queue = [];
1017
+ let queuedBytes = 0;
1018
+ let wake;
1019
+ let complete = false;
1020
+ let failure;
960
1021
  const push = (event) => {
961
- events.push(event);
962
- notify?.();
963
- notify = void 0;
1022
+ if (event.type !== "result") {
1023
+ queuedBytes += event.data.byteLength;
1024
+ if (queuedBytes > this.configuration.runtime.maxOutputBytes) {
1025
+ streamAbort.abort(
1026
+ new DockerSandboxError("Sandbox stream consumer is too slow.", "docker_command_failed")
1027
+ );
1028
+ return;
1029
+ }
1030
+ }
1031
+ queue.push(event);
1032
+ wake?.();
1033
+ wake = void 0;
964
1034
  };
965
- const wait = () => new Promise((resolve) => {
966
- notify = resolve;
1035
+ const run = this.runOperation(combined, async (abortSignal) => {
1036
+ const result = await runDockerCli(this.execArgs(options), {
1037
+ dockerPath: this.dockerPath,
1038
+ timeoutMs: options.timeoutMs ?? this.configuration.runtime.commandTimeoutMs,
1039
+ maxOutputBytes: this.configuration.runtime.maxOutputBytes,
1040
+ input: options.input,
1041
+ signal: abortSignal,
1042
+ onStdout: (data) => push({ type: "stdout", data: data.slice() }),
1043
+ onStderr: (data) => push({ type: "stderr", data: data.slice() })
1044
+ });
1045
+ push({ type: "result", result: toExecResult(result) });
967
1046
  });
968
- const run = this.exec({
969
- ...options,
970
- onStdout: (chunk) => {
971
- options.onStdout?.(chunk);
972
- push({ type: "stdout", chunk, text: Buffer.from(chunk).toString("utf8") });
1047
+ void run.then(
1048
+ () => {
1049
+ complete = true;
1050
+ wake?.();
973
1051
  },
974
- onStderr: (chunk) => {
975
- options.onStderr?.(chunk);
976
- push({ type: "stderr", chunk, text: Buffer.from(chunk).toString("utf8") });
1052
+ (error) => {
1053
+ failure = error;
1054
+ complete = true;
1055
+ wake?.();
977
1056
  }
978
- }).then((result) => {
979
- push({ type: "exit", result });
980
- }).catch((caught) => {
981
- error = caught;
982
- }).finally(() => {
983
- done = true;
984
- notify?.();
985
- notify = void 0;
986
- });
1057
+ );
987
1058
  try {
988
- while (!done || events.length > 0) {
989
- const event = events.shift();
1059
+ while (!complete || queue.length > 0) {
1060
+ const event = queue.shift();
990
1061
  if (event !== void 0) {
1062
+ if (event.type !== "result") queuedBytes -= event.data.byteLength;
991
1063
  yield event;
992
1064
  continue;
993
1065
  }
994
- await wait();
995
- }
996
- if (error !== void 0) {
997
- throw error;
1066
+ await new Promise((resolve) => {
1067
+ wake = resolve;
1068
+ });
998
1069
  }
1070
+ if (failure !== void 0) throw failure;
999
1071
  } finally {
1000
- await run;
1072
+ if (!complete) streamAbort.abort(new DOMException("Stream closed", "AbortError"));
1073
+ await run.catch(() => void 0);
1001
1074
  }
1002
1075
  }
1003
- async startProcess(options) {
1004
- return this.runOperation(async () => this.processManager.start(options));
1005
- }
1006
- async listProcesses() {
1007
- return this.runOperation(async () => this.processManager.list());
1008
- }
1009
- async readProcessLogs(processId, options) {
1010
- return this.runOperation(async () => this.processManager.logs(processId, options));
1011
- }
1012
- async stopProcess(processId, options) {
1013
- return this.runOperation(async () => this.processManager.stop(processId, options));
1014
- }
1015
- async waitForPort(containerPort, options = {}) {
1016
- return this.runOperation(async () => {
1017
- const publishedPort = this.publishedPorts.find(
1018
- (candidate) => candidate.containerPort === containerPort
1019
- );
1020
- if (publishedPort === void 0) {
1021
- throw new SandboxPortError(`Sandbox port is not published: ${containerPort}/tcp`);
1022
- }
1023
- const timeoutMs = options.timeoutMs ?? this.limits.timeoutMs ?? defaultTimeoutMs;
1024
- const intervalMs = options.intervalMs ?? 250;
1025
- assertWaitOptions(timeoutMs, intervalMs);
1026
- const deadline = Date.now() + timeoutMs;
1027
- while (true) {
1028
- this.assertActive();
1029
- if (options.signal?.aborted === true) throw abortReason(options.signal);
1030
- const remainingMs = deadline - Date.now();
1031
- if (remainingMs <= 0) {
1032
- throw new SandboxTimeoutError(`Waiting for sandbox port ${containerPort}/tcp timed out.`);
1033
- }
1034
- const probeTimeoutMs = Math.min(1e3, remainingMs);
1035
- if (await this.isPortListening(containerPort, probeTimeoutMs)) {
1036
- return publishedPort;
1037
- }
1038
- const remainingAfterProbeMs = deadline - Date.now();
1039
- if (remainingAfterProbeMs <= 0) {
1040
- throw new SandboxTimeoutError(`Waiting for sandbox port ${containerPort}/tcp timed out.`);
1041
- }
1042
- await waitWithSignal(Math.min(intervalMs, remainingAfterProbeMs), options.signal);
1043
- }
1044
- });
1045
- }
1046
- async readFile(filePath) {
1047
- return this.runOperation(async () => {
1048
- const normalized = normalizeSandboxPath(filePath);
1076
+ async readFile(options) {
1077
+ assertReadOptions(options);
1078
+ return this.runOperation(options.abortSignal, async (abortSignal) => {
1079
+ const normalized = normalizeSandboxPath(options.path);
1049
1080
  const tempDir = await mkdtemp(path2.join(os.tmpdir(), "anvia-sandbox-read-"));
1050
1081
  const target = path2.join(tempDir, path2.basename(normalized));
1051
1082
  try {
1052
1083
  await assertDockerCli(
1053
- ["cp", `${this.containerName}:${containerPath(this.workdir, normalized)}`, target],
1054
- this.cliOptions()
1084
+ [
1085
+ "cp",
1086
+ `${this.configuration.containerName}:${containerPath(this.configuration.workdir, normalized)}`,
1087
+ target
1088
+ ],
1089
+ { dockerPath: this.dockerPath, signal: abortSignal }
1055
1090
  );
1056
- const { readFile } = await import("fs/promises");
1057
- const bytes = await readFile(target);
1058
- this.assertFileSize(bytes.byteLength, filePath);
1059
- return bytes;
1091
+ await assertCopiedRegularFile(tempDir, target, normalized);
1092
+ const bytes = await readFile(target, { signal: abortSignal });
1093
+ this.assertFileSize(bytes.byteLength, normalized);
1094
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength).slice();
1060
1095
  } finally {
1061
1096
  await rm(tempDir, { recursive: true, force: true });
1062
1097
  }
1063
1098
  });
1064
1099
  }
1065
- async readTextFile(filePath) {
1066
- const bytes = await this.readFile(filePath);
1067
- return new TextDecoder().decode(bytes);
1068
- }
1069
- async readTextFilePage(filePath, options = {}) {
1070
- return this.runOperation(async () => {
1071
- const startLine = options.startLine ?? 1;
1072
- const lineCount = options.lineCount ?? defaultTextFilePageLines;
1073
- const maxBytes = options.maxBytes ?? defaultTextFilePageBytes;
1074
- assertTextFileReadOptions(startLine, lineCount, maxBytes);
1075
- const normalized = normalizeSandboxPath(filePath);
1076
- const captureBytes = maxBytes + 4;
1077
- const result = await runDockerCli(
1078
- [
1079
- "exec",
1080
- "-w",
1081
- this.workdir,
1082
- this.containerName,
1083
- "sh",
1084
- "-c",
1085
- textFilePageScript,
1086
- "anvia-read-file-page",
1087
- String(startLine),
1088
- String(lineCount),
1089
- String(captureBytes),
1090
- containerPath(this.workdir, normalized)
1091
- ],
1092
- {
1093
- ...this.cliOptions(),
1094
- maxOutputBytes: captureBytes
1095
- }
1096
- );
1097
- if (result.exitCode !== 0) {
1098
- throw new SandboxDockerCommandError(
1099
- `Unable to read sandbox text file: ${filePath}`,
1100
- result
1101
- );
1102
- }
1103
- return createTextFilePage(result.stdout, {
1104
- startLine,
1105
- lineCount,
1106
- maxBytes,
1107
- contentStartLine: startLine
1108
- });
1109
- });
1100
+ async readTextFile(options) {
1101
+ return decodeUtf8(await this.readFile(options));
1102
+ }
1103
+ async readTextFilePage(options) {
1104
+ assertReadOptions(options);
1105
+ const startLine = options.startLine ?? 1;
1106
+ const lineCount = options.lineCount ?? defaultTextFilePageLines;
1107
+ const maxBytes = options.maxBytes ?? defaultTextFilePageBytes;
1108
+ assertPositiveSafeInteger(startLine, "startLine");
1109
+ assertPositiveSafeInteger(lineCount, "lineCount");
1110
+ assertPositiveSafeInteger(maxBytes, "maxBytes");
1111
+ let readOptions = {
1112
+ path: options.path
1113
+ };
1114
+ if (options.abortSignal !== void 0) {
1115
+ readOptions = { ...readOptions, abortSignal: options.abortSignal };
1116
+ }
1117
+ const text = await this.readTextFile(readOptions);
1118
+ return createTextFilePage(text, { startLine, lineCount, maxBytes });
1119
+ }
1120
+ async writeFile(options) {
1121
+ assertOptionsObject(options);
1122
+ if (!(options.data instanceof Uint8Array)) throw new TypeError("data must be a Uint8Array.");
1123
+ const data = options.data.slice();
1124
+ await this.writeBytes(options.path, data, options.abortSignal);
1125
+ }
1126
+ async writeTextFile(options) {
1127
+ assertOptionsObject(options);
1128
+ if (typeof options.text !== "string") throw new TypeError("text must be a string.");
1129
+ await this.writeBytes(
1130
+ options.path,
1131
+ new TextEncoder().encode(options.text),
1132
+ options.abortSignal
1133
+ );
1110
1134
  }
1111
- async writeFile(filePath, data) {
1112
- await this.runOperation(async () => {
1113
- const size = byteLength(data);
1114
- this.assertFileSize(size, filePath);
1115
- const normalized = normalizeSandboxPath(filePath);
1116
- await this.mkdir(parentSandboxPath(normalized));
1135
+ async writeBytes(filePath, data, abortSignal) {
1136
+ const normalized = normalizeSandboxPath(filePath);
1137
+ this.assertFileSize(data.byteLength, normalized);
1138
+ await this.runOperation(abortSignal, async (effectiveSignal) => {
1139
+ await this.mkdir(parentSandboxPath(normalized), effectiveSignal);
1117
1140
  const tempDir = await mkdtemp(path2.join(os.tmpdir(), "anvia-sandbox-write-"));
1118
1141
  const source = path2.join(tempDir, path2.basename(normalized));
1119
1142
  try {
1120
- await writeFile(source, data);
1143
+ await writeFile(source, data, { signal: effectiveSignal });
1121
1144
  await assertDockerCli(
1122
- ["cp", source, `${this.containerName}:${containerPath(this.workdir, normalized)}`],
1123
- this.cliOptions()
1145
+ [
1146
+ "cp",
1147
+ source,
1148
+ `${this.configuration.containerName}:${containerPath(this.configuration.workdir, normalized)}`
1149
+ ],
1150
+ { dockerPath: this.dockerPath, signal: effectiveSignal }
1124
1151
  );
1125
- await this.hooks.onFileWrite?.({ ...this.event(), path: normalized, size });
1126
1152
  } finally {
1127
1153
  await rm(tempDir, { recursive: true, force: true });
1128
1154
  }
1129
1155
  });
1130
1156
  }
1131
- async writeTextFile(filePath, content) {
1132
- await this.writeFile(filePath, content);
1133
- }
1134
- async listFiles(filePath = ".") {
1135
- return this.runOperation(async () => {
1136
- const normalized = normalizeSandboxPath(filePath, { allowRoot: true });
1137
- const target = containerPath(this.workdir, normalized);
1138
- const result = await this.execCommand({
1139
- command: "find",
1140
- args: [target, "-mindepth", "1", "-maxdepth", "1", "-printf", "%p %y %s\n"]
1141
- });
1142
- if (result.timedOut) {
1143
- throw new SandboxTimeoutError(`Listing files timed out for ${filePath}.`);
1144
- }
1145
- if (result.exitCode !== 0) {
1146
- throw new SandboxDockerCommandError(`Unable to list sandbox path: ${filePath}`, result);
1157
+ async listFiles(options = {}) {
1158
+ assertOptionsObject(options);
1159
+ const requestedPath = options.path ?? ".";
1160
+ const normalized = normalizeSandboxPath(requestedPath, { allowRoot: true });
1161
+ return this.runOperation(options.abortSignal, async (abortSignal) => {
1162
+ const result = await this.execCommand(
1163
+ {
1164
+ command: "find",
1165
+ args: [
1166
+ containerPath(this.configuration.workdir, normalized),
1167
+ "-mindepth",
1168
+ "1",
1169
+ "-maxdepth",
1170
+ "1",
1171
+ "-printf",
1172
+ "%p %y %s\n"
1173
+ ]
1174
+ },
1175
+ abortSignal
1176
+ );
1177
+ if (result.status !== "exited" || result.exitCode !== 0) {
1178
+ throw new DockerSandboxError(
1179
+ "Unable to list sandbox files.",
1180
+ "docker_command_failed",
1181
+ result
1182
+ );
1147
1183
  }
1148
- return result.stdout.split("\n").filter((line) => line.trim().length > 0).map((line) => this.parseFindEntry(line));
1184
+ const output = decodeUtf8(result.stdout);
1185
+ return output.split("\n").filter((line) => line.length > 0).map((line) => parseFindEntry(line, this.configuration.workdir));
1149
1186
  });
1150
1187
  }
1151
- async destroy() {
1152
- if (this.destroyed) {
1153
- return;
1154
- }
1155
- this.destroyed = true;
1156
- this.clearLifecycleTimers();
1157
- await this.processManager.dispose();
1158
- await runDockerCli(["rm", "-f", this.containerName], this.cliOptions()).catch(() => void 0);
1159
- if (this.removeVolumeOnDestroy) {
1160
- await runDockerCli(["volume", "rm", "-f", this.volumeName], this.cliOptions()).catch(
1161
- () => void 0
1162
- );
1188
+ async startProcess(options) {
1189
+ validateProcessStartOptions(options);
1190
+ return this.runOperation(
1191
+ options.abortSignal,
1192
+ async (abortSignal) => this.processManager.start({ ...options, abortSignal })
1193
+ );
1194
+ }
1195
+ async listProcesses(options = {}) {
1196
+ assertOptionsObject(options);
1197
+ return this.runOperation(options.abortSignal, async () => this.processManager.list());
1198
+ }
1199
+ async readProcessLogs(options) {
1200
+ assertOptionsObject(options);
1201
+ assertNonEmptyString(options.processId, "processId");
1202
+ if (options.tailBytes !== void 0)
1203
+ assertNonNegativeSafeInteger(options.tailBytes, "tailBytes");
1204
+ return this.runOperation(
1205
+ options.abortSignal,
1206
+ async () => this.processManager.logs(options.processId, options.tailBytes)
1207
+ );
1208
+ }
1209
+ async stopProcess(options) {
1210
+ assertOptionsObject(options);
1211
+ assertNonEmptyString(options.processId, "processId");
1212
+ if (options.gracePeriodMs !== void 0) {
1213
+ assertNonNegativeSafeInteger(options.gracePeriodMs, "gracePeriodMs");
1163
1214
  }
1164
- await this.hooks.onDestroy?.(this.event());
1215
+ return this.runOperation(
1216
+ options.abortSignal,
1217
+ async (abortSignal) => this.processManager.stop(options.processId, options.gracePeriodMs, abortSignal)
1218
+ );
1165
1219
  }
1166
- async mkdir(directoryPath) {
1167
- const normalized = normalizeSandboxPath(directoryPath, { allowRoot: true });
1168
- const result = await this.execCommand({
1169
- command: "mkdir",
1170
- args: ["-p", containerPath(this.workdir, normalized)]
1171
- });
1172
- if (result.exitCode !== 0) {
1173
- throw new SandboxDockerCommandError(
1174
- `Unable to create sandbox directory: ${directoryPath}`,
1175
- result
1220
+ async waitForPort(options) {
1221
+ assertOptionsObject(options);
1222
+ assertPort(options.containerPort);
1223
+ const publishedPort = this.configuration.publishedPorts.find(
1224
+ (candidate) => candidate.containerPort === options.containerPort
1225
+ );
1226
+ if (publishedPort === void 0) {
1227
+ throw new DockerSandboxError(
1228
+ `Sandbox port is not published: ${options.containerPort}/tcp`,
1229
+ "port"
1176
1230
  );
1177
1231
  }
1232
+ const timeoutMs = options.timeoutMs ?? defaultPortWaitTimeoutMs;
1233
+ const intervalMs = options.intervalMs ?? defaultPortWaitIntervalMs;
1234
+ assertPositiveSafeInteger(timeoutMs, "timeoutMs");
1235
+ assertPositiveSafeInteger(intervalMs, "intervalMs");
1236
+ return this.runOperation(options.abortSignal, async (abortSignal) => {
1237
+ const deadline = Date.now() + timeoutMs;
1238
+ while (true) {
1239
+ const result = await this.execCommand(
1240
+ {
1241
+ command: "sh",
1242
+ args: ["-c", portProbeScript, "anvia-port-probe", `${options.containerPort}`],
1243
+ timeoutMs: Math.min(5e3, timeoutMs)
1244
+ },
1245
+ abortSignal
1246
+ );
1247
+ if (result.status === "exited" && result.exitCode === 0) return { ...publishedPort };
1248
+ if (Date.now() >= deadline) {
1249
+ throw new DockerSandboxError(
1250
+ `Waiting for sandbox port timed out: ${options.containerPort}`,
1251
+ "timeout"
1252
+ );
1253
+ }
1254
+ await waitWithSignal(Math.min(intervalMs, deadline - Date.now()), abortSignal);
1255
+ }
1256
+ });
1178
1257
  }
1179
- parseFindEntry(line) {
1180
- const [rawPath, rawType, rawSize] = line.split(" ");
1181
- const absolutePath = rawPath ?? "";
1182
- const relativePath = absolutePath.startsWith(`${this.workdir}/`) ? absolutePath.slice(this.workdir.length + 1) : absolutePath;
1183
- const size = rawSize === void 0 ? void 0 : Number(rawSize);
1184
- const entry = {
1185
- path: relativePath,
1186
- type: mapFindType(rawType)
1187
- };
1188
- if (size !== void 0 && Number.isFinite(size)) {
1189
- entry.size = size;
1258
+ async closeActiveOperations(message) {
1259
+ const reason = new DockerSandboxError(message, "invalid_state");
1260
+ for (const controller of this.activeControllers) controller.abort(reason);
1261
+ await Promise.allSettled([...this.activeOperations]);
1262
+ }
1263
+ async disposeProcesses(abortSignal) {
1264
+ if (this.processDisposed) return;
1265
+ await this.processManager.dispose(abortSignal);
1266
+ this.processDisposed = true;
1267
+ }
1268
+ async runOperation(abortSignal, operation) {
1269
+ this.assertRunning();
1270
+ abortSignal?.throwIfAborted();
1271
+ const controller = new AbortController();
1272
+ const effectiveSignal = combineSignals(abortSignal, controller.signal);
1273
+ const execution = operation(effectiveSignal);
1274
+ this.activeControllers.add(controller);
1275
+ this.activeOperations.add(execution);
1276
+ try {
1277
+ return await execution;
1278
+ } finally {
1279
+ this.activeControllers.delete(controller);
1280
+ this.activeOperations.delete(execution);
1190
1281
  }
1191
- return entry;
1192
1282
  }
1193
- cliOptions() {
1194
- return {
1283
+ assertRunning() {
1284
+ const current = this.state();
1285
+ if (current !== "running") throw invalidState(this.configuration.id, current);
1286
+ }
1287
+ execArgs(options) {
1288
+ const args = ["exec"];
1289
+ if (options.input !== void 0) args.push("-i");
1290
+ args.push("-w", containerPath(this.configuration.workdir, options.cwd ?? "."));
1291
+ for (const [key, value] of Object.entries(options.env ?? {}))
1292
+ args.push("-e", `${key}=${value}`);
1293
+ args.push(this.configuration.containerName, options.command, ...options.args ?? []);
1294
+ return args;
1295
+ }
1296
+ async execCommand(options, abortSignal) {
1297
+ const result = await runDockerCli(this.execArgs(options), {
1195
1298
  dockerPath: this.dockerPath,
1196
- maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes
1197
- };
1299
+ timeoutMs: options.timeoutMs ?? this.configuration.runtime.commandTimeoutMs,
1300
+ maxOutputBytes: this.configuration.runtime.maxOutputBytes,
1301
+ input: options.input,
1302
+ signal: abortSignal
1303
+ });
1304
+ return toExecResult(result);
1198
1305
  }
1199
- async isPortListening(containerPort, timeoutMs) {
1200
- const result = await runDockerCli(
1201
- [
1202
- "exec",
1203
- this.containerName,
1204
- "sh",
1205
- "-c",
1206
- portProbeScript,
1207
- "anvia-port-probe",
1208
- String(containerPort)
1209
- ],
1210
- {
1211
- ...this.cliOptions(),
1212
- timeoutMs: Math.max(1, timeoutMs)
1213
- }
1306
+ async mkdir(directory, abortSignal) {
1307
+ if (directory === ".") return;
1308
+ const result = await this.execCommand(
1309
+ { command: "mkdir", args: ["-p", containerPath(this.configuration.workdir, directory)] },
1310
+ abortSignal
1214
1311
  );
1215
- return result.exitCode === 0;
1216
- }
1217
- async execCommand(options) {
1218
- if (options.command.trim().length === 0) {
1219
- throw new SandboxDockerCommandError("Sandbox command cannot be empty.", {
1220
- stdout: "",
1221
- stderr: "",
1222
- exitCode: 1
1223
- });
1312
+ if (result.status !== "exited" || result.exitCode !== 0) {
1313
+ throw new DockerSandboxError(
1314
+ "Unable to create sandbox directory.",
1315
+ "docker_command_failed",
1316
+ result
1317
+ );
1224
1318
  }
1225
- const args = ["exec"];
1226
- if (options.input !== void 0) {
1227
- args.push("-i");
1319
+ }
1320
+ assertFileSize(size, filePath) {
1321
+ if (size > this.configuration.runtime.maxFileBytes) {
1322
+ throw new DockerSandboxError(
1323
+ `Sandbox file exceeds maxFileBytes (${size} > ${this.configuration.runtime.maxFileBytes}): ${filePath}`,
1324
+ "file_too_large"
1325
+ );
1228
1326
  }
1229
- const cwd = containerPath(this.workdir, options.cwd ?? ".");
1230
- args.push("-w", cwd);
1231
- for (const [key, value] of Object.entries({ ...this.env, ...options.env })) {
1232
- args.push("-e", `${key}=${value}`);
1327
+ }
1328
+ };
1329
+ async function applyInitialContent(runtime, options) {
1330
+ for (const directory of options.directories ?? []) {
1331
+ const marker = path2.posix.join(normalizeSandboxPath(directory), ".anvia-keep");
1332
+ const abort = options.abortSignal === void 0 ? {} : { abortSignal: options.abortSignal };
1333
+ await runtime.writeFile({ path: marker, data: new Uint8Array(), ...abort });
1334
+ const removed = await runtime.exec({ command: "rm", args: [marker], ...abort });
1335
+ if (removed.status !== "exited" || removed.exitCode !== 0) {
1336
+ throw new DockerSandboxError(
1337
+ `Unable to create initial sandbox directory: ${directory}`,
1338
+ "docker_command_failed",
1339
+ removed
1340
+ );
1233
1341
  }
1234
- args.push(this.containerName, options.command, ...options.args ?? []);
1235
- const cliOptions = {
1236
- dockerPath: this.dockerPath,
1237
- timeoutMs: options.timeoutMs ?? this.limits.timeoutMs ?? defaultTimeoutMs,
1238
- maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes
1239
- };
1240
- if (options.input !== void 0) cliOptions.input = options.input;
1241
- if (options.signal !== void 0) cliOptions.signal = options.signal;
1242
- if (options.onStdout !== void 0) cliOptions.onStdout = options.onStdout;
1243
- if (options.onStderr !== void 0) cliOptions.onStderr = options.onStderr;
1244
- const result = await runDockerCli(args, cliOptions);
1245
- if (result.timedOut) {
1246
- return {
1247
- ...result,
1248
- exitCode: result.exitCode === 0 ? 124 : result.exitCode
1342
+ }
1343
+ for (const [filePath, content] of Object.entries(options.files ?? {})) {
1344
+ if (typeof content === "string") {
1345
+ let writeOptions = {
1346
+ path: filePath,
1347
+ text: content
1348
+ };
1349
+ if (options.abortSignal !== void 0) {
1350
+ writeOptions = { ...writeOptions, abortSignal: options.abortSignal };
1351
+ }
1352
+ await runtime.writeTextFile(writeOptions);
1353
+ } else {
1354
+ let writeOptions = {
1355
+ path: filePath,
1356
+ data: content
1249
1357
  };
1358
+ if (options.abortSignal !== void 0) {
1359
+ writeOptions = { ...writeOptions, abortSignal: options.abortSignal };
1360
+ }
1361
+ await runtime.writeFile(writeOptions);
1250
1362
  }
1251
- return result;
1252
1363
  }
1253
- assertActive() {
1254
- if (this.destroyed) {
1255
- throw new SandboxSessionDestroyedError(`Sandbox session ${this.id} has been destroyed.`);
1256
- }
1364
+ }
1365
+ function createRunArgs(options) {
1366
+ const runtimeLabels = {
1367
+ [labels.schema]: schemaVersion,
1368
+ [labels.id]: options.id,
1369
+ [labels.workdir]: options.workdir,
1370
+ [labels.workspaceType]: options.workspace.type,
1371
+ [labels.workspaceVolume]: options.volumeName,
1372
+ [labels.networkMode]: options.network.mode,
1373
+ [labels.commandTimeoutMs]: `${options.runtime.commandTimeoutMs}`,
1374
+ [labels.maxOutputBytes]: `${options.runtime.maxOutputBytes}`,
1375
+ [labels.maxFileBytes]: `${options.runtime.maxFileBytes}`,
1376
+ [labels.maxProcesses]: `${options.runtime.maxProcesses}`
1377
+ };
1378
+ const args = [
1379
+ "run",
1380
+ "-d",
1381
+ "--name",
1382
+ options.containerName,
1383
+ "--mount",
1384
+ `type=volume,src=${options.volumeName},dst=${options.workdir}`,
1385
+ "-w",
1386
+ options.workdir
1387
+ ];
1388
+ for (const [key, value] of Object.entries({ ...options.userLabels, ...runtimeLabels })) {
1389
+ args.push("--label", `${key}=${value}`);
1390
+ }
1391
+ for (const [key, value] of Object.entries(options.env)) args.push("--env", `${key}=${value}`);
1392
+ if (options.user !== void 0) args.push("--user", options.user);
1393
+ if (options.network.mode === "none") {
1394
+ args.push("--network", "none");
1395
+ } else {
1396
+ for (const port of options.network.ports ?? [])
1397
+ args.push("--publish", `127.0.0.1::${port}/tcp`);
1257
1398
  }
1258
- event() {
1259
- return {
1260
- sessionId: this.id,
1261
- provider: this.provider,
1262
- workdir: this.workdir
1263
- };
1399
+ if (options.resources?.memoryMb !== void 0)
1400
+ args.push("--memory", `${options.resources.memoryMb}m`);
1401
+ if (options.resources?.cpus !== void 0) args.push("--cpus", `${options.resources.cpus}`);
1402
+ if (options.resources?.pidsLimit !== void 0) {
1403
+ args.push("--pids-limit", `${options.resources.pidsLimit}`);
1264
1404
  }
1265
- async runOperation(operation) {
1266
- this.assertActive();
1267
- this.activeOperations += 1;
1268
- this.clearIdleTimer();
1269
- try {
1270
- return await operation();
1271
- } finally {
1272
- this.activeOperations -= 1;
1273
- this.scheduleIdleTimer();
1274
- }
1405
+ if (options.resources?.sharedMemoryMb !== void 0) {
1406
+ args.push("--shm-size", `${options.resources.sharedMemoryMb}m`);
1275
1407
  }
1276
- assertFileSize(size, filePath) {
1277
- if (this.limits.maxFileBytes !== void 0 && size > this.limits.maxFileBytes) {
1278
- throw new SandboxFileSizeError(
1279
- `Sandbox file exceeds maxFileBytes (${size} > ${this.limits.maxFileBytes}): ${filePath}`
1408
+ if (options.security?.readonlyRootfs === true) args.push("--read-only");
1409
+ if (options.security?.noNewPrivileges ?? true)
1410
+ args.push("--security-opt", "no-new-privileges:true");
1411
+ if (options.security?.seccompProfile !== void 0) {
1412
+ args.push("--security-opt", `seccomp=${options.security.seccompProfile.path}`);
1413
+ }
1414
+ for (const capability of options.security?.dropCapabilities ?? ["ALL"]) {
1415
+ args.push("--cap-drop", capability);
1416
+ }
1417
+ for (const capability of options.security?.addCapabilities ?? []) {
1418
+ args.push("--cap-add", capability);
1419
+ }
1420
+ args.push(
1421
+ options.image,
1422
+ "sh",
1423
+ "-c",
1424
+ "trap 'exit 0' TERM INT; while :; do sleep 3600 & wait $!; done"
1425
+ );
1426
+ return args;
1427
+ }
1428
+ function validateCreateOptions(options) {
1429
+ if (options === null || typeof options !== "object")
1430
+ throw new TypeError("options must be an object.");
1431
+ assertNonEmptyString(options.image, "image");
1432
+ if (options.id !== void 0) assertSandboxId(options.id);
1433
+ const workdir = options.workdir ?? defaultWorkdir;
1434
+ if (!path2.posix.isAbsolute(workdir) || workdir.includes("\0")) {
1435
+ throw new TypeError("workdir must be an absolute POSIX path.");
1436
+ }
1437
+ if (!isRecord(options.workspace)) throw new TypeError("workspace must be an object.");
1438
+ if (options.workspace.type === "docker-volume") assertDockerVolumeName(options.workspace.name);
1439
+ if (options.workspace.type !== "ephemeral" && options.workspace.type !== "docker-volume") {
1440
+ throw new TypeError("workspace must use type ephemeral or docker-volume.");
1441
+ }
1442
+ if (options.workspace.type === "ephemeral" && "name" in options.workspace) {
1443
+ throw new TypeError("An ephemeral workspace cannot specify a Docker volume name.");
1444
+ }
1445
+ if (!isRecord(options.network)) throw new TypeError("network must be an object.");
1446
+ if (options.network.mode !== "none" && options.network.mode !== "bridge") {
1447
+ throw new TypeError("network must use mode none or bridge.");
1448
+ }
1449
+ if (options.network.mode === "none" && "ports" in options.network) {
1450
+ throw new TypeError("Network mode none cannot publish ports.");
1451
+ }
1452
+ if (options.network.mode === "bridge") {
1453
+ if (options.network.ports !== void 0 && !Array.isArray(options.network.ports)) {
1454
+ throw new TypeError("network.ports must be an array.");
1455
+ }
1456
+ validatePorts(options.network.ports ?? []);
1457
+ }
1458
+ validateRuntimeLimits(options.runtime);
1459
+ validateResources(options.resources);
1460
+ validateSecurity(options.security);
1461
+ copyStringRecord(options.env, "env");
1462
+ const userLabels = copyStringRecord(options.labels, "labels");
1463
+ for (const key of Object.keys(userLabels)) {
1464
+ if (key.startsWith(labelPrefix)) throw new TypeError(`Docker label is reserved: ${key}`);
1465
+ }
1466
+ if (options.user !== void 0) assertNonEmptyString(options.user, "user");
1467
+ if (options.directories !== void 0 && !Array.isArray(options.directories)) {
1468
+ throw new TypeError("directories must be an array.");
1469
+ }
1470
+ for (const directory of options.directories ?? []) {
1471
+ if (typeof directory !== "string") throw new TypeError("directories must contain strings.");
1472
+ normalizeSandboxPath(directory);
1473
+ }
1474
+ if (options.files !== void 0 && !isRecord(options.files)) {
1475
+ throw new TypeError("files must be an object.");
1476
+ }
1477
+ for (const [filePath, content] of Object.entries(options.files ?? {})) {
1478
+ normalizeSandboxPath(filePath);
1479
+ if (typeof content !== "string" && !(content instanceof Uint8Array)) {
1480
+ throw new TypeError(`Initial file must be a string or Uint8Array: ${filePath}`);
1481
+ }
1482
+ const byteLength = typeof content === "string" ? new TextEncoder().encode(content).byteLength : content.byteLength;
1483
+ const maxFileBytes = options.runtime?.maxFileBytes ?? defaultMaxFileBytes;
1484
+ if (byteLength > maxFileBytes) {
1485
+ throw new DockerSandboxError(
1486
+ `Initial file exceeds maxFileBytes (${byteLength} > ${maxFileBytes}): ${filePath}`,
1487
+ "file_too_large"
1280
1488
  );
1281
1489
  }
1282
1490
  }
1283
- startLifecycleTimers() {
1284
- if (!this.lifecycle.autoDestroy) {
1285
- return;
1286
- }
1287
- if (this.lifecycle.ttlMs !== void 0) {
1288
- this.ttlTimer = setTimeout(() => {
1289
- void this.destroy().catch(() => void 0);
1290
- }, this.lifecycle.ttlMs);
1291
- this.ttlTimer.unref?.();
1292
- }
1293
- this.scheduleIdleTimer();
1491
+ }
1492
+ function validateRuntimeLimits(runtime) {
1493
+ if (runtime !== void 0 && !isRecord(runtime)) {
1494
+ throw new TypeError("runtime must be an object.");
1495
+ }
1496
+ if (runtime?.commandTimeoutMs !== void 0)
1497
+ assertPositiveSafeInteger(runtime.commandTimeoutMs, "commandTimeoutMs");
1498
+ if (runtime?.maxOutputBytes !== void 0)
1499
+ assertNonNegativeSafeInteger(runtime.maxOutputBytes, "maxOutputBytes");
1500
+ if (runtime?.maxFileBytes !== void 0)
1501
+ assertNonNegativeSafeInteger(runtime.maxFileBytes, "maxFileBytes");
1502
+ if (runtime?.maxProcesses !== void 0)
1503
+ assertNonNegativeSafeInteger(runtime.maxProcesses, "maxProcesses");
1504
+ }
1505
+ function validateResources(resources) {
1506
+ if (resources !== void 0 && !isRecord(resources)) {
1507
+ throw new TypeError("resources must be an object.");
1508
+ }
1509
+ if (resources?.memoryMb !== void 0) assertPositiveSafeInteger(resources.memoryMb, "memoryMb");
1510
+ if (resources?.pidsLimit !== void 0)
1511
+ assertPositiveSafeInteger(resources.pidsLimit, "pidsLimit");
1512
+ if (resources?.sharedMemoryMb !== void 0)
1513
+ assertPositiveSafeInteger(resources.sharedMemoryMb, "sharedMemoryMb");
1514
+ if (resources?.cpus !== void 0 && (!Number.isFinite(resources.cpus) || resources.cpus <= 0)) {
1515
+ throw new RangeError("cpus must be a positive finite number.");
1294
1516
  }
1295
- scheduleIdleTimer() {
1296
- if (!this.lifecycle.autoDestroy || this.lifecycle.idleTimeoutMs === void 0) {
1297
- return;
1298
- }
1299
- if (this.destroyed || this.activeOperations > 0) {
1300
- return;
1301
- }
1302
- this.clearIdleTimer();
1303
- this.idleTimer = setTimeout(() => {
1304
- void this.destroy().catch(() => void 0);
1305
- }, this.lifecycle.idleTimeoutMs);
1306
- this.idleTimer.unref?.();
1517
+ }
1518
+ function validateSecurity(security) {
1519
+ if (security !== void 0 && !isRecord(security)) {
1520
+ throw new TypeError("security must be an object.");
1307
1521
  }
1308
- clearLifecycleTimers() {
1309
- if (this.ttlTimer !== void 0) {
1310
- clearTimeout(this.ttlTimer);
1311
- this.ttlTimer = void 0;
1312
- }
1313
- this.clearIdleTimer();
1522
+ if (security?.dropCapabilities !== void 0 && !Array.isArray(security.dropCapabilities)) {
1523
+ throw new TypeError("dropCapabilities must be an array.");
1524
+ }
1525
+ if (security?.addCapabilities !== void 0 && !Array.isArray(security.addCapabilities)) {
1526
+ throw new TypeError("addCapabilities must be an array.");
1314
1527
  }
1315
- clearIdleTimer() {
1316
- if (this.idleTimer !== void 0) {
1317
- clearTimeout(this.idleTimer);
1318
- this.idleTimer = void 0;
1528
+ if (security?.readonlyRootfs !== void 0 && typeof security.readonlyRootfs !== "boolean") {
1529
+ throw new TypeError("readonlyRootfs must be a boolean.");
1530
+ }
1531
+ if (security?.noNewPrivileges !== void 0 && typeof security.noNewPrivileges !== "boolean") {
1532
+ throw new TypeError("noNewPrivileges must be a boolean.");
1533
+ }
1534
+ if (security?.seccompProfile !== void 0) {
1535
+ if (!isRecord(security.seccompProfile) || security.seccompProfile.type !== "path") {
1536
+ throw new TypeError('seccompProfile must be a { type: "path", path } object.');
1537
+ }
1538
+ assertNonEmptyString(security.seccompProfile.path, "seccompProfile.path");
1539
+ if (!path2.isAbsolute(security.seccompProfile.path) || security.seccompProfile.path.includes("\0")) {
1540
+ throw new TypeError("seccompProfile.path must be an absolute host path.");
1319
1541
  }
1320
1542
  }
1321
- };
1322
- function getWorkspaceId(workspace, sessionId) {
1323
- if (workspace.mode === "persistent") {
1324
- return sanitizeResourceId(workspace.id);
1543
+ const seen = /* @__PURE__ */ new Set();
1544
+ for (const capability of security?.dropCapabilities ?? []) {
1545
+ assertNonEmptyString(capability, "dropCapabilities value");
1546
+ if (seen.has(capability))
1547
+ throw new TypeError(`dropCapabilities contains a duplicate: ${capability}`);
1548
+ seen.add(capability);
1549
+ }
1550
+ seen.clear();
1551
+ for (const capability of security?.addCapabilities ?? []) {
1552
+ assertNonEmptyString(capability, "addCapabilities value");
1553
+ if (seen.has(capability))
1554
+ throw new TypeError(`addCapabilities contains a duplicate: ${capability}`);
1555
+ seen.add(capability);
1325
1556
  }
1326
- return sessionId;
1327
1557
  }
1328
- function shouldDestroyWorkspace(workspace) {
1329
- if (workspace.mode === "persistent") {
1330
- return workspace.destroyOnSessionDestroy ?? false;
1558
+ function validateExecOptions(options) {
1559
+ assertOptionsObject(options);
1560
+ assertNonEmptyString(options.command, "command");
1561
+ if (options.args !== void 0 && (!Array.isArray(options.args) || !options.args.every((value) => typeof value === "string"))) {
1562
+ throw new TypeError("args must contain only strings.");
1563
+ }
1564
+ if (options.cwd !== void 0) normalizeSandboxPath(options.cwd, { allowRoot: true });
1565
+ copyStringRecord(options.env, "env");
1566
+ if (options.timeoutMs !== void 0) assertPositiveSafeInteger(options.timeoutMs, "timeoutMs");
1567
+ if (options.input !== void 0 && typeof options.input !== "string" && !(options.input instanceof Uint8Array)) {
1568
+ throw new TypeError("input must be a string or Uint8Array.");
1331
1569
  }
1332
- return true;
1333
1570
  }
1334
- function sanitizeResourceId(id) {
1335
- const sanitized = id.toLowerCase().replaceAll(/[^a-z0-9_.-]/g, "-").replaceAll(/^-+|-+$/g, "");
1336
- return sanitized.length > 0 ? sanitized : randomUUID2();
1571
+ function validateProcessStartOptions(options) {
1572
+ assertOptionsObject(options);
1573
+ assertNonEmptyString(options.command, "command");
1574
+ if (options.args !== void 0 && (!Array.isArray(options.args) || !options.args.every((value) => typeof value === "string"))) {
1575
+ throw new TypeError("args must contain only strings.");
1576
+ }
1577
+ if (options.cwd !== void 0) normalizeSandboxPath(options.cwd, { allowRoot: true });
1578
+ copyStringRecord(options.env, "env");
1337
1579
  }
1338
- function byteLength(data) {
1339
- return typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
1580
+ function assertReadOptions(options) {
1581
+ assertOptionsObject(options);
1582
+ normalizeSandboxPath(options.path);
1340
1583
  }
1341
- function mapFindType(type) {
1342
- if (type === "f") {
1343
- return "file";
1584
+ function assertOptionsObject(value) {
1585
+ if (!isRecord(value)) throw new TypeError("options must be an object.");
1586
+ }
1587
+ function resolveRuntimeLimits(runtime) {
1588
+ return {
1589
+ commandTimeoutMs: runtime?.commandTimeoutMs ?? defaultCommandTimeoutMs,
1590
+ maxOutputBytes: runtime?.maxOutputBytes ?? defaultMaxOutputBytes,
1591
+ maxFileBytes: runtime?.maxFileBytes ?? defaultMaxFileBytes,
1592
+ maxProcesses: runtime?.maxProcesses ?? defaultMaxProcesses
1593
+ };
1594
+ }
1595
+ function snapshotCreateOptions(options) {
1596
+ const files = {};
1597
+ for (const [filePath, content] of Object.entries(options.files ?? {})) {
1598
+ files[filePath] = typeof content === "string" ? content : content.slice();
1599
+ }
1600
+ const workspace = copyWorkspace(options.workspace);
1601
+ const network = copyNetwork(options.network);
1602
+ let snapshot = {
1603
+ image: options.image,
1604
+ workspace,
1605
+ network
1606
+ };
1607
+ if (options.id !== void 0) snapshot = { ...snapshot, id: options.id };
1608
+ if (options.workdir !== void 0) snapshot = { ...snapshot, workdir: options.workdir };
1609
+ if (options.files !== void 0) snapshot = { ...snapshot, files: Object.freeze(files) };
1610
+ if (options.directories !== void 0) {
1611
+ snapshot = { ...snapshot, directories: Object.freeze([...options.directories]) };
1344
1612
  }
1345
- if (type === "d") {
1346
- return "directory";
1613
+ if (options.env !== void 0) {
1614
+ snapshot = { ...snapshot, env: Object.freeze({ ...options.env }) };
1347
1615
  }
1348
- if (type === "l") {
1349
- return "symlink";
1616
+ if (options.user !== void 0) snapshot = { ...snapshot, user: options.user };
1617
+ if (options.labels !== void 0) {
1618
+ snapshot = { ...snapshot, labels: Object.freeze({ ...options.labels }) };
1350
1619
  }
1351
- return "other";
1352
- }
1353
- function validatePublishedPorts(ports) {
1354
- const unique = /* @__PURE__ */ new Set();
1355
- for (const port of ports) {
1356
- if (!isValidPort(port)) {
1357
- throw new SandboxPortError(`Sandbox port must be an integer from 1 to 65535: ${port}`);
1620
+ if (options.resources !== void 0) {
1621
+ snapshot = { ...snapshot, resources: Object.freeze({ ...options.resources }) };
1622
+ }
1623
+ if (options.runtime !== void 0) {
1624
+ snapshot = { ...snapshot, runtime: Object.freeze({ ...options.runtime }) };
1625
+ }
1626
+ if (options.security !== void 0) {
1627
+ let security = {
1628
+ ...options.security
1629
+ };
1630
+ if (options.security.seccompProfile !== void 0) {
1631
+ security = {
1632
+ ...security,
1633
+ seccompProfile: Object.freeze({ ...options.security.seccompProfile })
1634
+ };
1635
+ }
1636
+ if (options.security.dropCapabilities !== void 0) {
1637
+ security = {
1638
+ ...security,
1639
+ dropCapabilities: Object.freeze([...options.security.dropCapabilities])
1640
+ };
1358
1641
  }
1359
- if (unique.has(port)) {
1360
- throw new SandboxPortError(`Sandbox port is duplicated: ${port}`);
1642
+ if (options.security.addCapabilities !== void 0) {
1643
+ security = {
1644
+ ...security,
1645
+ addCapabilities: Object.freeze([...options.security.addCapabilities])
1646
+ };
1361
1647
  }
1362
- unique.add(port);
1648
+ snapshot = { ...snapshot, security: Object.freeze(security) };
1363
1649
  }
1364
- return [...unique];
1650
+ if (options.abortSignal !== void 0) {
1651
+ snapshot = { ...snapshot, abortSignal: options.abortSignal };
1652
+ }
1653
+ return Object.freeze(snapshot);
1365
1654
  }
1366
- function isValidPort(port) {
1367
- return Number.isInteger(port) && port >= 1 && port <= 65535;
1655
+ function configurationFromInspection(id, containerName, inspection) {
1656
+ const containerLabels = inspection.Config.Labels ?? {};
1657
+ if (containerLabels[labels.schema] !== schemaVersion || containerLabels[labels.id] !== id) {
1658
+ throw new DockerSandboxError(
1659
+ `Container is not an Anvia sandbox: ${containerName}`,
1660
+ "sandbox_not_found"
1661
+ );
1662
+ }
1663
+ const workdir = requiredLabel(containerLabels, labels.workdir);
1664
+ const workspaceType = requiredLabel(containerLabels, labels.workspaceType);
1665
+ const volumeName = requiredLabel(containerLabels, labels.workspaceVolume);
1666
+ const networkMode = requiredLabel(containerLabels, labels.networkMode);
1667
+ if (networkMode !== "none" && networkMode !== "bridge") invalidInspection("network mode");
1668
+ const workspace = workspaceType === "ephemeral" ? { type: "ephemeral" } : workspaceType === "docker-volume" ? { type: "docker-volume", name: volumeName } : invalidInspection("workspace type");
1669
+ const runtime = {
1670
+ commandTimeoutMs: parseLabelInteger(containerLabels, labels.commandTimeoutMs, true),
1671
+ maxOutputBytes: parseLabelInteger(containerLabels, labels.maxOutputBytes, false),
1672
+ maxFileBytes: parseLabelInteger(containerLabels, labels.maxFileBytes, false),
1673
+ maxProcesses: parseLabelInteger(containerLabels, labels.maxProcesses, false)
1674
+ };
1675
+ return {
1676
+ id,
1677
+ containerName,
1678
+ workdir,
1679
+ workspace,
1680
+ volumeName,
1681
+ ownsVolume: workspace.type === "ephemeral",
1682
+ env: {},
1683
+ runtime,
1684
+ publishedPorts: []
1685
+ };
1368
1686
  }
1369
- function isRecord(value) {
1370
- return typeof value === "object" && value !== null && !Array.isArray(value);
1687
+ async function inspectContainer(dockerPath, containerName, abortSignal, notFoundAsSandboxError) {
1688
+ const result = await runDockerCli(["container", "inspect", containerName], {
1689
+ dockerPath,
1690
+ signal: abortSignal
1691
+ });
1692
+ if (result.exitCode !== 0) {
1693
+ const message = safeDecode(result.stderr);
1694
+ if (notFoundAsSandboxError && message.toLowerCase().includes("no such")) {
1695
+ throw new DockerSandboxError(`Sandbox does not exist: ${containerName}`, "sandbox_not_found");
1696
+ }
1697
+ throw new DockerSandboxError(
1698
+ "Unable to inspect Docker sandbox.",
1699
+ "docker_command_failed",
1700
+ result
1701
+ );
1702
+ }
1703
+ let value;
1704
+ try {
1705
+ value = JSON.parse(decodeUtf8(result.stdout));
1706
+ } catch (error) {
1707
+ throw new DockerSandboxError(
1708
+ "Docker returned invalid container metadata.",
1709
+ "docker_command_failed",
1710
+ void 0,
1711
+ { cause: error }
1712
+ );
1713
+ }
1714
+ if (!Array.isArray(value) || value.length !== 1 || !isInspection(value[0])) {
1715
+ throw new DockerSandboxError(
1716
+ "Docker returned invalid container metadata.",
1717
+ "docker_command_failed"
1718
+ );
1719
+ }
1720
+ return value[0];
1371
1721
  }
1372
- function assertWaitOptions(timeoutMs, intervalMs) {
1373
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
1374
- throw new SandboxPortError("Port wait timeoutMs must be a positive integer.");
1722
+ function isInspection(value) {
1723
+ return isRecord(value) && isRecord(value.Config) && isRecord(value.State);
1724
+ }
1725
+ async function inspectPublishedPorts(options) {
1726
+ if (options.ports.length === 0) return [];
1727
+ const inspection = await inspectContainer(
1728
+ options.dockerPath,
1729
+ options.containerName,
1730
+ options.abortSignal,
1731
+ false
1732
+ );
1733
+ const mappings = inspection.NetworkSettings?.Ports ?? {};
1734
+ return options.ports.map((containerPort) => {
1735
+ const entries = mappings[`${containerPort}/tcp`];
1736
+ const entry = entries?.find((candidate) => candidate.HostIp === "127.0.0.1");
1737
+ const hostPort = Number(entry?.HostPort);
1738
+ if (!isPort(hostPort)) {
1739
+ throw new DockerSandboxError(`Docker did not publish port: ${containerPort}`, "port");
1740
+ }
1741
+ return { containerPort, host: "127.0.0.1", hostPort, protocol: "tcp" };
1742
+ });
1743
+ }
1744
+ function configuredContainerPorts(inspection) {
1745
+ return Object.keys(inspection.HostConfig?.PortBindings ?? {}).flatMap((key) => {
1746
+ const match = /^(\d+)\/tcp$/.exec(key);
1747
+ if (match?.[1] === void 0) return [];
1748
+ const value = Number(match[1]);
1749
+ return isPort(value) ? [value] : [];
1750
+ });
1751
+ }
1752
+ async function assertCopiedRegularFile(temporaryDirectory, target, sandboxPath) {
1753
+ const metadata = await lstat(target);
1754
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
1755
+ throw new DockerSandboxError(
1756
+ `Sandbox path is not a regular file: ${sandboxPath}`,
1757
+ "invalid_path"
1758
+ );
1375
1759
  }
1376
- if (!Number.isInteger(intervalMs) || intervalMs <= 0) {
1377
- throw new SandboxPortError("Port wait intervalMs must be a positive integer.");
1760
+ const resolvedTarget = await realpath(target);
1761
+ const relativeTarget = path2.relative(temporaryDirectory, resolvedTarget);
1762
+ if (relativeTarget === "" || relativeTarget === ".." || relativeTarget.startsWith(`..${path2.sep}`) || path2.isAbsolute(relativeTarget)) {
1763
+ throw new DockerSandboxError(
1764
+ `Sandbox file escaped its temporary read boundary: ${sandboxPath}`,
1765
+ "invalid_path"
1766
+ );
1378
1767
  }
1379
1768
  }
1380
- function assertTextFileReadOptions(startLine, lineCount, maxBytes) {
1381
- if (!Number.isInteger(startLine) || startLine <= 0) {
1382
- throw new RangeError("Text file startLine must be a positive integer.");
1769
+ async function removeContainer(dockerPath, containerName) {
1770
+ const result = await runDockerCli(["rm", "-f", containerName], { dockerPath });
1771
+ if (result.exitCode === 0 || safeDecode(result.stderr).toLowerCase().includes("no such")) return;
1772
+ throw new DockerSandboxError("Unable to remove Docker sandbox.", "docker_command_failed", result);
1773
+ }
1774
+ async function removeVolume(dockerPath, volumeName) {
1775
+ const result = await runDockerCli(["volume", "rm", volumeName], { dockerPath });
1776
+ if (result.exitCode === 0 || safeDecode(result.stderr).toLowerCase().includes("no such")) return;
1777
+ throw new DockerSandboxError(
1778
+ "Unable to remove Docker sandbox volume.",
1779
+ "docker_command_failed",
1780
+ result
1781
+ );
1782
+ }
1783
+ function parseFindEntry(line, workdir) {
1784
+ const [absolutePath, rawType, rawSize] = line.split(" ");
1785
+ if (absolutePath === void 0 || rawType === void 0 || rawSize === void 0) {
1786
+ throw new DockerSandboxError("Docker returned invalid file metadata.", "docker_command_failed");
1787
+ }
1788
+ const relativePath = path2.posix.relative(workdir, absolutePath);
1789
+ const size = Number(rawSize);
1790
+ const type = mapFindType(rawType);
1791
+ let entry = {
1792
+ path: normalizeSandboxPath(relativePath),
1793
+ type
1794
+ };
1795
+ if (type === "file" && Number.isSafeInteger(size) && size >= 0) {
1796
+ entry = { ...entry, size };
1383
1797
  }
1384
- if (!Number.isInteger(lineCount) || lineCount <= 0) {
1385
- throw new RangeError("Text file lineCount must be a positive integer.");
1798
+ return entry;
1799
+ }
1800
+ function mapFindType(value) {
1801
+ if (value === "f") return "file";
1802
+ if (value === "d") return "directory";
1803
+ if (value === "l") return "symlink";
1804
+ return "other";
1805
+ }
1806
+ function toExecResult(result) {
1807
+ const common = {
1808
+ stdout: result.stdout.slice(),
1809
+ stderr: result.stderr.slice(),
1810
+ durationMs: result.durationMs,
1811
+ stdoutTruncated: result.stdoutTruncated,
1812
+ stderrTruncated: result.stderrTruncated
1813
+ };
1814
+ return result.timedOut ? { ...common, status: "timed_out" } : { ...common, status: "exited", exitCode: result.exitCode };
1815
+ }
1816
+ function copyWorkspace(workspace) {
1817
+ return workspace.type === "ephemeral" ? Object.freeze({ type: "ephemeral" }) : Object.freeze({ type: "docker-volume", name: workspace.name });
1818
+ }
1819
+ function copyNetwork(network) {
1820
+ return network.mode === "none" ? Object.freeze({ mode: "none" }) : Object.freeze({ mode: "bridge", ports: Object.freeze([...network.ports ?? []]) });
1821
+ }
1822
+ function copyStringRecord(value, name) {
1823
+ if (value === void 0) return {};
1824
+ if (!isRecord(value)) throw new TypeError(`${name} must be an object.`);
1825
+ const copy = {};
1826
+ for (const [key, item] of Object.entries(value)) {
1827
+ if (typeof item !== "string") throw new TypeError(`${name}.${key} must be a string.`);
1828
+ if (name === "env" && !envKeyPattern.test(key))
1829
+ throw new TypeError(`Invalid environment variable name: ${key}`);
1830
+ copy[key] = item;
1386
1831
  }
1387
- if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
1388
- throw new RangeError("Text file maxBytes must be a positive integer.");
1832
+ return copy;
1833
+ }
1834
+ function requiredLabel(containerLabels, name) {
1835
+ const value = containerLabels[name];
1836
+ if (value === void 0 || value.length === 0) invalidInspection(name);
1837
+ return value;
1838
+ }
1839
+ function parseLabelInteger(containerLabels, name, positive) {
1840
+ const value = Number(requiredLabel(containerLabels, name));
1841
+ if (!Number.isSafeInteger(value) || (positive ? value <= 0 : value < 0)) invalidInspection(name);
1842
+ return value;
1843
+ }
1844
+ function invalidInspection(field) {
1845
+ throw new DockerSandboxError(`Sandbox metadata is invalid: ${field}`, "invalid_state");
1846
+ }
1847
+ function validatePorts(ports) {
1848
+ if (!Array.isArray(ports)) throw new TypeError("ports must be an array.");
1849
+ const seen = /* @__PURE__ */ new Set();
1850
+ for (const port of ports) {
1851
+ assertPort(port);
1852
+ if (seen.has(port)) throw new DockerSandboxError(`Sandbox port is duplicated: ${port}`, "port");
1853
+ seen.add(port);
1389
1854
  }
1390
1855
  }
1391
- async function waitWithSignal(timeoutMs, signal) {
1392
- if (signal?.aborted === true) throw abortReason(signal);
1856
+ function assertPort(port) {
1857
+ if (!isPort(port))
1858
+ throw new DockerSandboxError(
1859
+ `Sandbox port must be an integer from 1 to 65535: ${port}`,
1860
+ "port"
1861
+ );
1862
+ }
1863
+ function isPort(port) {
1864
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
1865
+ }
1866
+ function assertSandboxId(id) {
1867
+ if (typeof id !== "string" || !idPattern.test(id)) {
1868
+ throw new TypeError(
1869
+ "Sandbox id must be 1-63 lowercase letters, numbers, dots, underscores, or hyphens."
1870
+ );
1871
+ }
1872
+ }
1873
+ function assertDockerVolumeName(name) {
1874
+ assertNonEmptyString(name, "workspace.name");
1875
+ if (name.includes(",") || name.includes("\0"))
1876
+ throw new TypeError("workspace.name is not a valid Docker volume name.");
1877
+ }
1878
+ function assertNonEmptyString(value, name) {
1879
+ if (typeof value !== "string" || value.length === 0)
1880
+ throw new TypeError(`${name} must be a non-empty string.`);
1881
+ }
1882
+ function assertPositiveSafeInteger(value, name) {
1883
+ if (!Number.isSafeInteger(value) || value <= 0)
1884
+ throw new RangeError(`${name} must be a positive safe integer.`);
1885
+ }
1886
+ function assertNonNegativeSafeInteger(value, name) {
1887
+ if (!Number.isSafeInteger(value) || value < 0)
1888
+ throw new RangeError(`${name} must be a non-negative safe integer.`);
1889
+ }
1890
+ function invalidState(id, state) {
1891
+ return new DockerSandboxError(`Sandbox ${id} is not running (${state}).`, "invalid_state", {
1892
+ state
1893
+ });
1894
+ }
1895
+ function containerNameFor(id) {
1896
+ return `anvia-sandbox-${id}`;
1897
+ }
1898
+ function combineSignals(first, second) {
1899
+ return first === void 0 ? second : AbortSignal.any([first, second]);
1900
+ }
1901
+ async function waitWithSignal(timeoutMs, abortSignal) {
1902
+ abortSignal.throwIfAborted();
1393
1903
  await new Promise((resolve, reject) => {
1394
1904
  const timeout = setTimeout(() => {
1395
- signal?.removeEventListener("abort", abort);
1905
+ abortSignal.removeEventListener("abort", abort);
1396
1906
  resolve();
1397
1907
  }, timeoutMs);
1398
1908
  const abort = () => {
1399
1909
  clearTimeout(timeout);
1400
- reject(abortReason(signal));
1910
+ reject(abortSignal.reason ?? new DOMException("Aborted", "AbortError"));
1401
1911
  };
1402
- signal?.addEventListener("abort", abort, { once: true });
1912
+ abortSignal.addEventListener("abort", abort, { once: true });
1403
1913
  });
1404
1914
  }
1405
- function abortReason(signal) {
1406
- return signal?.reason ?? new SandboxPortError("Waiting for sandbox port was aborted.");
1915
+ function safeDecode(bytes) {
1916
+ try {
1917
+ return decodeUtf8(bytes);
1918
+ } catch {
1919
+ return "";
1920
+ }
1921
+ }
1922
+ function isRecord(value) {
1923
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1407
1924
  }
1408
1925
 
1409
1926
  // src/tools.ts
1410
1927
  import { createTool } from "@anvia/core/tool";
1411
1928
  import { z } from "zod";
1929
+ var allToolNames = [
1930
+ "exec_command",
1931
+ "read_file",
1932
+ "write_file",
1933
+ "list_files",
1934
+ "list_ports",
1935
+ "start_process",
1936
+ "list_processes",
1937
+ "read_process_logs",
1938
+ "stop_process",
1939
+ "wait_for_port"
1940
+ ];
1412
1941
  var execCommandInput = z.object({
1413
- command: z.string().min(1).describe("Executable to run inside the sandbox workspace."),
1414
- args: z.array(z.string()).optional().describe("Command arguments."),
1415
- cwd: z.string().optional().describe("Relative working directory inside the sandbox."),
1416
- env: z.record(z.string(), z.string()).optional().describe("Environment variables for this command."),
1417
- timeoutMs: z.number().int().positive().max(3e5).optional().describe("Optional command timeout in milliseconds."),
1418
- input: z.string().optional().describe("Optional stdin text to pass to the command.")
1942
+ command: z.string().min(1),
1943
+ args: z.array(z.string()).optional(),
1944
+ cwd: z.string().optional(),
1945
+ env: z.record(z.string(), z.string()).optional(),
1946
+ timeoutMs: z.number().int().positive().max(3e5).optional(),
1947
+ input: z.string().optional()
1419
1948
  });
1949
+ var execResultOutput = z.discriminatedUnion("status", [
1950
+ z.object({
1951
+ status: z.literal("exited"),
1952
+ exitCode: z.number().int(),
1953
+ stdout: z.string(),
1954
+ stderr: z.string(),
1955
+ durationMs: z.number().nonnegative(),
1956
+ stdoutTruncated: z.boolean(),
1957
+ stderrTruncated: z.boolean()
1958
+ }),
1959
+ z.object({
1960
+ status: z.literal("timed_out"),
1961
+ stdout: z.string(),
1962
+ stderr: z.string(),
1963
+ durationMs: z.number().nonnegative(),
1964
+ stdoutTruncated: z.boolean(),
1965
+ stderrTruncated: z.boolean()
1966
+ })
1967
+ ]);
1420
1968
  var readFileInput = z.object({
1421
- path: z.string().min(1).describe("Relative file path inside the sandbox."),
1422
- startLine: z.number().int().positive().optional().describe("One-based first line to read. Defaults to 1."),
1423
- lineCount: z.number().int().positive().max(1e4).optional().describe("Maximum lines to return. Defaults to 500.")
1969
+ path: z.string().min(1),
1970
+ startLine: z.number().int().positive().optional(),
1971
+ lineCount: z.number().int().positive().max(1e4).optional()
1972
+ });
1973
+ var readFileOutput = z.object({
1974
+ content: z.string(),
1975
+ startLine: z.number().int().positive(),
1976
+ endLine: z.number().int().positive().nullable(),
1977
+ nextStartLine: z.number().int().positive().nullable(),
1978
+ truncated: z.boolean(),
1979
+ truncatedBy: z.enum(["lines", "bytes"]).nullable()
1424
1980
  });
1425
1981
  var writeFileInput = z.object({
1426
- path: z.string().min(1).describe("Relative file path inside the sandbox."),
1427
- content: z.string().describe("Complete text content to write.")
1982
+ path: z.string().min(1),
1983
+ content: z.string()
1984
+ });
1985
+ var writeFileOutput = z.object({
1986
+ path: z.string(),
1987
+ bytesWritten: z.number().int().nonnegative()
1988
+ });
1989
+ var listFilesInput = z.object({ path: z.string().optional() });
1990
+ var fileEntryOutput = z.object({
1991
+ path: z.string(),
1992
+ type: z.enum(["file", "directory", "symlink", "other"]),
1993
+ size: z.number().int().nonnegative().optional()
1428
1994
  });
1429
- var listFilesInput = z.object({
1430
- path: z.string().optional().describe("Relative directory path inside the sandbox. Defaults to root.")
1995
+ var listFilesOutput = z.object({
1996
+ path: z.string(),
1997
+ entries: z.array(fileEntryOutput)
1431
1998
  });
1432
1999
  var emptyInput = z.object({});
2000
+ var publishedPortOutput = z.object({
2001
+ containerPort: z.number().int(),
2002
+ host: z.literal("127.0.0.1"),
2003
+ hostPort: z.number().int(),
2004
+ protocol: z.literal("tcp")
2005
+ });
2006
+ var listPortsOutput = z.object({ ports: z.array(publishedPortOutput) });
1433
2007
  var startProcessInput = z.object({
1434
- command: z.string().min(1).describe("Executable to run as a managed sandbox process."),
1435
- args: z.array(z.string()).optional().describe("Command arguments."),
1436
- cwd: z.string().optional().describe("Relative working directory inside the sandbox."),
1437
- env: z.record(z.string(), z.string()).optional().describe("Environment variables for this process.")
2008
+ command: z.string().min(1),
2009
+ args: z.array(z.string()).optional(),
2010
+ cwd: z.string().optional(),
2011
+ env: z.record(z.string(), z.string()).optional()
1438
2012
  });
1439
- var processIdInput = z.object({
1440
- processId: z.string().min(1).describe("Managed process identifier.")
2013
+ var processInfoOutput = z.object({
2014
+ id: z.string(),
2015
+ command: z.string(),
2016
+ args: z.array(z.string()),
2017
+ cwd: z.string().optional(),
2018
+ status: z.enum(["running", "exited", "stopped"]),
2019
+ exitCode: z.number().int().optional(),
2020
+ startedAt: z.string(),
2021
+ endedAt: z.string().optional()
1441
2022
  });
2023
+ var listProcessesOutput = z.object({ processes: z.array(processInfoOutput) });
2024
+ var processIdInput = z.object({ processId: z.string().min(1) });
1442
2025
  var readProcessLogsInput = processIdInput.extend({
1443
- tailBytes: z.number().int().nonnegative().max(1024 * 1024).optional().describe("Maximum trailing bytes to return from each output stream.")
2026
+ tailBytes: z.number().int().nonnegative().max(1024 * 1024).optional()
2027
+ });
2028
+ var processLogsOutput = z.object({
2029
+ processId: z.string(),
2030
+ stdout: z.string(),
2031
+ stderr: z.string(),
2032
+ stdoutTruncated: z.boolean(),
2033
+ stderrTruncated: z.boolean()
1444
2034
  });
1445
2035
  var waitForPortInput = z.object({
1446
- containerPort: z.number().int().min(1).max(65535).describe("Pre-authorized TCP port inside the sandbox container."),
1447
- timeoutMs: z.number().int().positive().max(3e5).optional().describe("Maximum time to wait for the port to accept connections.")
2036
+ containerPort: z.number().int().min(1).max(65535),
2037
+ timeoutMs: z.number().int().positive().max(3e5).optional()
1448
2038
  });
1449
- var textOutput = z.string();
1450
- var maxToolLogBytes = 1024 * 1024;
2039
+ var waitForPortOutput = z.object({ port: publishedPortOutput });
1451
2040
  var defaultReadFileLineCount = 500;
1452
2041
  var defaultReadFileMaxLineCount = 2e3;
1453
2042
  var defaultReadFileMaxBytes = 64 * 1024;
1454
- var maxToolReadFileBytes = 1024 * 1024;
1455
2043
  var maxToolReadFileLines = 1e4;
1456
- var readFileOutput = z.object({
1457
- content: z.string(),
1458
- startLine: z.number().int().positive(),
1459
- endLine: z.number().int().positive().nullable(),
1460
- nextStartLine: z.number().int().positive().nullable(),
1461
- truncated: z.boolean(),
1462
- truncatedBy: z.enum(["lines", "bytes"]).nullable()
1463
- });
1464
- var sandboxToolMetadataKey = /* @__PURE__ */ Symbol.for("anvia.sandbox.tool.metadata");
1465
- function createSandboxTools(session, options = {}) {
1466
- const include = new Set(
1467
- options.allow ?? options.include ?? ["exec_command", "read_file", "write_file", "list_files"]
1468
- );
2044
+ var maxToolBytes = 1024 * 1024;
2045
+ var maxToolTimeoutMs = 3e5;
2046
+ function createDockerSandboxTools(options) {
2047
+ validateFactoryOptions(options);
2048
+ options = snapshotFactoryOptions(options);
2049
+ const selected = new Set(options.tools);
1469
2050
  const tools = [];
1470
- if (include.has("exec_command")) {
1471
- tools.push(createExecCommandTool(session, options));
1472
- }
1473
- if (include.has("read_file")) {
1474
- tools.push(createReadFileTool(session, options));
1475
- }
1476
- if (include.has("write_file")) {
1477
- tools.push(createWriteFileTool(session, options));
1478
- }
1479
- if (include.has("list_files")) {
1480
- tools.push(createListFilesTool(session));
1481
- }
1482
- const portToolsRequested = include.has("list_ports") || include.has("wait_for_port");
1483
- const portSession = portToolsRequested ? requirePortSession(session) : void 0;
1484
- if (include.has("list_ports") && portSession !== void 0) {
1485
- tools.push(createListPortsTool(portSession));
1486
- }
1487
- const processToolsRequested = include.has("start_process") || include.has("list_processes") || include.has("read_process_logs") || include.has("stop_process");
1488
- if (include.has("wait_for_port") || processToolsRequested) assertProcessToolPolicy(options);
1489
- const processSession = processToolsRequested ? requireProcessSession(session) : void 0;
1490
- if (include.has("start_process") && processSession !== void 0) {
1491
- tools.push(createStartProcessTool(processSession, options));
1492
- }
1493
- if (include.has("list_processes") && processSession !== void 0) {
1494
- tools.push(createListProcessesTool(processSession));
1495
- }
1496
- if (include.has("read_process_logs") && processSession !== void 0) {
1497
- tools.push(createReadProcessLogsTool(processSession, options));
1498
- }
1499
- if (include.has("stop_process") && processSession !== void 0) {
1500
- tools.push(createStopProcessTool(processSession, options));
1501
- }
1502
- if (include.has("wait_for_port") && portSession !== void 0) {
1503
- tools.push(createWaitForPortTool(portSession, options));
1504
- }
1505
- for (const tool of tools) {
1506
- Object.defineProperty(tool, sandboxToolMetadataKey, {
1507
- value: { session },
1508
- enumerable: false
1509
- });
1510
- }
1511
- return tools;
2051
+ for (const name of options.tools) {
2052
+ if (name === "exec_command") tools.push(createExecCommandTool(options));
2053
+ else if (name === "read_file") tools.push(createReadFileTool(options));
2054
+ else if (name === "write_file") tools.push(createWriteFileTool(options));
2055
+ else if (name === "list_files") tools.push(createListFilesTool(options.sandbox));
2056
+ else if (name === "list_ports") tools.push(createListPortsTool(options.sandbox));
2057
+ else if (name === "start_process") tools.push(createStartProcessTool(options));
2058
+ else if (name === "list_processes") tools.push(createListProcessesTool(options.sandbox));
2059
+ else if (name === "read_process_logs") tools.push(createReadProcessLogsTool(options));
2060
+ else if (name === "stop_process") tools.push(createStopProcessTool(options));
2061
+ else if (name === "wait_for_port") tools.push(createWaitForPortTool(options));
2062
+ }
2063
+ if (tools.length !== selected.size) {
2064
+ throw toolPolicyError("Sandbox tool selection contains an unsupported tool name.");
2065
+ }
2066
+ return Object.freeze(tools);
1512
2067
  }
1513
- function createExecCommandTool(session, options) {
1514
- const policy = options.exec ?? {};
2068
+ function createExecCommandTool(options) {
1515
2069
  return createTool({
1516
2070
  name: "exec_command",
1517
- description: "Run a command inside the sandbox workspace. Use structured args instead of shell quoting.",
1518
- input: execCommandInput,
1519
- output: textOutput,
1520
- execute: async ({ command, args, cwd, env, timeoutMs, input }) => {
1521
- assertCommandAllowed(command, options);
1522
- const execOptions = {
2071
+ description: "Run one executable inside the sandbox with structured arguments.",
2072
+ inputSchema: execCommandInput,
2073
+ outputSchema: execResultOutput,
2074
+ execute: async ({ command, args, cwd, env, timeoutMs, input }, context) => {
2075
+ assertCommandAllowed(command, options.exec?.commands);
2076
+ const effectiveTimeoutMs = timeoutMs ?? options.exec?.defaultTimeoutMs;
2077
+ assertTimeoutAllowed(effectiveTimeoutMs, options.exec?.maxTimeoutMs);
2078
+ let execOptions = {
1523
2079
  command
1524
2080
  };
1525
- if (args !== void 0) {
1526
- execOptions.args = args;
1527
- }
1528
- if (cwd !== void 0) {
1529
- execOptions.cwd = cwd;
1530
- }
1531
- if (env !== void 0) {
1532
- execOptions.env = env;
1533
- }
1534
- const effectiveTimeoutMs = timeoutMs ?? policy.defaultTimeoutMs ?? options.execTimeoutMs;
2081
+ if (args !== void 0) execOptions = { ...execOptions, args };
2082
+ if (cwd !== void 0) execOptions = { ...execOptions, cwd };
2083
+ if (env !== void 0) execOptions = { ...execOptions, env };
1535
2084
  if (effectiveTimeoutMs !== void 0) {
1536
- assertTimeoutAllowed(effectiveTimeoutMs, options);
1537
- execOptions.timeoutMs = effectiveTimeoutMs;
2085
+ execOptions = { ...execOptions, timeoutMs: effectiveTimeoutMs };
1538
2086
  }
1539
- if (input !== void 0) {
1540
- execOptions.input = input;
2087
+ if (input !== void 0) execOptions = { ...execOptions, input };
2088
+ if (context.abortSignal !== void 0) {
2089
+ execOptions = { ...execOptions, abortSignal: context.abortSignal };
1541
2090
  }
1542
- const result = await session.exec(execOptions);
1543
- return formatExecResult(result);
2091
+ return serializeExecResult(await options.sandbox.exec(execOptions));
1544
2092
  }
1545
2093
  });
1546
2094
  }
1547
- function createReadFileTool(session, options) {
2095
+ function createReadFileTool(options) {
2096
+ const limits = resolveReadFileLimits(options);
1548
2097
  return createTool({
1549
2098
  name: "read_file",
1550
- description: "Read a bounded page of a text file from the sandbox workspace. Continue with nextStartLine when provided.",
1551
- input: readFileInput,
1552
- output: readFileOutput,
1553
- execute: async ({ path: path3, startLine, lineCount }) => {
1554
- const limits = resolveReadFileLimits(options);
1555
- const effectiveStartLine = startLine ?? 1;
2099
+ description: "Read a bounded page of a UTF-8 text file from the sandbox workspace.",
2100
+ inputSchema: readFileInput,
2101
+ outputSchema: readFileOutput,
2102
+ execute: async ({ path: path3, startLine, lineCount }, context) => {
1556
2103
  const effectiveLineCount = lineCount ?? limits.defaultLineCount;
1557
2104
  if (effectiveLineCount > limits.maxLineCount) {
1558
- throw new SandboxToolPolicyError(
1559
- `File read line count exceeds sandbox tool policy (${effectiveLineCount} > ${limits.maxLineCount}).`
2105
+ throw toolPolicyError(
2106
+ `File read line count exceeds policy (${effectiveLineCount} > ${limits.maxLineCount}).`
1560
2107
  );
1561
2108
  }
1562
- if (session.readTextFilePage !== void 0) {
1563
- return session.readTextFilePage(path3, {
1564
- startLine: effectiveStartLine,
1565
- lineCount: effectiveLineCount,
1566
- maxBytes: limits.maxBytes
1567
- });
1568
- }
1569
- return createTextFilePage(await session.readTextFile(path3), {
1570
- startLine: effectiveStartLine,
2109
+ let readOptions = {
2110
+ path: path3,
2111
+ startLine: startLine ?? 1,
1571
2112
  lineCount: effectiveLineCount,
1572
2113
  maxBytes: limits.maxBytes
1573
- });
2114
+ };
2115
+ if (context.abortSignal !== void 0) {
2116
+ readOptions = { ...readOptions, abortSignal: context.abortSignal };
2117
+ }
2118
+ return options.sandbox.readTextFilePage(readOptions);
1574
2119
  }
1575
2120
  });
1576
2121
  }
1577
- function createWriteFileTool(session, options) {
2122
+ function createWriteFileTool(options) {
1578
2123
  return createTool({
1579
2124
  name: "write_file",
1580
- description: "Write a text file inside the sandbox workspace. Creates parent directories.",
1581
- input: writeFileInput,
1582
- output: textOutput,
1583
- execute: async ({ path: path3, content }) => {
1584
- assertContentAllowed(content, options);
1585
- await session.writeTextFile(path3, content);
1586
- return `Wrote ${path3}`;
2125
+ description: "Write a complete UTF-8 text file inside the sandbox workspace.",
2126
+ inputSchema: writeFileInput,
2127
+ outputSchema: writeFileOutput,
2128
+ execute: async ({ path: path3, content }, context) => {
2129
+ const bytesWritten = new TextEncoder().encode(content).byteLength;
2130
+ const maxBytes = options.writeFile?.maxBytes;
2131
+ if (maxBytes !== void 0 && bytesWritten > maxBytes) {
2132
+ throw toolPolicyError(`File content exceeds policy (${bytesWritten} > ${maxBytes}).`);
2133
+ }
2134
+ let writeOptions = {
2135
+ path: path3,
2136
+ text: content
2137
+ };
2138
+ if (context.abortSignal !== void 0) {
2139
+ writeOptions = { ...writeOptions, abortSignal: context.abortSignal };
2140
+ }
2141
+ await options.sandbox.writeTextFile(writeOptions);
2142
+ return { path: path3, bytesWritten };
1587
2143
  }
1588
2144
  });
1589
2145
  }
1590
- function createListFilesTool(session) {
2146
+ function createListFilesTool(sandbox) {
1591
2147
  return createTool({
1592
2148
  name: "list_files",
1593
- description: "List files and directories inside the sandbox workspace.",
1594
- input: listFilesInput,
1595
- output: textOutput,
1596
- execute: async ({ path: path3 }) => {
1597
- const entries = await session.listFiles(path3);
1598
- if (entries.length === 0) {
1599
- return "No files found.";
2149
+ description: "List direct children of a directory in the sandbox workspace.",
2150
+ inputSchema: listFilesInput,
2151
+ outputSchema: listFilesOutput,
2152
+ execute: async ({ path: path3 }, context) => {
2153
+ let listOptions = {};
2154
+ if (path3 !== void 0) listOptions = { ...listOptions, path: path3 };
2155
+ if (context.abortSignal !== void 0) {
2156
+ listOptions = { ...listOptions, abortSignal: context.abortSignal };
1600
2157
  }
1601
- return entries.map((entry) => {
1602
- const size = entry.size === void 0 ? "" : ` ${entry.size}b`;
1603
- return `${entry.type}${size} ${entry.path}`;
1604
- }).join("\n");
2158
+ return { path: path3 ?? ".", entries: [...await sandbox.listFiles(listOptions)] };
1605
2159
  }
1606
2160
  });
1607
2161
  }
1608
- function createListPortsTool(session) {
2162
+ function createListPortsTool(sandbox) {
1609
2163
  return createTool({
1610
2164
  name: "list_ports",
1611
- description: "List pre-authorized sandbox preview ports. Servers must bind to 0.0.0.0 on a listed container port.",
1612
- input: emptyInput,
1613
- output: textOutput,
1614
- execute: async () => {
1615
- if (session.publishedPorts.length === 0) {
1616
- return "No sandbox ports are published.";
1617
- }
1618
- return session.publishedPorts.map((port) => `${port.containerPort}/${port.protocol} ${port.host}:${port.hostPort}`).join("\n");
1619
- }
2165
+ description: "List explicitly published localhost TCP ports for the sandbox.",
2166
+ inputSchema: emptyInput,
2167
+ outputSchema: listPortsOutput,
2168
+ execute: async () => ({ ports: [...sandbox.publishedPorts] })
1620
2169
  });
1621
2170
  }
1622
- function createStartProcessTool(session, options) {
2171
+ function createStartProcessTool(options) {
1623
2172
  return createTool({
1624
2173
  name: "start_process",
1625
- description: "Start a managed long-running process inside the sandbox. Use list_ports first and bind web servers to 0.0.0.0.",
1626
- input: startProcessInput,
1627
- output: textOutput,
1628
- execute: async ({ command, args, cwd, env }) => {
1629
- assertCommandAllowed(command, options);
1630
- const processOptions = { command };
1631
- if (args !== void 0) processOptions.args = args;
1632
- if (cwd !== void 0) processOptions.cwd = cwd;
1633
- if (env !== void 0) processOptions.env = env;
1634
- return formatProcessInfo(await session.startProcess(processOptions));
2174
+ description: "Start a managed long-running process inside the sandbox.",
2175
+ inputSchema: startProcessInput,
2176
+ outputSchema: processInfoOutput,
2177
+ execute: async ({ command, args, cwd, env }, context) => {
2178
+ assertCommandAllowed(command, options.exec?.commands);
2179
+ let startOptions = { command };
2180
+ if (args !== void 0) startOptions = { ...startOptions, args };
2181
+ if (cwd !== void 0) startOptions = { ...startOptions, cwd };
2182
+ if (env !== void 0) startOptions = { ...startOptions, env };
2183
+ if (context.abortSignal !== void 0) {
2184
+ startOptions = { ...startOptions, abortSignal: context.abortSignal };
2185
+ }
2186
+ return serializeProcessInfo(await options.sandbox.startProcess(startOptions));
1635
2187
  }
1636
2188
  });
1637
2189
  }
1638
- function createListProcessesTool(session) {
2190
+ function createListProcessesTool(sandbox) {
1639
2191
  return createTool({
1640
2192
  name: "list_processes",
1641
- description: "List managed sandbox processes and their current status.",
1642
- input: emptyInput,
1643
- output: textOutput,
1644
- execute: async () => {
1645
- const processes = await session.listProcesses();
1646
- if (processes.length === 0) return "No managed processes.";
1647
- return processes.map(formatProcessInfo).join("\n\n");
1648
- }
2193
+ description: "List processes managed by this live sandbox handle.",
2194
+ inputSchema: emptyInput,
2195
+ outputSchema: listProcessesOutput,
2196
+ execute: async (_, context) => ({
2197
+ processes: (await sandbox.listProcesses(
2198
+ context.abortSignal === void 0 ? {} : { abortSignal: context.abortSignal }
2199
+ )).map(serializeProcessInfo)
2200
+ })
1649
2201
  });
1650
2202
  }
1651
- function createReadProcessLogsTool(session, options) {
2203
+ function createReadProcessLogsTool(options) {
2204
+ const maxLogBytes = options.process?.maxLogBytes ?? 64 * 1024;
1652
2205
  return createTool({
1653
2206
  name: "read_process_logs",
1654
- description: "Read recent stdout and stderr from a managed sandbox process.",
1655
- input: readProcessLogsInput,
1656
- output: textOutput,
1657
- execute: async ({ processId, tailBytes }) => {
1658
- const configuredMaxLogBytes = options.process?.maxLogBytes ?? 64 * 1024;
1659
- if (!Number.isInteger(configuredMaxLogBytes) || configuredMaxLogBytes < 0) {
1660
- throw new SandboxToolPolicyError("Process maxLogBytes must be a non-negative integer.");
1661
- }
1662
- const maxLogBytes = Math.min(configuredMaxLogBytes, maxToolLogBytes);
2207
+ description: "Read bounded UTF-8 output from a managed sandbox process.",
2208
+ inputSchema: readProcessLogsInput,
2209
+ outputSchema: processLogsOutput,
2210
+ execute: async ({ processId, tailBytes }, context) => {
1663
2211
  const effectiveTailBytes = tailBytes ?? maxLogBytes;
1664
2212
  if (effectiveTailBytes > maxLogBytes) {
1665
- throw new SandboxToolPolicyError(
1666
- `Process log request exceeds sandbox tool policy (${effectiveTailBytes} > ${maxLogBytes}).`
2213
+ throw toolPolicyError(
2214
+ `Process log request exceeds policy (${effectiveTailBytes} > ${maxLogBytes}).`
1667
2215
  );
1668
2216
  }
1669
- const logs = await session.readProcessLogs(processId, {
2217
+ let readOptions = {
2218
+ processId,
1670
2219
  tailBytes: effectiveTailBytes
1671
- });
1672
- const parts = [];
1673
- if (logs.stdout.length > 0) parts.push(`stdout:
1674
- ${logs.stdout.trimEnd()}`);
1675
- if (logs.stderr.length > 0) parts.push(`stderr:
1676
- ${logs.stderr.trimEnd()}`);
1677
- if (logs.stdoutTruncated || logs.stderrTruncated) parts.push("output_truncated: true");
1678
- return parts.length > 0 ? parts.join("\n\n") : "No process output.";
2220
+ };
2221
+ if (context.abortSignal !== void 0) {
2222
+ readOptions = { ...readOptions, abortSignal: context.abortSignal };
2223
+ }
2224
+ const logs = await options.sandbox.readProcessLogs(readOptions);
2225
+ return {
2226
+ processId,
2227
+ stdout: decodeUtf8(logs.stdout),
2228
+ stderr: decodeUtf8(logs.stderr),
2229
+ stdoutTruncated: logs.stdoutTruncated,
2230
+ stderrTruncated: logs.stderrTruncated
2231
+ };
1679
2232
  }
1680
2233
  });
1681
2234
  }
1682
- function createStopProcessTool(session, options) {
2235
+ function createStopProcessTool(options) {
1683
2236
  return createTool({
1684
2237
  name: "stop_process",
1685
- description: "Stop a managed sandbox process.",
1686
- input: processIdInput,
1687
- output: textOutput,
1688
- execute: async ({ processId }) => formatProcessInfo(
1689
- await session.stopProcess(processId, {
2238
+ description: "Stop a process managed by this live sandbox handle.",
2239
+ inputSchema: processIdInput,
2240
+ outputSchema: processInfoOutput,
2241
+ execute: async ({ processId }, context) => {
2242
+ let stopOptions = {
2243
+ processId,
1690
2244
  gracePeriodMs: options.process?.stopGracePeriodMs ?? 5e3
1691
- })
1692
- )
2245
+ };
2246
+ if (context.abortSignal !== void 0) {
2247
+ stopOptions = { ...stopOptions, abortSignal: context.abortSignal };
2248
+ }
2249
+ return serializeProcessInfo(await options.sandbox.stopProcess(stopOptions));
2250
+ }
1693
2251
  });
1694
2252
  }
1695
- function createWaitForPortTool(session, options) {
2253
+ function createWaitForPortTool(options) {
1696
2254
  return createTool({
1697
2255
  name: "wait_for_port",
1698
- description: "Wait until a pre-authorized sandbox TCP port is accepting connections.",
1699
- input: waitForPortInput,
1700
- output: textOutput,
1701
- execute: async ({ containerPort, timeoutMs }) => {
2256
+ description: "Wait until an explicitly published sandbox TCP port is listening.",
2257
+ inputSchema: waitForPortInput,
2258
+ outputSchema: waitForPortOutput,
2259
+ execute: async ({ containerPort, timeoutMs }, context) => {
1702
2260
  const effectiveTimeoutMs = timeoutMs ?? options.process?.defaultWaitTimeoutMs ?? 3e4;
1703
- const maxWaitTimeoutMs = options.process?.maxWaitTimeoutMs ?? 3e5;
2261
+ const maxWaitTimeoutMs = options.process?.maxWaitTimeoutMs ?? maxToolTimeoutMs;
1704
2262
  if (effectiveTimeoutMs > maxWaitTimeoutMs) {
1705
- throw new SandboxToolPolicyError(
1706
- `Port wait timeout exceeds sandbox tool policy (${effectiveTimeoutMs} > ${maxWaitTimeoutMs}).`
2263
+ throw toolPolicyError(
2264
+ `Port wait timeout exceeds policy (${effectiveTimeoutMs} > ${maxWaitTimeoutMs}).`
1707
2265
  );
1708
2266
  }
1709
- const port = await session.waitForPort(containerPort, {
2267
+ let waitOptions = {
2268
+ containerPort,
1710
2269
  timeoutMs: effectiveTimeoutMs
1711
- });
1712
- return `ready: ${port.containerPort}/${port.protocol} -> ${port.host}:${port.hostPort}`;
2270
+ };
2271
+ if (context.abortSignal !== void 0) {
2272
+ waitOptions = { ...waitOptions, abortSignal: context.abortSignal };
2273
+ }
2274
+ return { port: await options.sandbox.waitForPort(waitOptions) };
1713
2275
  }
1714
2276
  });
1715
2277
  }
1716
- function formatExecResult(result) {
1717
- const parts = [`exit_code: ${result.exitCode}`];
1718
- if (result.timedOut) {
1719
- parts.push("timed_out: true");
1720
- }
1721
- if (result.aborted) {
1722
- parts.push("aborted: true");
1723
- }
1724
- if (result.stdout.length > 0) {
1725
- parts.push(`stdout:
1726
- ${result.stdout.trimEnd()}`);
1727
- }
1728
- if (result.stderr.length > 0) {
1729
- parts.push(`stderr:
1730
- ${result.stderr.trimEnd()}`);
1731
- }
1732
- if (result.stdoutTruncated || result.stderrTruncated) {
1733
- parts.push("output_truncated: true");
1734
- }
1735
- return parts.join("\n\n");
2278
+ function serializeExecResult(result) {
2279
+ const output = {
2280
+ status: result.status,
2281
+ stdout: decodeUtf8(result.stdout),
2282
+ stderr: decodeUtf8(result.stderr),
2283
+ durationMs: result.durationMs,
2284
+ stdoutTruncated: result.stdoutTruncated,
2285
+ stderrTruncated: result.stderrTruncated
2286
+ };
2287
+ return result.status === "exited" ? { ...output, status: "exited", exitCode: result.exitCode } : { ...output, status: "timed_out" };
1736
2288
  }
1737
- function formatProcessInfo(process) {
1738
- const parts = [
1739
- `process_id: ${process.id}`,
1740
- `status: ${process.status}`,
1741
- `command: ${[process.command, ...process.args].join(" ")}`
1742
- ];
1743
- if (process.exitCode !== void 0) parts.push(`exit_code: ${process.exitCode}`);
1744
- return parts.join("\n");
2289
+ function serializeProcessInfo(process) {
2290
+ let serialized = {
2291
+ id: process.id,
2292
+ command: process.command,
2293
+ args: [...process.args],
2294
+ status: process.status,
2295
+ startedAt: process.startedAt
2296
+ };
2297
+ if (process.cwd !== void 0) serialized = { ...serialized, cwd: process.cwd };
2298
+ if (process.exitCode !== void 0) serialized = { ...serialized, exitCode: process.exitCode };
2299
+ if (process.endedAt !== void 0) serialized = { ...serialized, endedAt: process.endedAt };
2300
+ return serialized;
1745
2301
  }
1746
- function requirePortSession(session) {
1747
- if (!isSandboxPortSession(session)) {
1748
- throw new SandboxToolPolicyError("The sandbox session does not support published port tools.");
2302
+ function validateFactoryOptions(options) {
2303
+ if (!isRecord2(options)) {
2304
+ throw new TypeError("options must be an object.");
2305
+ }
2306
+ if (!isDockerSandboxRuntime(options.sandbox)) {
2307
+ throw new TypeError("sandbox must be a DockerSandboxRuntime.");
2308
+ }
2309
+ if (!Array.isArray(options.tools) || options.tools.length === 0) {
2310
+ throw new TypeError("tools must be a non-empty array.");
2311
+ }
2312
+ const known = new Set(allToolNames);
2313
+ const seen = /* @__PURE__ */ new Set();
2314
+ for (const name of options.tools) {
2315
+ if (typeof name !== "string" || !known.has(name)) {
2316
+ throw new TypeError("tools contains an unsupported sandbox tool name.");
2317
+ }
2318
+ if (seen.has(name)) throw new TypeError(`tools contains a duplicate: ${name}`);
2319
+ seen.add(name);
2320
+ }
2321
+ for (const [name, policy] of [
2322
+ ["exec", options.exec],
2323
+ ["readFile", options.readFile],
2324
+ ["writeFile", options.writeFile],
2325
+ ["process", options.process]
2326
+ ]) {
2327
+ if (policy !== void 0 && !isRecord2(policy)) {
2328
+ throw new TypeError(`${name} must be an object.`);
2329
+ }
2330
+ }
2331
+ validateCommandPolicy(options.exec?.commands);
2332
+ assertOptionalPositiveInteger(options.exec?.defaultTimeoutMs, "exec.defaultTimeoutMs");
2333
+ assertOptionalPositiveInteger(options.exec?.maxTimeoutMs, "exec.maxTimeoutMs");
2334
+ if ((options.exec?.defaultTimeoutMs ?? 0) > maxToolTimeoutMs || (options.exec?.maxTimeoutMs ?? 0) > maxToolTimeoutMs) {
2335
+ throw toolPolicyError(`Sandbox tool command timeouts cannot exceed ${maxToolTimeoutMs}.`);
2336
+ }
2337
+ if (options.exec?.defaultTimeoutMs !== void 0 && options.exec.maxTimeoutMs !== void 0 && options.exec.defaultTimeoutMs > options.exec.maxTimeoutMs) {
2338
+ throw toolPolicyError("exec.defaultTimeoutMs cannot exceed exec.maxTimeoutMs.");
2339
+ }
2340
+ assertOptionalBoundedPositiveInteger(options.readFile?.maxBytes, "readFile.maxBytes");
2341
+ assertOptionalPositiveInteger(options.readFile?.defaultLineCount, "readFile.defaultLineCount");
2342
+ assertOptionalPositiveInteger(options.readFile?.maxLineCount, "readFile.maxLineCount");
2343
+ assertOptionalBoundedNonNegativeInteger(options.writeFile?.maxBytes, "writeFile.maxBytes");
2344
+ assertOptionalBoundedNonNegativeInteger(options.process?.maxLogBytes, "process.maxLogBytes");
2345
+ assertOptionalPositiveInteger(
2346
+ options.process?.defaultWaitTimeoutMs,
2347
+ "process.defaultWaitTimeoutMs"
2348
+ );
2349
+ assertOptionalPositiveInteger(options.process?.maxWaitTimeoutMs, "process.maxWaitTimeoutMs");
2350
+ assertOptionalNonNegativeInteger(options.process?.stopGracePeriodMs, "process.stopGracePeriodMs");
2351
+ const defaultWaitTimeoutMs = options.process?.defaultWaitTimeoutMs;
2352
+ const maxWaitTimeoutMs = options.process?.maxWaitTimeoutMs ?? maxToolTimeoutMs;
2353
+ if (maxWaitTimeoutMs > maxToolTimeoutMs) {
2354
+ throw toolPolicyError(`process.maxWaitTimeoutMs cannot exceed ${maxToolTimeoutMs}.`);
1749
2355
  }
1750
- return session;
1751
- }
1752
- function requireProcessSession(session) {
1753
- if (!isSandboxProcessSession(session)) {
1754
- throw new SandboxToolPolicyError("The sandbox session does not support managed process tools.");
2356
+ if (defaultWaitTimeoutMs !== void 0 && defaultWaitTimeoutMs > maxWaitTimeoutMs) {
2357
+ throw toolPolicyError("process.defaultWaitTimeoutMs cannot exceed process.maxWaitTimeoutMs.");
1755
2358
  }
1756
- return session;
1757
2359
  }
1758
- function assertProcessToolPolicy(options) {
1759
- const policy = options.process;
1760
- if (policy === void 0) return;
1761
- if (policy.maxLogBytes !== void 0 && (!Number.isInteger(policy.maxLogBytes) || policy.maxLogBytes < 0 || policy.maxLogBytes > maxToolLogBytes)) {
1762
- throw new SandboxToolPolicyError(
1763
- `Process maxLogBytes must be an integer from 0 to ${maxToolLogBytes}.`
1764
- );
2360
+ function snapshotFactoryOptions(options) {
2361
+ const toolNames = Object.freeze([...options.tools]);
2362
+ const commands = options.exec?.commands === void 0 ? void 0 : Object.freeze({
2363
+ mode: options.exec.commands.mode,
2364
+ values: Object.freeze([...options.exec.commands.values])
2365
+ });
2366
+ let snapshot = {
2367
+ sandbox: options.sandbox,
2368
+ tools: toolNames
2369
+ };
2370
+ if (options.exec !== void 0) {
2371
+ let exec = { ...options.exec };
2372
+ if (commands !== void 0) exec = { ...exec, commands };
2373
+ snapshot = { ...snapshot, exec: Object.freeze(exec) };
1765
2374
  }
1766
- const maxWaitTimeoutMs = policy.maxWaitTimeoutMs ?? 3e5;
1767
- if (!Number.isInteger(maxWaitTimeoutMs) || maxWaitTimeoutMs <= 0 || maxWaitTimeoutMs > 3e5) {
1768
- throw new SandboxToolPolicyError(
1769
- "Process maxWaitTimeoutMs must be an integer from 1 to 300000."
1770
- );
2375
+ if (options.readFile !== void 0) {
2376
+ snapshot = { ...snapshot, readFile: Object.freeze({ ...options.readFile }) };
1771
2377
  }
1772
- if (policy.defaultWaitTimeoutMs !== void 0 && (!Number.isInteger(policy.defaultWaitTimeoutMs) || policy.defaultWaitTimeoutMs <= 0 || policy.defaultWaitTimeoutMs > maxWaitTimeoutMs)) {
1773
- throw new SandboxToolPolicyError(
1774
- "Process defaultWaitTimeoutMs must be positive and no greater than maxWaitTimeoutMs."
1775
- );
2378
+ if (options.writeFile !== void 0) {
2379
+ snapshot = { ...snapshot, writeFile: Object.freeze({ ...options.writeFile }) };
1776
2380
  }
1777
- if (policy.stopGracePeriodMs !== void 0 && (!Number.isInteger(policy.stopGracePeriodMs) || policy.stopGracePeriodMs < 0)) {
1778
- throw new SandboxToolPolicyError("Process stopGracePeriodMs must be a non-negative integer.");
2381
+ if (options.process !== void 0) {
2382
+ snapshot = { ...snapshot, process: Object.freeze({ ...options.process }) };
1779
2383
  }
2384
+ return Object.freeze(snapshot);
1780
2385
  }
1781
- function assertCommandAllowed(command, options) {
1782
- const policy = options.exec;
1783
- if (policy?.blockedCommands?.includes(command)) {
1784
- throw new SandboxToolPolicyError(`Command is blocked by sandbox tool policy: ${command}`);
2386
+ function validateCommandPolicy(policy) {
2387
+ if (policy === void 0) return;
2388
+ if (!isRecord2(policy)) throw toolPolicyError("exec.commands must be an object.");
2389
+ if (policy.mode !== "allow" && policy.mode !== "block") {
2390
+ throw toolPolicyError("exec.commands must use mode allow or block.");
1785
2391
  }
1786
- if (policy?.allowedCommands !== void 0 && !policy.allowedCommands.includes(command)) {
1787
- throw new SandboxToolPolicyError(`Command is not allowed by sandbox tool policy: ${command}`);
2392
+ if (!Array.isArray(policy.values))
2393
+ throw toolPolicyError("exec.commands.values must be an array.");
2394
+ const seen = /* @__PURE__ */ new Set();
2395
+ for (const value of policy.values) {
2396
+ if (typeof value !== "string" || value.length === 0) {
2397
+ throw toolPolicyError("exec.commands.values must contain non-empty strings.");
2398
+ }
2399
+ if (seen.has(value))
2400
+ throw toolPolicyError(`exec.commands.values contains a duplicate: ${value}`);
2401
+ seen.add(value);
1788
2402
  }
1789
2403
  }
1790
- function assertTimeoutAllowed(timeoutMs, options) {
1791
- const maxTimeoutMs = options.exec?.maxTimeoutMs;
1792
- if (maxTimeoutMs !== void 0 && timeoutMs > maxTimeoutMs) {
1793
- throw new SandboxToolPolicyError(
1794
- `Command timeout exceeds sandbox tool policy (${timeoutMs} > ${maxTimeoutMs}).`
1795
- );
2404
+ function assertCommandAllowed(command, policy) {
2405
+ if (policy === void 0) return;
2406
+ const included = policy.values.includes(command);
2407
+ if (policy.mode === "allow" && !included || policy.mode === "block" && included) {
2408
+ throw toolPolicyError(`Command is rejected by sandbox tool policy: ${command}`);
1796
2409
  }
1797
2410
  }
1798
- function assertContentAllowed(content, options) {
1799
- const maxBytes = options.writeFile?.maxBytes;
1800
- if (maxBytes !== void 0 && Buffer.byteLength(content) > maxBytes) {
1801
- throw new SandboxToolPolicyError("File content exceeds sandbox tool policy.");
2411
+ function assertTimeoutAllowed(timeoutMs, maxTimeoutMs) {
2412
+ if (timeoutMs !== void 0 && maxTimeoutMs !== void 0 && timeoutMs > maxTimeoutMs) {
2413
+ throw toolPolicyError(`Command timeout exceeds policy (${timeoutMs} > ${maxTimeoutMs}).`);
1802
2414
  }
1803
2415
  }
1804
2416
  function resolveReadFileLimits(options) {
1805
2417
  const defaultLineCount = options.readFile?.defaultLineCount ?? defaultReadFileLineCount;
1806
2418
  const maxLineCount = options.readFile?.maxLineCount ?? defaultReadFileMaxLineCount;
1807
2419
  const maxBytes = options.readFile?.maxBytes ?? defaultReadFileMaxBytes;
1808
- if (!Number.isInteger(defaultLineCount) || defaultLineCount <= 0) {
1809
- throw new SandboxToolPolicyError("File defaultLineCount must be a positive integer.");
1810
- }
1811
- if (!Number.isInteger(maxLineCount) || maxLineCount <= 0 || maxLineCount > maxToolReadFileLines) {
1812
- throw new SandboxToolPolicyError(
1813
- `File maxLineCount must be a positive integer no greater than ${maxToolReadFileLines}.`
1814
- );
1815
- }
1816
2420
  if (defaultLineCount > maxLineCount) {
1817
- throw new SandboxToolPolicyError(
1818
- `File defaultLineCount exceeds maxLineCount (${defaultLineCount} > ${maxLineCount}).`
1819
- );
2421
+ throw toolPolicyError("readFile.defaultLineCount cannot exceed readFile.maxLineCount.");
1820
2422
  }
1821
- if (!Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > maxToolReadFileBytes) {
1822
- throw new SandboxToolPolicyError(
1823
- `File maxBytes must be a positive integer no greater than ${maxToolReadFileBytes}.`
1824
- );
2423
+ if (maxLineCount > maxToolReadFileLines) {
2424
+ throw toolPolicyError(`readFile.maxLineCount cannot exceed ${maxToolReadFileLines}.`);
1825
2425
  }
1826
2426
  return { defaultLineCount, maxLineCount, maxBytes };
1827
2427
  }
2428
+ function assertOptionalPositiveInteger(value, name) {
2429
+ if (value !== void 0 && (!Number.isSafeInteger(value) || value <= 0)) {
2430
+ throw toolPolicyError(`${name} must be a positive safe integer.`);
2431
+ }
2432
+ }
2433
+ function assertOptionalNonNegativeInteger(value, name) {
2434
+ if (value !== void 0 && (!Number.isSafeInteger(value) || value < 0)) {
2435
+ throw toolPolicyError(`${name} must be a non-negative safe integer.`);
2436
+ }
2437
+ }
2438
+ function assertOptionalBoundedNonNegativeInteger(value, name) {
2439
+ assertOptionalNonNegativeInteger(value, name);
2440
+ if (value !== void 0 && value > maxToolBytes) {
2441
+ throw toolPolicyError(`${name} cannot exceed ${maxToolBytes}.`);
2442
+ }
2443
+ }
2444
+ function assertOptionalBoundedPositiveInteger(value, name) {
2445
+ assertOptionalPositiveInteger(value, name);
2446
+ if (value !== void 0 && value > maxToolBytes) {
2447
+ throw toolPolicyError(`${name} cannot exceed ${maxToolBytes}.`);
2448
+ }
2449
+ }
2450
+ function isDockerSandboxRuntime(value) {
2451
+ if (!isRecord2(value)) return false;
2452
+ return typeof value.id === "string" && value.provider === "docker" && typeof value.workdir === "string" && Array.isArray(value.publishedPorts) && [
2453
+ "exec",
2454
+ "execStream",
2455
+ "readFile",
2456
+ "readTextFile",
2457
+ "readTextFilePage",
2458
+ "writeFile",
2459
+ "writeTextFile",
2460
+ "listFiles",
2461
+ "startProcess",
2462
+ "listProcesses",
2463
+ "readProcessLogs",
2464
+ "stopProcess",
2465
+ "waitForPort"
2466
+ ].every((name) => typeof value[name] === "function");
2467
+ }
2468
+ function isRecord2(value) {
2469
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2470
+ }
2471
+ function toolPolicyError(message) {
2472
+ return new DockerSandboxError(message, "tool_policy");
2473
+ }
1828
2474
  export {
1829
- DockerSandbox,
1830
- SandboxDockerCommandError,
1831
- SandboxDockerUnavailableError,
1832
- SandboxError,
1833
- SandboxFileSizeError,
1834
- SandboxPathError,
1835
- SandboxPortError,
1836
- SandboxProcessError,
1837
- SandboxSessionDestroyedError,
1838
- SandboxTimeoutError,
1839
- SandboxToolPolicyError,
1840
- createSandboxTools,
1841
- isSandboxPortSession,
1842
- isSandboxProcessSession
2475
+ DockerSandboxClient,
2476
+ DockerSandboxError,
2477
+ createDockerSandboxTools
1843
2478
  };
1844
2479
  //# sourceMappingURL=index.js.map