@stixxert/pi-docker-sandbox 0.1.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/index.ts ADDED
@@ -0,0 +1,1752 @@
1
+ /**
2
+ * Docker sandbox extension for pi.
3
+ *
4
+ * Gives a pi agent a private deploy target: a Docker Sandbox ("sbx") microVM
5
+ * on the host, with its OWN docker daemon inside it. The sandbox runs in
6
+ * parallel to the agent's own sandbox (e.g. the gondolin micro-VM); the agent
7
+ * deploys into the sbx sandbox, and the host's docker (Docker Desktop /
8
+ * colima / plain dockerd) is NEVER touched.
9
+ *
10
+ * How it works:
11
+ * - The extension runs in the HOST pi process.
12
+ * - Every docker_* tool wraps: sbx exec <sandbox> -- docker -H unix:///var/run/docker.sock <args>
13
+ * - The sandbox's workspace (the host dir mounted at /workspace in the
14
+ * agent's VM) is direct-mounted into the sbx microVM, so /workspace/<rel>
15
+ * -> <host cwd>/<rel> is valid on the host and inside the sandbox.
16
+ *
17
+ * Isolation guarantees (see security.md):
18
+ * - No host docker socket is ever opened; the host `docker` CLI is never
19
+ * invoked. Only the `sbx` CLI is used.
20
+ * - Docker-related env vars (DOCKER_*, COMPOSE_*) are scrubbed before any
21
+ * `sbx exec`, and the inner docker CLI is pinned to the sandbox daemon
22
+ * with -H unix:///var/run/docker.sock — a leaked DOCKER_HOST on the host
23
+ * cannot redirect the inner CLI.
24
+ * - The sandbox has its own images/containers/volumes and its own kernel.
25
+ * - `docker_verify` runs a live audit of these properties.
26
+ *
27
+ * Multi-session naming:
28
+ * - Each pi session gets its own sandbox: pi-sbx-<pid>-<random> by default
29
+ * (always unique, never repeats, no environment dependencies) — or a stable
30
+ * name pinned via env DOCKER_SANDBOX for shared/persistent sandboxes.
31
+ * - The sandbox is auto-provisioned on first use with the session's
32
+ * workspace mounted (disable with DOCKER_SANDBOX_AUTOCREATE=0).
33
+ *
34
+ * Config env vars:
35
+ * DOCKER_SANDBOX explicit sandbox name (default: derived)
36
+ * DOCKER_SANDBOX_AUTOCREATE 0 disables auto-provisioning (default: on)
37
+ * DOCKER_SANDBOX_CPUS CPUs for auto-created sandboxes (default 2)
38
+ * DOCKER_SANDBOX_MEMORY memory, binary units (default 2g)
39
+ */
40
+
41
+ import { execFile, spawn } from "node:child_process";
42
+ import http from "node:http";
43
+ import path from "node:path";
44
+ import fs from "node:fs";
45
+ import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
46
+ import { Type, type Static } from "@earendil-works/pi-ai";
47
+
48
+ // Host pi cwd is the root that the agent's VM mounts at /workspace.
49
+ const hostRoot = process.cwd();
50
+ const env = process.env;
51
+
52
+ const ALLOWED_NAME = /[^A-Za-z0-9._+\-]/g;
53
+
54
+ function sanitizeName(s: string): string {
55
+ return s
56
+ .replace(ALLOWED_NAME, "-")
57
+ .replace(/-+/g, "-")
58
+ .replace(/^-+|-+$/g, "")
59
+ .slice(0, 60);
60
+ }
61
+
62
+ /**
63
+ * Per-session sandbox name: explicit DOCKER_SANDBOX override, else
64
+ * pi-sbx-<pid>-<rand>, which is unique per process and never repeats (no
65
+ * stale-sandbox adoption across restarts). Env/session ids are deliberately
66
+ * NOT used: uniqueness is already guaranteed by pid+random, and stable names
67
+ * are the job of the explicit DOCKER_SANDBOX override for persistent sandboxes.
68
+ *
69
+ * The derived name is computed ONCE and memoized: every tool call in this
70
+ * process must target the SAME sandbox, or each call would auto-provision a
71
+ * fresh VM and all state (containers, images, published ports) would scatter.
72
+ * The explicit DOCKER_SANDBOX override is still read live at every call.
73
+ */
74
+ let derivedSandboxName: string | undefined;
75
+ function sessionSandboxName(): string {
76
+ const explicit = (env.DOCKER_SANDBOX ?? "").trim();
77
+ if (explicit) return sanitizeName(explicit);
78
+ derivedSandboxName ??= `pi-sbx-${process.pid}-${Math.random().toString(36).slice(2, 6)}`;
79
+ return derivedSandboxName;
80
+ }
81
+
82
+ function isExplicitSandbox(): boolean {
83
+ return Boolean((env.DOCKER_SANDBOX ?? "").trim());
84
+ }
85
+
86
+ /** Whether the project workspace should be mounted READ-ONLY into the sandbox. */
87
+ function workspaceRo(): boolean {
88
+ const v = (env.DOCKER_SANDBOX_WORKSPACE_RO ?? "").trim().toLowerCase();
89
+ return v === "1" || v === "true" || v === "ro" || v === "yes";
90
+ }
91
+
92
+ /** Scratch rw primary workspace used when the project is mounted read-only. */
93
+ function primaryWorkspace(name: string): string {
94
+ return path.join(env.HOME ?? "/tmp", `.sbx-prime-${name}`);
95
+ }
96
+
97
+ /** Teardown policy at session end: remove (default for session sandboxes), stop, or none. */
98
+ function teardownMode(): "remove" | "stop" | "none" {
99
+ const v = (env.DOCKER_SANDBOX_TEARDOWN ?? "").trim().toLowerCase();
100
+ if (v === "remove" || v === "stop" || v === "none") return v;
101
+ // Explicit/shared sandbox names default to no auto-teardown; session-scoped ones are removed.
102
+ return isExplicitSandbox() ? "none" : "remove";
103
+ }
104
+
105
+ /** Best-effort teardown of this session's sandbox (used by session_shutdown). Never throws. */
106
+ async function teardownSandbox(reason: string): Promise<void> {
107
+ const mode = teardownMode();
108
+ if (mode === "none") return;
109
+ const name = sessionSandboxName();
110
+ try {
111
+ const exists = await sandboxExists(name);
112
+ if (!exists) return;
113
+ // Spawn DETACHED so the removal survives pi's own exit (shutdown handlers
114
+ // may be cut off by a fast SIGTERM/SIGKILL). Retry a few times because
115
+ // sandboxd may be mid-stop on the VM when we fire.
116
+ const action = mode === "stop" ? "stop" : "rm";
117
+ const op = action === "rm" ? `rm --force ${name}` : `stop ${name}`;
118
+ const script = [
119
+ `for i in 1 2 3 4 5; do`,
120
+ ` ${findSbxCli()} ${op} 2>/dev/null && exit 0`,
121
+ ` sleep 2`,
122
+ `done`,
123
+ `exit 1`,
124
+ ].join("\n");
125
+ const child = spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore", env: scrubbedEnv() });
126
+ child.unref();
127
+ console.error(`[docker-sandbox] session ${reason}: ${action} sandbox "${name}" (detached, retrying)`);
128
+ } catch (e) {
129
+ console.error(`[docker-sandbox] session ${reason}: teardown of "${name}" failed: ${(e as Error).message}`);
130
+ }
131
+ }
132
+
133
+ function findSbxCli(): string {
134
+ for (const c of ["/opt/homebrew/bin/sbx", "/usr/local/bin/sbx", "sbx"]) {
135
+ try {
136
+ if (c.includes("/") ? fs.existsSync(c) : true) return c;
137
+ } catch {
138
+ /* ignore */
139
+ }
140
+ }
141
+ return "sbx";
142
+ }
143
+
144
+ /**
145
+ * Non-secret vars always forwarded to children (the sbx CLI and shells need
146
+ * them to function; they are not credentials).
147
+ */
148
+ const MINIMAL_ENV_VARS = ["HOME", "PATH", "USER", "LOGNAME", "TMPDIR", "SHELL", "LANG", "TERM"];
149
+
150
+ /** DOCKER_SANDBOX_ENV_PASSTHROUGH=1 → forward host env minus the DOCKER/COMPOSE vars (explicit opt-out). */
151
+ function envPassthrough(): boolean {
152
+ const v = (env.DOCKER_SANDBOX_ENV_PASSTHROUGH ?? "").trim().toLowerCase();
153
+ return v === "1" || v === "true" || v === "yes" || v === "on";
154
+ }
155
+
156
+ /** DOCKER_SANDBOX_ENV_ALLOWLIST="A,B,C" → forward MINIMAL_ENV_VARS + exactly those. */
157
+ function envAllowlist(): string[] {
158
+ const raw = (env.DOCKER_SANDBOX_ENV_ALLOWLIST ?? "").split(",");
159
+ const out: string[] = [];
160
+ for (const s of raw) {
161
+ const name = s.trim();
162
+ if (name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !out.includes(name)) out.push(name);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /** Human-readable forwarding mode, for status/verify output. */
168
+ function envForwardMode(): string {
169
+ if (envPassthrough()) return "passthrough (host env minus DOCKER_*/COMPOSE_*; explicit opt-out)";
170
+ const allow = envAllowlist();
171
+ if (allow.length) return `allowlist (minimal + ${allow.join(", ")})`;
172
+ return "strict (minimal safe set only) — default";
173
+ }
174
+
175
+ /**
176
+ * Environment passed to every child process (sbx CLI, watchdog, teardown).
177
+ *
178
+ * Isolation (ALWAYS, any mode): DOCKER_HOST / DOCKER_CONTEXT / DOCKER_* /
179
+ * COMPOSE_* are stripped — a leaked docker-affecting var could redirect the
180
+ * inner docker client to a host daemon.
181
+ *
182
+ * Confidentiality (secure by default): only MINIMAL_ENV_VARS (non-secret,
183
+ * needed by the sbx CLI/shells) are forwarded. Opt in precisely with
184
+ * DOCKER_SANDBOX_ENV_ALLOWLIST="A,B" (adds exactly A and B), or opt out
185
+ * entirely with DOCKER_SANDBOX_ENV_PASSTHROUGH=1 (host env minus the docker
186
+ * vars — matches raw `sbx exec` semantics for legacy compose interpolation).
187
+ */
188
+ function scrubbedEnv(): NodeJS.ProcessEnv {
189
+ const e: NodeJS.ProcessEnv = {};
190
+ const add = (name: string, value: string | undefined) => {
191
+ if (value === undefined) return;
192
+ if (name === "DOCKER_HOST" || name === "DOCKER_CONTEXT" || name.startsWith("DOCKER_") || name.startsWith("COMPOSE_")) return;
193
+ e[name] = value;
194
+ };
195
+ if (envPassthrough()) {
196
+ for (const [k, v] of Object.entries(env)) add(k, v);
197
+ return e;
198
+ }
199
+ for (const name of MINIMAL_ENV_VARS) add(name, env[name]);
200
+ for (const name of envAllowlist()) add(name, env[name]);
201
+ return e;
202
+ }
203
+
204
+ /**
205
+ * Split a command string into args, honoring single/double quotes (no escape
206
+ * handling — for anything more complex pass the array form instead).
207
+ */
208
+ function splitCommand(cmd: string): string[] {
209
+ const out: string[] = [];
210
+ let cur = "";
211
+ let quote: string | null = null;
212
+ for (const ch of cmd) {
213
+ if (quote) {
214
+ if (ch === quote) quote = null;
215
+ else cur += ch;
216
+ } else if (ch === "'" || ch === '"') {
217
+ quote = ch;
218
+ } else if (/\s/.test(ch)) {
219
+ if (cur) {
220
+ out.push(cur);
221
+ cur = "";
222
+ }
223
+ } else {
224
+ cur += ch;
225
+ }
226
+ }
227
+ if (cur) out.push(cur);
228
+ return out;
229
+ }
230
+
231
+ function mapHostPath(input: string): string {
232
+ const trimmed = (input ?? "").trim();
233
+ if (!trimmed) throw new Error("docker: empty path");
234
+ if (trimmed.startsWith("/workspace")) {
235
+ const rel = trimmed.slice("/workspace".length).replace(/^\/+/, "");
236
+ return rel ? path.join(hostRoot, rel) : hostRoot;
237
+ }
238
+ if (path.isAbsolute(trimmed)) return trimmed;
239
+ return path.resolve(hostRoot, trimmed);
240
+ }
241
+
242
+ /* ------------------------------------------------------------------ */
243
+ /* sbx exec transport */
244
+ /* ------------------------------------------------------------------ */
245
+
246
+ type ExecResult = { code: number; stdout: string; stderr: string };
247
+
248
+ function runSbxCli(args: string[], timeoutMs?: number): Promise<ExecResult> {
249
+ return new Promise((resolve) => {
250
+ execFile(
251
+ findSbxCli(),
252
+ args,
253
+ { env: scrubbedEnv(), timeout: timeoutMs, maxBuffer: 128 * 1024 * 1024, windowsHide: true },
254
+ (err, stdout, stderr) => {
255
+ const code = err ? (err as NodeJS.ErrnoException & { code?: number }).code ?? 1 : 0;
256
+ resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
257
+ },
258
+ );
259
+ });
260
+ }
261
+
262
+ async function sandboxExists(name: string): Promise<boolean> {
263
+ const ls = await runSbxCli(["ls"]);
264
+ const hay = `${ls.stdout}\n${ls.stderr}`;
265
+ return new RegExp(`(^|\\s)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`).test(hay);
266
+ }
267
+
268
+ async function ensureSandbox(): Promise<string> {
269
+ const name = sessionSandboxName();
270
+ if (await sandboxExists(name)) return name;
271
+ if ((env.DOCKER_SANDBOX_AUTOCREATE ?? "1") === "0") {
272
+ throw new Error(
273
+ `sandbox "${name}" does not exist and auto-provisioning is disabled (DOCKER_SANDBOX_AUTOCREATE=0).\n` +
274
+ `Create it from a host pane: sbx create --name ${name} --cpus ${env.DOCKER_SANDBOX_CPUS ?? "2"} --memory ${env.DOCKER_SANDBOX_MEMORY ?? "2g"} shell ${hostRoot}`,
275
+ );
276
+ }
277
+ const cpus = env.DOCKER_SANDBOX_CPUS ?? "2";
278
+ let mem = env.DOCKER_SANDBOX_MEMORY ?? "2g";
279
+ // sbx requires >= 1 GiB of memory
280
+ const m = /^(\d+)\s*([gGmM])?$/.exec(mem.trim());
281
+ if (m) {
282
+ const v = Number(m[1]);
283
+ const unit = (m[2] ?? "g").toLowerCase();
284
+ if (unit === "g" && v < 1) mem = "1g";
285
+ if (unit === "m" && v < 1024) mem = "1g";
286
+ } else {
287
+ mem = "2g";
288
+ }
289
+ const createArgs = ["create", "--quiet", "--name", name, "--cpus", cpus, "--memory", mem];
290
+ const template = (env.DOCKER_SANDBOX_TEMPLATE ?? "").trim();
291
+ if (template) createArgs.push("--template", template);
292
+ createArgs.push("shell");
293
+ if (workspaceRo()) {
294
+ // sbx requires the PRIMARY workspace to be rw; mount the project as an
295
+ // additional READ-ONLY workspace. The agent writes via /workspace (its own VM),
296
+ // the sandbox only reads it.
297
+ const prime = primaryWorkspace(name);
298
+ try {
299
+ fs.mkdirSync(prime, { recursive: true });
300
+ } catch {
301
+ /* if we cannot create the prime dir, fall through to plain rw below */
302
+ }
303
+ createArgs.push(prime, `${hostRoot}:ro`);
304
+ } else {
305
+ createArgs.push(hostRoot);
306
+ }
307
+ const r = await runSbxCli(createArgs, 600_000);
308
+ if (r.code !== 0) {
309
+ throw new Error(
310
+ `auto-provisioning sandbox "${name}" failed: ${`${r.stdout}\n${r.stderr}`.trim().slice(0, 1200)}\n` +
311
+ `Try creating it from a host pane: sbx create --name ${name} --cpus ${cpus} --memory ${mem} shell ${hostRoot}`,
312
+ );
313
+ }
314
+ spawnWatchdog();
315
+ writeOwnerMarker(name);
316
+ return name;
317
+ }
318
+
319
+ const SANDBOX_HINT = (name: string) =>
320
+ `HINT: sandbox "${name}" missing or unreachable. It is auto-provisioned on first use; ` +
321
+ `check \`sbx ls\` from a host pane, or set DOCKER_SANDBOX_AUTOCREATE=0 and create it manually:\n` +
322
+ ` sbx create --name ${name} shell <workspace-dir>`;
323
+
324
+ /** Run a docker command inside the sandbox; throw on failure with a helpful hint. */
325
+ async function docker(cmdArgs: string[], timeoutMs?: number): Promise<string> {
326
+ const name = await ensureSandbox();
327
+ // Default timeout so an OOM-hung sandbox VM fails the tool call instead of
328
+ // hanging the agent's turn. Long operations (pull/build) pass a larger one.
329
+ const r = await runSbxCli(["exec", name, "--", "docker", "-H", "unix:///var/run/docker.sock", ...cmdArgs], timeoutMs ?? 120_000);
330
+ const out = `${r.stdout}\n${r.stderr}`.trim();
331
+ if (r.code !== 0) {
332
+ // Only offer the sandbox-missing hint when the error actually names this
333
+ // sandbox — docker's own "Unable to find image" / "No such container"
334
+ // errors must NOT trigger it (they'd be misleading).
335
+ const missing = out.includes(name) && /no sandbox|not found|unknown sandbox|does not exist|missing|unreachable/i.test(out);
336
+ throw new Error(
337
+ `docker (in sandbox "${name}") failed (exit ${r.code}): ${out.slice(0, 1500)}${missing ? `\n${SANDBOX_HINT(name)}` : ""}`,
338
+ );
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /** Run a docker command that prints JSON lines and return the parsed array. */
344
+ async function dockerJson(cmdArgs: string[]): Promise<Record<string, unknown>[]> {
345
+ const out = await docker(cmdArgs);
346
+ const rows: Record<string, unknown>[] = [];
347
+ for (const line of out.split("\n")) {
348
+ const t = line.trim();
349
+ if (!t) continue;
350
+ try {
351
+ rows.push(JSON.parse(t));
352
+ } catch {
353
+ /* ignore non-JSON */
354
+ }
355
+ }
356
+ return rows;
357
+ }
358
+
359
+ /* ------------------------------------------------------------------ */
360
+ /* formatting helpers */
361
+ /* ------------------------------------------------------------------ */
362
+
363
+ function humanBytes(n: number): string {
364
+ if (!Number.isFinite(n) || n <= 0) return "-";
365
+ const units = ["B", "KB", "MB", "GB", "TB"];
366
+ let i = 0;
367
+ let v = n;
368
+ while (v >= 1024 && i < units.length - 1) {
369
+ v /= 1024;
370
+ i++;
371
+ }
372
+ return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)}${units[i]}`;
373
+ }
374
+
375
+ function formatPorts(ports: unknown[]): string {
376
+ if (!Array.isArray(ports) || ports.length === 0) return "-";
377
+ return ports
378
+ .map((p) => {
379
+ const o = p as Record<string, unknown>;
380
+ const host = o.HostIp && o.HostIp !== "0.0.0.0" && o.HostIp !== "::"
381
+ ? `${o.HostIp}:${o.HostPort ?? ""}`
382
+ : o.HostPort
383
+ ? `0.0.0.0:${o.HostPort}`
384
+ : "";
385
+ return `${o.PrivatePort ?? "?"}/${String(o.Type ?? "tcp")}${host ? ` -> ${host}` : ""}`;
386
+ })
387
+ .join(", ");
388
+ }
389
+
390
+ function formatImageTable(images: Record<string, unknown>[]): string {
391
+ const rows: string[] = [];
392
+ for (const img of images) {
393
+ const tags = Array.isArray(img.RepoTags) ? (img.RepoTags as string[]) : [];
394
+ const tag = tags.find((t) => !t.endsWith(":<none>")) ?? (tags[0] ?? "<none>");
395
+ rows.push(
396
+ `${tag.padEnd(48)} ${(String(img.Id ?? "")).slice(7, 19).padEnd(12)} ${humanBytes(Number(img.Size ?? 0)).padStart(9)} ${new Date(Number(img.Created ?? 0) * 1000).toISOString().slice(0, 10)}`,
397
+ );
398
+ }
399
+ return rows.length ? `IMAGE (name:tag, id, size, created)\n${rows.join("\n")}` : "(no images)";
400
+ }
401
+
402
+ function formatContainerTable(containers: Record<string, unknown>[]): string {
403
+ const rows: string[] = [];
404
+ for (const c of containers) {
405
+ const names = Array.isArray(c.Names) ? (c.Names as string[]).map((n) => n.replace(/^\//, "")).join(",") : "?";
406
+ rows.push(
407
+ `${String(c.Id ?? "").slice(0, 12).padEnd(12)} ${String(c.Image ?? "").slice(0, 30).padEnd(30)} ${String(c.Status ?? "").padEnd(28)} ${formatPorts(c.Ports as unknown[])} ${names}`,
408
+ );
409
+ }
410
+ return rows.length ? `CONTAINER (id, image, status, ports, names)\n${rows.join("\n")}` : "(no containers)";
411
+ }
412
+
413
+ /* ------------------------------------------------------------------ */
414
+ /* dockerfile scaffolding (docker_init) */
415
+ /* ------------------------------------------------------------------ */
416
+
417
+ type Lang = "node" | "pnpm" | "go" | "python" | "rust" | "generic";
418
+
419
+ function detectLang(dir: string): Lang {
420
+ if (fs.existsSync(path.join(dir, "pnpm-lock.yaml")) && fs.existsSync(path.join(dir, "package.json"))) return "pnpm";
421
+ if (fs.existsSync(path.join(dir, "package.json"))) return "node";
422
+ if (fs.existsSync(path.join(dir, "go.mod"))) return "go";
423
+ if (fs.existsSync(path.join(dir, "pyproject.toml")) || fs.existsSync(path.join(dir, "requirements.txt"))) return "python";
424
+ if (fs.existsSync(path.join(dir, "Cargo.toml"))) return "rust";
425
+ return "generic";
426
+ }
427
+
428
+ function dockerfileFor(lang: Lang, port: number): string {
429
+ switch (lang) {
430
+ case "node":
431
+ return [
432
+ "FROM node:22-alpine",
433
+ "WORKDIR /app",
434
+ "COPY package*.json ./",
435
+ "RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi",
436
+ "COPY . .",
437
+ `EXPOSE ${port}`,
438
+ 'CMD ["npm", "start"]',
439
+ "",
440
+ ].join("\n");
441
+ case "pnpm":
442
+ return [
443
+ "FROM node:22-alpine",
444
+ "RUN corepack enable",
445
+ "WORKDIR /app",
446
+ "COPY package.json pnpm-lock.yaml* ./",
447
+ "RUN pnpm install --frozen-lockfile || pnpm install",
448
+ "COPY . .",
449
+ `EXPOSE ${port}`,
450
+ 'CMD ["pnpm", "start"]',
451
+ "",
452
+ ].join("\n");
453
+ case "go":
454
+ return [
455
+ "FROM golang:1.24-alpine AS build",
456
+ "WORKDIR /src",
457
+ "COPY go.mod go.sum* ./",
458
+ "RUN go mod download",
459
+ "COPY . .",
460
+ "RUN CGO_ENABLED=0 go build -o /app .",
461
+ "FROM alpine:3.20",
462
+ "COPY --from=build /app /app",
463
+ `EXPOSE ${port}`,
464
+ 'CMD ["/app"]',
465
+ "",
466
+ ].join("\n");
467
+ case "python":
468
+ return [
469
+ "FROM python:3.12-slim",
470
+ "WORKDIR /app",
471
+ "COPY requirements.txt* ./",
472
+ "RUN pip install --no-cache-dir -r requirements.txt || true",
473
+ "COPY . .",
474
+ `EXPOSE ${port}`,
475
+ 'CMD ["python", "-m", "http.server", "8000"]',
476
+ "",
477
+ ].join("\n");
478
+ case "rust":
479
+ return [
480
+ "FROM rust:1.80-alpine AS build",
481
+ "WORKDIR /src",
482
+ "COPY . .",
483
+ "RUN cargo build --release && cp target/release/$(awk -F'\"' '/^name *=/ {print $2; exit}' Cargo.toml) /out-app",
484
+ "FROM alpine:3.20",
485
+ "COPY --from=build /out-app /app/app",
486
+ `EXPOSE ${port}`,
487
+ 'CMD ["/app/app"]',
488
+ "",
489
+ ].join("\n");
490
+ default:
491
+ return [
492
+ "FROM alpine:3.20",
493
+ "WORKDIR /app",
494
+ "COPY . .",
495
+ `EXPOSE ${port}`,
496
+ "CMD [\"sh\"]",
497
+ "",
498
+ ].join("\n");
499
+ }
500
+ }
501
+
502
+ const DOCKERIGNORE = [
503
+ ".git",
504
+ ".gitignore",
505
+ ".pi",
506
+ "node_modules",
507
+ "dist",
508
+ "build",
509
+ "coverage",
510
+ ".env",
511
+ ".env.*",
512
+ "*.log",
513
+ ".DS_Store",
514
+ "",
515
+ ].join("\n");
516
+
517
+ function composeFor(port: number): string {
518
+ return [
519
+ "services:",
520
+ " app:",
521
+ " build: .",
522
+ ` ports:`,
523
+ ` - "${port}:${port}"`,
524
+ " restart: unless-stopped",
525
+ "",
526
+ ].join("\n");
527
+ }
528
+
529
+ /* ------------------------------------------------------------------ */
530
+ /* tool implementations */
531
+ /* ------------------------------------------------------------------ */
532
+
533
+ async function toolStatus(): Promise<string> {
534
+ const name = sessionSandboxName();
535
+ let exists = false;
536
+ try {
537
+ exists = await sandboxExists(name);
538
+ } catch {
539
+ /* sbx CLI unavailable — report below */
540
+ }
541
+ const ls = await runSbxCli(["ls"]);
542
+ const lsOut = `${ls.stdout}\n${ls.stderr}`.trim();
543
+ if (ls.code !== 0) {
544
+ return `docker sandbox: "sbx ls" failed (${lsOut.slice(0, 400)}). Is the sbx CLI installed and sandboxd running? (brew install docker/tap/sbx)`;
545
+ }
546
+ if (!exists) {
547
+ return (
548
+ `docker sandbox "${name}": not provisioned yet.` +
549
+ `\nteardown on session end: ${teardownMode()}${isExplicitSandbox() ? " (DOCKER_SANDBOX pinned)" : ""}` +
550
+ `\nAutomatic provisioning is ${(env.DOCKER_SANDBOX_AUTOCREATE ?? "1") === "0" ? "DISABLED" : "enabled"} — it will be created on the first docker_* call with the session workspace (${hostRoot}) mounted.` +
551
+ `\nCurrent sandboxes:\n${lsOut || "(none)"}`
552
+ );
553
+ }
554
+ const line = lsOut.split("\n").find((l) => l.includes(name))?.trim() ?? name;
555
+ try {
556
+ const ver = await docker(["version", "--format", "server {{.Server.Version}} ({{.Server.Os}}-{{.Server.Arch}})"]);
557
+ const info = await dockerJson(["info", "--format", "{{json .}}"]);
558
+ const first = info[0] ?? {};
559
+ return [
560
+ `docker sandbox "${name}" — private docker daemon in an sbx microVM (host docker untouched)`,
561
+ `sbx ls: ${line}`,
562
+ `daemon: ${ver}`,
563
+ `resources: ${first.NCPU ?? "?"} CPUs, ${humanBytes(Number(first.MemTotal ?? 0))} RAM`,
564
+ `state: ${first.Containers ?? 0} containers (${first.ContainersRunning ?? 0} running), ${first.Images ?? 0} images`,
565
+ `workspace mounted: ${hostRoot}${workspaceRo() ? " (READ-ONLY; agent writes via /workspace in its own VM)" : " (read-write)"}`,
566
+ `env forwarding: ${envForwardMode()} (default strict; DOCKER_SANDBOX_ENV_ALLOWLIST opts in, _PASSTHROUGH opts out)`,
567
+ `teardown on session end: ${teardownMode()}${isExplicitSandbox() ? " (DOCKER_SANDBOX pinned)" : ""}`,
568
+ `containers are labeled com.pi.sandbox=true. Run docker_verify for the isolation audit.`,
569
+ ].join("\n");
570
+ } catch (e) {
571
+ return `docker sandbox "${name}": ${(e as Error).message}`;
572
+ }
573
+ }
574
+
575
+ async function toolImages(): Promise<string> {
576
+ const images = await dockerJson(["images", "--format", "{{json .}}"]);
577
+ return formatImageTable(images);
578
+ }
579
+
580
+ async function toolPs(all: boolean): Promise<string> {
581
+ const args = ["ps"];
582
+ if (all) args.push("-a");
583
+ args.push("--format", "{{json .}}");
584
+ const containers = await dockerJson(args);
585
+ return formatContainerTable(containers);
586
+ }
587
+
588
+ async function toolPull(image: string): Promise<string> {
589
+ const out = await docker(["pull", image], 600_000);
590
+ const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
591
+ const interesting = lines.filter((l) => /Status:|Digest:|Downloaded newer|up to date/i.test(l));
592
+ return [`pull ${image}`, ...(interesting.length ? interesting : lines.slice(-3))].join("\n");
593
+ }
594
+
595
+ const runParamsSchema = Type.Object(
596
+ {
597
+ image: Type.String({ description: "Image to run, e.g. nginx:1.27" }),
598
+ name: Type.Optional(Type.String({ description: "Optional container name" })),
599
+ command: Type.Optional(
600
+ Type.Union([Type.String(), Type.Array(Type.String())], { description: "Command to run, string or array" }),
601
+ ),
602
+ detach: Type.Optional(
603
+ Type.Boolean({ description: "Detach and return immediately (default true). false = wait for exit and return output" }),
604
+ ),
605
+ rm: Type.Optional(Type.Boolean({ description: "Remove container after foreground run (default false)" })),
606
+ ports: Type.Optional(Type.Array(Type.String(), { description: "Port mappings, e.g. [\"8080:80\"]" })),
607
+ env: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Environment variables, K=V" })),
608
+ memory: Type.Optional(
609
+ Type.String({ description: "Memory limit for the container, e.g. 512m, 1g (docker -m); protects the sandbox VM from OOM" }),
610
+ ),
611
+ volumes: Type.Optional(Type.Array(Type.String(), { description: "Volume binds, host:container[:ro]" })),
612
+ network: Type.Optional(
613
+ Type.Union([Type.Literal("bridge"), Type.Literal("host"), Type.Literal("none")], { description: "Network mode (default bridge)" }),
614
+ ),
615
+ restart: Type.Optional(
616
+ Type.Union(
617
+ [Type.Literal("no"), Type.Literal("always"), Type.Literal("unless-stopped"), Type.Literal("on-failure")],
618
+ { description: "Restart policy (default: unless-stopped for detached runs, no for foreground)" },
619
+ ),
620
+ ),
621
+ workdir: Type.Optional(Type.String({ description: "Working directory inside the container" })),
622
+ },
623
+ { additionalProperties: false },
624
+ );
625
+ type RunParams = Static<typeof runParamsSchema>;
626
+
627
+ async function toolRun(params: RunParams): Promise<string> {
628
+ const args = ["run"];
629
+ args.push("--label", "com.pi.sandbox=true");
630
+ const foreground = params.detach === false;
631
+ if (!foreground) args.push("-d");
632
+ if (params.rm) args.push("--rm");
633
+ if (params.name) args.push("--name", params.name);
634
+ for (const p of params.ports ?? []) args.push("-p", p);
635
+ for (const e of Array.isArray(params.env) ? params.env : (params.env ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
636
+ if (e) args.push("-e", e);
637
+ }
638
+ for (const v of params.volumes ?? []) {
639
+ const parts = v.split(":");
640
+ if (parts.length < 2) throw new Error(`docker run: bad volume spec "${v}" (use host:container[:ro])`);
641
+ const hostPart = mapHostPath(parts[0]);
642
+ const rest = parts.slice(1).join(":");
643
+ // In RO-workspace mode, binds sourced from the project are read-only by
644
+ // construction; make it explicit so the container's expectations match.
645
+ if (workspaceRo() && !/:(ro|rw)$/.test(rest)) args.push("-v", `${hostPart}:${rest}:ro`);
646
+ else args.push("-v", `${hostPart}:${rest}`);
647
+ }
648
+ if (params.network && params.network !== "bridge") args.push("--network", params.network);
649
+ // Services (detached runs) default to unless-stopped so they survive the
650
+ // sandbox VM being idle-stopped by sandboxd; foreground runs stay ephemeral.
651
+ const restart = params.restart ?? (foreground ? "no" : "unless-stopped");
652
+ if (restart && restart !== "no") args.push("--restart", restart);
653
+ if (params.memory) args.push("-m", params.memory);
654
+ if (params.workdir) args.push("-w", params.workdir);
655
+ args.push(params.image);
656
+ if (params.command) {
657
+ const cmd = Array.isArray(params.command) ? params.command : splitCommand(params.command);
658
+ args.push(...cmd);
659
+ }
660
+
661
+ if (foreground) {
662
+ const out = await docker(args, 600_000);
663
+ return `container exited (${params.image})${params.rm ? ", removed" : ""}:\n${out || "(no output)"}`;
664
+ }
665
+
666
+ const name = sessionSandboxName();
667
+ const out = await docker(args);
668
+ const id = out.trim().split("\n").pop() ?? "?";
669
+ let portsLine = "";
670
+ try {
671
+ const portsOut = (await docker(["port", id])).trim();
672
+ if (portsOut) portsLine = `\nports (sandbox-internal):\n${portsOut}`;
673
+ } catch {
674
+ /* no published ports */
675
+ }
676
+ // sbx does NOT auto-forward docker -p mappings — publish explicitly and report the host URL.
677
+ const hostUrls: string[] = [];
678
+ if (params.ports?.length) {
679
+ // Correlate reported host URLs to THIS run: only show mappings whose
680
+ // sandbox-side port matches a port this run requested (don't attribute
681
+ // other containers' mappings to this one).
682
+ const containerPorts = new Set<string>();
683
+ for (const spec of params.ports) {
684
+ const m = /^(\d+)(?::(\d+))?(\/udp|\/tcp)?$/.exec(spec.trim());
685
+ if (m) containerPorts.add(`${m[2] ?? m[1]}/${(m[3] ?? "tcp").replace(/^\//, "")}`);
686
+ }
687
+ for (const spec of params.ports) {
688
+ const m = /^(\d+)(?::(\d+))?(\/udp|\/tcp)?$/.exec(spec.trim());
689
+ if (!m) continue;
690
+ // Keep the protocol suffix: sbx ports accepts H:C/udp (a /udp publish
691
+ // silently falling back to tcp would answer with connection resets).
692
+ const pubArg = (m[2] ? `${m[1]}:${m[2]}` : m[1]) + (m[3] ?? "");
693
+ let r = await runSbxCli(["ports", name, "--publish", pubArg]);
694
+ if (r.code !== 0 && m[2]) {
695
+ // host port likely taken — fall back to an ephemeral host port
696
+ r = await runSbxCli(["ports", name, "--publish", `${m[2]}${m[3] ?? ""}`]);
697
+ }
698
+ if (r.code !== 0) {
699
+ hostUrls.push(`${pubArg} (publish failed: ${`${r.stdout}\n${r.stderr}`.trim().slice(0, 120)})`);
700
+ }
701
+ }
702
+ try {
703
+ const portsList = await runSbxCli(["ports", name]);
704
+ for (const line of portsList.stdout.split("\n")) {
705
+ const pm = /127\.0\.0\.1\s+(\d+)\s+(\d+)\s+(tcp|udp)/.exec(line);
706
+ if (pm && containerPorts.has(`${pm[2]}/${pm[3]}`)) hostUrls.push(`http://127.0.0.1:${pm[1]}/ (host ${pm[3]}, sandbox :${pm[2]})`);
707
+ }
708
+ } catch {
709
+ /* ignore */
710
+ }
711
+ }
712
+ const hostLine = hostUrls.length ? `\nhost reachable at:\n${hostUrls.join("\n")}` : "";
713
+ return `started ${params.image} as ${id.slice(0, 12)}${params.name ? ` (${params.name})` : ""} in sandbox "${name}"` +
714
+ ` (restart=${restart})${portsLine}${hostLine}\nuse docker_logs / docker_exec to interact, docker_stop / docker_rm to tear down.`;
715
+ }
716
+
717
+ async function toolLogs(id: string, tail: number, timestamps: boolean): Promise<string> {
718
+ const args = ["logs"];
719
+ if (tail > 0) args.push("--tail", String(tail));
720
+ if (timestamps) args.push("--timestamps");
721
+ args.push(id);
722
+ const out = await docker(args);
723
+ return out.trimEnd() || "(no logs)";
724
+ }
725
+
726
+ async function toolExec(id: string, command: string | string[]): Promise<string> {
727
+ const cmd = Array.isArray(command) ? command : splitCommand(command);
728
+ if (!cmd.length) throw new Error("docker exec: empty command");
729
+ const out = await docker(["exec", id, ...cmd]);
730
+ return out.trimEnd() || "(no output)";
731
+ }
732
+
733
+ async function toolBuild(
734
+ context: string,
735
+ tag: string,
736
+ dockerfile: string | undefined,
737
+ buildArgs: string | undefined,
738
+ ): Promise<string> {
739
+ const hostContext = mapHostPath(context);
740
+ if (!fs.existsSync(hostContext)) throw new Error(`docker build: context not found: ${context}`);
741
+ const stat = fs.statSync(hostContext);
742
+ if (!stat.isDirectory()) throw new Error(`docker build: context must be a directory: ${context}`);
743
+
744
+ const args = ["build", "-t", tag];
745
+ if (dockerfile) args.push("-f", path.join(hostContext, dockerfile));
746
+ if (buildArgs) {
747
+ try {
748
+ const parsed = JSON.parse(buildArgs) as Record<string, unknown>;
749
+ for (const [k, v] of Object.entries(parsed)) args.push("--build-arg", `${k}=${String(v)}`);
750
+ } catch {
751
+ throw new Error("docker build: buildargs must be a JSON object string, e.g. {\"VERSION\":\"1.0\"}");
752
+ }
753
+ }
754
+ args.push(hostContext);
755
+ const name = sessionSandboxName();
756
+ const out = await docker(args, 600_000);
757
+ const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
758
+ const useful = lines.filter((l) => /(^#|naming to|writing image|exporting|built |digest|sha256)/i.test(l));
759
+ return [`build ${hostContext} -> ${tag} (inside sandbox "${name}")`, ...(useful.length ? useful.slice(-10) : lines.slice(-3))].join("\n");
760
+ }
761
+
762
+ /** VM + docker resource usage inside the sandbox, with warnings. */
763
+ async function toolResources(): Promise<string> {
764
+ const name = sessionSandboxName();
765
+ const lines: string[] = [];
766
+ const warnings: string[] = [];
767
+
768
+ // VM-level memory
769
+ try {
770
+ const mem = (await runSbxCli(["exec", name, "--", "free", "-m"])).stdout;
771
+ const row = mem.split("\n").find((l) => /^Mem:/.test(l));
772
+ if (row) {
773
+ const c = row.split(/\s+/).filter(Boolean);
774
+ const total = Number(c[1]);
775
+ const used = Number(c[2]);
776
+ const avail = Number(c[6] ?? c[3]);
777
+ const pct = total > 0 ? Math.round(((total - avail) / total) * 100) : 0;
778
+ lines.push(`VM memory: ${used}MiB used / ${total}MiB (${pct}% — ${avail}MiB available)`);
779
+ if (pct > 85) warnings.push(`VM memory at ${pct}% — containers are at risk of OOM-kill.`);
780
+ }
781
+ } catch {
782
+ /* skip */
783
+ }
784
+ // VM CPUs
785
+ try {
786
+ const cpu = (await runSbxCli(["exec", name, "--", "nproc"])).stdout.trim();
787
+ if (cpu) lines.push(`VM CPUs: ${cpu}`);
788
+ } catch {
789
+ /* skip */
790
+ }
791
+ // VM disk
792
+ try {
793
+ const df = (await runSbxCli(["exec", name, "--", "df", "-h", "/"])).stdout;
794
+ const row = df.split("\n").slice(1).find((l) => l.trim());
795
+ if (row) {
796
+ const c = row.split(/\s+/).filter(Boolean);
797
+ const pct = Number((c[4] ?? "0%").replace("%", ""));
798
+ lines.push(`VM disk: ${c[2]} used / ${c[1]} (${c[4]})`);
799
+ if (pct > 85) warnings.push(`VM disk at ${pct}% — docker builds/pulls may fail with no-space errors.`);
800
+ }
801
+ } catch {
802
+ /* skip */
803
+ }
804
+ // docker disk usage
805
+ try {
806
+ const df = (await docker(["system", "df"])).trim();
807
+ if (df) lines.push(`\ndocker disk:\n${df}`);
808
+ } catch {
809
+ /* skip */
810
+ }
811
+ // per-container usage
812
+ try {
813
+ const stats = (await docker(["stats", "--no-stream", "--format", "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"])).trim();
814
+ if (stats) {
815
+ const rows = stats.split("\n").map((l) => ` ${l.replace(/^\//, "")}`);
816
+ lines.push(`\nrunning containers (name, cpu, mem):\n${rows.join("\n")}`);
817
+ }
818
+ } catch {
819
+ /* skip */
820
+ }
821
+
822
+ if (warnings.length) {
823
+ lines.push(
824
+ "\n⚠ WARNINGS:\n" +
825
+ warnings.map((w) => ` - ${w}`).join("\n") +
826
+ "\nRemediation: docker_rm/docker_stop containers; docker_prune for space; " +
827
+ `cap containers with docker_run(memory=...); or raise the sandbox limits (DOCKER_SANDBOX_MEMORY/CPUS) and ` +
828
+ "recreate via docker_sandbox_rm (the sandbox is re-provisioned on the next docker_* call).",
829
+ );
830
+ } else {
831
+ lines.push("\nNo resource warnings. See docker_prune to reclaim space.");
832
+ }
833
+ return lines.join("\n");
834
+ }
835
+
836
+ async function toolPrune(volumes: boolean): Promise<string> {
837
+ const args = ["system", "prune", "-af"];
838
+ if (volumes) args.push("--volumes");
839
+ const out = await docker(args, 600_000);
840
+ return out.trim() || "nothing to prune";
841
+ }
842
+
843
+ async function toolCompose(
844
+ file: string | undefined,
845
+ action: string,
846
+ service: string | undefined,
847
+ extraArgs: string[],
848
+ ): Promise<string> {
849
+ let hostFile: string | null = null;
850
+ if (file) {
851
+ hostFile = mapHostPath(file);
852
+ if (!fs.existsSync(hostFile)) throw new Error(`docker compose: file not found: ${file}`);
853
+ } else {
854
+ for (const n of ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"]) {
855
+ const p = path.join(hostRoot, n);
856
+ if (fs.existsSync(p)) {
857
+ hostFile = p;
858
+ break;
859
+ }
860
+ }
861
+ if (!hostFile) throw new Error("docker compose: no compose file found in workspace (pass file=...)");
862
+ }
863
+
864
+ const args = ["compose", "-f", hostFile];
865
+ switch (action) {
866
+ case "up":
867
+ args.push("up", "-d", "--build");
868
+ break;
869
+ case "down":
870
+ args.push("down");
871
+ break;
872
+ case "ps":
873
+ args.push("ps");
874
+ break;
875
+ case "logs":
876
+ args.push("logs", "--tail", "200");
877
+ break;
878
+ case "restart":
879
+ args.push("restart");
880
+ break;
881
+ case "stop":
882
+ args.push("stop");
883
+ break;
884
+ case "config":
885
+ args.push("config");
886
+ break;
887
+ default:
888
+ args.push(action);
889
+ }
890
+ if (service) args.push(service);
891
+ args.push(...extraArgs);
892
+
893
+ // For `down`, capture the project's ports first so we can unpublish the
894
+ // corresponding sbx host mappings afterwards (they would otherwise linger).
895
+ let preDownPorts: Set<string> | null = null;
896
+ if (action === "down") {
897
+ try {
898
+ const ps = await dockerJson(["compose", "-f", hostFile, "ps", "--format", "{{json .}}"]);
899
+ const ports = new Set<string>();
900
+ for (const c of ps) {
901
+ for (const pm of composePortMappings(c)) ports.add(`${pm.container}/${pm.proto}`);
902
+ }
903
+ if (ports.size) preDownPorts = ports;
904
+ } catch {
905
+ /* best-effort */
906
+ }
907
+ }
908
+
909
+ const out = await docker(args, 600_000);
910
+
911
+ if (preDownPorts) {
912
+ try {
913
+ const done = await unpublishMappingsFor(preDownPorts);
914
+ if (done.length) return `${out}\nunpublished host ports: ${done.join(", ")}`;
915
+ } catch {
916
+ /* best-effort */
917
+ }
918
+ }
919
+
920
+ // After a successful `up`, publish compose-declared ports on the host
921
+ // (sbx does not auto-forward docker -p mappings).
922
+ let hostSummary = "";
923
+ if (action === "up") {
924
+ try {
925
+ const name = sessionSandboxName();
926
+ const ps = await dockerJson(["compose", "-f", hostFile, "ps", "--format", "{{json .}}"]);
927
+ const toPublish = new Set<string>();
928
+ for (const c of ps) {
929
+ for (const pm of composePortMappings(c)) toPublish.add(`${pm.host}:${pm.container}/${pm.proto}`);
930
+ }
931
+ for (const p of toPublish) {
932
+ const r = await runSbxCli(["ports", name, "--publish", p]);
933
+ if (r.code !== 0) hostSummary += `\nport publish ${p} failed (host port may be taken)`;
934
+ }
935
+ if (toPublish.size) {
936
+ const portsList = await runSbxCli(["ports", name]);
937
+ const urls: string[] = [];
938
+ for (const line of portsList.stdout.split("\n")) {
939
+ const pm = /127\.0\.0\.1\s+(\d+)\s+(\d+)\s+(tcp|udp)/.exec(line);
940
+ if (pm) urls.push(`http://127.0.0.1:${pm[1]}/ (host ${pm[3]}, sandbox :${pm[2]})`);
941
+ }
942
+ if (urls.length) hostSummary = `\nhost reachable at:\n${urls.join("\n")}`;
943
+ }
944
+ } catch {
945
+ /* port publishing is best-effort */
946
+ }
947
+ }
948
+ return `${out}${hostSummary}`;
949
+ }
950
+
951
+ /**
952
+ * Extract host-published port mappings from a `docker compose ps --format
953
+ * "{{json .}}"` row. compose v2 can emit the legacy `Ports` string
954
+ * ("0.0.0.0:8080->80/tcp") and/or the structured `Publishers` array
955
+ * ({PublishedPort, TargetPort, Protocol}); accept both.
956
+ */
957
+ function composePortMappings(c: Record<string, unknown>): { host: string; container: string; proto: string }[] {
958
+ const out: { host: string; container: string; proto: string }[] = [];
959
+ const ports = typeof c.Ports === "string" ? (c.Ports as string) : "";
960
+ for (const part of ports.split(",")) {
961
+ const m = /(?:0\.0\.0\.0|\[::\]):(\d+)->(\d+)\/(tcp|udp)/.exec(part.trim());
962
+ if (m) out.push({ host: m[1], container: m[2], proto: m[3] });
963
+ }
964
+ if (Array.isArray(c.Publishers)) {
965
+ for (const p of c.Publishers as Record<string, unknown>[]) {
966
+ const host = String(p.PublishedPort ?? "");
967
+ const container = String(p.TargetPort ?? "");
968
+ const proto = String(p.Protocol ?? "tcp");
969
+ if (host && container) out.push({ host, container, proto });
970
+ }
971
+ }
972
+ return out;
973
+ }
974
+
975
+ /** Unpublish sbx host mappings whose sandbox port is in `sandboxPorts` ("port/proto"). */
976
+ async function unpublishMappingsFor(sandboxPorts: Set<string>): Promise<string[]> {
977
+ if (!sandboxPorts.size) return [];
978
+ const name = sessionSandboxName();
979
+ const list = await runSbxCli(["ports", name]);
980
+ const toUnpublish: string[] = [];
981
+ for (const line of list.stdout.split("\n")) {
982
+ const pm = /127\.0\.0\.1\s+(\d+)\s+(\d+)\s+(tcp|udp)/.exec(line);
983
+ if (pm && sandboxPorts.has(`${pm[2]}/${pm[3]}`)) toUnpublish.push(`${pm[1]}:${pm[2]}/${pm[3]}`);
984
+ }
985
+ for (const p of toUnpublish) {
986
+ await runSbxCli(["ports", name, "--unpublish", p]);
987
+ }
988
+ return toUnpublish;
989
+ }
990
+
991
+ async function toolLifecycle(id: string, op: "stop" | "start" | "rm"): Promise<string> {
992
+ switch (op) {
993
+ case "stop":
994
+ await docker(["stop", "--time", "10", id]);
995
+ return `stopped ${id}`;
996
+ case "start":
997
+ await docker(["start", id]);
998
+ return `started ${id}`;
999
+ case "rm": {
1000
+ // Unpublish the container's host mappings (sbx does NOT unpublish on
1001
+ // container removal; stale mappings would answer with resets).
1002
+ const sandboxPorts = new Set<string>();
1003
+ try {
1004
+ // `docker port` prints CONTAINER_PORT/PROTO -> HOST_IP:HOST_PORT
1005
+ // (e.g. "80/tcp -> 0.0.0.0:8080") — note the reversed order vs docker ps.
1006
+ const portOut = (await docker(["port", id])).trim();
1007
+ for (const line of portOut.split("\n")) {
1008
+ const m = /(\d+)\/(tcp|udp)\s*->\s*0\.0\.0\.0:(\d+)/.exec(line.trim());
1009
+ if (m) sandboxPorts.add(`${m[1]}/${m[2]}`);
1010
+ }
1011
+ } catch {
1012
+ /* container already gone */
1013
+ }
1014
+ await docker(["rm", "-f", "-v", id]);
1015
+ let un = "";
1016
+ try {
1017
+ const done = await unpublishMappingsFor(sandboxPorts);
1018
+ if (done.length) un = `, unpublished host ports ${done.join(", ")}`;
1019
+ } catch {
1020
+ /* best-effort */
1021
+ }
1022
+ return `removed ${id}${un}`;
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ /** Host-side HTTP request to a host-local published port (sbx forwards 127.0.0.1 only). */
1028
+ async function toolCurl(url: string, timeoutSec: number, method: string, body: string | undefined): Promise<string> {
1029
+ let u: URL;
1030
+ try {
1031
+ u = new URL(url);
1032
+ } catch {
1033
+ throw new Error(`docker_curl: invalid URL "${url}" (use e.g. http://127.0.0.1:8080/health)`);
1034
+ }
1035
+ if (!["127.0.0.1", "localhost", "::1"].includes(u.hostname)) {
1036
+ throw new Error(
1037
+ `docker_curl: only host-local published ports are reachable from the host process ` +
1038
+ `(sbx binds 127.0.0.1; tried host "${u.hostname}"). For the sandbox-internal address use docker_exec.`,
1039
+ );
1040
+ }
1041
+ const meth = (method || "GET").toUpperCase();
1042
+ const hasBody = body !== undefined;
1043
+ const result = await new Promise<{ status: number; headers: http.IncomingHttpHeaders; text: string }>((resolve, reject) => {
1044
+ const req = http.request(
1045
+ {
1046
+ host: u.hostname,
1047
+ port: Number(u.port || 80),
1048
+ path: `${u.pathname}${u.search}`,
1049
+ method: meth,
1050
+ timeout: Math.max(1, timeoutSec) * 1000,
1051
+ headers: {
1052
+ accept: "*/*",
1053
+ "user-agent": "pi-docker-sandbox/1",
1054
+ ...(hasBody ? { "content-type": "application/json", "content-length": String(Buffer.byteLength(body ?? "")) } : {}),
1055
+ },
1056
+ },
1057
+ (res) => {
1058
+ const chunks: Buffer[] = [];
1059
+ res.on("data", (c: Buffer) => chunks.push(c));
1060
+ res.on("end", () =>
1061
+ resolve({ status: res.statusCode ?? 0, headers: res.headers, text: Buffer.concat(chunks).toString("utf8") }),
1062
+ );
1063
+ },
1064
+ );
1065
+ req.on("timeout", () => req.destroy(new Error("timeout")));
1066
+ req.on("error", reject);
1067
+ if (hasBody) req.write(body ?? "");
1068
+ req.end();
1069
+ });
1070
+ const head = `HTTP ${result.status} ${result.headers["content-type"] ? `content-type: ${result.headers["content-type"]}` : ""}`.trim();
1071
+ const text = result.text.slice(0, 4000);
1072
+ return text.length ? `${head}\n\n${text}` : `${head}\n(empty response body)`;
1073
+ }
1074
+
1075
+ async function toolInit(
1076
+ context: string,
1077
+ opts: { lang?: string; force?: boolean; compose?: boolean },
1078
+ ): Promise<string> {
1079
+ const dir = mapHostPath(context || ".");
1080
+ if (!fs.existsSync(dir)) throw new Error(`docker_init: directory not found: ${context}`);
1081
+ if (!fs.statSync(dir).isDirectory()) throw new Error(`docker_init: not a directory: ${context}`);
1082
+
1083
+ const existing = fs.existsSync(path.join(dir, "Dockerfile"));
1084
+ if (existing && !opts.force) {
1085
+ return `docker_init: ${path.join(dir, "Dockerfile")} already exists (pass force=true to overwrite).`;
1086
+ }
1087
+
1088
+ const lang: Lang = opts.lang ? (opts.lang.toLowerCase() as Lang) : detectLang(dir);
1089
+ const port = opts.lang === "go" || lang === "go" ? 8080 : lang === "python" ? 8000 : lang === "generic" ? 8080 : 3000;
1090
+
1091
+ fs.writeFileSync(path.join(dir, "Dockerfile"), dockerfileFor(lang, port));
1092
+ if (!fs.existsSync(path.join(dir, ".dockerignore"))) {
1093
+ fs.writeFileSync(path.join(dir, ".dockerignore"), DOCKERIGNORE);
1094
+ }
1095
+ const wroteCompose = Boolean(opts.compose) && !fs.existsSync(path.join(dir, "compose.yaml"));
1096
+ if (wroteCompose) fs.writeFileSync(path.join(dir, "compose.yaml"), composeFor(port));
1097
+
1098
+ return [
1099
+ `docker_init: scaffolded "${lang}" in ${dir}${existing ? " (overwrote Dockerfile)" : ""}`,
1100
+ ` wrote: Dockerfile${wroteCompose ? ", compose.yaml" : ""} (+ .dockerignore)`,
1101
+ ` image base: ${dockerfileFor(lang, port).split("\n")[0]}`,
1102
+ "next: docker_build(context=<dir>, tag=<name>:latest) then docker_run / docker_compose.",
1103
+ "Note: adjust CMD/ENTRYPOINT if your entrypoint differs (e.g. package.json scripts).",
1104
+ ].join("\n");
1105
+ }
1106
+
1107
+ async function toolVerify(): Promise<string> {
1108
+ const name = await ensureSandbox();
1109
+ const results: { check: string; ok: boolean; evidence: string; warning?: string }[] = [];
1110
+
1111
+ // 1. transport (design assertion)
1112
+ results.push({
1113
+ check: "transport: tools invoke only `sbx exec` — no host docker socket, no host docker CLI",
1114
+ ok: true,
1115
+ evidence: "extension code imports only node built-ins (child_process/fs/http/path); it never references docker.sock, /var/run/docker.sock on the host, or the host docker binary",
1116
+ });
1117
+
1118
+ // 2. env scrub — nothing docker-related leaks into the sandbox. A
1119
+ // DOCKER_* sentinel is injected into the HOST process env; runSbxCli's
1120
+ // scrubber must strip it before the sbx exec child (and thus the sandbox)
1121
+ // ever sees it. If the scrubber regresses, the sentinel leaks and fails.
1122
+ env.DOCKER_HOST_SENTINEL = "sbx-scrub-probe";
1123
+ let envOk = false;
1124
+ let envOut = "";
1125
+ try {
1126
+ const envCheck = await runSbxCli(["exec", name, "--", "sh", "-c", "env | grep -iE '^(DOCKER_|COMPOSE_)' || echo __CLEAN__"]);
1127
+ envOut = `${envCheck.stdout}\n${envCheck.stderr}`.trim();
1128
+ envOk = envCheck.code === 0 && envOut.includes("__CLEAN__") && !envOut.includes("DOCKER_HOST_SENTINEL");
1129
+ } finally {
1130
+ delete env.DOCKER_HOST_SENTINEL;
1131
+ }
1132
+ results.push({
1133
+ check: "env: no DOCKER_*/COMPOSE_* variables leak into the sandbox",
1134
+ ok: envOk,
1135
+ evidence: envOk ? "sandbox env contains no docker-related variables (scrubbed before sbx exec)" : `LEAKED: ${envOut.slice(0, 300)}`,
1136
+ });
1137
+
1138
+ // 2b. env forwarding policy — secure-by-default: unless passthrough is
1139
+ // explicitly enabled, nothing outside the minimal safe set / allowlist may
1140
+ // reach the sandbox (probe technique, but for a NON-docker var so it is
1141
+ // subject to the allowlist gate, not just the docker scrub).
1142
+ if (!envPassthrough()) {
1143
+ env.__PI_DOCKER_SANDBOX_VERIFY_PROBE__ = "sbx-env-probe";
1144
+ let probeOk = false;
1145
+ let probeOut = "";
1146
+ try {
1147
+ const probe = await runSbxCli(["exec", name, "--", "sh", "-c", "env | grep __PI_DOCKER_SANDBOX_VERIFY_PROBE__ || echo __PROBE_ABSENT__"]);
1148
+ probeOut = `${probe.stdout}\n${probe.stderr}`.trim();
1149
+ probeOk = probe.code === 0 && probeOut.includes("__PROBE_ABSENT__") && !probeOut.includes("__PI_DOCKER_SANDBOX_VERIFY_PROBE__");
1150
+ } finally {
1151
+ delete env.__PI_DOCKER_SANDBOX_VERIFY_PROBE__;
1152
+ }
1153
+ results.push({
1154
+ check: "env: restricted forwarding (allowlist/strict) — non-allowlisted vars do not reach the sandbox",
1155
+ ok: probeOk,
1156
+ evidence: probeOk
1157
+ ? `mode ${envForwardMode()}; the probe var is not visible inside the sandbox`
1158
+ : `PROBE LEAKED: ${probeOut.slice(0, 300)}`,
1159
+ });
1160
+ } else {
1161
+ results.push({
1162
+ check: "env: forwarding is in explicit passthrough mode (host env minus DOCKER_*/COMPOSE_*)",
1163
+ ok: true,
1164
+ evidence: `${envForwardMode()} — set DOCKER_SANDBOX_ENV_ALLOWLIST for precise opt-in, or unset DOCKER_SANDBOX_ENV_PASSTHROUGH to return to the secure default`,
1165
+ warning:
1166
+ "the host env (minus DOCKER_*/COMPOSE_*) is forwarded into the sandbox; anything deployed there can read it, " +
1167
+ "and compose interpolation can carry those vars into containers — use DOCKER_SANDBOX_ENV_ALLOWLIST instead unless you " +
1168
+ "deliberately need raw sbx semantics",
1169
+ });
1170
+ }
1171
+
1172
+ // 3. inner daemon identity + pinned socket
1173
+ try {
1174
+ const id = (await docker(["info", "--format", "{{.ID}}"])).trim();
1175
+ results.push({
1176
+ check: "daemon: docker calls pinned to the sandbox daemon via -H unix:///var/run/docker.sock",
1177
+ ok: true,
1178
+ evidence: `sandbox daemon ID ${id}`,
1179
+ });
1180
+ } catch (e) {
1181
+ results.push({ check: "daemon: sandbox daemon reachable", ok: false, evidence: (e as Error).message.slice(0, 300) });
1182
+ }
1183
+
1184
+ // 4. host docker config / host home not mounted into the sandbox
1185
+ // (exact host-user paths, so the sandbox's own /home/* is not a false positive)
1186
+ const hostUser = (env.USER ?? env.USERNAME ?? (env.HOME ? path.basename(env.HOME) : "user")).trim();
1187
+ const hostPaths = [`/Users/${hostUser}/.docker`, `/home/${hostUser}/.docker`, `/Users/${hostUser}/.ssh`, `/home/${hostUser}/.ssh`];
1188
+ const mountCheck = await runSbxCli(["exec", name, "--", "sh", "-c", `ls -d ${hostPaths.join(" ")} 2>/dev/null; echo __MOUNT__`]);
1189
+ const mountOut = `${mountCheck.stdout}\n${mountCheck.stderr}`.trim();
1190
+ const leakedPaths = mountOut.split("\n").filter((l) => l.trim() && !l.includes("__MOUNT__"));
1191
+ results.push({
1192
+ check: "mounts: host docker config and host home are NOT mounted into the sandbox",
1193
+ ok: mountCheck.code === 0 && leakedPaths.length === 0,
1194
+ evidence: leakedPaths.length ? `VISIBLE INSIDE SANDBOX: ${leakedPaths.join(", ")}` : `host paths (${hostPaths.join(", ")}) are not visible inside the sandbox`,
1195
+ });
1196
+
1197
+ // 5. docker contexts inside the sandbox: only the sandbox's own
1198
+ const ctx = await docker(["context", "ls", "--format", "{{.Name}}"]);
1199
+ const ctxNames = ctx.split("\n").map((l) => l.trim()).filter(Boolean);
1200
+ results.push({
1201
+ check: "contexts: only the sandbox's own default docker context is reachable",
1202
+ ok: ctxNames.length === 1 && ctxNames[0] === "default",
1203
+ evidence: ctxNames.length ? ctxNames.join(", ") : "(none)",
1204
+ });
1205
+
1206
+ // 6. host socket not visible inside the sandbox. On macOS the host (Docker
1207
+ // Desktop / colima) socket lives at ~/.docker/run/docker.sock and must NOT
1208
+ // exist inside the sandbox. On Linux the canonical /var/run/docker.sock
1209
+ // path is ALSO the sandbox's own pinned socket, so it cannot be
1210
+ // distinguished by path — daemon identity is asserted by check 3 instead.
1211
+ let sockResult: { check: string; ok: boolean; evidence: string };
1212
+ if (process.platform === "darwin") {
1213
+ const hostSock = path.join(env.HOME ?? "/Users/unknown", ".docker/run/docker.sock");
1214
+ const sockCheck = await runSbxCli(["exec", name, "--", "sh", "-c", `test -S ${hostSock} && echo __HOST_SOCK_VISIBLE__ || echo __OK__`]);
1215
+ const sockOut = `${sockCheck.stdout}\n${sockCheck.stderr}`.trim();
1216
+ const sockOk = sockCheck.code === 0 && sockOut.includes("__OK__");
1217
+ sockResult = {
1218
+ check: "socket: the host Docker socket is not visible inside the sandbox",
1219
+ ok: sockOk,
1220
+ evidence: sockOk ? `host socket ${hostSock} does not exist inside the sandbox` : sockOut.slice(0, 300),
1221
+ };
1222
+ } else {
1223
+ sockResult = {
1224
+ check: "socket: the host Docker socket is not visible inside the sandbox (linux: N/A by path)",
1225
+ ok: true,
1226
+ evidence:
1227
+ "on linux the canonical /var/run/docker.sock path is also the sandbox's own pinned socket, so a path check cannot distinguish host from sandbox; daemon identity is asserted by the daemon check above",
1228
+ };
1229
+ }
1230
+ results.push(sockResult);
1231
+
1232
+ // 7. port bindings are host-localhost only
1233
+ const ls = await runSbxCli(["ls"]);
1234
+ const row = ls.stdout.split("\n").find((l) => l.includes(name)) ?? "";
1235
+ const nonLocal = /0\.0\.0\.0:[0-9]/.test(row) && !/127\.0\.0\.1/.test(row);
1236
+ results.push({
1237
+ check: "network: published ports bind to host 127.0.0.1 only",
1238
+ ok: !nonLocal,
1239
+ evidence: nonLocal ? row.trim() : (row.trim() || "no published ports currently"),
1240
+ });
1241
+
1242
+ const failed = results.filter((r) => !r.ok);
1243
+ const warned = results.filter((r) => r.warning);
1244
+ const lines = [
1245
+ `Isolation audit for sandbox "${name}"`,
1246
+ ...results.map((r) => ` [${r.ok ? "PASS" : "FAIL"}] ${r.check}\n ${r.evidence}${r.warning ? `\n \u26a0 ${r.warning}` : ""}`),
1247
+ ];
1248
+ lines.push(failed.length ? `\n${failed.length} check(s) FAILED — do not deploy until resolved.` : "\nAll isolation checks passed. The agent's docker reach is confined to this sandbox.");
1249
+ if (warned.length) {
1250
+ lines.push(`\n\u26a0 ${warned.length} warning(s):`);
1251
+ for (const w of warned) lines.push(` - ${w.check} — ${w.warning}`);
1252
+ }
1253
+ return lines.join("\n");
1254
+ }
1255
+
1256
+ async function toolSandboxRm(): Promise<string> {
1257
+ const name = sessionSandboxName();
1258
+ const r = await runSbxCli(["rm", "--force", name]);
1259
+ if (r.code !== 0) {
1260
+ throw new Error(`removing sandbox "${name}" failed: ${`${r.stdout}\n${r.stderr}`.trim().slice(0, 800)}`);
1261
+ }
1262
+ removeOwnerMarker(name);
1263
+ return `sandbox "${name}" removed (microVM and everything inside it deleted).`;
1264
+ }
1265
+
1266
+ /** Whether to keep the sandbox VM running while this pi process is alive. */
1267
+ function keepalive(): boolean {
1268
+ const v = (env.DOCKER_SANDBOX_KEEPALIVE ?? "").trim().toLowerCase();
1269
+ return v === "1" || v === "true" || v === "yes" || v === "on";
1270
+ }
1271
+
1272
+ /**
1273
+ * Arm a detached watchdog that tears the sandbox down when THIS pi process
1274
+ * exits — works even for SIGKILL/power kills that never fire session_shutdown.
1275
+ * The watchdog is spawned detached (new session) so process-group kills of pi
1276
+ * do not take it down. Captures the teardown mode at arm time.
1277
+ * With DOCKER_SANDBOX_KEEPALIVE=1 it also pokes the sandbox every ~60s so
1278
+ * sandboxd's idle-stop (which fires ~2-4 min after the last sbx client
1279
+ * disconnects) does not take services down mid-session.
1280
+ */
1281
+ function spawnWatchdog(): void {
1282
+ const mode = teardownMode();
1283
+ const name = sessionSandboxName();
1284
+ const pid = process.pid;
1285
+ const keep = keepalive();
1286
+ // The watchdog has two independent jobs: teardown on pi exit, and (with
1287
+ // DOCKER_SANDBOX_KEEPALIVE=1) keepalive pokes while pi lives. Keepalive
1288
+ // applies even when teardown is "none" — a pinned/shared sandbox is exactly
1289
+ // the case where you want the VM kept alive but NOT removed.
1290
+ if (mode === "none" && !keep) return;
1291
+ const op = mode === "stop" ? `stop ${name}` : `rm --force ${name}`;
1292
+ const keepLine = keep ? `if [ $((i % 12)) -eq 0 ]; then ${findSbxCli()} ls 2>/dev/null | grep -Fq ${name} && ${findSbxCli()} exec ${name} -- true 2>/dev/null; fi` : "";
1293
+ const lines = [
1294
+ `PID=${pid}`,
1295
+ `i=0`,
1296
+ `while kill -0 $PID 2>/dev/null; do`,
1297
+ ` i=$((i + 1))`,
1298
+ keepLine,
1299
+ ` sleep 5`,
1300
+ `done`,
1301
+ ];
1302
+ if (mode !== "none") {
1303
+ lines.push(
1304
+ `for i in 1 2 3 4 5; do`,
1305
+ ` ${findSbxCli()} ${op} 2>/dev/null && exit 0`,
1306
+ ` sleep 2`,
1307
+ `done`,
1308
+ `exit 1`,
1309
+ );
1310
+ }
1311
+ const script = lines.filter((l) => l !== "").join("\n");
1312
+ try {
1313
+ const child = spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore", env: scrubbedEnv() });
1314
+ child.unref();
1315
+ console.error(`[docker-sandbox] watchdog armed for sandbox "${name}" (pid ${pid}, teardown=${mode}, keepalive=${keep})`);
1316
+ } catch (e) {
1317
+ console.error(`[docker-sandbox] failed to arm watchdog: ${(e as Error).message}`);
1318
+ }
1319
+ }
1320
+
1321
+ /* ------------------------------------------------------------------ */
1322
+ /* stale-sandbox garbage collection (crash safety net) */
1323
+ /* ------------------------------------------------------------------ */
1324
+
1325
+ /** Possible locations of sandboxd's per-sandbox state files. */
1326
+ function stateDirCandidates(): string[] {
1327
+ const base = env.HOME ?? "";
1328
+ return [
1329
+ path.join(base, "Library/Application Support/com.docker.sandboxes/sandboxes/sandboxd/runtimes"),
1330
+ path.join(base, ".local/share/docker-sandboxes/sandboxes/sandboxd/runtimes"),
1331
+ ];
1332
+ }
1333
+
1334
+ /** Age in ms of a sandbox's state file (mtime), or null if unknown. */
1335
+ function sandboxAgeMs(name: string): number | null {
1336
+ for (const dir of stateDirCandidates()) {
1337
+ try {
1338
+ const st = fs.statSync(path.join(dir, `${name}.json`));
1339
+ if (st.isFile()) return Date.now() - st.mtimeMs;
1340
+ } catch {
1341
+ /* try next */
1342
+ }
1343
+ }
1344
+ return null;
1345
+ }
1346
+
1347
+ /** Record which pi process owns this sandbox (used by the GC sibling guard). */
1348
+ function writeOwnerMarker(name: string): void {
1349
+ for (const dir of stateDirCandidates()) {
1350
+ try {
1351
+ if (fs.existsSync(dir)) {
1352
+ fs.writeFileSync(path.join(dir, `${name}.pi-owner`), String(process.pid));
1353
+ return;
1354
+ }
1355
+ } catch {
1356
+ /* try next */
1357
+ }
1358
+ }
1359
+ }
1360
+
1361
+ /** Owner pid recorded for a sandbox, or null. */
1362
+ function readOwnerPid(name: string): number | null {
1363
+ for (const dir of stateDirCandidates()) {
1364
+ try {
1365
+ const v = Number(fs.readFileSync(path.join(dir, `${name}.pi-owner`), "utf8").trim());
1366
+ if (Number.isInteger(v) && v > 0) return v;
1367
+ } catch {
1368
+ /* try next */
1369
+ }
1370
+ }
1371
+ return null;
1372
+ }
1373
+
1374
+ function ownerAlive(pid: number): boolean {
1375
+ try {
1376
+ process.kill(pid, 0);
1377
+ return true;
1378
+ } catch {
1379
+ return false;
1380
+ }
1381
+ }
1382
+
1383
+ function removeOwnerMarker(name: string): void {
1384
+ for (const dir of stateDirCandidates()) {
1385
+ try {
1386
+ fs.rmSync(path.join(dir, `${name}.pi-owner`), { force: true });
1387
+ } catch {
1388
+ /* ignore */
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ /**
1394
+ * Remove stale session sandboxes: names starting with pi-sbx-, currently
1395
+ * stopped, not this session's own, and older than `hours` (0 = any age).
1396
+ * Never touches running sandboxes or non-pi sandboxes.
1397
+ */
1398
+ async function gcSweep(hours: number): Promise<string> {
1399
+ const ls = await runSbxCli(["ls", "--json"]);
1400
+ if (ls.code !== 0) {
1401
+ return `gc: "sbx ls --json" failed (${`${ls.stdout}\n${ls.stderr}`.trim().slice(0, 300)})`;
1402
+ }
1403
+ let boxes: { name?: string; status?: string }[] = [];
1404
+ try {
1405
+ const parsed = JSON.parse(ls.stdout) as { sandboxes?: unknown };
1406
+ boxes = Array.isArray(parsed.sandboxes) ? (parsed.sandboxes as { name?: string; status?: string }[]) : [];
1407
+ } catch {
1408
+ return "gc: could not parse `sbx ls --json` output.";
1409
+ }
1410
+
1411
+ const current = sessionSandboxName();
1412
+ const thresholdMs = hours > 0 ? hours * 3600_000 : 0;
1413
+ const removed: string[] = [];
1414
+ const kept: string[] = [];
1415
+ for (const b of boxes) {
1416
+ const n = b.name;
1417
+ if (!n || !n.startsWith("pi-sbx-") || n === current) continue;
1418
+ if (b.status === "running") {
1419
+ kept.push(`${n} (running)`);
1420
+ continue;
1421
+ }
1422
+ // Sibling guard: never remove a sandbox whose owner pi process is alive
1423
+ // (it may be idle with an idle-stopped VM — concurrent sessions must not
1424
+ // reap each other). Only stop-orphaned sandboxes are candidates.
1425
+ const owner = readOwnerPid(n);
1426
+ if (owner !== null && ownerAlive(owner)) {
1427
+ kept.push(`${n} (owner pid ${owner} alive)`);
1428
+ continue;
1429
+ }
1430
+ if (thresholdMs > 0) {
1431
+ const age = sandboxAgeMs(n);
1432
+ if (age === null || age < thresholdMs) {
1433
+ kept.push(`${n} (stopped but recent/unknown age)`);
1434
+ continue;
1435
+ }
1436
+ }
1437
+ const r = await runSbxCli(["rm", "--force", n]);
1438
+ if (r.code === 0) {
1439
+ removed.push(n);
1440
+ removeOwnerMarker(n);
1441
+ } else {
1442
+ kept.push(`${n} (rm failed: ${`${r.stdout}\n${r.stderr}`.trim().slice(0, 120)})`);
1443
+ }
1444
+ }
1445
+ return [
1446
+ `gc sweep (${hours > 0 ? `>${hours}h` : "any age"}, namespace pi-sbx-*, stopped only):`,
1447
+ removed.length ? ` removed: ${removed.join(", ")}` : " removed: none",
1448
+ kept.length ? ` kept: ${kept.join("; ")}` : " kept: none",
1449
+ ].join("\n");
1450
+ }
1451
+
1452
+ /* ------------------------------------------------------------------ */
1453
+ /* tool result helper */
1454
+ /* ------------------------------------------------------------------ */
1455
+
1456
+ /** Standard text-only tool result (structured `details` are unused by this extension). */
1457
+ function textResult(text: string): AgentToolResult<undefined> {
1458
+ return { content: [{ type: "text", text }], details: undefined };
1459
+ }
1460
+
1461
+ /* ------------------------------------------------------------------ */
1462
+ /* extension registration */
1463
+ /* ------------------------------------------------------------------ */
1464
+
1465
+ export default function (pi: ExtensionAPI) {
1466
+ // Lifecycle: tear down this session's sandbox when the session ends
1467
+ // (exit / Ctrl+C / Ctrl+D / SIGHUP / SIGTERM, /new, /resume, /fork).
1468
+ pi.on("session_shutdown", async () => {
1469
+ await teardownSandbox("session_shutdown");
1470
+ });
1471
+
1472
+ // Crash safety net: sweep stale pi-sbx-* sandboxes at session start, and
1473
+ // arm the watchdog + owner marker for the current sandbox name (covers
1474
+ // /resume case).
1475
+ pi.on("session_start", async () => {
1476
+ spawnWatchdog();
1477
+ writeOwnerMarker(sessionSandboxName());
1478
+ const raw = Number(env.DOCKER_SANDBOX_GC_HOURS ?? "24");
1479
+ if (!Number.isFinite(raw) || raw <= 0) return;
1480
+ try {
1481
+ console.error(`[docker-sandbox] ${await gcSweep(raw)}`);
1482
+ } catch (e) {
1483
+ console.error(`[docker-sandbox] gc at startup failed: ${(e as Error).message}`);
1484
+ }
1485
+ });
1486
+
1487
+ pi.registerTool({
1488
+ name: "docker_status",
1489
+ label: "Docker sandbox status",
1490
+ description:
1491
+ "Check this session's docker sandbox (an sbx microVM with its own private daemon): engine version, " +
1492
+ "resources, container/image counts. The sandbox is auto-provisioned on first use if missing. " +
1493
+ "NOTE: this sandbox is the deploy target; the host's docker is never used. Run docker_verify for the isolation audit.",
1494
+ parameters: Type.Object({}),
1495
+ execute: async () => textResult(await toolStatus()),
1496
+ });
1497
+
1498
+ pi.registerTool({
1499
+ name: "docker_verify",
1500
+ label: "Verify sandbox isolation",
1501
+ description:
1502
+ "Run a live isolation audit of the docker sandbox: transport (sbx only, no host docker), env scrub, " +
1503
+ "daemon pinning, host mounts not visible, docker contexts, host socket not visible, localhost-only port binds. " +
1504
+ "Returns PASS/FAIL per check with evidence. Run this before/after deploys to confirm isolation is maintained.",
1505
+ parameters: Type.Object({}),
1506
+ execute: async () => textResult(await toolVerify()),
1507
+ });
1508
+
1509
+ pi.registerTool({
1510
+ name: "docker_images",
1511
+ label: "List sandbox images",
1512
+ description: "List images inside the docker sandbox (name:tag, id, size, created).",
1513
+ parameters: Type.Object({}),
1514
+ execute: async () => textResult(await toolImages()),
1515
+ });
1516
+
1517
+ pi.registerTool({
1518
+ name: "docker_ps",
1519
+ label: "List sandbox containers",
1520
+ description:
1521
+ "List containers in the docker sandbox. Pass all=true to include stopped ones. " +
1522
+ "Containers created by this extension carry the label com.pi.sandbox=true.",
1523
+ parameters: Type.Object({ all: Type.Optional(Type.Boolean({ description: "Include stopped containers (default false)" })) }),
1524
+ execute: async (_id, params: { all?: boolean }) => textResult(await toolPs(Boolean(params.all))),
1525
+ });
1526
+
1527
+ pi.registerTool({
1528
+ name: "docker_pull",
1529
+ label: "Pull image into sandbox",
1530
+ description: "Pull an image into the sandbox daemon, e.g. \"nginx:1.27\" or \"node:22-alpine\". Returns status/digest.",
1531
+ parameters: Type.Object({ image: Type.String({ description: "Image reference, e.g. nginx:1.27" }) }),
1532
+ execute: async (_id, params: { image: string }) => textResult(await toolPull(params.image)),
1533
+ });
1534
+
1535
+ pi.registerTool({
1536
+ name: "docker_run",
1537
+ label: "Run a container in the sandbox",
1538
+ description:
1539
+ "Run a container inside the docker sandbox. Default: detach (returns id + published ports). " +
1540
+ "detach=false runs in foreground and returns the container's output when it exits. " +
1541
+ "ports: array like [\"8080:3000\"] (host:container) — host port must be >= 1024, and AVOID container " +
1542
+ "port 80 (the sandbox's port proxy resets it); the extension publishes to host 127.0.0.1 and reports " +
1543
+ "the URL. Detached services default to restart=unless-stopped (sandboxd idle-stops sandbox VMs). " +
1544
+ "volumes: array like [\"/workspace/app:/srv/app:ro\"] — /workspace paths map to the sandbox mount. " +
1545
+ "env: array of K=V. network: bridge|host|none. workdir: workdir in container.",
1546
+ parameters: runParamsSchema,
1547
+ execute: async (_id, params: RunParams) => textResult(await toolRun(params)),
1548
+ });
1549
+
1550
+ pi.registerTool({
1551
+ name: "docker_logs",
1552
+ label: "Sandbox container logs",
1553
+ description: "Fetch logs from a container in the sandbox (by id or name). tail limits lines, timestamps adds them.",
1554
+ parameters: Type.Object({
1555
+ id: Type.String({ description: "Container id (12+ chars) or name" }),
1556
+ tail: Type.Optional(Type.Number({ description: "Last N lines (default 200)" })),
1557
+ timestamps: Type.Optional(Type.Boolean({ description: "Prefix timestamps (default false)" })),
1558
+ }),
1559
+ execute: async (_id, params: { id: string; tail?: number; timestamps?: boolean }) =>
1560
+ textResult(await toolLogs(params.id, params.tail ?? 200, Boolean(params.timestamps))),
1561
+ });
1562
+
1563
+ pi.registerTool({
1564
+ name: "docker_exec",
1565
+ label: "Exec in sandbox container",
1566
+ description: "Run a command inside a running container in the sandbox and return its output, e.g. docker_exec(id, \"ls -la /app\").",
1567
+ parameters: Type.Object({
1568
+ id: Type.String({ description: "Container id or name" }),
1569
+ command: Type.Optional(
1570
+ Type.Union([Type.String(), Type.Array(Type.String())], { description: "Command, string or array" }),
1571
+ ),
1572
+ }),
1573
+ execute: async (_id, params: { id: string; command: string | string[] }) =>
1574
+ textResult(await toolExec(params.id, params.command)),
1575
+ });
1576
+
1577
+ pi.registerTool({
1578
+ name: "docker_build",
1579
+ label: "Build image in sandbox",
1580
+ description:
1581
+ "Build a docker image inside the sandbox from a directory in the workspace (host path mapping applies; the " +
1582
+ "context is read from the sandbox's mounted workspace). context: workspace dir containing the Dockerfile. " +
1583
+ "tag: e.g. myapp:latest. dockerfile: optional relative Dockerfile path. buildargs: optional JSON string of ARG values. " +
1584
+ "Use docker_init first to scaffold a Dockerfile for a directory.",
1585
+ parameters: Type.Object({
1586
+ context: Type.String({ description: "Workspace directory with the Dockerfile, e.g. /workspace/app" }),
1587
+ tag: Type.String({ description: "Image tag, e.g. myapp:latest" }),
1588
+ dockerfile: Type.Optional(Type.String({ description: "Optional Dockerfile path relative to context" })),
1589
+ buildargs: Type.Optional(Type.String({ description: "Optional JSON string of build args, e.g. {\"VERSION\":\"1.0\"}" })),
1590
+ }),
1591
+ execute: async (_id, params: { context: string; tag: string; dockerfile?: string; buildargs?: string }) =>
1592
+ textResult(await toolBuild(params.context, params.tag, params.dockerfile, params.buildargs)),
1593
+ });
1594
+
1595
+ pi.registerTool({
1596
+ name: "docker_init",
1597
+ label: "Scaffold a Dockerfile",
1598
+ description:
1599
+ "Scaffold a Dockerfile (+ .dockerignore, optional compose.yaml) for a workspace directory so you can quickly " +
1600
+ "containerize a project. Detects language from files: package.json (node), pnpm-lock.yaml (pnpm), go.mod (go), " +
1601
+ "pyproject.toml/requirements.txt (python), Cargo.toml (rust), else generic alpine. " +
1602
+ "lang overrides detection. force overwrites an existing Dockerfile. compose also writes compose.yaml. " +
1603
+ "After scaffolding, use docker_build then docker_run or docker_compose.",
1604
+ parameters: Type.Object({
1605
+ context: Type.Optional(Type.String({ description: "Workspace directory to scaffold (default: workspace root)" })),
1606
+ lang: Type.Optional(Type.String({ description: "Override language: node|pnpm|go|python|rust|generic" })),
1607
+ force: Type.Optional(Type.Boolean({ description: "Overwrite existing Dockerfile (default false)" })),
1608
+ compose: Type.Optional(Type.Boolean({ description: "Also write compose.yaml (default false)" })),
1609
+ }),
1610
+ execute: async (_id, params: { context?: string; lang?: string; force?: boolean; compose?: boolean }) =>
1611
+ textResult(await toolInit(params.context ?? ".", { lang: params.lang, force: Boolean(params.force), compose: Boolean(params.compose) })),
1612
+ });
1613
+
1614
+ pi.registerTool({
1615
+ name: "docker_compose",
1616
+ label: "Compose deploy in sandbox",
1617
+ description:
1618
+ "Deploy or manage a docker compose project inside the sandbox. file: compose file in the workspace " +
1619
+ "(default: compose.yaml/compose.yml/docker-compose.yml in workspace root). action: up (default; builds + starts " +
1620
+ "detached), down, ps, logs, restart, stop, config, or any compose verb. service: optional service name. " +
1621
+ "extraArgs: optional extra CLI args. This is the main deploy path.",
1622
+ parameters: Type.Object({
1623
+ file: Type.Optional(Type.String({ description: "Compose file path in workspace (default auto-detect)" })),
1624
+ action: Type.Optional(Type.String({ description: "up (default), down, ps, logs, restart, stop, config, or compose verb" })),
1625
+ service: Type.Optional(Type.String({ description: "Optional service name" })),
1626
+ extraArgs: Type.Optional(Type.Array(Type.String(), { description: "Extra compose CLI args" })),
1627
+ }),
1628
+ execute: async (_id, params: { file?: string; action?: string; service?: string; extraArgs?: string[] }) =>
1629
+ textResult(await toolCompose(params.file, params.action ?? "up", params.service, params.extraArgs ?? [])),
1630
+ });
1631
+
1632
+ pi.registerTool({
1633
+ name: "docker_stop",
1634
+ label: "Stop sandbox container",
1635
+ description: "Stop a container in the sandbox by id or name (graceful, 10s timeout).",
1636
+ parameters: Type.Object({ id: Type.String({ description: "Container id or name" }) }),
1637
+ execute: async (_id, params: { id: string }) => textResult(await toolLifecycle(params.id, "stop")),
1638
+ });
1639
+
1640
+ pi.registerTool({
1641
+ name: "docker_start",
1642
+ label: "Start sandbox container",
1643
+ description: "Start a stopped container in the sandbox by id or name.",
1644
+ parameters: Type.Object({ id: Type.String({ description: "Container id or name" }) }),
1645
+ execute: async (_id, params: { id: string }) => textResult(await toolLifecycle(params.id, "start")),
1646
+ });
1647
+
1648
+ pi.registerTool({
1649
+ name: "docker_rm",
1650
+ label: "Remove sandbox container",
1651
+ description: "Force-remove a container in the sandbox by id or name (also removes anonymous volumes).",
1652
+ parameters: Type.Object({ id: Type.String({ description: "Container id or name" }) }),
1653
+ execute: async (_id, params: { id: string }) => textResult(await toolLifecycle(params.id, "rm")),
1654
+ });
1655
+
1656
+ pi.registerTool({
1657
+ name: "docker_sandbox_rm",
1658
+ label: "Remove this session's sandbox",
1659
+ description:
1660
+ "DESTRUCTIVE: remove this pi session's entire sandbox (the sbx microVM and everything inside it — images, " +
1661
+ "containers, volumes). Use when the session is done and the sandbox is no longer needed. The sandbox is " +
1662
+ "auto-recreated on the next docker_* call. See also DOCKER_SANDBOX to share a persistent sandbox.",
1663
+ parameters: Type.Object({}),
1664
+ execute: async () => textResult(await toolSandboxRm()),
1665
+ });
1666
+
1667
+ pi.registerTool({
1668
+ name: "docker_curl",
1669
+ label: "Probe a published container port",
1670
+ description:
1671
+ "Send an HTTP request to a URL on the HOST's localhost to verify a deployed container is serving — sbx " +
1672
+ "publishes container ports on host 127.0.0.1 only, and this runs in the host pi process, so it is the way " +
1673
+ "to check a running service from the agent (the agent's VM cannot reach host loopback). " +
1674
+ "url: e.g. http://127.0.0.1:8080/health. method: GET (default), POST, PUT, etc. body: optional request body " +
1675
+ "(content-type application/json). Only 127.0.0.1/localhost/::1 hosts are allowed. Returns status + body.",
1676
+ parameters: Type.Object({
1677
+ url: Type.String({ description: "Host-local URL of the published port, e.g. http://127.0.0.1:8080/health" }),
1678
+ timeoutSec: Type.Optional(Type.Number({ description: "Timeout in seconds (default 10)" })),
1679
+ method: Type.Optional(Type.String({ description: "HTTP method: GET (default), POST, PUT, DELETE, HEAD, ..." })),
1680
+ body: Type.Optional(Type.String({ description: "Optional request body (sent as application/json)" })),
1681
+ }),
1682
+ execute: async (_id, params: { url: string; timeoutSec?: number; method?: string; body?: string }) =>
1683
+ textResult(await toolCurl(params.url, params.timeoutSec ?? 10, params.method ?? "GET", params.body)),
1684
+ });
1685
+
1686
+ pi.registerTool({
1687
+ name: "docker_resources",
1688
+ label: "Sandbox resource usage",
1689
+ description:
1690
+ "Report resource usage of the sandbox VM and its docker: VM memory/cpu/disk, docker disk (images/containers/" +
1691
+ "build cache), and per-running-container cpu/memory. Warns when memory or disk exceed 85% and lists " +
1692
+ "remediation (stop/rm containers, docker_prune, cap containers with docker_run(memory=...), or raise " +
1693
+ "DOCKER_SANDBOX_MEMORY/CPUS and recreate). Run this when deploys fail, builds error, or containers exit " +
1694
+ "abnormally (e.g. OOMKilled, exit 137).",
1695
+ parameters: Type.Object({}),
1696
+ execute: async () => textResult(await toolResources()),
1697
+ });
1698
+
1699
+ pi.registerTool({
1700
+ name: "docker_prune",
1701
+ label: "Reclaim sandbox space",
1702
+ description:
1703
+ "docker system prune -af inside the sandbox: removes stopped containers, unused networks, dangling images " +
1704
+ "and build cache. volumes=true also removes anonymous volumes. Use when docker_resources shows high disk " +
1705
+ "usage or builds fail with no-space errors.",
1706
+ parameters: Type.Object({ volumes: Type.Optional(Type.Boolean({ description: "Also remove anonymous volumes (default false)" })) }),
1707
+ execute: async (_id, params: { volumes?: boolean }) => textResult(await toolPrune(Boolean(params.volumes))),
1708
+ });
1709
+
1710
+ pi.registerTool({
1711
+ name: "docker_gc",
1712
+ label: "Garbage-collect stale sandboxes",
1713
+ description:
1714
+ "Remove stale session sandboxes left behind by crashed/killed pi sessions. Only touches sandboxes whose " +
1715
+ "name starts with pi-sbx-, that are currently STOPPED, whose owner pi process is DEAD (each session writes an " +
1716
+ "owner marker — live concurrent sessions are never reaped, even with hours=0), and older than the given age. " +
1717
+ "hours: age threshold (default 24; 0 = any stopped pi-sbx-* sandbox with a dead owner). Running sandboxes and " +
1718
+ "non-pi sandboxes are never touched. Runs automatically at session start per DOCKER_SANDBOX_GC_HOURS.",
1719
+ parameters: Type.Object({
1720
+ hours: Type.Optional(
1721
+ Type.Number({ description: "Remove stopped pi-sbx-* sandboxes older than this many hours (0 = any; default 24)" }),
1722
+ ),
1723
+ }),
1724
+ execute: async (_id, params: { hours?: number }) => textResult(await gcSweep(params.hours ?? 24)),
1725
+ });
1726
+
1727
+ pi.registerCommand("docker", {
1728
+ description: "Show docker sandbox status and how to use it",
1729
+ handler: async (_args, ctx) => {
1730
+ ctx.ui.notify(
1731
+ [
1732
+ "Docker sandbox extension (sbx)",
1733
+ `sandbox: ${sessionSandboxName()} (env DOCKER_SANDBOX overrides; auto-provisioned on first use)`,
1734
+ `workspace mounted: ${hostRoot}`,
1735
+ "Tools: docker_status, docker_verify, docker_ps, docker_images, docker_pull, docker_run,",
1736
+ "docker_logs, docker_exec, docker_build, docker_init, docker_compose, docker_stop/start/rm,",
1737
+ "docker_sandbox_rm, docker_gc.",
1738
+ "Lifecycle: sandbox auto-created on first use; torn down on session end",
1739
+ `(DOCKER_SANDBOX_TEARDOWN=${teardownMode()}); stale sandboxes GC'd per DOCKER_SANDBOX_GC_HOURS.`,
1740
+ `Workspace: ${workspaceRo() ? "READ-ONLY in sandbox (agent writes via /workspace)" : "read-write"} (DOCKER_SANDBOX_WORKSPACE_RO).`,
1741
+ `Env forwarding: ${envForwardMode()} (default strict; _ALLOWLIST opts in, _PASSTHROUGH opts out).`,
1742
+ "Containers get label com.pi.sandbox=true.",
1743
+ "Deploy target = sandbox daemon only; host docker is never used.",
1744
+ ].join("\n"),
1745
+ "info",
1746
+ );
1747
+ },
1748
+ });
1749
+ }
1750
+
1751
+ // Named exports for tests (pi's loader only calls the default factory).
1752
+ export { scrubbedEnv, envForwardMode, envAllowlist, envPassthrough, sessionSandboxName };