@stixxert/pi-docker-sandbox 1.1.0 → 1.1.2

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/README.md CHANGED
@@ -184,6 +184,11 @@ persistent name (e.g. a shared sandbox reused across restarts), pin
184
184
  - `DOCKER_SANDBOX_TEMPLATE=<name>` — use a pre-baked `sbx template` for
185
185
  auto-created sandboxes (avoids re-pulling common images every session;
186
186
  create with `sbx template save <name>` from a prepared sandbox).
187
+ - `DOCKER_SANDBOX_DEBUG=1` — emit the extension's lifecycle/GC diagnostics
188
+ (watchdog armed, teardown, startup sweep) to stderr. **Off by default**: the
189
+ extension runs inside the pi process, so stray console output would land on
190
+ the same terminal the TUI is drawing and corrupt the chat. Turn it on when
191
+ running `pi -p`, in a plain shell, or when diagnosing lifecycle issues.
187
192
 
188
193
  ## Ports (verified rules)
189
194
 
package/index.ts CHANGED
@@ -124,9 +124,9 @@ async function teardownSandbox(reason: string): Promise<void> {
124
124
  ].join("\n");
125
125
  const child = spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore", env: scrubbedEnv() });
126
126
  child.unref();
127
- console.error(`[docker-sandbox] session ${reason}: ${action} sandbox "${name}" (detached, retrying)`);
127
+ note(`session ${reason}: ${action} sandbox "${name}" (detached, retrying)`);
128
128
  } catch (e) {
129
- console.error(`[docker-sandbox] session ${reason}: teardown of "${name}" failed: ${(e as Error).message}`);
129
+ note(`session ${reason}: teardown of "${name}" failed: ${(e as Error).message}`);
130
130
  }
131
131
  }
132
132
 
@@ -141,6 +141,22 @@ function findSbxCli(): string {
141
141
  return "sbx";
142
142
  }
143
143
 
144
+ /**
145
+ * Diagnostics are OFF by default on purpose: the extension runs inside the pi
146
+ * process, so a raw console write lands on the terminal the TUI is drawing and
147
+ * corrupts the chat transcript. Set DOCKER_SANDBOX_DEBUG=1 (or SBX_PI_DEBUG=1 /
148
+ * SBX_DEBUG=1) to get the lifecycle/GC lines on stderr — useful in `pi -p`, a
149
+ * plain shell or when diagnosing, harmless in the TUI because it is opt-in.
150
+ */
151
+ function debugEnabled(): boolean {
152
+ return /^(1|true|yes|on)$/i.test((env.DOCKER_SANDBOX_DEBUG ?? env.SBX_PI_DEBUG ?? env.SBX_DEBUG ?? "").trim());
153
+ }
154
+
155
+ /** Diagnostic line — silent unless debug logging is enabled (see debugEnabled). */
156
+ function note(message: string): void {
157
+ if (debugEnabled()) console.error(`[docker-sandbox] ${message}`);
158
+ }
159
+
144
160
  /**
145
161
  * Non-secret vars always forwarded to children (the sbx CLI and shells need
146
162
  * them to function; they are not credentials).
@@ -1486,9 +1502,9 @@ function spawnWatchdog(): void {
1486
1502
  try {
1487
1503
  const child = spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore", env: scrubbedEnv() });
1488
1504
  child.unref();
1489
- console.error(`[docker-sandbox] watchdog armed for sandbox "${name}" (pid ${pid}, teardown=${mode}, keepalive=${keep})`);
1505
+ note(`watchdog armed for sandbox "${name}" (pid ${pid}, teardown=${mode}, keepalive=${keep})`);
1490
1506
  } catch (e) {
1491
- console.error(`[docker-sandbox] failed to arm watchdog: ${(e as Error).message}`);
1507
+ note(`failed to arm watchdog: ${(e as Error).message}`);
1492
1508
  }
1493
1509
  }
1494
1510
 
@@ -1670,8 +1686,8 @@ async function armSessionLifecycle(): Promise<void> {
1670
1686
  // sandboxes from crashed sessions, and session start must not wait on
1671
1687
  // `sbx ls` (and any sandboxd round trip) to get there.
1672
1688
  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}`));
1689
+ .then((summary) => note(summary))
1690
+ .catch((e) => note(`gc at startup failed: ${(e as Error).message}`));
1675
1691
  }
1676
1692
 
1677
1693
  export default function (pi: ExtensionAPI) {
@@ -1973,4 +1989,5 @@ export {
1973
1989
  ensureSandbox,
1974
1990
  teardownSandbox,
1975
1991
  armSessionLifecycle,
1992
+ debugEnabled,
1976
1993
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stixxert/pi-docker-sandbox",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
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": {
package/sandbox/e2e.mjs CHANGED
@@ -375,6 +375,7 @@ try {
375
375
  process.env.DOCKER_SANDBOX_KEEPALIVE = "1";
376
376
  process.env.DOCKER_SANDBOX_TEARDOWN = "none"; // no stray teardown attempt
377
377
  process.env.DOCKER_SANDBOX_GC_HOURS = "0"; // skip the startup sweep
378
+ process.env.DOCKER_SANDBOX_DEBUG = "1"; // lifecycle notes are debug-gated (silent in the TUI by default)
378
379
  const { armSessionLifecycle } = await loadTs("index.ts");
379
380
  const lifecycleLog = [];
380
381
  const realError = console.error;
@@ -389,6 +390,7 @@ try {
389
390
  );
390
391
  delete process.env.DOCKER_SANDBOX_TEARDOWN;
391
392
  delete process.env.DOCKER_SANDBOX_GC_HOURS;
393
+ delete process.env.DOCKER_SANDBOX_DEBUG;
392
394
 
393
395
  /* --- lightweight template handshake -------------------------------- */
394
396
  console.log("\nlightweight template handshake");
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Classification of a *sandbox-runtime* failure.
3
+ *
4
+ * `sbx exec` normally exits non-zero when the command inside the sandbox exits
5
+ * non-zero — but it ALSO exits non-zero when it never managed to reach the
6
+ * command at all, because the sandbox VM's runtime failed to come up. Those two
7
+ * cases look identical from the transport's point of view (a non-zero exit and
8
+ * some text on stderr), and confusing them is actively harmful:
9
+ *
10
+ * - reporting "not readable: <path>" / "Path not found" for a dead VM sends
11
+ * the agent chasing a file that is perfectly fine, and
12
+ * - treating it as a normal command result means nothing ever invalidates the
13
+ * memoised transport, so the backend never recovers.
14
+ *
15
+ * This module is deliberately tiny and has ZERO imports (not even node
16
+ * builtins) so the classifier can be unit-tested without the pi packages —
17
+ * see the consumer in `operations.ts`.
18
+ */
19
+
20
+ /** The `ExecOutcome` shape, structurally: an exit code plus its output. */
21
+ export interface ExecLike {
22
+ /** null when the process was killed by a signal. */
23
+ exitCode: number | null;
24
+ stdout?: Uint8Array | string;
25
+ stderr?: Uint8Array | string;
26
+ }
27
+
28
+ function asText(value: Uint8Array | string | undefined): string {
29
+ if (value === undefined) return "";
30
+ return typeof value === "string" ? value : new TextDecoder().decode(value);
31
+ }
32
+
33
+ /**
34
+ * The characteristic signatures of "the sandbox runtime could not start".
35
+ *
36
+ * Chosen to be NARROW. Each one is a phrase the `sbx` CLI or its in-VM runtime
37
+ * emits about the sandbox itself, not something a user command would plausibly
38
+ * print as its own output:
39
+ *
40
+ * 1. `failed to start sandbox` — the CLI's own wrapper line for "I could not
41
+ * bring this sandbox up", e.g. the reproduced
42
+ * `failed to start sandbox: start runtime: request failed: 500 ...`.
43
+ * 2. `start runtime: request failed: 5xx` — sandboxd's runtime API rejecting
44
+ * the start with an HTTP 5xx. Anchored on `start runtime: request failed:`
45
+ * rather than a bare `request failed: 500`, because the bare form could
46
+ * come from any command's stderr (a curl wrapper, an API client, ...) and
47
+ * would then misclassify an ordinary failing command.
48
+ * 3. `docker daemon failed to start` — the in-VM cause the runtime reports
49
+ * (`docker daemon failed to start inside the sandbox`).
50
+ *
51
+ * Matching deliberately requires BOTH a non-zero exit AND a signature on
52
+ * *stderr* (diagnostics live there; stdout is the command's own output, where a
53
+ * phrase like "request failed: 500" is far more likely to be a false match).
54
+ * A signal kill (exitCode null — our own abort/timeout) is never classified.
55
+ */
56
+ const RUNTIME_FAILURE_SIGNATURES: readonly RegExp[] = [
57
+ /failed to start sandbox\b/i,
58
+ /start runtime: request failed:\s*5\d\d\b/i,
59
+ /docker daemon failed to start\b/i,
60
+ ];
61
+
62
+ /**
63
+ * Is this outcome "the sandbox runtime failed", rather than "the command
64
+ * exited non-zero"? Conservative on purpose: a plain `exitCode 1` with ordinary
65
+ * stderr (e.g. `grep: no match`) must NOT match.
66
+ */
67
+ export function isSandboxUnavailableFailure(outcome: ExecLike): boolean {
68
+ if (outcome.exitCode === 0 || outcome.exitCode === null) return false;
69
+ const stderr = asText(outcome.stderr);
70
+ if (!stderr) return false;
71
+ return RUNTIME_FAILURE_SIGNATURES.some((signature) => signature.test(stderr));
72
+ }
73
+
74
+ /**
75
+ * The typed error for a dead sandbox runtime.
76
+ *
77
+ * Carries the sandbox target and the raw stderr, and its message names the
78
+ * sandbox explicitly so it is actionable wherever it surfaces — the caller
79
+ * (see `sandbox/index.ts`) turns it into a one-per-episode user notification
80
+ * and invalidates the memoised transport, so the NEXT tool call can start the
81
+ * VM again. It is never a licence to retry the command or to fall back to the
82
+ * host: re-running arbitrary work is unsafe, and the host is not the execution
83
+ * environment.
84
+ */
85
+ export class SandboxUnavailableError extends Error {
86
+ readonly target: string;
87
+ readonly stderr: string;
88
+
89
+ constructor(target: string, stderr: string) {
90
+ const detail = stderr.trim();
91
+ super(
92
+ `sbx sandbox "${target}" is unavailable: the sandbox runtime failed to start, so the command ` +
93
+ `never ran inside it (this is not the command's own exit status).\n` +
94
+ `The VM may need recreating (check \`sbx ls\`, \`sbx stop ${target}\`, or recreate it) — ` +
95
+ `the next tool call will try to start the sandbox again.` +
96
+ (detail ? `\n\n${detail}` : ""),
97
+ );
98
+ this.name = "SandboxUnavailableError";
99
+ this.target = target;
100
+ this.stderr = detail;
101
+ }
102
+ }
package/sandbox/index.ts CHANGED
@@ -40,7 +40,8 @@ import {
40
40
  createReadToolDefinition,
41
41
  createWriteToolDefinition,
42
42
  } from "@earendil-works/pi-coding-agent";
43
- import { armSessionLifecycle, envAllowlist, teardownSandbox } from "../index.ts";
43
+ import { armSessionLifecycle, debugEnabled, envAllowlist, teardownSandbox } from "../index.ts";
44
+ import { SandboxUnavailableError } from "./failure.ts";
44
45
  import {
45
46
  createBashOps,
46
47
  createEditOps,
@@ -78,6 +79,55 @@ export default function (pi: ExtensionAPI) {
78
79
  let transport: ExecTransport | undefined;
79
80
  let starting: Promise<ExecTransport | undefined> | undefined;
80
81
  let lastError: string | undefined;
82
+ /** Have we already told the user about the CURRENT run of runtime failures? */
83
+ let sandboxFailureReported = false;
84
+
85
+ /**
86
+ * Forget the memoised transport so the next tool call re-resolves it.
87
+ *
88
+ * This is the whole recovery mechanism: `resolveTransport()` re-derives the
89
+ * sandbox and (re)starts the VM, so a runtime failure is an episode rather
90
+ * than a permanent bricking of every remaining tool call in the session.
91
+ */
92
+ function invalidateTransport(): void {
93
+ transport = undefined;
94
+ starting = undefined;
95
+ }
96
+
97
+ /**
98
+ * Run one sandbox round-trip, turning a dead sandbox into: invalidate +
99
+ * notify (ONCE per episode) + rethrow.
100
+ *
101
+ * Deliberately NOT done here: retrying the command, or running it locally.
102
+ * Re-running arbitrary work is unsafe (side effects), and the host is not the
103
+ * execution environment — that would be a sandbox escape. The resolution-time
104
+ * local fallback (see `ensureTransport`) is unchanged and only applies when no
105
+ * transport can be resolved at all; it never triggers from here.
106
+ */
107
+ async function withSandboxFailureHandling<T>(
108
+ ctx: ExtensionContext | undefined,
109
+ run: () => Promise<T>,
110
+ ): Promise<T> {
111
+ try {
112
+ const result = await run();
113
+ sandboxFailureReported = false; // a success ends the episode
114
+ return result;
115
+ } catch (err) {
116
+ if (!(err instanceof SandboxUnavailableError)) throw err;
117
+ invalidateTransport();
118
+ if (!sandboxFailureReported) {
119
+ sandboxFailureReported = true;
120
+ ctx?.ui.notify(
121
+ `sbx sandbox "${err.target}" is unavailable — the sandbox runtime failed to start, so this tool ` +
122
+ `call did not run (it was NOT run on the host and was not retried). The next tool call will try ` +
123
+ `to start the sandbox again; if it keeps failing, the VM may need recreating (\`sbx ls\`, ` +
124
+ `then \`sbx stop ${err.target}\`).`,
125
+ "error",
126
+ );
127
+ }
128
+ throw err;
129
+ }
130
+ }
81
131
 
82
132
  /**
83
133
  * Resolve (and memoize) the transport. Never throws: if sbx is missing or
@@ -129,7 +179,9 @@ export default function (pi: ExtensionAPI) {
129
179
  async execute(id: unknown, params: unknown, signal: unknown, onUpdate: unknown, ctx?: ExtensionContext) {
130
180
  const t = await ensureTransport(ctx);
131
181
  if (!t) return (local.execute as Function)(id, params, signal, onUpdate, ctx);
132
- return (build(t).execute as Function)(id, params, signal, onUpdate, ctx);
182
+ return withSandboxFailureHandling(ctx, () =>
183
+ (build(t).execute as Function)(id, params, signal, onUpdate, ctx),
184
+ );
133
185
  },
134
186
  } as T;
135
187
  }
@@ -157,7 +209,10 @@ export default function (pi: ExtensionAPI) {
157
209
  void ensureTransport(ctx)
158
210
  .then((active) => (active?.kind === "sbx" ? armSessionLifecycle() : undefined))
159
211
  .catch((err) => {
160
- console.error(`[sbx] session start failed: ${err instanceof Error ? err.message : String(err)}`);
212
+ // Raw console writes land on the terminal the TUI is drawing, so cap
213
+ // the failure note behind the debug flag — the backend degrades to
214
+ // local tools either way (the `sbx` command reports live status).
215
+ if (debugEnabled()) console.error(`[sbx] session start failed: ${err instanceof Error ? err.message : String(err)}`);
161
216
  });
162
217
  });
163
218
 
@@ -202,7 +257,7 @@ export default function (pi: ExtensionAPI) {
202
257
  async execute(id, params, signal, onUpdate, ctx) {
203
258
  const t = await ensureTransport(ctx);
204
259
  if (!t) return localGrep.execute(id, params, signal, onUpdate, ctx);
205
- return executeSandboxGrep(t, localCwd, params as GrepToolInput);
260
+ return withSandboxFailureHandling(ctx, () => executeSandboxGrep(t, localCwd, params as GrepToolInput));
206
261
  },
207
262
  });
208
263
 
@@ -40,6 +40,7 @@ import type {
40
40
  ReadOperations,
41
41
  WriteOperations,
42
42
  } from "@earendil-works/pi-coding-agent";
43
+ import { SandboxUnavailableError, isSandboxUnavailableFailure } from "./failure.ts";
43
44
  import { type ExecOptions, type ExecOutcome, type ExecTransport, shArgs, shQuote } from "./transport.ts";
44
45
 
45
46
  /** Files larger than this would exceed a comfortable argv budget when base64'd. */
@@ -77,19 +78,42 @@ function opOpts(extra?: ExecOptions): ExecOptions {
77
78
  return { timeout: DEFAULT_OP_TIMEOUT, ...extra };
78
79
  }
79
80
 
81
+ /**
82
+ * Throw if this outcome means the sandbox runtime itself failed to start.
83
+ *
84
+ * A non-zero exit from `sbx exec` is ambiguous: it is the inner command's
85
+ * status when the VM was reachable, and the CLI's own failure when it was not.
86
+ * This is the one place that ambiguity is resolved, so no call site below can
87
+ * mistake "the whole sandbox is down" for "this file does not exist".
88
+ */
89
+ function throwIfSandboxUnavailable(t: ExecTransport, r: ExecOutcome): void {
90
+ if (isSandboxUnavailableFailure(r)) throw new SandboxUnavailableError(t.target, r.stderr.toString("utf8"));
91
+ }
92
+
80
93
  async function must(t: ExecTransport, argv: string[], fallback: string, opts?: ExecOptions): Promise<ExecOutcome> {
81
94
  const r = await t.exec(argv, opOpts(opts));
95
+ throwIfSandboxUnavailable(t, r);
82
96
  if (r.exitCode !== 0) throw new Error(errText(r, fallback));
83
97
  return r;
84
98
  }
85
99
 
100
+ /**
101
+ * "Did this probe exit 0?" for the `test -e` / `test -r` style primitives.
102
+ *
103
+ * A transport-level rejection (spawn failure, abort, timeout) is still reported
104
+ * as `false` — that is what this helper is for. A sandbox-runtime failure is
105
+ * NOT: it propagates, so callers cannot silently turn a dead VM into "this path
106
+ * does not exist".
107
+ */
86
108
  async function ok(t: ExecTransport, argv: string[]): Promise<boolean> {
109
+ let r: ExecOutcome;
87
110
  try {
88
- const r = await t.exec(argv, opOpts());
89
- return r.exitCode === 0;
111
+ r = await t.exec(argv, opOpts());
90
112
  } catch {
91
113
  return false;
92
114
  }
115
+ throwIfSandboxUnavailable(t, r);
116
+ return r.exitCode === 0;
93
117
  }
94
118
 
95
119
  /* ------------------------------------------------------------------ */
@@ -215,7 +239,13 @@ export function createLsOps(t: ExecTransport): LsOperations {
215
239
  if (knownHit !== undefined) return { isDirectory: () => knownHit };
216
240
  const cached = listings.get(path.dirname(p));
217
241
  if (cached) {
218
- const entries = await cached.catch(() => undefined);
242
+ // A failed parent listing is normally "not there / unreadable", which
243
+ // just means "fall through to a direct probe". A sandbox-runtime
244
+ // failure is not that, and must not be swallowed.
245
+ const entries = await cached.catch((err) => {
246
+ if (err instanceof SandboxUnavailableError) throw err;
247
+ return undefined;
248
+ });
219
249
  const hit = entries?.get(path.basename(p));
220
250
  if (hit !== undefined) {
221
251
  known.set(p, hit);
@@ -253,13 +283,16 @@ async function gitSearchableFiles(t: ExecTransport, root: string): Promise<strin
253
283
  shArgs('git -c safe.directory=\'*\' -C "$1" ls-files -z --cached --others --exclude-standard', root),
254
284
  opOpts(),
255
285
  );
286
+ // Not a repo / git missing: fall back to the walk. A dead sandbox: throw.
287
+ throwIfSandboxUnavailable(t, r);
256
288
  if (r.exitCode !== 0) return null;
257
289
  const files: string[] = [];
258
290
  for (const relative of r.stdout.toString("utf8").split("\0")) {
259
291
  if (relative) files.push(path.join(root, relative));
260
292
  }
261
293
  return files;
262
- } catch {
294
+ } catch (err) {
295
+ if (err instanceof SandboxUnavailableError) throw err;
263
296
  return null;
264
297
  }
265
298
  }
@@ -326,7 +359,8 @@ async function walkFiles(
326
359
  let entries: Map<string, boolean>;
327
360
  try {
328
361
  entries = await listDirEntries(t, dir);
329
- } catch {
362
+ } catch (err) {
363
+ if (err instanceof SandboxUnavailableError) throw err;
330
364
  return true; // unreadable subtree: skip, like the built-in does
331
365
  }
332
366
  for (const [name, isDir] of entries) {
@@ -367,7 +401,8 @@ export async function executeSandboxGrep(
367
401
  let content: string;
368
402
  try {
369
403
  content = (await readBytes(t, absolute)).toString("utf8");
370
- } catch {
404
+ } catch (err) {
405
+ if (err instanceof SandboxUnavailableError) throw err;
371
406
  return true; // binary/unreadable file
372
407
  }
373
408
  const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
@@ -490,6 +525,13 @@ export function createBashOps(t: ExecTransport, options: BashOpsOptions = {}): B
490
525
  .filter(Boolean)
491
526
  .join("\n");
492
527
  const r = await t.exec(["sh", "-lc", script], { onData, signal, timeout });
528
+ // A non-zero exit here is usually the command's OWN status (grep found
529
+ // nothing, a test failed, `false`) and must stay an exit code. But when
530
+ // the signature says the VM never started, the command did not run at
531
+ // all — surface that instead of a bogus status, so the caller can
532
+ // invalidate the transport. bash is the most common way to hit a dead
533
+ // sandbox, so it must not be the one path that never recovers.
534
+ throwIfSandboxUnavailable(t, r);
493
535
  return { exitCode: r.exitCode };
494
536
  },
495
537
  };