@agentforge-ai/sandbox 0.6.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.js ADDED
@@ -0,0 +1,701 @@
1
+ // src/docker-sandbox.ts
2
+ import Dockerode from "dockerode";
3
+ import { randomUUID } from "crypto";
4
+
5
+ // src/security.ts
6
+ var BLOCKED_BIND_PREFIXES = [
7
+ "/var/run/docker.sock",
8
+ "/etc",
9
+ "/proc",
10
+ "/sys",
11
+ "/dev",
12
+ "/boot",
13
+ "/root"
14
+ ];
15
+ var DEFAULT_CAP_DROP = ["ALL"];
16
+ var BASE_ALLOWED_IMAGE_PREFIXES = [
17
+ "node:",
18
+ "python:",
19
+ "ubuntu:",
20
+ "debian:",
21
+ "alpine:",
22
+ "agentforge/"
23
+ ];
24
+ function validateBind(bind) {
25
+ const hostPath = bind.split(":")[0];
26
+ if (!hostPath) {
27
+ throw new SecurityError(`Invalid bind mount spec: "${bind}"`);
28
+ }
29
+ for (const blocked of BLOCKED_BIND_PREFIXES) {
30
+ if (hostPath === blocked || hostPath.startsWith(blocked + "/") || hostPath.startsWith(blocked)) {
31
+ throw new SecurityError(
32
+ `Bind mount "${bind}" is blocked. Host path "${hostPath}" matches blocked prefix "${blocked}".`
33
+ );
34
+ }
35
+ }
36
+ }
37
+ function validateBinds(binds) {
38
+ for (const bind of binds) {
39
+ validateBind(bind);
40
+ }
41
+ }
42
+ function validateImageName(image) {
43
+ if (!image || typeof image !== "string") {
44
+ throw new SecurityError("Image name must be a non-empty string.");
45
+ }
46
+ if (/[;&|`$(){}[\]<>]/.test(image)) {
47
+ throw new SecurityError(`Image name "${image}" contains forbidden characters.`);
48
+ }
49
+ if (process.env["NODE_ENV"] !== "production") {
50
+ return;
51
+ }
52
+ const extraPrefixes = (process.env["AGENTFORGE_ALLOWED_IMAGES"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
53
+ const allowedPrefixes = [...BASE_ALLOWED_IMAGE_PREFIXES, ...extraPrefixes];
54
+ const allowed = allowedPrefixes.some((prefix) => image.startsWith(prefix));
55
+ if (!allowed) {
56
+ throw new SecurityError(
57
+ `Image "${image}" is not on the allow-list. Allowed prefixes: ${allowedPrefixes.join(", ")}. Add custom prefixes via AGENTFORGE_ALLOWED_IMAGES env var.`
58
+ );
59
+ }
60
+ }
61
+ function validateCommand(command) {
62
+ if (!command || typeof command !== "string") {
63
+ throw new SecurityError("Command must be a non-empty string.");
64
+ }
65
+ const dangerousPatterns = [
66
+ /docker\.sock/i,
67
+ /nsenter\s/i,
68
+ /mount\s+-t\s+proc/i
69
+ ];
70
+ for (const pattern of dangerousPatterns) {
71
+ if (pattern.test(command)) {
72
+ throw new SecurityError(
73
+ `Command contains a potentially dangerous pattern: ${pattern.source}`
74
+ );
75
+ }
76
+ }
77
+ }
78
+ var SecurityError = class extends Error {
79
+ constructor(message) {
80
+ super(message);
81
+ this.name = "SecurityError";
82
+ }
83
+ };
84
+
85
+ // src/docker-sandbox.ts
86
+ var DEFAULT_IMAGE = "node:22-slim";
87
+ var DEFAULT_EXEC_TIMEOUT_MS = 3e4;
88
+ var DEFAULT_CONTAINER_WORKSPACE = "/workspace";
89
+ function demuxDockerStream(buffer) {
90
+ let stdout = "";
91
+ let stderr = "";
92
+ let offset = 0;
93
+ while (offset + 8 <= buffer.length) {
94
+ const streamType = buffer[offset];
95
+ const frameSize = buffer.readUInt32BE(offset + 4);
96
+ offset += 8;
97
+ if (offset + frameSize > buffer.length) break;
98
+ const chunk = buffer.slice(offset, offset + frameSize).toString("utf8");
99
+ offset += frameSize;
100
+ if (streamType === 1) {
101
+ stdout += chunk;
102
+ } else if (streamType === 2) {
103
+ stderr += chunk;
104
+ }
105
+ }
106
+ return { stdout, stderr };
107
+ }
108
+ var DockerSandbox = class {
109
+ config;
110
+ docker;
111
+ container = null;
112
+ containerId = null;
113
+ killTimer = null;
114
+ /**
115
+ * @param config - Sandbox configuration.
116
+ * @param docker - Optional pre-configured Dockerode instance (useful in tests).
117
+ */
118
+ constructor(config, docker) {
119
+ const image = config.image ?? DEFAULT_IMAGE;
120
+ validateImageName(image);
121
+ if (config.binds && config.binds.length > 0) {
122
+ validateBinds(config.binds);
123
+ }
124
+ this.config = {
125
+ ...config,
126
+ image,
127
+ containerWorkspacePath: config.containerWorkspacePath ?? DEFAULT_CONTAINER_WORKSPACE
128
+ };
129
+ this.docker = docker ?? new Dockerode(
130
+ process.env["DOCKER_HOST"] ? { host: process.env["DOCKER_HOST"] } : { socketPath: "/var/run/docker.sock" }
131
+ );
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Lifecycle
135
+ // ---------------------------------------------------------------------------
136
+ /**
137
+ * Create and start the Docker container.
138
+ * Idempotent — calling start() on an already-running sandbox is a no-op.
139
+ */
140
+ async start() {
141
+ if (this.container) return;
142
+ const { image, scope, resourceLimits, binds, env, timeout, workspaceAccess, workspacePath, containerWorkspacePath } = this.config;
143
+ const name = `agentforge-${scope}-${randomUUID().slice(0, 8)}`;
144
+ const envArray = Object.entries(env ?? {}).map(([k, v]) => `${k}=${v}`);
145
+ const allBinds = [...binds ?? []];
146
+ if (workspaceAccess !== "none" && workspacePath) {
147
+ const mode = workspaceAccess === "ro" ? "ro" : "rw";
148
+ allBinds.push(`${workspacePath}:${containerWorkspacePath}:${mode}`);
149
+ }
150
+ const hostConfig = {
151
+ // Resource limits
152
+ CpuShares: resourceLimits?.cpuShares,
153
+ Memory: resourceLimits?.memoryMb ? resourceLimits.memoryMb * 1024 * 1024 : void 0,
154
+ PidsLimit: resourceLimits?.pidsLimit ?? 256,
155
+ // Security hardening
156
+ CapDrop: [...DEFAULT_CAP_DROP],
157
+ SecurityOpt: ["no-new-privileges:true"],
158
+ ReadonlyRootfs: false,
159
+ // Bind mounts
160
+ Binds: allBinds.length > 0 ? allBinds : void 0
161
+ };
162
+ this.container = await this.docker.createContainer({
163
+ name,
164
+ Image: image,
165
+ // Keep container alive — we run commands via exec
166
+ Cmd: ["/bin/sh", "-c", "while true; do sleep 3600; done"],
167
+ Env: envArray,
168
+ AttachStdin: false,
169
+ AttachStdout: false,
170
+ AttachStderr: false,
171
+ Tty: false,
172
+ NetworkDisabled: resourceLimits?.networkDisabled ?? false,
173
+ WorkingDir: containerWorkspacePath,
174
+ Labels: {
175
+ "agentforge.scope": scope,
176
+ "agentforge.managed": "true"
177
+ },
178
+ HostConfig: hostConfig
179
+ });
180
+ await this.container.start();
181
+ this.containerId = this.container.id;
182
+ if (timeout && timeout > 0) {
183
+ this.killTimer = setTimeout(() => {
184
+ void this.destroy();
185
+ }, timeout * 1e3);
186
+ }
187
+ }
188
+ /**
189
+ * Stop the container gracefully (10 s grace period then SIGKILL).
190
+ * The container is kept for potential restart.
191
+ */
192
+ async stop() {
193
+ this._clearKillTimer();
194
+ if (!this.container) return;
195
+ try {
196
+ const info = await this.container.inspect();
197
+ if (info.State.Running) {
198
+ await this.container.stop({ t: 10 });
199
+ }
200
+ } catch {
201
+ }
202
+ }
203
+ /**
204
+ * Destroy the container and release all resources.
205
+ * Safe to call multiple times.
206
+ */
207
+ async destroy() {
208
+ this._clearKillTimer();
209
+ const container = this.container;
210
+ this.container = null;
211
+ this.containerId = null;
212
+ if (!container) return;
213
+ try {
214
+ await container.remove({ force: true });
215
+ } catch {
216
+ }
217
+ }
218
+ // ---------------------------------------------------------------------------
219
+ // Execution
220
+ // ---------------------------------------------------------------------------
221
+ /**
222
+ * Execute a shell command inside the running container.
223
+ *
224
+ * @param command - Shell command string passed to `/bin/sh -c`.
225
+ * @param options - Per-call options (timeout, cwd, env overrides).
226
+ */
227
+ async exec(command, options = {}) {
228
+ if (!this.container) {
229
+ throw new Error("DockerSandbox: container is not running. Call start() first.");
230
+ }
231
+ validateCommand(command);
232
+ const timeoutMs = options.timeout ?? DEFAULT_EXEC_TIMEOUT_MS;
233
+ const envOverride = Object.entries(options.env ?? {}).map(([k, v]) => `${k}=${v}`);
234
+ const execInstance = await this.container.exec({
235
+ Cmd: ["/bin/sh", "-c", command],
236
+ AttachStdout: true,
237
+ AttachStderr: true,
238
+ Tty: false,
239
+ WorkingDir: options.cwd,
240
+ Env: envOverride.length > 0 ? envOverride : void 0
241
+ });
242
+ return new Promise((resolve, reject) => {
243
+ const timer = setTimeout(() => {
244
+ reject(new Error(`DockerSandbox: exec timed out after ${timeoutMs}ms`));
245
+ }, timeoutMs);
246
+ execInstance.start({ hijack: true, stdin: false }, (err, stream) => {
247
+ if (err) {
248
+ clearTimeout(timer);
249
+ reject(err);
250
+ return;
251
+ }
252
+ if (!stream) {
253
+ clearTimeout(timer);
254
+ reject(new Error("DockerSandbox: no stream returned from exec"));
255
+ return;
256
+ }
257
+ const chunks = [];
258
+ stream.on("data", (chunk) => chunks.push(chunk));
259
+ stream.on("end", async () => {
260
+ clearTimeout(timer);
261
+ try {
262
+ const raw = Buffer.concat(chunks);
263
+ const { stdout, stderr } = demuxDockerStream(raw);
264
+ const inspectResult = await execInstance.inspect();
265
+ const exitCode = inspectResult.ExitCode ?? 0;
266
+ resolve({ stdout, stderr, exitCode });
267
+ } catch (inspectErr) {
268
+ reject(inspectErr);
269
+ }
270
+ });
271
+ stream.on("error", (streamErr) => {
272
+ clearTimeout(timer);
273
+ reject(streamErr);
274
+ });
275
+ });
276
+ });
277
+ }
278
+ /**
279
+ * Read a file from the container filesystem by running `cat`.
280
+ *
281
+ * @param path - Absolute path inside the container.
282
+ */
283
+ async readFile(path) {
284
+ const result = await this.exec(`cat "${path.replace(/"/g, '\\"')}"`);
285
+ if (result.exitCode !== 0) {
286
+ throw new Error(
287
+ `DockerSandbox.readFile: failed to read "${path}" (exit ${result.exitCode}): ${result.stderr}`
288
+ );
289
+ }
290
+ return result.stdout;
291
+ }
292
+ /**
293
+ * Write content to a file inside the container using base64 encoding
294
+ * to avoid shell quoting issues.
295
+ *
296
+ * @param path - Absolute path inside the container.
297
+ * @param content - UTF-8 string content.
298
+ */
299
+ async writeFile(path, content) {
300
+ const b64 = Buffer.from(content, "utf8").toString("base64");
301
+ const cmd = `printf '%s' "${b64}" | base64 -d > "${path.replace(/"/g, '\\"')}"`;
302
+ const result = await this.exec(cmd);
303
+ if (result.exitCode !== 0) {
304
+ throw new Error(
305
+ `DockerSandbox.writeFile: failed to write "${path}" (exit ${result.exitCode}): ${result.stderr}`
306
+ );
307
+ }
308
+ }
309
+ // ---------------------------------------------------------------------------
310
+ // Health
311
+ // ---------------------------------------------------------------------------
312
+ /**
313
+ * Returns true if the underlying Docker container is running.
314
+ */
315
+ async isRunning() {
316
+ if (!this.container) return false;
317
+ try {
318
+ const info = await this.container.inspect();
319
+ return info.State.Running === true;
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
324
+ /**
325
+ * Returns the Docker container ID or null if not yet started.
326
+ */
327
+ getContainerId() {
328
+ return this.containerId;
329
+ }
330
+ // ---------------------------------------------------------------------------
331
+ // Private helpers
332
+ // ---------------------------------------------------------------------------
333
+ _clearKillTimer() {
334
+ if (this.killTimer !== null) {
335
+ clearTimeout(this.killTimer);
336
+ this.killTimer = null;
337
+ }
338
+ }
339
+ };
340
+
341
+ // src/container-pool.ts
342
+ import { randomUUID as randomUUID2 } from "crypto";
343
+ var DEFAULT_MAX_SIZE = 3;
344
+ var DEFAULT_IDLE_TIMEOUT_SECONDS = 300;
345
+ var SWEEP_INTERVAL_MS = 3e4;
346
+ var ContainerPool = class {
347
+ config;
348
+ docker;
349
+ entries = /* @__PURE__ */ new Map();
350
+ sweepTimer = null;
351
+ draining = false;
352
+ constructor(config, docker) {
353
+ this.config = {
354
+ maxSize: DEFAULT_MAX_SIZE,
355
+ idleTimeoutSeconds: DEFAULT_IDLE_TIMEOUT_SECONDS,
356
+ ...config
357
+ };
358
+ this.docker = docker;
359
+ }
360
+ // ---------------------------------------------------------------------------
361
+ // Public API
362
+ // ---------------------------------------------------------------------------
363
+ /**
364
+ * Pre-warm the pool up to `maxSize` containers.
365
+ * Resolves once all warm containers are started.
366
+ */
367
+ async warmUp() {
368
+ const needed = this.config.maxSize - this.entries.size;
369
+ if (needed <= 0) return;
370
+ await Promise.all(
371
+ Array.from({ length: needed }).map(async () => {
372
+ await this._addEntry();
373
+ })
374
+ );
375
+ this._startSweep();
376
+ }
377
+ /**
378
+ * Acquire an idle sandbox from the pool.
379
+ *
380
+ * If an idle entry is available, it is marked in-use and returned immediately.
381
+ * If the pool is not yet at capacity, a new container is started and returned.
382
+ * If the pool is at capacity and all containers are in use, the LRU idle
383
+ * container is evicted and a fresh one is started.
384
+ *
385
+ * @throws If the pool is draining.
386
+ */
387
+ async acquire() {
388
+ if (this.draining) {
389
+ throw new Error("ContainerPool: pool is draining \u2014 cannot acquire new sandboxes.");
390
+ }
391
+ const idle = this._lruIdle();
392
+ if (idle) {
393
+ idle.inUse = true;
394
+ idle.lastUsedAt = Date.now();
395
+ return idle.sandbox;
396
+ }
397
+ if (this.entries.size < this.config.maxSize) {
398
+ const entry2 = await this._addEntry();
399
+ entry2.inUse = true;
400
+ entry2.lastUsedAt = Date.now();
401
+ return entry2.sandbox;
402
+ }
403
+ const lruKey = this._lruAnyKey();
404
+ if (lruKey) {
405
+ const evicted = this.entries.get(lruKey);
406
+ this.entries.delete(lruKey);
407
+ void evicted.sandbox.destroy().catch(() => {
408
+ });
409
+ }
410
+ const entry = await this._addEntry();
411
+ entry.inUse = true;
412
+ entry.lastUsedAt = Date.now();
413
+ return entry.sandbox;
414
+ }
415
+ /**
416
+ * Release a previously acquired sandbox back to the pool.
417
+ * The sandbox is reset to an idle state for future reuse.
418
+ */
419
+ async release(sandbox) {
420
+ for (const entry of this.entries.values()) {
421
+ if (entry.sandbox === sandbox) {
422
+ entry.inUse = false;
423
+ entry.lastUsedAt = Date.now();
424
+ return;
425
+ }
426
+ }
427
+ await sandbox.destroy();
428
+ }
429
+ /**
430
+ * Drain the pool: stop acquiring and destroy all containers.
431
+ * After draining, the pool stays in a drained state and rejects new acquires.
432
+ */
433
+ async drain() {
434
+ if (this.draining && this.entries.size === 0) return;
435
+ this.draining = true;
436
+ this._stopSweep();
437
+ await Promise.all(
438
+ Array.from(this.entries.values()).map(
439
+ (entry) => entry.sandbox.destroy().catch(() => {
440
+ })
441
+ )
442
+ );
443
+ this.entries.clear();
444
+ }
445
+ /**
446
+ * Returns the current number of entries (in-use + idle).
447
+ */
448
+ get size() {
449
+ return this.entries.size;
450
+ }
451
+ /**
452
+ * Returns the number of idle (available) entries.
453
+ */
454
+ get idleCount() {
455
+ let count = 0;
456
+ for (const entry of this.entries.values()) {
457
+ if (!entry.inUse) count++;
458
+ }
459
+ return count;
460
+ }
461
+ // ---------------------------------------------------------------------------
462
+ // Private helpers
463
+ // ---------------------------------------------------------------------------
464
+ async _addEntry() {
465
+ const sandboxConfig = {
466
+ image: this.config.image,
467
+ scope: this.config.scope,
468
+ workspaceAccess: "none"
469
+ };
470
+ const sandbox = new DockerSandbox(sandboxConfig, this.docker);
471
+ await sandbox.start();
472
+ const id = randomUUID2();
473
+ const entry = {
474
+ sandbox,
475
+ createdAt: Date.now(),
476
+ lastUsedAt: Date.now(),
477
+ inUse: false
478
+ };
479
+ this.entries.set(id, entry);
480
+ return entry;
481
+ }
482
+ /** Return the idle entry with the smallest lastUsedAt (LRU idle). */
483
+ _lruIdle() {
484
+ let best = null;
485
+ for (const entry of this.entries.values()) {
486
+ if (!entry.inUse) {
487
+ if (!best || entry.lastUsedAt < best.lastUsedAt) {
488
+ best = entry;
489
+ }
490
+ }
491
+ }
492
+ return best;
493
+ }
494
+ /** Return the key of the entry with the smallest lastUsedAt (LRU any). */
495
+ _lruAnyKey() {
496
+ let bestKey = null;
497
+ let bestTime = Infinity;
498
+ for (const [key, entry] of this.entries) {
499
+ if (entry.lastUsedAt < bestTime) {
500
+ bestTime = entry.lastUsedAt;
501
+ bestKey = key;
502
+ }
503
+ }
504
+ return bestKey;
505
+ }
506
+ /** Start periodic idle-eviction sweep. */
507
+ _startSweep() {
508
+ if (this.sweepTimer) return;
509
+ this.sweepTimer = setInterval(() => void this._sweep(), SWEEP_INTERVAL_MS);
510
+ if (this.sweepTimer.unref) this.sweepTimer.unref();
511
+ }
512
+ _stopSweep() {
513
+ if (this.sweepTimer) {
514
+ clearInterval(this.sweepTimer);
515
+ this.sweepTimer = null;
516
+ }
517
+ }
518
+ /** Remove and destroy containers that have been idle past the timeout. */
519
+ async _sweep() {
520
+ if (this.draining) return;
521
+ const nowMs = Date.now();
522
+ const idleTimeoutMs = this.config.idleTimeoutSeconds * 1e3;
523
+ const evictions = [];
524
+ for (const [key, entry] of this.entries) {
525
+ if (!entry.inUse && nowMs - entry.lastUsedAt > idleTimeoutMs) {
526
+ evictions.push([key, entry]);
527
+ }
528
+ }
529
+ for (const [key, entry] of evictions) {
530
+ this.entries.delete(key);
531
+ await entry.sandbox.destroy().catch(() => {
532
+ });
533
+ }
534
+ }
535
+ };
536
+
537
+ // src/sandbox-manager.ts
538
+ import Dockerode2 from "dockerode";
539
+ import { randomUUID as randomUUID3 } from "crypto";
540
+ var E2BProviderStub = class {
541
+ async start() {
542
+ throw new Error(
543
+ "SandboxManager: E2B provider is not bundled in @agentforge-ai/sandbox. Use the SandboxManager from @agentforge-ai/core instead."
544
+ );
545
+ }
546
+ async stop() {
547
+ }
548
+ async destroy() {
549
+ }
550
+ async exec(_cmd, _opts) {
551
+ throw new Error("E2BProviderStub: not implemented");
552
+ }
553
+ async readFile(_path) {
554
+ throw new Error("E2BProviderStub: not implemented");
555
+ }
556
+ async writeFile(_path, _content) {
557
+ throw new Error("E2BProviderStub: not implemented");
558
+ }
559
+ async isRunning() {
560
+ return false;
561
+ }
562
+ getContainerId() {
563
+ return null;
564
+ }
565
+ };
566
+ async function isDockerAvailable(docker) {
567
+ try {
568
+ await docker.ping();
569
+ return true;
570
+ } catch {
571
+ return false;
572
+ }
573
+ }
574
+ var SandboxManager = class {
575
+ config;
576
+ docker;
577
+ active = /* @__PURE__ */ new Map();
578
+ shutdownRegistered = false;
579
+ constructor(config = {}) {
580
+ this.config = { provider: "docker", ...config };
581
+ const dockerHostCfg = config.dockerHost;
582
+ if (dockerHostCfg?.host) {
583
+ this.docker = new Dockerode2({
584
+ host: dockerHostCfg.host,
585
+ port: dockerHostCfg.port ?? 2376,
586
+ protocol: dockerHostCfg.protocol ?? "http"
587
+ });
588
+ } else {
589
+ this.docker = new Dockerode2({
590
+ socketPath: dockerHostCfg?.socketPath ?? "/var/run/docker.sock"
591
+ });
592
+ }
593
+ }
594
+ // ---------------------------------------------------------------------------
595
+ // Public API
596
+ // ---------------------------------------------------------------------------
597
+ /**
598
+ * Initialize the manager. For the Docker provider this verifies that the
599
+ * Docker daemon is reachable. Call this once at application startup.
600
+ *
601
+ * @throws Error if the Docker daemon cannot be reached (Docker provider only).
602
+ */
603
+ async initialize() {
604
+ if (this.config.provider === "docker") {
605
+ const available = await isDockerAvailable(this.docker);
606
+ if (!available) {
607
+ console.warn(
608
+ "[SandboxManager] Docker daemon is not reachable. Agent tool execution will fail until Docker is started. Install Docker: https://docs.docker.com/get-docker/"
609
+ );
610
+ }
611
+ }
612
+ this._registerShutdownHandlers();
613
+ }
614
+ /**
615
+ * Create and start a new sandbox.
616
+ *
617
+ * The sandbox is registered internally; call {@link SandboxManager.destroy}
618
+ * or {@link SandboxManager.shutdown} to release it.
619
+ *
620
+ * @param overrides - Per-sandbox config overrides merged with manager defaults.
621
+ */
622
+ async create(overrides) {
623
+ if (this.config.provider === "e2b") {
624
+ const stub = new E2BProviderStub();
625
+ const id2 = this._generateId(overrides.scope);
626
+ this.active.set(id2, stub);
627
+ return stub;
628
+ }
629
+ const mergedConfig = {
630
+ image: "node:22-slim",
631
+ ...this.config.dockerConfig,
632
+ ...overrides
633
+ };
634
+ const sandbox = new DockerSandbox(mergedConfig, this.docker);
635
+ await sandbox.start();
636
+ const id = this._generateId(overrides.scope);
637
+ this.active.set(id, sandbox);
638
+ return sandbox;
639
+ }
640
+ /**
641
+ * Destroy a specific sandbox and remove it from the active registry.
642
+ */
643
+ async destroy(sandbox) {
644
+ await sandbox.destroy();
645
+ for (const [key, value] of this.active) {
646
+ if (value === sandbox) {
647
+ this.active.delete(key);
648
+ break;
649
+ }
650
+ }
651
+ }
652
+ /**
653
+ * Destroy all active sandboxes and shut the manager down.
654
+ * Called automatically on SIGTERM / SIGINT when registered.
655
+ */
656
+ async shutdown() {
657
+ const destroyAll = Array.from(this.active.values()).map(
658
+ (sb) => sb.destroy().catch((err) => {
659
+ console.error("[SandboxManager] Error destroying sandbox during shutdown:", err);
660
+ })
661
+ );
662
+ await Promise.all(destroyAll);
663
+ this.active.clear();
664
+ }
665
+ /**
666
+ * Returns the number of currently active sandboxes.
667
+ */
668
+ get activeCount() {
669
+ return this.active.size;
670
+ }
671
+ // ---------------------------------------------------------------------------
672
+ // Private helpers
673
+ // ---------------------------------------------------------------------------
674
+ _generateId(scope) {
675
+ return `agentforge-${scope}-${randomUUID3().slice(0, 8)}`;
676
+ }
677
+ _registerShutdownHandlers() {
678
+ if (this.shutdownRegistered) return;
679
+ this.shutdownRegistered = true;
680
+ const handler = () => {
681
+ void this.shutdown().finally(() => process.exit(0));
682
+ };
683
+ process.once("SIGTERM", handler);
684
+ process.once("SIGINT", handler);
685
+ process.once("beforeExit", () => void this.shutdown());
686
+ }
687
+ };
688
+ export {
689
+ BLOCKED_BIND_PREFIXES,
690
+ ContainerPool,
691
+ DEFAULT_CAP_DROP,
692
+ DockerSandbox,
693
+ SandboxManager,
694
+ SecurityError,
695
+ isDockerAvailable,
696
+ validateBind,
697
+ validateBinds,
698
+ validateCommand,
699
+ validateImageName
700
+ };
701
+ //# sourceMappingURL=index.js.map