@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 +7 -0
- package/package.json +1 -1
- package/sandbox/index.ts +48 -9
- package/sandbox/session-scope.ts +63 -0
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.
|
|
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(
|
|
299
|
-
pi.registerTool(routed(localWrite, (t) => createWriteToolDefinition(
|
|
300
|
-
pi.registerTool(routed(localEdit, (t) => createEditToolDefinition(
|
|
301
|
-
pi.registerTool(routed(localBash, (t) => createBashToolDefinition(
|
|
302
|
-
pi.registerTool(routed(localLs, (t) => createLsToolDefinition(
|
|
303
|
-
pi.registerTool(routed(localFind, (t) => createFindToolDefinition(
|
|
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,
|
|
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
|
+
}
|