@anvia/sandbox 0.3.6 → 0.4.0
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.d.ts +75 -3
- package/dist/index.js +932 -25
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
// src/capabilities.ts
|
|
2
|
+
function isSandboxPortSession(session) {
|
|
3
|
+
const candidate = session;
|
|
4
|
+
return Array.isArray(candidate.publishedPorts) && typeof candidate.waitForPort === "function";
|
|
5
|
+
}
|
|
6
|
+
function isSandboxProcessSession(session) {
|
|
7
|
+
const candidate = session;
|
|
8
|
+
return typeof candidate.startProcess === "function" && typeof candidate.listProcesses === "function" && typeof candidate.readProcessLogs === "function" && typeof candidate.stopProcess === "function";
|
|
9
|
+
}
|
|
10
|
+
|
|
1
11
|
// src/docker-sandbox.ts
|
|
2
|
-
import { randomUUID } from "crypto";
|
|
12
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
3
13
|
import { mkdtemp, rm, writeFile } from "fs/promises";
|
|
4
14
|
import os from "os";
|
|
5
15
|
import path2 from "path";
|
|
@@ -35,6 +45,10 @@ var SandboxFileSizeError = class extends SandboxError {
|
|
|
35
45
|
};
|
|
36
46
|
var SandboxToolPolicyError = class extends SandboxError {
|
|
37
47
|
};
|
|
48
|
+
var SandboxPortError = class extends SandboxError {
|
|
49
|
+
};
|
|
50
|
+
var SandboxProcessError = class extends SandboxError {
|
|
51
|
+
};
|
|
38
52
|
|
|
39
53
|
// src/docker-cli.ts
|
|
40
54
|
var defaultMaxOutputBytes = 1024 * 1024;
|
|
@@ -138,6 +152,10 @@ function createOutputCollector(maxBytes, onChunk) {
|
|
|
138
152
|
};
|
|
139
153
|
}
|
|
140
154
|
|
|
155
|
+
// src/docker-process.ts
|
|
156
|
+
import { spawn as spawn2 } from "child_process";
|
|
157
|
+
import { randomUUID } from "crypto";
|
|
158
|
+
|
|
141
159
|
// src/path.ts
|
|
142
160
|
import path from "path";
|
|
143
161
|
function normalizeSandboxPath(input, options = {}) {
|
|
@@ -172,11 +190,499 @@ function parentSandboxPath(relativePath) {
|
|
|
172
190
|
return parent === "." ? "." : parent;
|
|
173
191
|
}
|
|
174
192
|
|
|
193
|
+
// src/docker-process.ts
|
|
194
|
+
var processMarkerPrefix = "ANVIA_PROCESS";
|
|
195
|
+
var processWrapper = [
|
|
196
|
+
'marker="$1"',
|
|
197
|
+
"shift",
|
|
198
|
+
"if command -v setsid >/dev/null 2>&1; then",
|
|
199
|
+
' setsid "$@" &',
|
|
200
|
+
" child=$!",
|
|
201
|
+
" group=$child",
|
|
202
|
+
"else",
|
|
203
|
+
' "$@" &',
|
|
204
|
+
" child=$!",
|
|
205
|
+
" group=$$",
|
|
206
|
+
"fi",
|
|
207
|
+
`printf '\\036%s:%s:%s:%s\\036' "$marker" "$$" "$child" "$group"`,
|
|
208
|
+
"terminate() {",
|
|
209
|
+
' wait "$child" 2>/dev/null || true',
|
|
210
|
+
" exit 143",
|
|
211
|
+
"}",
|
|
212
|
+
"trap terminate TERM INT",
|
|
213
|
+
'wait "$child"',
|
|
214
|
+
"exit $?"
|
|
215
|
+
].join("\n");
|
|
216
|
+
var DockerProcessManager = class {
|
|
217
|
+
constructor(options) {
|
|
218
|
+
this.options = options;
|
|
219
|
+
if (!Number.isInteger(options.maxProcesses) || options.maxProcesses < 0) {
|
|
220
|
+
throw new SandboxProcessError("Sandbox maxProcesses must be a non-negative integer.");
|
|
221
|
+
}
|
|
222
|
+
if (!Number.isInteger(options.maxOutputBytes) || options.maxOutputBytes < 0) {
|
|
223
|
+
throw new SandboxProcessError("Sandbox maxOutputBytes must be a non-negative integer.");
|
|
224
|
+
}
|
|
225
|
+
if (!Number.isInteger(options.startupTimeoutMs) || options.startupTimeoutMs <= 0) {
|
|
226
|
+
throw new SandboxProcessError("Sandbox process startup timeout must be a positive integer.");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
options;
|
|
230
|
+
records = /* @__PURE__ */ new Map();
|
|
231
|
+
disposed = false;
|
|
232
|
+
async start(options) {
|
|
233
|
+
this.assertActive();
|
|
234
|
+
assertStartOptions(options);
|
|
235
|
+
await this.pruneCompletedRecords();
|
|
236
|
+
const trackedCount = this.records.size;
|
|
237
|
+
if (trackedCount >= this.options.maxProcesses) {
|
|
238
|
+
throw new SandboxProcessError(
|
|
239
|
+
`Sandbox process limit reached (${trackedCount} >= ${this.options.maxProcesses}).`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const id = randomUUID();
|
|
243
|
+
const marker = `${processMarkerPrefix}:${id}`;
|
|
244
|
+
const dockerArgs = this.createExecArgs(options, marker);
|
|
245
|
+
const child = spawn2(this.options.dockerPath, dockerArgs, {
|
|
246
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
247
|
+
});
|
|
248
|
+
child.stdin.end();
|
|
249
|
+
let resolveStarted;
|
|
250
|
+
let rejectStarted;
|
|
251
|
+
const started = new Promise((resolve, reject) => {
|
|
252
|
+
resolveStarted = resolve;
|
|
253
|
+
rejectStarted = reject;
|
|
254
|
+
});
|
|
255
|
+
let resolveClosed;
|
|
256
|
+
const closed = new Promise((resolve) => {
|
|
257
|
+
resolveClosed = resolve;
|
|
258
|
+
});
|
|
259
|
+
const info = {
|
|
260
|
+
id,
|
|
261
|
+
command: options.command,
|
|
262
|
+
args: [...options.args ?? []],
|
|
263
|
+
status: "running",
|
|
264
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
265
|
+
};
|
|
266
|
+
if (options.cwd !== void 0) info.cwd = options.cwd;
|
|
267
|
+
const record = {
|
|
268
|
+
info,
|
|
269
|
+
startedAtMs: Date.now(),
|
|
270
|
+
child,
|
|
271
|
+
stdout: new TailOutputCollector(this.options.maxOutputBytes),
|
|
272
|
+
stderr: new TailOutputCollector(this.options.maxOutputBytes),
|
|
273
|
+
markerStart: Buffer.from(`${marker}:`),
|
|
274
|
+
markerBuffer: Buffer.alloc(0),
|
|
275
|
+
spawnFailed: false,
|
|
276
|
+
stopRequested: false,
|
|
277
|
+
startResolved: false,
|
|
278
|
+
exitNotified: false,
|
|
279
|
+
resolveStarted,
|
|
280
|
+
rejectStarted,
|
|
281
|
+
started,
|
|
282
|
+
resolveClosed,
|
|
283
|
+
closed
|
|
284
|
+
};
|
|
285
|
+
this.records.set(id, record);
|
|
286
|
+
this.observe(record);
|
|
287
|
+
try {
|
|
288
|
+
await this.waitForStart(record);
|
|
289
|
+
await this.options.onStart?.(copyProcessInfo(record.info));
|
|
290
|
+
record.startResolved = true;
|
|
291
|
+
if (record.info.status !== "running") this.notifyExit(record);
|
|
292
|
+
return copyProcessInfo(record.info);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (await this.cleanupFailedStart(record)) this.records.delete(id);
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
list() {
|
|
299
|
+
this.assertActive();
|
|
300
|
+
return [...this.records.values()].map((record) => copyProcessInfo(record.info));
|
|
301
|
+
}
|
|
302
|
+
logs(processId, options = {}) {
|
|
303
|
+
this.assertActive();
|
|
304
|
+
const record = this.getRecord(processId);
|
|
305
|
+
const stdout = record.stdout.snapshot(options.tailBytes);
|
|
306
|
+
const stderr = record.stderr.snapshot(options.tailBytes);
|
|
307
|
+
return {
|
|
308
|
+
stdout: stdout.text,
|
|
309
|
+
stderr: stderr.text,
|
|
310
|
+
stdoutTruncated: stdout.truncated,
|
|
311
|
+
stderrTruncated: stderr.truncated
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
async stop(processId, options = {}) {
|
|
315
|
+
this.assertActive();
|
|
316
|
+
const record = this.getRecord(processId);
|
|
317
|
+
const gracePeriodMs = options.gracePeriodMs ?? 5e3;
|
|
318
|
+
if (!Number.isInteger(gracePeriodMs) || gracePeriodMs < 0) {
|
|
319
|
+
throw new SandboxProcessError("Process gracePeriodMs must be a non-negative integer.");
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
if (!await this.terminateRecord(record, gracePeriodMs)) {
|
|
323
|
+
throw new SandboxProcessError(`Sandbox process did not stop: ${processId}`);
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (record.info.status === "running") record.stopRequested = false;
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
return copyProcessInfo(record.info);
|
|
330
|
+
}
|
|
331
|
+
async dispose() {
|
|
332
|
+
if (this.disposed) return;
|
|
333
|
+
this.disposed = true;
|
|
334
|
+
await Promise.all(
|
|
335
|
+
[...this.records.values()].map(async (record) => {
|
|
336
|
+
await this.terminateRecord(record, 1e3).catch(() => false);
|
|
337
|
+
})
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
createExecArgs(options, marker) {
|
|
341
|
+
const args = ["exec", "-w", containerPath(this.options.workdir, options.cwd ?? ".")];
|
|
342
|
+
for (const [key, value] of Object.entries({ ...this.options.env, ...options.env })) {
|
|
343
|
+
args.push("-e", `${key}=${value}`);
|
|
344
|
+
}
|
|
345
|
+
args.push(
|
|
346
|
+
this.options.containerName,
|
|
347
|
+
"sh",
|
|
348
|
+
"-c",
|
|
349
|
+
processWrapper,
|
|
350
|
+
"anvia-managed-process",
|
|
351
|
+
marker,
|
|
352
|
+
options.command,
|
|
353
|
+
...options.args ?? []
|
|
354
|
+
);
|
|
355
|
+
return args;
|
|
356
|
+
}
|
|
357
|
+
observe(record) {
|
|
358
|
+
record.child.stdout.on("data", (chunk) => this.acceptStdout(record, chunk));
|
|
359
|
+
record.child.stderr.on("data", (chunk) => record.stderr.accept(chunk));
|
|
360
|
+
record.child.on("error", (error) => {
|
|
361
|
+
record.spawnFailed = true;
|
|
362
|
+
const normalized = error.code === "ENOENT" ? new SandboxDockerUnavailableError("Docker CLI was not found.", error) : error;
|
|
363
|
+
record.rejectStarted(normalized);
|
|
364
|
+
});
|
|
365
|
+
record.child.on("close", (code) => {
|
|
366
|
+
if (record.markerBuffer.length > 0) {
|
|
367
|
+
record.stdout.accept(record.markerBuffer);
|
|
368
|
+
record.markerBuffer = Buffer.alloc(0);
|
|
369
|
+
}
|
|
370
|
+
record.info.status = record.stopRequested ? "stopped" : "exited";
|
|
371
|
+
record.info.exitCode = code ?? 1;
|
|
372
|
+
record.info.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
373
|
+
record.rejectStarted(
|
|
374
|
+
new SandboxProcessError(
|
|
375
|
+
`Sandbox process exited before startup completed: ${record.info.id}`
|
|
376
|
+
)
|
|
377
|
+
);
|
|
378
|
+
record.resolveClosed();
|
|
379
|
+
if (record.startResolved) this.notifyExit(record);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
acceptStdout(record, chunk) {
|
|
383
|
+
if (record.supervisorPid !== void 0) {
|
|
384
|
+
record.stdout.accept(chunk);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
record.markerBuffer = Buffer.concat([record.markerBuffer, chunk]);
|
|
388
|
+
const start = record.markerBuffer.indexOf(record.markerStart);
|
|
389
|
+
if (start < 0) {
|
|
390
|
+
const retainedBytes = Math.max(0, record.markerStart.length - 1);
|
|
391
|
+
if (record.markerBuffer.length > retainedBytes) {
|
|
392
|
+
const split = record.markerBuffer.length - retainedBytes;
|
|
393
|
+
record.stdout.accept(record.markerBuffer.subarray(0, split));
|
|
394
|
+
record.markerBuffer = record.markerBuffer.subarray(split);
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const end = record.markerBuffer.indexOf(30, start + record.markerStart.length);
|
|
399
|
+
if (end < 0) {
|
|
400
|
+
if (start > 0) {
|
|
401
|
+
record.stdout.accept(record.markerBuffer.subarray(0, start));
|
|
402
|
+
record.markerBuffer = record.markerBuffer.subarray(start);
|
|
403
|
+
}
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const rawPids = record.markerBuffer.subarray(start + record.markerStart.length, end).toString("utf8").split(":");
|
|
407
|
+
const supervisorPid = Number(rawPids[0]);
|
|
408
|
+
const childPid = Number(rawPids[1]);
|
|
409
|
+
const processGroupId = Number(rawPids[2]);
|
|
410
|
+
if (!isProcessId(supervisorPid) || !isProcessId(childPid) || !isProcessId(processGroupId)) {
|
|
411
|
+
record.rejectStarted(
|
|
412
|
+
new SandboxProcessError("Sandbox process returned invalid process IDs.")
|
|
413
|
+
);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (start > 0) record.stdout.accept(record.markerBuffer.subarray(0, start));
|
|
417
|
+
if (end + 1 < record.markerBuffer.length) {
|
|
418
|
+
record.stdout.accept(record.markerBuffer.subarray(end + 1));
|
|
419
|
+
}
|
|
420
|
+
record.markerBuffer = Buffer.alloc(0);
|
|
421
|
+
record.supervisorPid = supervisorPid;
|
|
422
|
+
record.childPid = childPid;
|
|
423
|
+
record.processGroupId = processGroupId;
|
|
424
|
+
record.resolveStarted();
|
|
425
|
+
}
|
|
426
|
+
async waitForStart(record) {
|
|
427
|
+
let timeout;
|
|
428
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
429
|
+
timeout = setTimeout(() => {
|
|
430
|
+
reject(new SandboxTimeoutError("Starting sandbox process timed out."));
|
|
431
|
+
}, this.options.startupTimeoutMs);
|
|
432
|
+
timeout.unref?.();
|
|
433
|
+
});
|
|
434
|
+
try {
|
|
435
|
+
await Promise.race([record.started, timeoutPromise]);
|
|
436
|
+
} finally {
|
|
437
|
+
if (timeout !== void 0) clearTimeout(timeout);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async cleanupFailedStart(record) {
|
|
441
|
+
record.stopRequested = true;
|
|
442
|
+
if (record.processGroupId === void 0 && record.info.status === "running") {
|
|
443
|
+
await waitForPromise(record.closed, 250);
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
return await this.terminateRecord(record, 1e3);
|
|
447
|
+
} catch {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async terminateRecord(record, gracePeriodMs) {
|
|
452
|
+
record.stopRequested = true;
|
|
453
|
+
if (record.processGroupId === void 0) {
|
|
454
|
+
if (record.info.status === "running") {
|
|
455
|
+
await waitForPromise(record.closed, Math.min(gracePeriodMs, 250));
|
|
456
|
+
}
|
|
457
|
+
if (record.processGroupId === void 0) {
|
|
458
|
+
return record.spawnFailed && record.info.status !== "running";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (!await this.isProcessGroupRunning(record)) {
|
|
462
|
+
return this.finishAfterGroupExit(record);
|
|
463
|
+
}
|
|
464
|
+
await this.signal(record, "TERM");
|
|
465
|
+
if (await this.waitForRecordExit(record, gracePeriodMs)) return true;
|
|
466
|
+
await this.signal(record, "KILL");
|
|
467
|
+
if (await this.waitForRecordExit(record, 1e3)) return true;
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
async waitForRecordExit(record, timeoutMs) {
|
|
471
|
+
const deadline = Date.now() + timeoutMs;
|
|
472
|
+
while (true) {
|
|
473
|
+
if (!await this.isProcessGroupRunning(record)) {
|
|
474
|
+
return this.finishAfterGroupExit(record);
|
|
475
|
+
}
|
|
476
|
+
const remainingMs = deadline - Date.now();
|
|
477
|
+
if (remainingMs <= 0) return false;
|
|
478
|
+
const intervalMs = Math.min(50, remainingMs);
|
|
479
|
+
if (record.info.status === "running") {
|
|
480
|
+
await waitForPromise(record.closed, intervalMs);
|
|
481
|
+
} else {
|
|
482
|
+
await waitForDelay(intervalMs);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async finishAfterGroupExit(record) {
|
|
487
|
+
if (record.info.status !== "running") return true;
|
|
488
|
+
record.child.kill("SIGKILL");
|
|
489
|
+
return waitForPromise(record.closed, 1e3);
|
|
490
|
+
}
|
|
491
|
+
async isProcessGroupRunning(record) {
|
|
492
|
+
if (record.processGroupId === void 0) return false;
|
|
493
|
+
const result = await runDockerCli(
|
|
494
|
+
[
|
|
495
|
+
"exec",
|
|
496
|
+
this.options.containerName,
|
|
497
|
+
"sh",
|
|
498
|
+
"-c",
|
|
499
|
+
'kill -0 "-$1" 2>/dev/null',
|
|
500
|
+
"anvia-process-group-check",
|
|
501
|
+
String(record.processGroupId)
|
|
502
|
+
],
|
|
503
|
+
{
|
|
504
|
+
dockerPath: this.options.dockerPath,
|
|
505
|
+
timeoutMs: 5e3,
|
|
506
|
+
maxOutputBytes: this.options.maxOutputBytes
|
|
507
|
+
}
|
|
508
|
+
);
|
|
509
|
+
return result.exitCode === 0;
|
|
510
|
+
}
|
|
511
|
+
async signal(record, signal) {
|
|
512
|
+
if (record.processGroupId === void 0) return;
|
|
513
|
+
const result = await runDockerCli(
|
|
514
|
+
[
|
|
515
|
+
"exec",
|
|
516
|
+
this.options.containerName,
|
|
517
|
+
"sh",
|
|
518
|
+
"-c",
|
|
519
|
+
'kill "-$2" "-$1" 2>/dev/null || ! kill -0 "-$1" 2>/dev/null',
|
|
520
|
+
"anvia-process-signal",
|
|
521
|
+
String(record.processGroupId),
|
|
522
|
+
signal
|
|
523
|
+
],
|
|
524
|
+
{
|
|
525
|
+
dockerPath: this.options.dockerPath,
|
|
526
|
+
timeoutMs: 5e3,
|
|
527
|
+
maxOutputBytes: this.options.maxOutputBytes
|
|
528
|
+
}
|
|
529
|
+
);
|
|
530
|
+
if (result.exitCode !== 0) {
|
|
531
|
+
throw new SandboxDockerCommandError(
|
|
532
|
+
`Unable to stop sandbox process: ${record.info.id}`,
|
|
533
|
+
result
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
getRecord(processId) {
|
|
538
|
+
const record = this.records.get(processId);
|
|
539
|
+
if (record === void 0) {
|
|
540
|
+
throw new SandboxProcessError(`Unknown sandbox process: ${processId}`);
|
|
541
|
+
}
|
|
542
|
+
return record;
|
|
543
|
+
}
|
|
544
|
+
async pruneCompletedRecords() {
|
|
545
|
+
for (const [id, record] of this.records) {
|
|
546
|
+
if (this.records.size < this.options.maxProcesses) return;
|
|
547
|
+
const cleanupConfirmed = record.processGroupId === void 0 ? record.spawnFailed : !await this.isProcessGroupRunning(record);
|
|
548
|
+
if (record.info.status !== "running" && cleanupConfirmed) {
|
|
549
|
+
this.records.delete(id);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
logsUnsafe(record) {
|
|
554
|
+
const stdout = record.stdout.snapshot();
|
|
555
|
+
const stderr = record.stderr.snapshot();
|
|
556
|
+
return {
|
|
557
|
+
stdout: stdout.text,
|
|
558
|
+
stderr: stderr.text,
|
|
559
|
+
stdoutTruncated: stdout.truncated,
|
|
560
|
+
stderrTruncated: stderr.truncated
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
notifyExit(record) {
|
|
564
|
+
if (record.exitNotified) return;
|
|
565
|
+
record.exitNotified = true;
|
|
566
|
+
const durationMs = Date.now() - record.startedAtMs;
|
|
567
|
+
const notify = async () => this.options.onExit?.(copyProcessInfo(record.info), this.logsUnsafe(record), durationMs);
|
|
568
|
+
void notify().catch(() => void 0);
|
|
569
|
+
}
|
|
570
|
+
assertActive() {
|
|
571
|
+
if (this.disposed) {
|
|
572
|
+
throw new SandboxProcessError("Sandbox process manager has been disposed.");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
var TailOutputCollector = class {
|
|
577
|
+
constructor(maxBytes) {
|
|
578
|
+
this.maxBytes = maxBytes;
|
|
579
|
+
}
|
|
580
|
+
maxBytes;
|
|
581
|
+
chunks = [];
|
|
582
|
+
length = 0;
|
|
583
|
+
didTruncate = false;
|
|
584
|
+
accept(chunk) {
|
|
585
|
+
if (chunk.length === 0) return;
|
|
586
|
+
if (this.maxBytes <= 0) {
|
|
587
|
+
this.didTruncate = true;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (chunk.length >= this.maxBytes) {
|
|
591
|
+
this.chunks = [chunk.subarray(chunk.length - this.maxBytes)];
|
|
592
|
+
this.length = this.maxBytes;
|
|
593
|
+
this.didTruncate = true;
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
this.chunks.push(chunk);
|
|
597
|
+
this.length += chunk.length;
|
|
598
|
+
while (this.length > this.maxBytes) {
|
|
599
|
+
const first = this.chunks[0];
|
|
600
|
+
if (first === void 0) break;
|
|
601
|
+
const overflow = this.length - this.maxBytes;
|
|
602
|
+
if (first.length <= overflow) {
|
|
603
|
+
this.chunks.shift();
|
|
604
|
+
this.length -= first.length;
|
|
605
|
+
} else {
|
|
606
|
+
this.chunks[0] = first.subarray(overflow);
|
|
607
|
+
this.length -= overflow;
|
|
608
|
+
}
|
|
609
|
+
this.didTruncate = true;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
snapshot(tailBytes) {
|
|
613
|
+
if (tailBytes !== void 0 && (!Number.isInteger(tailBytes) || tailBytes < 0)) {
|
|
614
|
+
throw new SandboxProcessError("Process tailBytes must be a non-negative integer.");
|
|
615
|
+
}
|
|
616
|
+
const bytes = Buffer.concat(this.chunks, this.length);
|
|
617
|
+
const selected = tailBytes === 0 ? Buffer.alloc(0) : tailBytes === void 0 || bytes.length <= tailBytes ? bytes : bytes.subarray(bytes.length - tailBytes);
|
|
618
|
+
return {
|
|
619
|
+
text: selected.toString("utf8"),
|
|
620
|
+
truncated: this.didTruncate || selected.length < bytes.length
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
function assertStartOptions(options) {
|
|
625
|
+
if (options.command.trim().length === 0) {
|
|
626
|
+
throw new SandboxProcessError("Sandbox process command cannot be empty.");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function isProcessId(value) {
|
|
630
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
631
|
+
}
|
|
632
|
+
function copyProcessInfo(info) {
|
|
633
|
+
const copy = {
|
|
634
|
+
id: info.id,
|
|
635
|
+
command: info.command,
|
|
636
|
+
args: [...info.args],
|
|
637
|
+
status: info.status,
|
|
638
|
+
startedAt: info.startedAt
|
|
639
|
+
};
|
|
640
|
+
if (info.cwd !== void 0) copy.cwd = info.cwd;
|
|
641
|
+
if (info.exitCode !== void 0) copy.exitCode = info.exitCode;
|
|
642
|
+
if (info.endedAt !== void 0) copy.endedAt = info.endedAt;
|
|
643
|
+
return copy;
|
|
644
|
+
}
|
|
645
|
+
async function waitForPromise(promise, timeoutMs) {
|
|
646
|
+
let timeout;
|
|
647
|
+
try {
|
|
648
|
+
return await Promise.race([
|
|
649
|
+
promise.then(() => true),
|
|
650
|
+
new Promise((resolve) => {
|
|
651
|
+
timeout = setTimeout(() => resolve(false), timeoutMs);
|
|
652
|
+
timeout.unref?.();
|
|
653
|
+
})
|
|
654
|
+
]);
|
|
655
|
+
} finally {
|
|
656
|
+
if (timeout !== void 0) clearTimeout(timeout);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
async function waitForDelay(timeoutMs) {
|
|
660
|
+
await new Promise((resolve) => {
|
|
661
|
+
const timeout = setTimeout(resolve, timeoutMs);
|
|
662
|
+
timeout.unref?.();
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
175
666
|
// src/docker-sandbox.ts
|
|
176
667
|
var defaultImage = "node:22-bookworm";
|
|
177
668
|
var defaultWorkdir = "/workspace";
|
|
178
669
|
var defaultTimeoutMs = 3e4;
|
|
179
670
|
var defaultMaxOutputBytes2 = 1024 * 1024;
|
|
671
|
+
var defaultMaxProcesses = 4;
|
|
672
|
+
var portProbeScript = [
|
|
673
|
+
`port="$(printf '%04X' "$1")"`,
|
|
674
|
+
"for table in /proc/net/tcp /proc/net/tcp6; do",
|
|
675
|
+
' [ -r "$table" ] || continue',
|
|
676
|
+
" while read -r _ local _ state _; do",
|
|
677
|
+
' case "$local" in',
|
|
678
|
+
' "00000000:$port"|"00000000000000000000000000000000:$port")',
|
|
679
|
+
' [ "$state" = "0A" ] && exit 0',
|
|
680
|
+
" ;;",
|
|
681
|
+
" esac",
|
|
682
|
+
' done < "$table"',
|
|
683
|
+
"done",
|
|
684
|
+
"exit 1"
|
|
685
|
+
].join("\n");
|
|
180
686
|
var DockerSandbox = class _DockerSandbox {
|
|
181
687
|
provider = "docker";
|
|
182
688
|
image;
|
|
@@ -196,11 +702,14 @@ var DockerSandbox = class _DockerSandbox {
|
|
|
196
702
|
this.pull = options.pull ?? "missing";
|
|
197
703
|
this.workdir = options.workdir ?? defaultWorkdir;
|
|
198
704
|
this.workspace = options.workspace ?? { mode: "ephemeral" };
|
|
199
|
-
|
|
200
|
-
autoDestroy: options.lifecycle?.autoDestroy ?? true
|
|
201
|
-
...options.lifecycle?.ttlMs === void 0 ? {} : { ttlMs: options.lifecycle.ttlMs },
|
|
202
|
-
...options.lifecycle?.idleTimeoutMs === void 0 ? {} : { idleTimeoutMs: options.lifecycle.idleTimeoutMs }
|
|
705
|
+
const lifecycle = {
|
|
706
|
+
autoDestroy: options.lifecycle?.autoDestroy ?? true
|
|
203
707
|
};
|
|
708
|
+
if (options.lifecycle?.ttlMs !== void 0) lifecycle.ttlMs = options.lifecycle.ttlMs;
|
|
709
|
+
if (options.lifecycle?.idleTimeoutMs !== void 0) {
|
|
710
|
+
lifecycle.idleTimeoutMs = options.lifecycle.idleTimeoutMs;
|
|
711
|
+
}
|
|
712
|
+
this.lifecycle = lifecycle;
|
|
204
713
|
this.network = options.network ?? false;
|
|
205
714
|
this.dockerPath = options.dockerPath ?? "docker";
|
|
206
715
|
this.labels = options.labels ?? {};
|
|
@@ -223,8 +732,10 @@ var DockerSandbox = class _DockerSandbox {
|
|
|
223
732
|
return new _DockerSandbox({ ...options, image: options.image ?? "denoland/deno:debian" });
|
|
224
733
|
}
|
|
225
734
|
async createSession(options = {}) {
|
|
735
|
+
const ports = validatePublishedPorts(options.ports ?? []);
|
|
736
|
+
this.assertPortNetworkCompatible(ports);
|
|
226
737
|
await this.ensureImage();
|
|
227
|
-
const id = sanitizeResourceId(options.id ??
|
|
738
|
+
const id = sanitizeResourceId(options.id ?? randomUUID2());
|
|
228
739
|
const workspace = options.workspace ?? this.workspace;
|
|
229
740
|
const workspaceId = getWorkspaceId(workspace, id);
|
|
230
741
|
const containerName = `anvia-sandbox-${id}`;
|
|
@@ -233,13 +744,14 @@ var DockerSandbox = class _DockerSandbox {
|
|
|
233
744
|
await assertDockerCli(["volume", "create", volumeName], this.cliOptions());
|
|
234
745
|
try {
|
|
235
746
|
await assertDockerCli(
|
|
236
|
-
this.createRunArgs(containerName, volumeName, workspace, options.metadata),
|
|
747
|
+
this.createRunArgs(containerName, volumeName, workspace, options.metadata, ports),
|
|
237
748
|
{
|
|
238
749
|
...this.cliOptions(),
|
|
239
750
|
timeoutMs: this.limits.timeoutMs ?? defaultTimeoutMs
|
|
240
751
|
}
|
|
241
752
|
);
|
|
242
|
-
const
|
|
753
|
+
const publishedPorts = await this.inspectPublishedPorts(containerName, ports);
|
|
754
|
+
const session = new DockerSandboxSessionImpl({
|
|
243
755
|
id,
|
|
244
756
|
containerName,
|
|
245
757
|
volumeName,
|
|
@@ -249,7 +761,8 @@ var DockerSandbox = class _DockerSandbox {
|
|
|
249
761
|
lifecycle: this.lifecycle,
|
|
250
762
|
removeVolumeOnDestroy,
|
|
251
763
|
env: options.manifest?.env ?? {},
|
|
252
|
-
hooks: this.hooks
|
|
764
|
+
hooks: this.hooks,
|
|
765
|
+
publishedPorts
|
|
253
766
|
});
|
|
254
767
|
await session.applyManifest(options.manifest);
|
|
255
768
|
await this.hooks.onSessionCreate?.(session.event());
|
|
@@ -271,7 +784,7 @@ var DockerSandbox = class _DockerSandbox {
|
|
|
271
784
|
}
|
|
272
785
|
}
|
|
273
786
|
}
|
|
274
|
-
createRunArgs(containerName, volumeName, workspace, metadata) {
|
|
787
|
+
createRunArgs(containerName, volumeName, workspace, metadata, ports) {
|
|
275
788
|
const args = [
|
|
276
789
|
"run",
|
|
277
790
|
"-d",
|
|
@@ -297,6 +810,9 @@ var DockerSandbox = class _DockerSandbox {
|
|
|
297
810
|
}
|
|
298
811
|
}
|
|
299
812
|
this.appendNetworkArgs(args);
|
|
813
|
+
for (const port of ports) {
|
|
814
|
+
args.push("--publish", `127.0.0.1::${port}/tcp`);
|
|
815
|
+
}
|
|
300
816
|
this.appendLimitArgs(args);
|
|
301
817
|
this.appendSecurityArgs(args);
|
|
302
818
|
if (this.user !== void 0) {
|
|
@@ -320,6 +836,52 @@ var DockerSandbox = class _DockerSandbox {
|
|
|
320
836
|
args.push("--network", mode);
|
|
321
837
|
}
|
|
322
838
|
}
|
|
839
|
+
assertPortNetworkCompatible(ports) {
|
|
840
|
+
if (ports.length === 0) return;
|
|
841
|
+
const mode = typeof this.network === "object" ? this.network.mode : this.network;
|
|
842
|
+
if (mode === false || mode === "none" || mode === "host" || typeof mode === "string" && mode.startsWith("container:")) {
|
|
843
|
+
throw new SandboxPortError(
|
|
844
|
+
"Published sandbox ports require network: true or a bridge-capable Docker network."
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
async inspectPublishedPorts(containerName, ports) {
|
|
849
|
+
if (ports.length === 0) return [];
|
|
850
|
+
const raw = await assertDockerCli(
|
|
851
|
+
["container", "inspect", "--format", "{{json .NetworkSettings.Ports}}", containerName],
|
|
852
|
+
this.cliOptions()
|
|
853
|
+
);
|
|
854
|
+
let mappings;
|
|
855
|
+
try {
|
|
856
|
+
mappings = JSON.parse(raw);
|
|
857
|
+
} catch (error) {
|
|
858
|
+
throw new SandboxPortError("Docker returned invalid published port metadata.", error);
|
|
859
|
+
}
|
|
860
|
+
if (!isRecord(mappings)) {
|
|
861
|
+
throw new SandboxPortError("Docker returned invalid published port metadata.");
|
|
862
|
+
}
|
|
863
|
+
return ports.map((containerPort) => {
|
|
864
|
+
const entries = mappings[`${containerPort}/tcp`];
|
|
865
|
+
if (!Array.isArray(entries)) {
|
|
866
|
+
throw new SandboxPortError(`Docker did not publish sandbox port ${containerPort}/tcp.`);
|
|
867
|
+
}
|
|
868
|
+
const entry = entries.find(
|
|
869
|
+
(candidate) => isRecord(candidate) && candidate.HostIp === "127.0.0.1" && typeof candidate.HostPort === "string"
|
|
870
|
+
);
|
|
871
|
+
if (!isRecord(entry) || typeof entry.HostPort !== "string") {
|
|
872
|
+
throw new SandboxPortError(
|
|
873
|
+
`Docker did not bind sandbox port ${containerPort}/tcp to 127.0.0.1.`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
const hostPort = Number(entry.HostPort);
|
|
877
|
+
if (!isValidPort(hostPort)) {
|
|
878
|
+
throw new SandboxPortError(
|
|
879
|
+
`Docker returned an invalid host port for ${containerPort}/tcp.`
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
return { containerPort, host: "127.0.0.1", hostPort, protocol: "tcp" };
|
|
883
|
+
});
|
|
884
|
+
}
|
|
323
885
|
appendLimitArgs(args) {
|
|
324
886
|
if (this.limits.memoryMb !== void 0) {
|
|
325
887
|
args.push("--memory", `${this.limits.memoryMb}m`);
|
|
@@ -357,10 +919,11 @@ var DockerSandbox = class _DockerSandbox {
|
|
|
357
919
|
}
|
|
358
920
|
}
|
|
359
921
|
};
|
|
360
|
-
var
|
|
922
|
+
var DockerSandboxSessionImpl = class {
|
|
361
923
|
provider = "docker";
|
|
362
924
|
id;
|
|
363
925
|
workdir;
|
|
926
|
+
publishedPorts;
|
|
364
927
|
containerName;
|
|
365
928
|
volumeName;
|
|
366
929
|
dockerPath;
|
|
@@ -369,6 +932,7 @@ var DockerSandboxSession = class {
|
|
|
369
932
|
removeVolumeOnDestroy;
|
|
370
933
|
env;
|
|
371
934
|
hooks;
|
|
935
|
+
processManager;
|
|
372
936
|
ttlTimer;
|
|
373
937
|
idleTimer;
|
|
374
938
|
activeOperations = 0;
|
|
@@ -384,6 +948,44 @@ var DockerSandboxSession = class {
|
|
|
384
948
|
this.removeVolumeOnDestroy = options.removeVolumeOnDestroy;
|
|
385
949
|
this.env = options.env;
|
|
386
950
|
this.hooks = options.hooks;
|
|
951
|
+
this.publishedPorts = options.publishedPorts;
|
|
952
|
+
this.processManager = new DockerProcessManager({
|
|
953
|
+
containerName: this.containerName,
|
|
954
|
+
dockerPath: this.dockerPath,
|
|
955
|
+
workdir: this.workdir,
|
|
956
|
+
env: this.env,
|
|
957
|
+
maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes2,
|
|
958
|
+
maxProcesses: this.limits.maxProcesses ?? defaultMaxProcesses,
|
|
959
|
+
startupTimeoutMs: this.limits.timeoutMs ?? defaultTimeoutMs,
|
|
960
|
+
onStart: async (process) => {
|
|
961
|
+
const event = {
|
|
962
|
+
...this.event(),
|
|
963
|
+
command: process.command,
|
|
964
|
+
args: process.args
|
|
965
|
+
};
|
|
966
|
+
if (process.cwd !== void 0) event.cwd = process.cwd;
|
|
967
|
+
await this.hooks.onExecStart?.(event);
|
|
968
|
+
},
|
|
969
|
+
onExit: async (process, logs, durationMs) => {
|
|
970
|
+
const event = {
|
|
971
|
+
...this.event(),
|
|
972
|
+
command: process.command,
|
|
973
|
+
args: process.args,
|
|
974
|
+
result: {
|
|
975
|
+
stdout: logs.stdout,
|
|
976
|
+
stderr: logs.stderr,
|
|
977
|
+
exitCode: process.exitCode ?? 1,
|
|
978
|
+
durationMs,
|
|
979
|
+
timedOut: false,
|
|
980
|
+
aborted: process.status === "stopped",
|
|
981
|
+
stdoutTruncated: logs.stdoutTruncated,
|
|
982
|
+
stderrTruncated: logs.stderrTruncated
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
if (process.cwd !== void 0) event.cwd = process.cwd;
|
|
986
|
+
await this.hooks.onExecEnd?.(event);
|
|
987
|
+
}
|
|
988
|
+
});
|
|
387
989
|
this.startLifecycleTimers();
|
|
388
990
|
}
|
|
389
991
|
async applyManifest(manifest) {
|
|
@@ -398,20 +1000,22 @@ var DockerSandboxSession = class {
|
|
|
398
1000
|
}
|
|
399
1001
|
async exec(options) {
|
|
400
1002
|
return this.runOperation(async () => {
|
|
401
|
-
|
|
1003
|
+
const startEvent = {
|
|
402
1004
|
...this.event(),
|
|
403
1005
|
command: options.command,
|
|
404
|
-
args: options.args ?? []
|
|
405
|
-
|
|
406
|
-
|
|
1006
|
+
args: options.args ?? []
|
|
1007
|
+
};
|
|
1008
|
+
if (options.cwd !== void 0) startEvent.cwd = options.cwd;
|
|
1009
|
+
await this.hooks.onExecStart?.(startEvent);
|
|
407
1010
|
const normalizedResult = await this.execCommand(options);
|
|
408
|
-
|
|
1011
|
+
const endEvent = {
|
|
409
1012
|
...this.event(),
|
|
410
1013
|
command: options.command,
|
|
411
1014
|
args: options.args ?? [],
|
|
412
|
-
...options.cwd === void 0 ? {} : { cwd: options.cwd },
|
|
413
1015
|
result: normalizedResult
|
|
414
|
-
}
|
|
1016
|
+
};
|
|
1017
|
+
if (options.cwd !== void 0) endEvent.cwd = options.cwd;
|
|
1018
|
+
await this.hooks.onExecEnd?.(endEvent);
|
|
415
1019
|
return normalizedResult;
|
|
416
1020
|
});
|
|
417
1021
|
}
|
|
@@ -463,6 +1067,49 @@ var DockerSandboxSession = class {
|
|
|
463
1067
|
await run;
|
|
464
1068
|
}
|
|
465
1069
|
}
|
|
1070
|
+
async startProcess(options) {
|
|
1071
|
+
return this.runOperation(async () => this.processManager.start(options));
|
|
1072
|
+
}
|
|
1073
|
+
async listProcesses() {
|
|
1074
|
+
return this.runOperation(async () => this.processManager.list());
|
|
1075
|
+
}
|
|
1076
|
+
async readProcessLogs(processId, options) {
|
|
1077
|
+
return this.runOperation(async () => this.processManager.logs(processId, options));
|
|
1078
|
+
}
|
|
1079
|
+
async stopProcess(processId, options) {
|
|
1080
|
+
return this.runOperation(async () => this.processManager.stop(processId, options));
|
|
1081
|
+
}
|
|
1082
|
+
async waitForPort(containerPort, options = {}) {
|
|
1083
|
+
return this.runOperation(async () => {
|
|
1084
|
+
const publishedPort = this.publishedPorts.find(
|
|
1085
|
+
(candidate) => candidate.containerPort === containerPort
|
|
1086
|
+
);
|
|
1087
|
+
if (publishedPort === void 0) {
|
|
1088
|
+
throw new SandboxPortError(`Sandbox port is not published: ${containerPort}/tcp`);
|
|
1089
|
+
}
|
|
1090
|
+
const timeoutMs = options.timeoutMs ?? this.limits.timeoutMs ?? defaultTimeoutMs;
|
|
1091
|
+
const intervalMs = options.intervalMs ?? 250;
|
|
1092
|
+
assertWaitOptions(timeoutMs, intervalMs);
|
|
1093
|
+
const deadline = Date.now() + timeoutMs;
|
|
1094
|
+
while (true) {
|
|
1095
|
+
this.assertActive();
|
|
1096
|
+
if (options.signal?.aborted === true) throw abortReason(options.signal);
|
|
1097
|
+
const remainingMs = deadline - Date.now();
|
|
1098
|
+
if (remainingMs <= 0) {
|
|
1099
|
+
throw new SandboxTimeoutError(`Waiting for sandbox port ${containerPort}/tcp timed out.`);
|
|
1100
|
+
}
|
|
1101
|
+
const probeTimeoutMs = Math.min(1e3, remainingMs);
|
|
1102
|
+
if (await this.isPortListening(containerPort, probeTimeoutMs)) {
|
|
1103
|
+
return publishedPort;
|
|
1104
|
+
}
|
|
1105
|
+
const remainingAfterProbeMs = deadline - Date.now();
|
|
1106
|
+
if (remainingAfterProbeMs <= 0) {
|
|
1107
|
+
throw new SandboxTimeoutError(`Waiting for sandbox port ${containerPort}/tcp timed out.`);
|
|
1108
|
+
}
|
|
1109
|
+
await waitWithSignal(Math.min(intervalMs, remainingAfterProbeMs), options.signal);
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
466
1113
|
async readFile(filePath) {
|
|
467
1114
|
return this.runOperation(async () => {
|
|
468
1115
|
const normalized = normalizeSandboxPath(filePath);
|
|
@@ -532,6 +1179,7 @@ var DockerSandboxSession = class {
|
|
|
532
1179
|
}
|
|
533
1180
|
this.destroyed = true;
|
|
534
1181
|
this.clearLifecycleTimers();
|
|
1182
|
+
await this.processManager.dispose();
|
|
535
1183
|
await runDockerCli(["rm", "-f", this.containerName], this.cliOptions()).catch(() => void 0);
|
|
536
1184
|
if (this.removeVolumeOnDestroy) {
|
|
537
1185
|
await runDockerCli(["volume", "rm", "-f", this.volumeName], this.cliOptions()).catch(
|
|
@@ -573,6 +1221,24 @@ var DockerSandboxSession = class {
|
|
|
573
1221
|
maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes2
|
|
574
1222
|
};
|
|
575
1223
|
}
|
|
1224
|
+
async isPortListening(containerPort, timeoutMs) {
|
|
1225
|
+
const result = await runDockerCli(
|
|
1226
|
+
[
|
|
1227
|
+
"exec",
|
|
1228
|
+
this.containerName,
|
|
1229
|
+
"sh",
|
|
1230
|
+
"-c",
|
|
1231
|
+
portProbeScript,
|
|
1232
|
+
"anvia-port-probe",
|
|
1233
|
+
String(containerPort)
|
|
1234
|
+
],
|
|
1235
|
+
{
|
|
1236
|
+
...this.cliOptions(),
|
|
1237
|
+
timeoutMs: Math.max(1, timeoutMs)
|
|
1238
|
+
}
|
|
1239
|
+
);
|
|
1240
|
+
return result.exitCode === 0;
|
|
1241
|
+
}
|
|
576
1242
|
async execCommand(options) {
|
|
577
1243
|
if (options.command.trim().length === 0) {
|
|
578
1244
|
throw new SandboxDockerCommandError("Sandbox command cannot be empty.", {
|
|
@@ -594,12 +1260,12 @@ var DockerSandboxSession = class {
|
|
|
594
1260
|
const cliOptions = {
|
|
595
1261
|
dockerPath: this.dockerPath,
|
|
596
1262
|
timeoutMs: options.timeoutMs ?? this.limits.timeoutMs ?? defaultTimeoutMs,
|
|
597
|
-
maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes2
|
|
598
|
-
...options.input === void 0 ? {} : { input: options.input },
|
|
599
|
-
...options.signal === void 0 ? {} : { signal: options.signal },
|
|
600
|
-
...options.onStdout === void 0 ? {} : { onStdout: options.onStdout },
|
|
601
|
-
...options.onStderr === void 0 ? {} : { onStderr: options.onStderr }
|
|
1263
|
+
maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes2
|
|
602
1264
|
};
|
|
1265
|
+
if (options.input !== void 0) cliOptions.input = options.input;
|
|
1266
|
+
if (options.signal !== void 0) cliOptions.signal = options.signal;
|
|
1267
|
+
if (options.onStdout !== void 0) cliOptions.onStdout = options.onStdout;
|
|
1268
|
+
if (options.onStderr !== void 0) cliOptions.onStderr = options.onStderr;
|
|
603
1269
|
const result = await runDockerCli(args, cliOptions);
|
|
604
1270
|
if (result.timedOut) {
|
|
605
1271
|
return {
|
|
@@ -692,7 +1358,7 @@ function shouldDestroyWorkspace(workspace) {
|
|
|
692
1358
|
}
|
|
693
1359
|
function sanitizeResourceId(id) {
|
|
694
1360
|
const sanitized = id.toLowerCase().replaceAll(/[^a-z0-9_.-]/g, "-").replaceAll(/^-+|-+$/g, "");
|
|
695
|
-
return sanitized.length > 0 ? sanitized :
|
|
1361
|
+
return sanitized.length > 0 ? sanitized : randomUUID2();
|
|
696
1362
|
}
|
|
697
1363
|
function byteLength(data) {
|
|
698
1364
|
return typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
|
|
@@ -709,6 +1375,50 @@ function mapFindType(type) {
|
|
|
709
1375
|
}
|
|
710
1376
|
return "other";
|
|
711
1377
|
}
|
|
1378
|
+
function validatePublishedPorts(ports) {
|
|
1379
|
+
const unique = /* @__PURE__ */ new Set();
|
|
1380
|
+
for (const port of ports) {
|
|
1381
|
+
if (!isValidPort(port)) {
|
|
1382
|
+
throw new SandboxPortError(`Sandbox port must be an integer from 1 to 65535: ${port}`);
|
|
1383
|
+
}
|
|
1384
|
+
if (unique.has(port)) {
|
|
1385
|
+
throw new SandboxPortError(`Sandbox port is duplicated: ${port}`);
|
|
1386
|
+
}
|
|
1387
|
+
unique.add(port);
|
|
1388
|
+
}
|
|
1389
|
+
return [...unique];
|
|
1390
|
+
}
|
|
1391
|
+
function isValidPort(port) {
|
|
1392
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
1393
|
+
}
|
|
1394
|
+
function isRecord(value) {
|
|
1395
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1396
|
+
}
|
|
1397
|
+
function assertWaitOptions(timeoutMs, intervalMs) {
|
|
1398
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
|
1399
|
+
throw new SandboxPortError("Port wait timeoutMs must be a positive integer.");
|
|
1400
|
+
}
|
|
1401
|
+
if (!Number.isInteger(intervalMs) || intervalMs <= 0) {
|
|
1402
|
+
throw new SandboxPortError("Port wait intervalMs must be a positive integer.");
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
async function waitWithSignal(timeoutMs, signal) {
|
|
1406
|
+
if (signal?.aborted === true) throw abortReason(signal);
|
|
1407
|
+
await new Promise((resolve, reject) => {
|
|
1408
|
+
const timeout = setTimeout(() => {
|
|
1409
|
+
signal?.removeEventListener("abort", abort);
|
|
1410
|
+
resolve();
|
|
1411
|
+
}, timeoutMs);
|
|
1412
|
+
const abort = () => {
|
|
1413
|
+
clearTimeout(timeout);
|
|
1414
|
+
reject(abortReason(signal));
|
|
1415
|
+
};
|
|
1416
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
function abortReason(signal) {
|
|
1420
|
+
return signal?.reason ?? new SandboxPortError("Waiting for sandbox port was aborted.");
|
|
1421
|
+
}
|
|
712
1422
|
|
|
713
1423
|
// src/tools.ts
|
|
714
1424
|
import { createTool } from "@anvia/core/tool";
|
|
@@ -731,7 +1441,25 @@ var writeFileInput = z.object({
|
|
|
731
1441
|
var listFilesInput = z.object({
|
|
732
1442
|
path: z.string().optional().describe("Relative directory path inside the sandbox. Defaults to root.")
|
|
733
1443
|
});
|
|
1444
|
+
var emptyInput = z.object({});
|
|
1445
|
+
var startProcessInput = z.object({
|
|
1446
|
+
command: z.string().min(1).describe("Executable to run as a managed sandbox process."),
|
|
1447
|
+
args: z.array(z.string()).optional().describe("Command arguments."),
|
|
1448
|
+
cwd: z.string().optional().describe("Relative working directory inside the sandbox."),
|
|
1449
|
+
env: z.record(z.string(), z.string()).optional().describe("Environment variables for this process.")
|
|
1450
|
+
});
|
|
1451
|
+
var processIdInput = z.object({
|
|
1452
|
+
processId: z.string().min(1).describe("Managed process identifier.")
|
|
1453
|
+
});
|
|
1454
|
+
var readProcessLogsInput = processIdInput.extend({
|
|
1455
|
+
tailBytes: z.number().int().nonnegative().max(1024 * 1024).optional().describe("Maximum trailing bytes to return from each output stream.")
|
|
1456
|
+
});
|
|
1457
|
+
var waitForPortInput = z.object({
|
|
1458
|
+
containerPort: z.number().int().min(1).max(65535).describe("Pre-authorized TCP port inside the sandbox container."),
|
|
1459
|
+
timeoutMs: z.number().int().positive().max(3e5).optional().describe("Maximum time to wait for the port to accept connections.")
|
|
1460
|
+
});
|
|
734
1461
|
var textOutput = z.string();
|
|
1462
|
+
var maxToolLogBytes = 1024 * 1024;
|
|
735
1463
|
function createSandboxTools(session, options = {}) {
|
|
736
1464
|
const include = new Set(
|
|
737
1465
|
options.allow ?? options.include ?? ["exec_command", "read_file", "write_file", "list_files"]
|
|
@@ -749,6 +1477,29 @@ function createSandboxTools(session, options = {}) {
|
|
|
749
1477
|
if (include.has("list_files")) {
|
|
750
1478
|
tools.push(createListFilesTool(session));
|
|
751
1479
|
}
|
|
1480
|
+
const portToolsRequested = include.has("list_ports") || include.has("wait_for_port");
|
|
1481
|
+
const portSession = portToolsRequested ? requirePortSession(session) : void 0;
|
|
1482
|
+
if (include.has("list_ports") && portSession !== void 0) {
|
|
1483
|
+
tools.push(createListPortsTool(portSession));
|
|
1484
|
+
}
|
|
1485
|
+
const processToolsRequested = include.has("start_process") || include.has("list_processes") || include.has("read_process_logs") || include.has("stop_process");
|
|
1486
|
+
if (include.has("wait_for_port") || processToolsRequested) assertProcessToolPolicy(options);
|
|
1487
|
+
const processSession = processToolsRequested ? requireProcessSession(session) : void 0;
|
|
1488
|
+
if (include.has("start_process") && processSession !== void 0) {
|
|
1489
|
+
tools.push(createStartProcessTool(processSession, options));
|
|
1490
|
+
}
|
|
1491
|
+
if (include.has("list_processes") && processSession !== void 0) {
|
|
1492
|
+
tools.push(createListProcessesTool(processSession));
|
|
1493
|
+
}
|
|
1494
|
+
if (include.has("read_process_logs") && processSession !== void 0) {
|
|
1495
|
+
tools.push(createReadProcessLogsTool(processSession, options));
|
|
1496
|
+
}
|
|
1497
|
+
if (include.has("stop_process") && processSession !== void 0) {
|
|
1498
|
+
tools.push(createStopProcessTool(processSession, options));
|
|
1499
|
+
}
|
|
1500
|
+
if (include.has("wait_for_port") && portSession !== void 0) {
|
|
1501
|
+
tools.push(createWaitForPortTool(portSession, options));
|
|
1502
|
+
}
|
|
752
1503
|
return tools;
|
|
753
1504
|
}
|
|
754
1505
|
function createExecCommandTool(session, options) {
|
|
@@ -829,6 +1580,114 @@ function createListFilesTool(session) {
|
|
|
829
1580
|
}
|
|
830
1581
|
});
|
|
831
1582
|
}
|
|
1583
|
+
function createListPortsTool(session) {
|
|
1584
|
+
return createTool({
|
|
1585
|
+
name: "list_ports",
|
|
1586
|
+
description: "List pre-authorized sandbox preview ports. Servers must bind to 0.0.0.0 on a listed container port.",
|
|
1587
|
+
input: emptyInput,
|
|
1588
|
+
output: textOutput,
|
|
1589
|
+
execute: async () => {
|
|
1590
|
+
if (session.publishedPorts.length === 0) {
|
|
1591
|
+
return "No sandbox ports are published.";
|
|
1592
|
+
}
|
|
1593
|
+
return session.publishedPorts.map((port) => `${port.containerPort}/${port.protocol} ${port.host}:${port.hostPort}`).join("\n");
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
function createStartProcessTool(session, options) {
|
|
1598
|
+
return createTool({
|
|
1599
|
+
name: "start_process",
|
|
1600
|
+
description: "Start a managed long-running process inside the sandbox. Use list_ports first and bind web servers to 0.0.0.0.",
|
|
1601
|
+
input: startProcessInput,
|
|
1602
|
+
output: textOutput,
|
|
1603
|
+
execute: async ({ command, args, cwd, env }) => {
|
|
1604
|
+
assertCommandAllowed(command, options);
|
|
1605
|
+
const processOptions = { command };
|
|
1606
|
+
if (args !== void 0) processOptions.args = args;
|
|
1607
|
+
if (cwd !== void 0) processOptions.cwd = cwd;
|
|
1608
|
+
if (env !== void 0) processOptions.env = env;
|
|
1609
|
+
return formatProcessInfo(await session.startProcess(processOptions));
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
function createListProcessesTool(session) {
|
|
1614
|
+
return createTool({
|
|
1615
|
+
name: "list_processes",
|
|
1616
|
+
description: "List managed sandbox processes and their current status.",
|
|
1617
|
+
input: emptyInput,
|
|
1618
|
+
output: textOutput,
|
|
1619
|
+
execute: async () => {
|
|
1620
|
+
const processes = await session.listProcesses();
|
|
1621
|
+
if (processes.length === 0) return "No managed processes.";
|
|
1622
|
+
return processes.map(formatProcessInfo).join("\n\n");
|
|
1623
|
+
}
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
function createReadProcessLogsTool(session, options) {
|
|
1627
|
+
return createTool({
|
|
1628
|
+
name: "read_process_logs",
|
|
1629
|
+
description: "Read recent stdout and stderr from a managed sandbox process.",
|
|
1630
|
+
input: readProcessLogsInput,
|
|
1631
|
+
output: textOutput,
|
|
1632
|
+
execute: async ({ processId, tailBytes }) => {
|
|
1633
|
+
const configuredMaxLogBytes = options.process?.maxLogBytes ?? 64 * 1024;
|
|
1634
|
+
if (!Number.isInteger(configuredMaxLogBytes) || configuredMaxLogBytes < 0) {
|
|
1635
|
+
throw new SandboxToolPolicyError("Process maxLogBytes must be a non-negative integer.");
|
|
1636
|
+
}
|
|
1637
|
+
const maxLogBytes = Math.min(configuredMaxLogBytes, maxToolLogBytes);
|
|
1638
|
+
const effectiveTailBytes = tailBytes ?? maxLogBytes;
|
|
1639
|
+
if (effectiveTailBytes > maxLogBytes) {
|
|
1640
|
+
throw new SandboxToolPolicyError(
|
|
1641
|
+
`Process log request exceeds sandbox tool policy (${effectiveTailBytes} > ${maxLogBytes}).`
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
const logs = await session.readProcessLogs(processId, {
|
|
1645
|
+
tailBytes: effectiveTailBytes
|
|
1646
|
+
});
|
|
1647
|
+
const parts = [];
|
|
1648
|
+
if (logs.stdout.length > 0) parts.push(`stdout:
|
|
1649
|
+
${logs.stdout.trimEnd()}`);
|
|
1650
|
+
if (logs.stderr.length > 0) parts.push(`stderr:
|
|
1651
|
+
${logs.stderr.trimEnd()}`);
|
|
1652
|
+
if (logs.stdoutTruncated || logs.stderrTruncated) parts.push("output_truncated: true");
|
|
1653
|
+
return parts.length > 0 ? parts.join("\n\n") : "No process output.";
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
function createStopProcessTool(session, options) {
|
|
1658
|
+
return createTool({
|
|
1659
|
+
name: "stop_process",
|
|
1660
|
+
description: "Stop a managed sandbox process.",
|
|
1661
|
+
input: processIdInput,
|
|
1662
|
+
output: textOutput,
|
|
1663
|
+
execute: async ({ processId }) => formatProcessInfo(
|
|
1664
|
+
await session.stopProcess(processId, {
|
|
1665
|
+
gracePeriodMs: options.process?.stopGracePeriodMs ?? 5e3
|
|
1666
|
+
})
|
|
1667
|
+
)
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
function createWaitForPortTool(session, options) {
|
|
1671
|
+
return createTool({
|
|
1672
|
+
name: "wait_for_port",
|
|
1673
|
+
description: "Wait until a pre-authorized sandbox TCP port is accepting connections.",
|
|
1674
|
+
input: waitForPortInput,
|
|
1675
|
+
output: textOutput,
|
|
1676
|
+
execute: async ({ containerPort, timeoutMs }) => {
|
|
1677
|
+
const effectiveTimeoutMs = timeoutMs ?? options.process?.defaultWaitTimeoutMs ?? 3e4;
|
|
1678
|
+
const maxWaitTimeoutMs = options.process?.maxWaitTimeoutMs ?? 3e5;
|
|
1679
|
+
if (effectiveTimeoutMs > maxWaitTimeoutMs) {
|
|
1680
|
+
throw new SandboxToolPolicyError(
|
|
1681
|
+
`Port wait timeout exceeds sandbox tool policy (${effectiveTimeoutMs} > ${maxWaitTimeoutMs}).`
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
const port = await session.waitForPort(containerPort, {
|
|
1685
|
+
timeoutMs: effectiveTimeoutMs
|
|
1686
|
+
});
|
|
1687
|
+
return `ready: ${port.containerPort}/${port.protocol} -> ${port.host}:${port.hostPort}`;
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
832
1691
|
function formatExecResult(result) {
|
|
833
1692
|
const parts = [`exit_code: ${result.exitCode}`];
|
|
834
1693
|
if (result.timedOut) {
|
|
@@ -850,6 +1709,50 @@ ${result.stderr.trimEnd()}`);
|
|
|
850
1709
|
}
|
|
851
1710
|
return parts.join("\n\n");
|
|
852
1711
|
}
|
|
1712
|
+
function formatProcessInfo(process) {
|
|
1713
|
+
const parts = [
|
|
1714
|
+
`process_id: ${process.id}`,
|
|
1715
|
+
`status: ${process.status}`,
|
|
1716
|
+
`command: ${[process.command, ...process.args].join(" ")}`
|
|
1717
|
+
];
|
|
1718
|
+
if (process.exitCode !== void 0) parts.push(`exit_code: ${process.exitCode}`);
|
|
1719
|
+
return parts.join("\n");
|
|
1720
|
+
}
|
|
1721
|
+
function requirePortSession(session) {
|
|
1722
|
+
if (!isSandboxPortSession(session)) {
|
|
1723
|
+
throw new SandboxToolPolicyError("The sandbox session does not support published port tools.");
|
|
1724
|
+
}
|
|
1725
|
+
return session;
|
|
1726
|
+
}
|
|
1727
|
+
function requireProcessSession(session) {
|
|
1728
|
+
if (!isSandboxProcessSession(session)) {
|
|
1729
|
+
throw new SandboxToolPolicyError("The sandbox session does not support managed process tools.");
|
|
1730
|
+
}
|
|
1731
|
+
return session;
|
|
1732
|
+
}
|
|
1733
|
+
function assertProcessToolPolicy(options) {
|
|
1734
|
+
const policy = options.process;
|
|
1735
|
+
if (policy === void 0) return;
|
|
1736
|
+
if (policy.maxLogBytes !== void 0 && (!Number.isInteger(policy.maxLogBytes) || policy.maxLogBytes < 0 || policy.maxLogBytes > maxToolLogBytes)) {
|
|
1737
|
+
throw new SandboxToolPolicyError(
|
|
1738
|
+
`Process maxLogBytes must be an integer from 0 to ${maxToolLogBytes}.`
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
const maxWaitTimeoutMs = policy.maxWaitTimeoutMs ?? 3e5;
|
|
1742
|
+
if (!Number.isInteger(maxWaitTimeoutMs) || maxWaitTimeoutMs <= 0 || maxWaitTimeoutMs > 3e5) {
|
|
1743
|
+
throw new SandboxToolPolicyError(
|
|
1744
|
+
"Process maxWaitTimeoutMs must be an integer from 1 to 300000."
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
if (policy.defaultWaitTimeoutMs !== void 0 && (!Number.isInteger(policy.defaultWaitTimeoutMs) || policy.defaultWaitTimeoutMs <= 0 || policy.defaultWaitTimeoutMs > maxWaitTimeoutMs)) {
|
|
1748
|
+
throw new SandboxToolPolicyError(
|
|
1749
|
+
"Process defaultWaitTimeoutMs must be positive and no greater than maxWaitTimeoutMs."
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
if (policy.stopGracePeriodMs !== void 0 && (!Number.isInteger(policy.stopGracePeriodMs) || policy.stopGracePeriodMs < 0)) {
|
|
1753
|
+
throw new SandboxToolPolicyError("Process stopGracePeriodMs must be a non-negative integer.");
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
853
1756
|
function assertCommandAllowed(command, options) {
|
|
854
1757
|
const policy = options.exec;
|
|
855
1758
|
if (policy?.blockedCommands?.includes(command)) {
|
|
@@ -886,9 +1789,13 @@ export {
|
|
|
886
1789
|
SandboxError,
|
|
887
1790
|
SandboxFileSizeError,
|
|
888
1791
|
SandboxPathError,
|
|
1792
|
+
SandboxPortError,
|
|
1793
|
+
SandboxProcessError,
|
|
889
1794
|
SandboxSessionDestroyedError,
|
|
890
1795
|
SandboxTimeoutError,
|
|
891
1796
|
SandboxToolPolicyError,
|
|
892
|
-
createSandboxTools
|
|
1797
|
+
createSandboxTools,
|
|
1798
|
+
isSandboxPortSession,
|
|
1799
|
+
isSandboxProcessSession
|
|
893
1800
|
};
|
|
894
1801
|
//# sourceMappingURL=index.js.map
|