@stixxert/pi-docker-sandbox 1.1.4 → 1.1.5

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
@@ -44,6 +44,7 @@ import path from "node:path";
44
44
  import fs from "node:fs";
45
45
  import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
46
46
  import { Type, type Static } from "@earendil-works/pi-ai";
47
+ import { claimLifecycleOwnership } from "./sandbox/session-scope.ts";
47
48
 
48
49
  // Host pi cwd is the root that the agent's VM mounts at /workspace.
49
50
  const hostRoot = process.cwd();
@@ -1691,9 +1692,15 @@ async function armSessionLifecycle(): Promise<void> {
1691
1692
  }
1692
1693
 
1693
1694
  export default function (pi: ExtensionAPI) {
1695
+ // The sandbox execution backend (`sandbox/`) shares this lifecycle, and the
1696
+ // auto-discovered `sbx-backend` bridge loads that backend into subagent
1697
+ // sessions so their built-in tools run in the same sandbox. Only the
1698
+ // top-level session — the first to load either entry — may tear it down.
1699
+ const ownsLifecycle = claimLifecycleOwnership();
1694
1700
  // Lifecycle: tear down this session's sandbox when the session ends
1695
1701
  // (exit / Ctrl+C / Ctrl+D / SIGHUP / SIGTERM, /new, /resume, /fork).
1696
1702
  pi.on("session_shutdown", async () => {
1703
+ if (!ownsLifecycle) return;
1697
1704
  await teardownSandbox("session_shutdown");
1698
1705
  });
1699
1706
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stixxert/pi-docker-sandbox",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
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/index.ts CHANGED
@@ -29,6 +29,7 @@
29
29
  * DOCKER_SANDBOX / DOCKER_SANDBOX_* see the docker_* extension
30
30
  */
31
31
 
32
+ import path from "node:path";
32
33
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
33
34
  import {
34
35
  type GrepToolInput,
@@ -58,8 +59,15 @@ import {
58
59
  executeSandboxGrep,
59
60
  } from "./operations.ts";
60
61
  import { type ExecTransport, defaultProjectSandbox, resolveTransport } from "./transport.ts";
62
+ import { claimLifecycleOwnership } from "./session-scope.ts";
61
63
 
62
64
  export default function (pi: ExtensionAPI) {
65
+ // Subagent sessions (pi-subagents) load this router too, through the
66
+ // auto-discovered `sbx-backend` bridge, so a subagent's built-in tools run in
67
+ // the same sandbox as the main agent's instead of on the host. Only the
68
+ // top-level session may tear the shared (per-process) sandbox down; child
69
+ // sessions share it.
70
+ const ownsLifecycle = claimLifecycleOwnership();
63
71
  const localCwd = process.cwd();
64
72
 
65
73
  // Settle the sandbox NAME synchronously, before any session event can compute
@@ -208,6 +216,30 @@ export default function (pi: ExtensionAPI) {
208
216
  return starting;
209
217
  }
210
218
 
219
+ /**
220
+ * The working directory a session's routed tools must operate in.
221
+ *
222
+ * The backend mounts the process working directory (`localCwd`) into the
223
+ * sandbox, while pi builds a session's tools with that SESSION's cwd. They
224
+ * coincide for the top-level session, but not necessarily for a subagent — a
225
+ * pi-subagents git worktree lives under the host tmpdir, outside the mount.
226
+ * Routing now happens for subagents too, so the cwd is resolved per call: a
227
+ * directory inside the mount is used as-is, one outside it is refused rather
228
+ * than silently executed against (or written to) the wrong tree.
229
+ */
230
+ function sessionCwd(ctx?: ExtensionContext): string {
231
+ const cwd = ctx?.cwd;
232
+ if (!cwd || cwd === localCwd) return localCwd;
233
+ const rel = path.relative(localCwd, cwd);
234
+ if (rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)) return cwd;
235
+ throw new Error(
236
+ `sbx sandbox mounts only ${localCwd}, but this session's working directory is ${cwd}. ` +
237
+ `Its tools cannot be routed into the sandbox; nothing was executed. ` +
238
+ `Do not use worktree isolation (\`isolation: "worktree"\`) under the sbx backend, ` +
239
+ `or re-run the session without it.`,
240
+ );
241
+ }
242
+
211
243
  /**
212
244
  * Route a tool to the sandbox, refusing to run it on the host when no sandbox
213
245
  * can be had (fail closed) unless the user opted in.
@@ -217,18 +249,21 @@ export default function (pi: ExtensionAPI) {
217
249
  */
218
250
  function routed<T extends { execute: (...args: never[]) => unknown }>(
219
251
  local: T,
220
- build: (t: ExecTransport) => T,
252
+ build: (t: ExecTransport, cwd: string) => T,
221
253
  ): T {
222
254
  return {
223
255
  ...local,
224
256
  async execute(id: unknown, params: unknown, signal: unknown, onUpdate: unknown, ctx?: ExtensionContext) {
257
+ // Resolved before the transport: an unroutable cwd must fail closed
258
+ // whether or not a sandbox happens to be available.
259
+ const cwd = sessionCwd(ctx);
225
260
  const t = await ensureTransport(ctx);
226
261
  if (!t) {
227
262
  assertLocalFallbackAllowed();
228
263
  return (local.execute as Function)(id, params, signal, onUpdate, ctx);
229
264
  }
230
265
  return withSandboxFailureHandling(ctx, () =>
231
- (build(t).execute as Function)(id, params, signal, onUpdate, ctx),
266
+ (build(t, cwd).execute as Function)(id, params, signal, onUpdate, ctx),
232
267
  );
233
268
  },
234
269
  } as T;
@@ -266,6 +301,9 @@ export default function (pi: ExtensionAPI) {
266
301
  });
267
302
 
268
303
  pi.on("session_shutdown", async () => {
304
+ // Child (subagent) sessions share the parent's per-process sandbox;
305
+ // tearing it down from one would kill the VM out from under the parent.
306
+ if (!ownsLifecycle) return;
269
307
  // Only an sbx sandbox is ours to reclaim; a container backend is a
270
308
  // caller-supplied environment (docker_* owns its own sandbox lifecycle).
271
309
  if (transport?.kind === "sbx") await teardownSandbox("session_shutdown");
@@ -295,12 +333,12 @@ export default function (pi: ExtensionAPI) {
295
333
  },
296
334
  });
297
335
 
298
- pi.registerTool(routed(localRead, (t) => createReadToolDefinition(localCwd, { operations: createReadOps(t) })));
299
- pi.registerTool(routed(localWrite, (t) => createWriteToolDefinition(localCwd, { operations: createWriteOps(t) })));
300
- pi.registerTool(routed(localEdit, (t) => createEditToolDefinition(localCwd, { operations: createEditOps(t) })));
301
- pi.registerTool(routed(localBash, (t) => createBashToolDefinition(localCwd, { operations: createBashOps(t, { allowEnv: bashAllowEnv }) })));
302
- pi.registerTool(routed(localLs, (t) => createLsToolDefinition(localCwd, { operations: createLsOps(t) })));
303
- pi.registerTool(routed(localFind, (t) => createFindToolDefinition(localCwd, { operations: createFindOps(t) })));
336
+ pi.registerTool(routed(localRead, (t, cwd) => createReadToolDefinition(cwd, { operations: createReadOps(t) })));
337
+ pi.registerTool(routed(localWrite, (t, cwd) => createWriteToolDefinition(cwd, { operations: createWriteOps(t) })));
338
+ pi.registerTool(routed(localEdit, (t, cwd) => createEditToolDefinition(cwd, { operations: createEditOps(t) })));
339
+ pi.registerTool(routed(localBash, (t, cwd) => createBashToolDefinition(cwd, { operations: createBashOps(t, { allowEnv: bashAllowEnv }) })));
340
+ pi.registerTool(routed(localLs, (t, cwd) => createLsToolDefinition(cwd, { operations: createLsOps(t) })));
341
+ pi.registerTool(routed(localFind, (t, cwd) => createFindToolDefinition(cwd, { operations: createFindOps(t) })));
304
342
  // grep is replaced wholesale, not merely re-pointed: pi's grep tool spawns
305
343
  // host ripgrep for match discovery regardless of custom operations, which
306
344
  // would scan the host filesystem and require rg on the host. The sandbox
@@ -308,12 +346,13 @@ export default function (pi: ExtensionAPI) {
308
346
  pi.registerTool({
309
347
  ...localGrep,
310
348
  async execute(id, params, signal, onUpdate, ctx) {
349
+ const cwd = sessionCwd(ctx);
311
350
  const t = await ensureTransport(ctx);
312
351
  if (!t) {
313
352
  assertLocalFallbackAllowed();
314
353
  return localGrep.execute(id, params, signal, onUpdate, ctx);
315
354
  }
316
- return withSandboxFailureHandling(ctx, () => executeSandboxGrep(t, localCwd, params as GrepToolInput));
355
+ return withSandboxFailureHandling(ctx, () => executeSandboxGrep(t, cwd, params as GrepToolInput));
317
356
  },
318
357
  });
319
358
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Session-scope guards for the sbx tool router.
3
+ *
4
+ * **Zero imports on purpose** (same discipline as `failure.ts`): the vendored
5
+ * repo's own suite cannot run from the config repo, but this module can be
6
+ * unit-tested by relative path from `agent/extensions/sbx-backend/`.
7
+ *
8
+ * Why these exist: `@tintinweb/pi-subagents` builds every subagent as a FRESH
9
+ * `AgentSession` **in the same process**, seeded from pi's *built-in* tool
10
+ * definitions and a fresh `DefaultResourceLoader`. It never sees the parent's
11
+ * CLI `-e <checkout>/sandbox` extension, so without help a subagent's `bash`
12
+ * runs on the HOST (measured: `uid=502(sas054)` / `Darwin`, CoreSimulator
13
+ * visible) while the main agent's runs in the VM.
14
+ *
15
+ * The bridge that fixes that (`agent/extensions/sbx-backend/`) is loaded into
16
+ * every subagent session, and it is the ONLY loader under `pidock` (the
17
+ * launcher no longer passes `-e`). Two invariants still need enforcing:
18
+ *
19
+ * 1. **Only the top-level session tears the sandbox down.** The sandbox name
20
+ * is per-process (`pi-sbx-<pid>-<rand>`); every child session shares the
21
+ * parent's VM. A child's `session_shutdown` would otherwise `sbx rm
22
+ * --force` the sandbox out from under the running parent.
23
+ *
24
+ * 2. **One loader per session.** Normally this is by construction, but if a
25
+ * caller ALSO loads the backend explicitly (`pi -e <checkout>/sandbox,
26
+ * the vendored repo's standalone usage) while the bridge is active, pi
27
+ * refuses the second registration (`Tool "read" conflicts with …`). There
28
+ * is no way to dedupe that from here: pi gives each EXTENSION its own
29
+ * `ExtensionAPI` object (measured — a marker stored on it did not dedupe),
30
+ * so the two loaders cannot see each other. Documented, not guarded.
31
+ */
32
+
33
+ /** Process-wide marker: which session owns sandbox teardown. */
34
+ const LIFECYCLE_OWNER = Symbol.for("pi-docker-sandbox.lifecycle.owner");
35
+
36
+ /**
37
+ * Claim process-wide lifecycle ownership.
38
+ *
39
+ * `true` only for the FIRST session to ask — the top-level one, which is
40
+ * necessarily the first to load the router. Child sessions get `false` and
41
+ * must skip teardown. The claim is intentionally never released: pi can create
42
+ * a new top-level session in the same process (`/new`), and that session must
43
+ * not tear down a sandbox other sessions may still be using; leaked sandboxes
44
+ * are reclaimed by the existing `gcSweep` safety net.
45
+ */
46
+ export function claimLifecycleOwnership(): boolean {
47
+ const g = globalThis as unknown as Record<symbol, unknown>;
48
+ if (g[LIFECYCLE_OWNER]) return false;
49
+ g[LIFECYCLE_OWNER] = true;
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Whether the launcher selected the sbx router for this process.
55
+ *
56
+ * `sbx/pidock` exports `PI_TOOL_ROUTER=sbx` to make the auto-discovered
57
+ * gondolin backend yield; this backend uses the same marker so the bridge is
58
+ * inert under bare `pi`, `pix` and `sbxpi` (none of which route host tools
59
+ * into an sbx sandbox).
60
+ */
61
+ export function isSbxRouterSelected(env: Record<string, string | undefined>): boolean {
62
+ return (env.PI_TOOL_ROUTER ?? "").trim().toLowerCase() === "sbx";
63
+ }