@stixxert/pi-docker-sandbox 1.1.3 → 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/README.md +5 -0
- package/index.ts +7 -0
- package/package.json +1 -1
- package/sandbox/README.md +10 -6
- package/sandbox/failure.ts +85 -0
- package/sandbox/index.ts +124 -27
- package/sandbox/session-scope.ts +63 -0
package/README.md
CHANGED
|
@@ -189,6 +189,11 @@ persistent name (e.g. a shared sandbox reused across restarts), pin
|
|
|
189
189
|
extension runs inside the pi process, so stray console output would land on
|
|
190
190
|
the same terminal the TUI is drawing and corrupt the chat. Turn it on when
|
|
191
191
|
running `pi -p`, in a plain shell, or when diagnosing lifecycle issues.
|
|
192
|
+
- `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1` — allow tools to run directly on the
|
|
193
|
+
**host** when no sandbox can be resolved. **Off by default**: a missing `sbx`
|
|
194
|
+
CLI or an unstartable VM makes tool calls **fail closed** (refused with an
|
|
195
|
+
actionable error) rather than silently executing on the host, which is a
|
|
196
|
+
sandbox escape. Set this only if you accept unsandboxed execution.
|
|
192
197
|
|
|
193
198
|
## Ports (verified rules)
|
|
194
199
|
|
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/README.md
CHANGED
|
@@ -87,10 +87,12 @@ entry; over `sbx exec` that would be N+3 sandbox round-trips per listing. A
|
|
|
87
87
|
single POSIX-sh pass returns `d`/`f` + name and is memoised for the duration
|
|
88
88
|
of that one tool call (verified: 25 entries => ≤ 4 round-trips).
|
|
89
89
|
|
|
90
|
-
**It degrades instead of breaking.** If `sbx` is missing or the sandbox
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
**It degrades instead of breaking.** If `sbx` is missing or the sandbox cannot
|
|
91
|
+
be provisioned, the tools **fail closed** rather than silently running on the
|
|
92
|
+
host: the call is refused with an actionable error naming the cause and the
|
|
93
|
+
opt-in, the user is notified, and the system prompt says so explicitly — the
|
|
94
|
+
agent is never led to believe it is sandboxed when it is not. Running directly
|
|
95
|
+
on the host requires an explicit `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1`.
|
|
94
96
|
|
|
95
97
|
## Trying it out (before publishing)
|
|
96
98
|
|
|
@@ -129,8 +131,9 @@ bash sandbox/try.sh --docker -- -p --tools read "Read /opt/only-in-sandbox.txt"
|
|
|
129
131
|
|
|
130
132
|
If the first reports the container's OS and the second returns the file, the
|
|
131
133
|
routing works. If the extension failed to load you get a missing-tool error
|
|
132
|
-
instead —
|
|
133
|
-
|
|
134
|
+
instead — and if no sandbox can be resolved, tool calls are **refused** by
|
|
135
|
+
default rather than falling back to the host (the refusal and its opt-in are
|
|
136
|
+
reported in the system prompt and via `/sbx`).
|
|
134
137
|
|
|
135
138
|
### On the host, with real sbx
|
|
136
139
|
|
|
@@ -171,6 +174,7 @@ edits go through pi's own tools.
|
|
|
171
174
|
| `DOCKER_SANDBOX_KEEPALIVE` | **default `1` here** — keeps the VM running for the life of the pi process; set `0` to allow idle-stop |
|
|
172
175
|
| `DOCKER_SANDBOX_ENV_ALLOWLIST` | additionally export these host vars into the sandbox shell (default: `PI_*` only) |
|
|
173
176
|
| `DOCKER_SANDBOX` | pin the sandbox name (also disables per-project derivation) |
|
|
177
|
+
| `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1` | **fail-closed default override** — permit tools to run directly on the host when no sandbox can be resolved (default: refuse) |
|
|
174
178
|
| `SBX_EPHEMERAL` | `1` = throwaway per-session sandbox, removed at exit |
|
|
175
179
|
| `SBX_PI_DEBUG` | `1` = log per-phase startup timings to stderr |
|
|
176
180
|
| `DOCKER_SANDBOX_TEARDOWN` | `remove` / `stop` / `none` (a per-project sandbox defaults to `none`) |
|
package/sandbox/failure.ts
CHANGED
|
@@ -111,6 +111,91 @@ export class SandboxUnavailableError extends Error {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/* ------------------------------------------------------------------ */
|
|
115
|
+
/* fail-closed policy when NO sandbox can be resolved at all */
|
|
116
|
+
/* ------------------------------------------------------------------ */
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The opt-in that permits running tools directly on the HOST when no sandbox
|
|
120
|
+
* can be resolved.
|
|
121
|
+
*
|
|
122
|
+
* The default is to REFUSE (fail closed): an unresolvable sandbox must never
|
|
123
|
+
* mean "silently run on the host" — that is a sandbox escape, and the host is
|
|
124
|
+
* not the execution environment. Named with the repo's own `DOCKER_SANDBOX_*`
|
|
125
|
+
* prefix and deliberately NOT tied to any launcher (e.g. pidock's separate
|
|
126
|
+
* `PIDOCK_ALLOW_UNSANDBOXED`), so the backend stays usable standalone.
|
|
127
|
+
*/
|
|
128
|
+
export const UNSANDBOXED_OPT_IN_ENV = "DOCKER_SANDBOX_ALLOW_UNSANDBOXED";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* True when the user has explicitly opted into unsandboxed operation via
|
|
132
|
+
* `UNSANDBOXED_OPT_IN_ENV`.
|
|
133
|
+
*
|
|
134
|
+
* Accepts `1`, `true`, `yes`, `on` (case-insensitive, surrounding whitespace
|
|
135
|
+
* ignored) — the same boolean vocabulary as the repo's other knobs
|
|
136
|
+
* (`DOCKER_SANDBOX_DEBUG`, `DOCKER_SANDBOX_ENV_PASSTHROUGH`, ...). Anything
|
|
137
|
+
* else, including unset and `0`/`false`/`no`/`off`, leaves the secure default:
|
|
138
|
+
* refuse.
|
|
139
|
+
*/
|
|
140
|
+
export function unsandboxedAllowed(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
141
|
+
return /^(1|true|yes|on)$/i.test((env[UNSANDBOXED_OPT_IN_ENV] ?? "").trim());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The typed error for a tool call REFUSED because no sandbox could be resolved
|
|
146
|
+
* and the host fallback is disabled.
|
|
147
|
+
*
|
|
148
|
+
* Distinct from `SandboxUnavailableError`: that one is a sandbox that existed
|
|
149
|
+
* and failed mid-round-trip (transient — the next call re-resolves and may
|
|
150
|
+
* recover). This one means there was never a sandbox to fail in, and the
|
|
151
|
+
* fail-closed policy has refused to run the tool anywhere. It is actionable,
|
|
152
|
+
* not a stack trace: it names the cause, states in as many words that the
|
|
153
|
+
* command was NOT run on the host, and names the exact variable that would opt
|
|
154
|
+
* into unsandboxed operation.
|
|
155
|
+
*/
|
|
156
|
+
export class SandboxRequiredError extends Error {
|
|
157
|
+
/** The resolution failure that led here (may be empty). */
|
|
158
|
+
readonly failure: string;
|
|
159
|
+
|
|
160
|
+
constructor(failure?: string) {
|
|
161
|
+
const detail = (failure ?? "").trim();
|
|
162
|
+
super(
|
|
163
|
+
`sbx sandbox unavailable: ${
|
|
164
|
+
detail ||
|
|
165
|
+
"no sandbox transport could be resolved (is the `sbx` CLI installed and the VM runnable?)"
|
|
166
|
+
}.\n` +
|
|
167
|
+
`This tool was NOT run: it did not execute in the sandbox, and it was NOT run on the host either.\n` +
|
|
168
|
+
`Refusing to run unsandboxed by default. To allow tools to run directly on the host instead, set ` +
|
|
169
|
+
`${UNSANDBOXED_OPT_IN_ENV}=1 and retry.`,
|
|
170
|
+
);
|
|
171
|
+
this.name = "SandboxRequiredError";
|
|
172
|
+
this.failure = detail;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The fail-closed policy for a resolution failure: from an environment snapshot
|
|
178
|
+
* plus the resolution failure, decide whether the caller may fall back to the
|
|
179
|
+
* LOCAL (host) tool or must refuse.
|
|
180
|
+
*
|
|
181
|
+
* `{ allow: true }` only when the user has explicitly opted in with
|
|
182
|
+
* `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1` (see `unsandboxedAllowed`). Otherwise the
|
|
183
|
+
* decision carries a `SandboxRequiredError` whose message names the cause, says
|
|
184
|
+
* the command was not run on the host, and names the opt-in variable.
|
|
185
|
+
*
|
|
186
|
+
* Pure and dependency-free (like the rest of this module) so the policy can be
|
|
187
|
+
* unit-tested without the pi packages.
|
|
188
|
+
*/
|
|
189
|
+
export type LocalFallbackDecision = { allow: true } | { allow: false; error: SandboxRequiredError };
|
|
190
|
+
|
|
191
|
+
export function decideLocalFallback(
|
|
192
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
193
|
+
failure?: string,
|
|
194
|
+
): LocalFallbackDecision {
|
|
195
|
+
if (unsandboxedAllowed(env)) return { allow: true };
|
|
196
|
+
return { allow: false, error: new SandboxRequiredError(failure) };
|
|
197
|
+
}
|
|
198
|
+
|
|
114
199
|
/**
|
|
115
200
|
* Per-episode state for the runtime-failure notification.
|
|
116
201
|
*
|
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,
|
|
@@ -41,7 +42,13 @@ import {
|
|
|
41
42
|
createWriteToolDefinition,
|
|
42
43
|
} from "@earendil-works/pi-coding-agent";
|
|
43
44
|
import { armSessionLifecycle, debugEnabled, envAllowlist, teardownSandbox } from "../index.ts";
|
|
44
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
SandboxFailureEpisode,
|
|
47
|
+
SandboxUnavailableError,
|
|
48
|
+
UNSANDBOXED_OPT_IN_ENV,
|
|
49
|
+
decideLocalFallback,
|
|
50
|
+
unsandboxedAllowed,
|
|
51
|
+
} from "./failure.ts";
|
|
45
52
|
import {
|
|
46
53
|
createBashOps,
|
|
47
54
|
createEditOps,
|
|
@@ -52,8 +59,15 @@ import {
|
|
|
52
59
|
executeSandboxGrep,
|
|
53
60
|
} from "./operations.ts";
|
|
54
61
|
import { type ExecTransport, defaultProjectSandbox, resolveTransport } from "./transport.ts";
|
|
62
|
+
import { claimLifecycleOwnership } from "./session-scope.ts";
|
|
55
63
|
|
|
56
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();
|
|
57
71
|
const localCwd = process.cwd();
|
|
58
72
|
|
|
59
73
|
// Settle the sandbox NAME synchronously, before any session event can compute
|
|
@@ -105,6 +119,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
105
119
|
transport = undefined;
|
|
106
120
|
}
|
|
107
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The fail-closed gate for "there is no transport at all".
|
|
124
|
+
*
|
|
125
|
+
* Returns normally ONLY when the user has explicitly opted into unsandboxed
|
|
126
|
+
* operation (`DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1`); otherwise throws the
|
|
127
|
+
* typed `SandboxRequiredError`, so the tool call is REFUSED instead of being
|
|
128
|
+
* silently executed on the host. The decision itself lives in `failure.ts`,
|
|
129
|
+
* which is import-free and unit-tested.
|
|
130
|
+
*/
|
|
131
|
+
function assertLocalFallbackAllowed(): void {
|
|
132
|
+
const decision = decideLocalFallback(process.env, lastError);
|
|
133
|
+
if (decision.allow === false) throw decision.error;
|
|
134
|
+
}
|
|
135
|
+
|
|
108
136
|
/**
|
|
109
137
|
* Run one sandbox round-trip, turning a dead sandbox into: invalidate +
|
|
110
138
|
* notify (ONCE per episode) + rethrow.
|
|
@@ -144,10 +172,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
144
172
|
}
|
|
145
173
|
|
|
146
174
|
/**
|
|
147
|
-
* Resolve (and memoize) the transport. Never throws: if sbx is missing or
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
175
|
+
* Resolve (and memoize) the transport. Never throws: if sbx is missing or the
|
|
176
|
+
* sandbox cannot be provisioned, `undefined` is returned and the degradation
|
|
177
|
+
* is reported — both to the user and in the system prompt. The CALLER of a
|
|
178
|
+
* tool call then decides what to do (see `assertLocalFallbackAllowed`): by
|
|
179
|
+
* default it REFUSES, so the host is never silently used as the execution
|
|
180
|
+
* environment; only an explicit `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1` opt-in
|
|
181
|
+
* permits the local fallback.
|
|
151
182
|
*/
|
|
152
183
|
async function ensureTransport(ctx?: ExtensionContext): Promise<ExecTransport | undefined> {
|
|
153
184
|
if (transport) return transport;
|
|
@@ -169,7 +200,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
169
200
|
} catch (err) {
|
|
170
201
|
lastError = err instanceof Error ? err.message : String(err);
|
|
171
202
|
ctx?.ui.setStatus("sbx", ctx.ui.theme.fg("error", "sbx: unavailable"));
|
|
172
|
-
ctx?.ui.notify(
|
|
203
|
+
ctx?.ui.notify(
|
|
204
|
+
unsandboxedAllowed(process.env)
|
|
205
|
+
? `sbx backend unavailable — ${UNSANDBOXED_OPT_IN_ENV}=1, so tools run directly on the host.\n${lastError}`
|
|
206
|
+
: `sbx backend unavailable — refusing tool calls; nothing will run on the host.\n` +
|
|
207
|
+
`Set ${UNSANDBOXED_OPT_IN_ENV}=1 to run tools directly on the host instead.\n${lastError}`,
|
|
208
|
+
"warning",
|
|
209
|
+
);
|
|
173
210
|
return undefined;
|
|
174
211
|
} finally {
|
|
175
212
|
starting = undefined;
|
|
@@ -180,21 +217,53 @@ export default function (pi: ExtensionAPI) {
|
|
|
180
217
|
}
|
|
181
218
|
|
|
182
219
|
/**
|
|
183
|
-
*
|
|
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
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Route a tool to the sandbox, refusing to run it on the host when no sandbox
|
|
245
|
+
* can be had (fail closed) unless the user opted in.
|
|
246
|
+
*
|
|
184
247
|
* `ctx` is forwarded — the built-ins use it to inject PI_* session metadata
|
|
185
248
|
* into the bash environment, and dropping it would silently change behaviour.
|
|
186
249
|
*/
|
|
187
250
|
function routed<T extends { execute: (...args: never[]) => unknown }>(
|
|
188
251
|
local: T,
|
|
189
|
-
build: (t: ExecTransport) => T,
|
|
252
|
+
build: (t: ExecTransport, cwd: string) => T,
|
|
190
253
|
): T {
|
|
191
254
|
return {
|
|
192
255
|
...local,
|
|
193
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);
|
|
194
260
|
const t = await ensureTransport(ctx);
|
|
195
|
-
if (!t)
|
|
261
|
+
if (!t) {
|
|
262
|
+
assertLocalFallbackAllowed();
|
|
263
|
+
return (local.execute as Function)(id, params, signal, onUpdate, ctx);
|
|
264
|
+
}
|
|
196
265
|
return withSandboxFailureHandling(ctx, () =>
|
|
197
|
-
(build(t).execute as Function)(id, params, signal, onUpdate, ctx),
|
|
266
|
+
(build(t, cwd).execute as Function)(id, params, signal, onUpdate, ctx),
|
|
198
267
|
);
|
|
199
268
|
},
|
|
200
269
|
} as T;
|
|
@@ -224,13 +293,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
224
293
|
.then((active) => (active?.kind === "sbx" ? armSessionLifecycle() : undefined))
|
|
225
294
|
.catch((err) => {
|
|
226
295
|
// Raw console writes land on the terminal the TUI is drawing, so cap
|
|
227
|
-
// the failure note behind the debug flag —
|
|
228
|
-
//
|
|
296
|
+
// the failure note behind the debug flag — tool calls refuse by default
|
|
297
|
+
// or run on the host only under the explicit opt-in (the `sbx` command
|
|
298
|
+
// reports live status).
|
|
229
299
|
if (debugEnabled()) console.error(`[sbx] session start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
230
300
|
});
|
|
231
301
|
});
|
|
232
302
|
|
|
233
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;
|
|
234
307
|
// Only an sbx sandbox is ours to reclaim; a container backend is a
|
|
235
308
|
// caller-supplied environment (docker_* owns its own sandbox lifecycle).
|
|
236
309
|
if (transport?.kind === "sbx") await teardownSandbox("session_shutdown");
|
|
@@ -250,18 +323,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
250
323
|
"",
|
|
251
324
|
"Tools routed into the sandbox: bash, read, write, edit, grep, find, ls",
|
|
252
325
|
].join("\n")
|
|
253
|
-
: `sbx backend unavailable —
|
|
326
|
+
: `sbx backend unavailable — ${
|
|
327
|
+
unsandboxedAllowed(process.env)
|
|
328
|
+
? `tools run directly on the host (${UNSANDBOXED_OPT_IN_ENV}=1)`
|
|
329
|
+
: `tool calls are refused; nothing runs on the host (set ${UNSANDBOXED_OPT_IN_ENV}=1 to allow unsandboxed execution)`
|
|
330
|
+
}.\n${lastError ?? ""}`,
|
|
254
331
|
t ? "info" : "warning",
|
|
255
332
|
);
|
|
256
333
|
},
|
|
257
334
|
});
|
|
258
335
|
|
|
259
|
-
pi.registerTool(routed(localRead, (t) => createReadToolDefinition(
|
|
260
|
-
pi.registerTool(routed(localWrite, (t) => createWriteToolDefinition(
|
|
261
|
-
pi.registerTool(routed(localEdit, (t) => createEditToolDefinition(
|
|
262
|
-
pi.registerTool(routed(localBash, (t) => createBashToolDefinition(
|
|
263
|
-
pi.registerTool(routed(localLs, (t) => createLsToolDefinition(
|
|
264
|
-
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) })));
|
|
265
342
|
// grep is replaced wholesale, not merely re-pointed: pi's grep tool spawns
|
|
266
343
|
// host ripgrep for match discovery regardless of custom operations, which
|
|
267
344
|
// would scan the host filesystem and require rg on the host. The sandbox
|
|
@@ -269,27 +346,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
269
346
|
pi.registerTool({
|
|
270
347
|
...localGrep,
|
|
271
348
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
349
|
+
const cwd = sessionCwd(ctx);
|
|
272
350
|
const t = await ensureTransport(ctx);
|
|
273
|
-
if (!t)
|
|
274
|
-
|
|
351
|
+
if (!t) {
|
|
352
|
+
assertLocalFallbackAllowed();
|
|
353
|
+
return localGrep.execute(id, params, signal, onUpdate, ctx);
|
|
354
|
+
}
|
|
355
|
+
return withSandboxFailureHandling(ctx, () => executeSandboxGrep(t, cwd, params as GrepToolInput));
|
|
275
356
|
},
|
|
276
357
|
});
|
|
277
358
|
|
|
278
359
|
// The user's own `!` commands belong in the sandbox too, exactly as gondolin
|
|
279
|
-
// routes them — otherwise `!` would silently execute on the host.
|
|
360
|
+
// routes them — otherwise `!` would silently execute on the host. When no
|
|
361
|
+
// sandbox can be had, the same fail-closed policy applies: refuse (throw)
|
|
362
|
+
// unless the user opted into unsandboxed operation.
|
|
280
363
|
pi.on("user_bash", async (_event, ctx) => {
|
|
281
364
|
const t = await ensureTransport(ctx);
|
|
282
|
-
if (!t)
|
|
365
|
+
if (!t) {
|
|
366
|
+
assertLocalFallbackAllowed();
|
|
367
|
+
return undefined; // opt-in set: run on the host, as explicitly requested
|
|
368
|
+
}
|
|
283
369
|
return { operations: createBashOps(t, { allowEnv: bashAllowEnv }) };
|
|
284
370
|
});
|
|
285
371
|
|
|
286
372
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
287
373
|
const t = await ensureTransport(ctx);
|
|
288
374
|
const localLine = `Current working directory: ${localCwd}`;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
375
|
+
let replacement: string;
|
|
376
|
+
if (t) {
|
|
377
|
+
replacement =
|
|
378
|
+
`Current working directory: ${localCwd} — commands run inside the ${t.kind} sandbox "${t.target}" ` +
|
|
379
|
+
`(the same absolute paths exist there; the host is not the execution environment)`;
|
|
380
|
+
} else if (unsandboxedAllowed(process.env)) {
|
|
381
|
+
replacement =
|
|
382
|
+
`${localLine} (WARNING: the sbx sandbox is unavailable and ${UNSANDBOXED_OPT_IN_ENV} is set, ` +
|
|
383
|
+
`so commands run directly on the host — not in the sandbox)`;
|
|
384
|
+
} else {
|
|
385
|
+
replacement =
|
|
386
|
+
`${localLine} (WARNING: the sbx sandbox is unavailable and ${UNSANDBOXED_OPT_IN_ENV} is not set, ` +
|
|
387
|
+
`so tool calls are REFUSED and will NOT run — neither in the sandbox nor on the host. ` +
|
|
388
|
+
`Set ${UNSANDBOXED_OPT_IN_ENV}=1 to run tools directly on the host instead.)`;
|
|
389
|
+
}
|
|
293
390
|
const systemPrompt = event.systemPrompt.includes(localLine)
|
|
294
391
|
? event.systemPrompt.replace(localLine, replacement)
|
|
295
392
|
: `${event.systemPrompt}\n\n${replacement}`;
|
|
@@ -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
|
+
}
|