@stixxert/pi-docker-sandbox 0.1.0 → 1.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 CHANGED
@@ -228,15 +228,81 @@ function splitCommand(cmd: string): string[] {
228
228
  return out;
229
229
  }
230
230
 
231
+ /** Real path of the workspace root (resolved once; follows symlinks in the path). */
232
+ let realHostRoot: string | undefined;
233
+ function realHostRootPath(): string {
234
+ if (realHostRoot === undefined) {
235
+ try {
236
+ realHostRoot = fs.realpathSync(hostRoot);
237
+ } catch {
238
+ realHostRoot = hostRoot;
239
+ }
240
+ }
241
+ return realHostRoot;
242
+ }
243
+
244
+ /**
245
+ * True if `target` resolves (through symlinks) to a path inside the workspace
246
+ * root. Handles non-existent targets by resolving the deepest existing
247
+ * ancestor and re-appending the missing suffix.
248
+ */
249
+ function realpathWithin(target: string): boolean {
250
+ const root = realHostRootPath();
251
+ let probe = target;
252
+ const suffix: string[] = [];
253
+ while (!fs.existsSync(probe)) {
254
+ const parent = path.dirname(probe);
255
+ if (parent === probe) return false; // reached filesystem root
256
+ suffix.unshift(path.basename(probe));
257
+ probe = parent;
258
+ }
259
+ let realProbe: string;
260
+ try {
261
+ realProbe = fs.realpathSync(probe);
262
+ } catch {
263
+ return false;
264
+ }
265
+ const realTarget = suffix.length ? path.join(realProbe, ...suffix) : realProbe;
266
+ const rel = path.relative(root, realTarget);
267
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
268
+ }
269
+
270
+ /** Reject values that could be interpreted as docker CLI flags or inject output. */
271
+ function assertSafeArg(value: string, what: string): void {
272
+ if (!value || value.startsWith("-")) {
273
+ throw new Error(`docker: invalid ${what} "${value}" (must not be empty or start with "-")`);
274
+ }
275
+ if (/[\r\n\x00]/.test(value)) {
276
+ throw new Error(`docker: invalid ${what} (must not contain newlines or NUL)`);
277
+ }
278
+ }
279
+
231
280
  function mapHostPath(input: string): string {
232
281
  const trimmed = (input ?? "").trim();
233
282
  if (!trimmed) throw new Error("docker: empty path");
283
+ let resolved: string;
234
284
  if (trimmed.startsWith("/workspace")) {
235
285
  const rel = trimmed.slice("/workspace".length).replace(/^\/+/, "");
236
- return rel ? path.join(hostRoot, rel) : hostRoot;
286
+ resolved = rel ? path.join(hostRoot, rel) : hostRoot;
287
+ } else if (path.isAbsolute(trimmed)) {
288
+ resolved = trimmed;
289
+ } else {
290
+ resolved = path.resolve(hostRoot, trimmed);
291
+ }
292
+ // Confine to the workspace: reject any path that escapes hostRoot. This is
293
+ // the core isolation guarantee — the agent must not be able to reach host
294
+ // paths outside the mounted workspace (via /workspace/.. traversal or an
295
+ // absolute host path), because these paths are used in host-side fs calls
296
+ // (existence checks, docker_init writes) as well as sandbox-side mounts.
297
+ const rel = path.relative(hostRoot, resolved);
298
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
299
+ throw new Error(`docker: path escapes the workspace: ${input}`);
237
300
  }
238
- if (path.isAbsolute(trimmed)) return trimmed;
239
- return path.resolve(hostRoot, trimmed);
301
+ // Also reject symlinks inside the workspace that point outside it.
302
+ if (!realpathWithin(resolved)) {
303
+ throw new Error(`docker: path escapes the workspace (symlink): ${input}`);
304
+ }
305
+ return resolved;
240
306
  }
241
307
 
242
308
  /* ------------------------------------------------------------------ */
@@ -252,7 +318,13 @@ function runSbxCli(args: string[], timeoutMs?: number): Promise<ExecResult> {
252
318
  args,
253
319
  { env: scrubbedEnv(), timeout: timeoutMs, maxBuffer: 128 * 1024 * 1024, windowsHide: true },
254
320
  (err, stdout, stderr) => {
255
- const code = err ? (err as NodeJS.ErrnoException & { code?: number }).code ?? 1 : 0;
321
+ // execFile's err.code is string (e.g. "ENOENT") | number (exit code) | null (signal).
322
+ // Normalize to a number so callers can compare reliably.
323
+ let code = 0;
324
+ if (err) {
325
+ const c = (err as NodeJS.ErrnoException).code;
326
+ code = typeof c === "number" ? c : 1;
327
+ }
256
328
  resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
257
329
  },
258
330
  );
@@ -276,13 +348,13 @@ async function ensureSandbox(): Promise<string> {
276
348
  }
277
349
  const cpus = env.DOCKER_SANDBOX_CPUS ?? "2";
278
350
  let mem = env.DOCKER_SANDBOX_MEMORY ?? "2g";
279
- // sbx requires >= 1 GiB of memory
280
- const m = /^(\d+)\s*([gGmM])?$/.exec(mem.trim());
351
+ // sbx requires >= 1 GiB of memory (accept decimal values like 2.5g / 512m).
352
+ const m = /^(\d+(?:\.\d+)?)\s*([gGmM])?$/.exec(mem.trim());
281
353
  if (m) {
282
354
  const v = Number(m[1]);
283
355
  const unit = (m[2] ?? "g").toLowerCase();
284
- if (unit === "g" && v < 1) mem = "1g";
285
- if (unit === "m" && v < 1024) mem = "1g";
356
+ const giB = unit === "m" ? v / 1024 : v;
357
+ if (giB < 1) mem = "1g";
286
358
  } else {
287
359
  mem = "2g";
288
360
  }
@@ -586,6 +658,7 @@ async function toolPs(all: boolean): Promise<string> {
586
658
  }
587
659
 
588
660
  async function toolPull(image: string): Promise<string> {
661
+ assertSafeArg(image, "image");
589
662
  const out = await docker(["pull", image], 600_000);
590
663
  const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
591
664
  const interesting = lines.filter((l) => /Status:|Digest:|Downloaded newer|up to date/i.test(l));
@@ -628,16 +701,32 @@ async function toolRun(params: RunParams): Promise<string> {
628
701
  const args = ["run"];
629
702
  args.push("--label", "com.pi.sandbox=true");
630
703
  const foreground = params.detach === false;
704
+ if (params.rm && !foreground) {
705
+ throw new Error("docker run: --rm cannot be combined with a detached run (detach defaults to true); use detach=false for a foreground run that auto-removes");
706
+ }
631
707
  if (!foreground) args.push("-d");
632
708
  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);
709
+ if (params.name) {
710
+ assertSafeArg(params.name, "container name");
711
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(params.name)) {
712
+ throw new Error(`docker run: invalid container name "${params.name}"`);
713
+ }
714
+ args.push("--name", params.name);
715
+ }
716
+ for (const p of params.ports ?? []) {
717
+ const spec = p.trim();
718
+ if (!/^\d+(?::\d+)?(\/(udp|tcp))?$/.test(spec)) {
719
+ throw new Error(`docker run: bad port spec "${p}" (use HOST:CONTAINER[/udp], e.g. "8080:3000")`);
720
+ }
721
+ args.push("-p", spec);
722
+ }
635
723
  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);
724
+ if (e.startsWith("-")) throw new Error(`docker run: bad env entry "${e}"`);
725
+ args.push("-e", e);
637
726
  }
638
727
  for (const v of params.volumes ?? []) {
639
728
  const parts = v.split(":");
640
- if (parts.length < 2) throw new Error(`docker run: bad volume spec "${v}" (use host:container[:ro])`);
729
+ if (parts.length < 2 || !parts[0] || !parts[1]) throw new Error(`docker run: bad volume spec "${v}" (use host:container[:ro])`);
641
730
  const hostPart = mapHostPath(parts[0]);
642
731
  const rest = parts.slice(1).join(":");
643
732
  // In RO-workspace mode, binds sourced from the project are read-only by
@@ -650,8 +739,14 @@ async function toolRun(params: RunParams): Promise<string> {
650
739
  // sandbox VM being idle-stopped by sandboxd; foreground runs stay ephemeral.
651
740
  const restart = params.restart ?? (foreground ? "no" : "unless-stopped");
652
741
  if (restart && restart !== "no") args.push("--restart", restart);
653
- if (params.memory) args.push("-m", params.memory);
742
+ if (params.memory) {
743
+ if (!/^\d+(\.\d+)?[bkmg]?$/i.test(params.memory.trim())) {
744
+ throw new Error(`docker run: bad memory limit "${params.memory}" (use e.g. 512m, 1g)`);
745
+ }
746
+ args.push("-m", params.memory.trim());
747
+ }
654
748
  if (params.workdir) args.push("-w", params.workdir);
749
+ assertSafeArg(params.image, "image");
655
750
  args.push(params.image);
656
751
  if (params.command) {
657
752
  const cmd = Array.isArray(params.command) ? params.command : splitCommand(params.command);
@@ -715,6 +810,7 @@ async function toolRun(params: RunParams): Promise<string> {
715
810
  }
716
811
 
717
812
  async function toolLogs(id: string, tail: number, timestamps: boolean): Promise<string> {
813
+ assertSafeArg(id, "container id");
718
814
  const args = ["logs"];
719
815
  if (tail > 0) args.push("--tail", String(tail));
720
816
  if (timestamps) args.push("--timestamps");
@@ -724,6 +820,10 @@ async function toolLogs(id: string, tail: number, timestamps: boolean): Promise<
724
820
  }
725
821
 
726
822
  async function toolExec(id: string, command: string | string[]): Promise<string> {
823
+ if (command === undefined || command === null || (typeof command === "string" && !command.trim())) {
824
+ throw new Error("docker exec: empty command");
825
+ }
826
+ assertSafeArg(id, "container id");
727
827
  const cmd = Array.isArray(command) ? command : splitCommand(command);
728
828
  if (!cmd.length) throw new Error("docker exec: empty command");
729
829
  const out = await docker(["exec", id, ...cmd]);
@@ -741,8 +841,16 @@ async function toolBuild(
741
841
  const stat = fs.statSync(hostContext);
742
842
  if (!stat.isDirectory()) throw new Error(`docker build: context must be a directory: ${context}`);
743
843
 
844
+ assertSafeArg(tag, "tag");
744
845
  const args = ["build", "-t", tag];
745
- if (dockerfile) args.push("-f", path.join(hostContext, dockerfile));
846
+ if (dockerfile) {
847
+ const df = path.resolve(hostContext, dockerfile);
848
+ const dfRel = path.relative(hostContext, df);
849
+ if (dfRel === ".." || dfRel.startsWith(`..${path.sep}`) || path.isAbsolute(dfRel)) {
850
+ throw new Error(`docker build: dockerfile path escapes the context: ${dockerfile}`);
851
+ }
852
+ args.push("-f", df);
853
+ }
746
854
  if (buildArgs) {
747
855
  try {
748
856
  const parsed = JSON.parse(buildArgs) as Record<string, unknown>;
@@ -862,6 +970,12 @@ async function toolCompose(
862
970
  }
863
971
 
864
972
  const args = ["compose", "-f", hostFile];
973
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(action)) {
974
+ throw new Error(`docker compose: invalid action "${action}"`);
975
+ }
976
+ if (service && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(service)) {
977
+ throw new Error(`docker compose: invalid service "${service}"`);
978
+ }
865
979
  switch (action) {
866
980
  case "up":
867
981
  args.push("up", "-d", "--build");
@@ -989,6 +1103,7 @@ async function unpublishMappingsFor(sandboxPorts: Set<string>): Promise<string[]
989
1103
  }
990
1104
 
991
1105
  async function toolLifecycle(id: string, op: "stop" | "start" | "rm"): Promise<string> {
1106
+ assertSafeArg(id, "container id");
992
1107
  switch (op) {
993
1108
  case "stop":
994
1109
  await docker(["stop", "--time", "10", id]);
@@ -1024,6 +1139,27 @@ async function toolLifecycle(id: string, op: "stop" | "start" | "rm"): Promise<s
1024
1139
  }
1025
1140
  }
1026
1141
 
1142
+ /** HTTP methods docker_curl may issue (no CONNECT/TRACE — no tunneling). */
1143
+ const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"]);
1144
+ /** Max request body size for docker_curl (1 MiB). */
1145
+ const MAX_CURL_BODY = 1024 * 1024;
1146
+
1147
+ /** Host ports currently published by this session's sandbox (from `sbx ports`). */
1148
+ async function publishedHostPorts(): Promise<Set<number>> {
1149
+ const name = sessionSandboxName();
1150
+ const out = new Set<number>();
1151
+ try {
1152
+ const r = await runSbxCli(["ports", name]);
1153
+ for (const line of r.stdout.split("\n")) {
1154
+ const m = /127\.0\.0\.1\s+(\d+)\s+\d+\s+(tcp|udp)/.exec(line);
1155
+ if (m) out.add(Number(m[1]));
1156
+ }
1157
+ } catch {
1158
+ /* no published ports */
1159
+ }
1160
+ return out;
1161
+ }
1162
+
1027
1163
  /** Host-side HTTP request to a host-local published port (sbx forwards 127.0.0.1 only). */
1028
1164
  async function toolCurl(url: string, timeoutSec: number, method: string, body: string | undefined): Promise<string> {
1029
1165
  let u: URL;
@@ -1032,22 +1168,45 @@ async function toolCurl(url: string, timeoutSec: number, method: string, body: s
1032
1168
  } catch {
1033
1169
  throw new Error(`docker_curl: invalid URL "${url}" (use e.g. http://127.0.0.1:8080/health)`);
1034
1170
  }
1035
- if (!["127.0.0.1", "localhost", "::1"].includes(u.hostname)) {
1171
+ // URL.hostname keeps brackets for IPv6 literals ("[::1]"); normalize for the
1172
+ // allowlist check and for http.request.
1173
+ const host = u.hostname.replace(/^\[|\]$/g, "");
1174
+ if (!["127.0.0.1", "localhost", "::1"].includes(host)) {
1036
1175
  throw new Error(
1037
1176
  `docker_curl: only host-local published ports are reachable from the host process ` +
1038
1177
  `(sbx binds 127.0.0.1; tried host "${u.hostname}"). For the sandbox-internal address use docker_exec.`,
1039
1178
  );
1040
1179
  }
1180
+ const port = Number(u.port || 80);
1181
+ // Confine to ports THIS sandbox actually published — docker_curl is for
1182
+ // verifying a deployed container, not a general host-localhost HTTP client
1183
+ // (which could otherwise probe unrelated host services on localhost).
1184
+ const published = await publishedHostPorts();
1185
+ if (!published.has(port)) {
1186
+ throw new Error(
1187
+ `docker_curl: port ${port} is not published by this sandbox. ` +
1188
+ `Published host ports: ${published.size ? [...published].sort((a, b) => a - b).join(", ") : "(none)"}. ` +
1189
+ `Start a container with docker_run(ports=[...]) or docker_compose up first.`,
1190
+ );
1191
+ }
1041
1192
  const meth = (method || "GET").toUpperCase();
1193
+ if (!ALLOWED_METHODS.has(meth)) {
1194
+ throw new Error(`docker_curl: unsupported method "${method}" (allowed: ${[...ALLOWED_METHODS].join(", ")})`);
1195
+ }
1042
1196
  const hasBody = body !== undefined;
1197
+ if (hasBody && Buffer.byteLength(body ?? "") > MAX_CURL_BODY) {
1198
+ throw new Error(`docker_curl: body exceeds ${MAX_CURL_BODY} bytes`);
1199
+ }
1200
+ const timeoutMs = (Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 10) * 1000;
1043
1201
  const result = await new Promise<{ status: number; headers: http.IncomingHttpHeaders; text: string }>((resolve, reject) => {
1044
1202
  const req = http.request(
1045
1203
  {
1046
- host: u.hostname,
1047
- port: Number(u.port || 80),
1204
+ host,
1205
+ port,
1048
1206
  path: `${u.pathname}${u.search}`,
1049
1207
  method: meth,
1050
- timeout: Math.max(1, timeoutSec) * 1000,
1208
+ timeout: timeoutMs,
1209
+ ...(host.includes(":") ? { family: 6 } : {}),
1051
1210
  headers: {
1052
1211
  accept: "*/*",
1053
1212
  "user-agent": "pi-docker-sandbox/1",
@@ -1085,6 +1244,10 @@ async function toolInit(
1085
1244
  return `docker_init: ${path.join(dir, "Dockerfile")} already exists (pass force=true to overwrite).`;
1086
1245
  }
1087
1246
 
1247
+ const LANGS = new Set(["node", "pnpm", "go", "python", "rust", "generic"]);
1248
+ if (opts.lang && !LANGS.has(opts.lang.toLowerCase())) {
1249
+ throw new Error(`docker_init: unknown lang "${opts.lang}" (use node|pnpm|go|python|rust|generic)`);
1250
+ }
1088
1251
  const lang: Lang = opts.lang ? (opts.lang.toLowerCase() as Lang) : detectLang(dir);
1089
1252
  const port = opts.lang === "go" || lang === "go" ? 8080 : lang === "python" ? 8000 : lang === "generic" ? 8080 : 3000;
1090
1253
 
@@ -1119,15 +1282,18 @@ async function toolVerify(): Promise<string> {
1119
1282
  // DOCKER_* sentinel is injected into the HOST process env; runSbxCli's
1120
1283
  // scrubber must strip it before the sbx exec child (and thus the sandbox)
1121
1284
  // ever sees it. If the scrubber regresses, the sentinel leaks and fails.
1122
- env.DOCKER_HOST_SENTINEL = "sbx-scrub-probe";
1285
+ // The sentinel name is unique per call so concurrent verifies cannot
1286
+ // interfere with each other's probe.
1287
+ const sentinel = `DOCKER_HOST_SENTINEL_${Math.random().toString(36).slice(2, 8)}`;
1288
+ env[sentinel] = "sbx-scrub-probe";
1123
1289
  let envOk = false;
1124
1290
  let envOut = "";
1125
1291
  try {
1126
1292
  const envCheck = await runSbxCli(["exec", name, "--", "sh", "-c", "env | grep -iE '^(DOCKER_|COMPOSE_)' || echo __CLEAN__"]);
1127
1293
  envOut = `${envCheck.stdout}\n${envCheck.stderr}`.trim();
1128
- envOk = envCheck.code === 0 && envOut.includes("__CLEAN__") && !envOut.includes("DOCKER_HOST_SENTINEL");
1294
+ envOk = envCheck.code === 0 && envOut.includes("__CLEAN__") && !envOut.includes(sentinel);
1129
1295
  } finally {
1130
- delete env.DOCKER_HOST_SENTINEL;
1296
+ delete env[sentinel];
1131
1297
  }
1132
1298
  results.push({
1133
1299
  check: "env: no DOCKER_*/COMPOSE_* variables leak into the sandbox",
@@ -1140,15 +1306,16 @@ async function toolVerify(): Promise<string> {
1140
1306
  // reach the sandbox (probe technique, but for a NON-docker var so it is
1141
1307
  // subject to the allowlist gate, not just the docker scrub).
1142
1308
  if (!envPassthrough()) {
1143
- env.__PI_DOCKER_SANDBOX_VERIFY_PROBE__ = "sbx-env-probe";
1309
+ const probeName = `__PI_DOCKER_SANDBOX_VERIFY_PROBE_${Math.random().toString(36).slice(2, 8)}__`;
1310
+ env[probeName] = "sbx-env-probe";
1144
1311
  let probeOk = false;
1145
1312
  let probeOut = "";
1146
1313
  try {
1147
- const probe = await runSbxCli(["exec", name, "--", "sh", "-c", "env | grep __PI_DOCKER_SANDBOX_VERIFY_PROBE__ || echo __PROBE_ABSENT__"]);
1314
+ const probe = await runSbxCli(["exec", name, "--", "sh", "-c", `env | grep -F ${probeName} || echo __PROBE_ABSENT__`]);
1148
1315
  probeOut = `${probe.stdout}\n${probe.stderr}`.trim();
1149
- probeOk = probe.code === 0 && probeOut.includes("__PROBE_ABSENT__") && !probeOut.includes("__PI_DOCKER_SANDBOX_VERIFY_PROBE__");
1316
+ probeOk = probe.code === 0 && probeOut.includes("__PROBE_ABSENT__") && !probeOut.includes(probeName);
1150
1317
  } finally {
1151
- delete env.__PI_DOCKER_SANDBOX_VERIFY_PROBE__;
1318
+ delete env[probeName];
1152
1319
  }
1153
1320
  results.push({
1154
1321
  check: "env: restricted forwarding (allowlist/strict) — non-allowlisted vars do not reach the sandbox",
@@ -1229,14 +1396,15 @@ async function toolVerify(): Promise<string> {
1229
1396
  }
1230
1397
  results.push(sockResult);
1231
1398
 
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);
1399
+ // 7. port bindings are host-localhost only (inspect the actual `sbx ports`
1400
+ // mappings rather than the `sbx ls` row, which may not render ports).
1401
+ const portsList = await runSbxCli(["ports", name]);
1402
+ const portLines = portsList.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
1403
+ const nonLocal = portLines.some((l) => !/^127\.0\.0\.1\s/.test(l) && /\d+\s+\d+\s+(tcp|udp)/.test(l));
1236
1404
  results.push({
1237
1405
  check: "network: published ports bind to host 127.0.0.1 only",
1238
1406
  ok: !nonLocal,
1239
- evidence: nonLocal ? row.trim() : (row.trim() || "no published ports currently"),
1407
+ evidence: nonLocal ? portLines.filter((l) => !/^127\.0\.0\.1\s/.test(l)).join("; ") : (portLines.join("; ") || "no published ports currently"),
1240
1408
  });
1241
1409
 
1242
1410
  const failed = results.filter((r) => !r.ok);
@@ -1269,6 +1437,8 @@ function keepalive(): boolean {
1269
1437
  return v === "1" || v === "true" || v === "yes" || v === "on";
1270
1438
  }
1271
1439
 
1440
+ let watchdogArmed = false;
1441
+
1272
1442
  /**
1273
1443
  * Arm a detached watchdog that tears the sandbox down when THIS pi process
1274
1444
  * exits — works even for SIGKILL/power kills that never fire session_shutdown.
@@ -1288,6 +1458,10 @@ function spawnWatchdog(): void {
1288
1458
  // applies even when teardown is "none" — a pinned/shared sandbox is exactly
1289
1459
  // the case where you want the VM kept alive but NOT removed.
1290
1460
  if (mode === "none" && !keep) return;
1461
+ // Arm once per process: session_start and ensureSandbox both call this, and
1462
+ // a second watchdog would just duplicate the same teardown/keepalive work.
1463
+ if (watchdogArmed) return;
1464
+ watchdogArmed = true;
1291
1465
  const op = mode === "stop" ? `stop ${name}` : `rm --force ${name}`;
1292
1466
  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
1467
  const lines = [
@@ -1415,10 +1589,18 @@ async function gcSweep(hours: number): Promise<string> {
1415
1589
  for (const b of boxes) {
1416
1590
  const n = b.name;
1417
1591
  if (!n || !n.startsWith("pi-sbx-") || n === current) continue;
1418
- if (b.status === "running") {
1592
+ const status = (b.status ?? "").trim().toLowerCase();
1593
+ if (status === "running") {
1419
1594
  kept.push(`${n} (running)`);
1420
1595
  continue;
1421
1596
  }
1597
+ // Conservative: only remove sandboxes we can positively identify as
1598
+ // stopped. Unknown/unexpected status strings are kept (never removed) —
1599
+ // a misread status must not cause a running sandbox to be reaped.
1600
+ if (status !== "stopped") {
1601
+ kept.push(`${n} (status "${b.status ?? "?"}" — kept)`);
1602
+ continue;
1603
+ }
1422
1604
  // Sibling guard: never remove a sandbox whose owner pi process is alive
1423
1605
  // (it may be idle with an idle-stopped VM — concurrent sessions must not
1424
1606
  // reap each other). Only stop-orphaned sandboxes are candidates.
@@ -1462,6 +1644,36 @@ function textResult(text: string): AgentToolResult<undefined> {
1462
1644
  /* extension registration */
1463
1645
  /* ------------------------------------------------------------------ */
1464
1646
 
1647
+ let lifecycleArmed = false;
1648
+
1649
+ /**
1650
+ * Arm everything that must outlive a session: the detached watchdog (teardown
1651
+ * on pi exit + keepalive pokes while pi lives), the owner marker that stops GC
1652
+ * reclaiming a live sandbox, and the startup sweep of stale sandboxes.
1653
+ *
1654
+ * Idempotent: the docker_* extension and the sandbox execution backend both
1655
+ * call this from their own session_start, and loading both must not double-arm.
1656
+ *
1657
+ * Called separately from ensureSandbox() on purpose — ensureSandbox returns
1658
+ * early when the sandbox ALREADY exists (a pinned DOCKER_SANDBOX, a resumed
1659
+ * session), so arming only there would silently skip keepalive/teardown for
1660
+ * every sandbox that was not created by this process.
1661
+ */
1662
+ async function armSessionLifecycle(): Promise<void> {
1663
+ if (lifecycleArmed) return;
1664
+ lifecycleArmed = true;
1665
+ spawnWatchdog();
1666
+ writeOwnerMarker(sessionSandboxName());
1667
+ const raw = Number(env.DOCKER_SANDBOX_GC_HOURS ?? "24");
1668
+ if (!Number.isFinite(raw) || raw <= 0) return;
1669
+ // Deliberately NOT awaited: the sweep is a background safety net for stale
1670
+ // sandboxes from crashed sessions, and session start must not wait on
1671
+ // `sbx ls` (and any sandboxd round trip) to get there.
1672
+ void gcSweep(raw)
1673
+ .then((summary) => console.error(`[docker-sandbox] ${summary}`))
1674
+ .catch((e) => console.error(`[docker-sandbox] gc at startup failed: ${(e as Error).message}`));
1675
+ }
1676
+
1465
1677
  export default function (pi: ExtensionAPI) {
1466
1678
  // Lifecycle: tear down this session's sandbox when the session ends
1467
1679
  // (exit / Ctrl+C / Ctrl+D / SIGHUP / SIGTERM, /new, /resume, /fork).
@@ -1470,18 +1682,11 @@ export default function (pi: ExtensionAPI) {
1470
1682
  });
1471
1683
 
1472
1684
  // 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).
1685
+ // arm the watchdog + owner marker for the current sandbox name (covers the
1686
+ // /resume case). Shared with the sandbox execution backend, which needs the
1687
+ // same lifecycle when it is loaded on its own.
1475
1688
  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
- }
1689
+ await armSessionLifecycle();
1485
1690
  });
1486
1691
 
1487
1692
  pi.registerTool({
@@ -1672,7 +1877,8 @@ export default function (pi: ExtensionAPI) {
1672
1877
  "publishes container ports on host 127.0.0.1 only, and this runs in the host pi process, so it is the way " +
1673
1878
  "to check a running service from the agent (the agent's VM cannot reach host loopback). " +
1674
1879
  "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.",
1880
+ "(content-type application/json). Only 127.0.0.1/localhost/::1 hosts AND ports published by this sandbox " +
1881
+ "are reachable. Returns status + body.",
1676
1882
  parameters: Type.Object({
1677
1883
  url: Type.String({ description: "Host-local URL of the published port, e.g. http://127.0.0.1:8080/health" }),
1678
1884
  timeoutSec: Type.Optional(Type.Number({ description: "Timeout in seconds (default 10)" })),
@@ -1749,4 +1955,22 @@ export default function (pi: ExtensionAPI) {
1749
1955
  }
1750
1956
 
1751
1957
  // Named exports for tests (pi's loader only calls the default factory).
1752
- export { scrubbedEnv, envForwardMode, envAllowlist, envPassthrough, sessionSandboxName };
1958
+ // The sbx kernel is also shared with the `sandbox/` execution-backend
1959
+ // extension (host pi + tools routed into the sandbox), so that the sandbox
1960
+ // lifecycle, env scrubbing and path confinement exist in exactly one place.
1961
+ export {
1962
+ scrubbedEnv,
1963
+ envForwardMode,
1964
+ envAllowlist,
1965
+ envPassthrough,
1966
+ sessionSandboxName,
1967
+ mapHostPath,
1968
+ assertSafeArg,
1969
+ hostRoot,
1970
+ findSbxCli,
1971
+ runSbxCli,
1972
+ sandboxExists,
1973
+ ensureSandbox,
1974
+ teardownSandbox,
1975
+ armSessionLifecycle,
1976
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stixxert/pi-docker-sandbox",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "description": "pi extension: a private docker sandbox (sbx microVM with its own daemon) as the agent's deploy target — the host's docker is never exposed.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -10,16 +10,33 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/stixxert/pi-docker-sandbox.git"
12
12
  },
13
- "files": ["index.ts", "README.md", "boundary.md", "security.md", "LICENSE"],
14
- "keywords": ["pi-package"],
13
+ "files": [
14
+ "index.ts",
15
+ "sandbox/",
16
+ "template/",
17
+ "test-loader.mjs",
18
+ "README.md",
19
+ "boundary.md",
20
+ "security.md",
21
+ "LICENSE"
22
+ ],
23
+ "keywords": [
24
+ "pi-package"
25
+ ],
15
26
  "type": "module",
16
27
  "scripts": {
17
28
  "typecheck": "tsc --noEmit",
18
29
  "test": "node smoke-test.mjs",
19
- "prepublishOnly": "npm run typecheck && npm test"
30
+ "e2e": "node sandbox/e2e.mjs",
31
+ "template": "bash template/build.sh",
32
+ "template:check": "bash template/build.sh --check",
33
+ "prepublishOnly": "npm run typecheck && npm test",
34
+ "release": "semantic-release"
20
35
  },
21
36
  "pi": {
22
- "extensions": ["./index.ts"]
37
+ "extensions": [
38
+ "./index.ts"
39
+ ]
23
40
  },
24
41
  "peerDependencies": {
25
42
  "@earendil-works/pi-ai": "*",
@@ -29,6 +46,7 @@
29
46
  "@earendil-works/pi-ai": "^0.84.1",
30
47
  "@earendil-works/pi-coding-agent": "^0.84.1",
31
48
  "@types/node": "^24.0.0",
49
+ "semantic-release": "^25.0.9",
32
50
  "typescript": "^5.9.0"
33
51
  }
34
52
  }