@stixxert/pi-docker-sandbox 1.0.1 → 1.1.1

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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * sbx execution backend for pi — "gondolin, but the sandbox is a Docker
3
+ * Sandbox".
4
+ *
5
+ * pi runs on the HOST (its own auth, config, sessions, model keys). Every
6
+ * built-in tool — `bash`, `read`, `write`, `edit`, `grep`, `find`, `ls` — is
7
+ * executed **inside an sbx microVM**, together with the user's `!` commands.
8
+ * The workspace is mounted in the sandbox at its host absolute path, so paths
9
+ * are identical on both sides and no rewriting is needed.
10
+ *
11
+ * Why this exists: the alternative way to use sbx is to run pi *inside* the
12
+ * sandbox, which needs a pi-bearing template, a bootstrap that seeds state
13
+ * into the sandbox, per-project writable pi state, and a rebuild to pick up a
14
+ * new pi version. Routing the tools instead needs none of that: the sandbox
15
+ * is just an execution environment, and pi stays on the host.
16
+ *
17
+ * **Prompt cost: zero.** Built-in tools are *overridden* (same names, same
18
+ * schemas — only `execute` is replaced), so no new tool schema is added to
19
+ * the system prompt. Adding an `sbx_exec` tool instead would cost tokens on
20
+ * every single turn, forever.
21
+ *
22
+ * Usage (same shape as the gondolin example):
23
+ * cd /path/to/project
24
+ * pi -e /path/to/pi-docker-sandbox/sandbox
25
+ *
26
+ * Env:
27
+ * SBX_BACKEND=docker + SBX_DOCKER_CONTAINER=<id> route into a container
28
+ * instead (test/alternate)
29
+ * DOCKER_SANDBOX / DOCKER_SANDBOX_* see the docker_* extension
30
+ */
31
+
32
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
33
+ import {
34
+ type GrepToolInput,
35
+ createBashToolDefinition,
36
+ createEditToolDefinition,
37
+ createFindToolDefinition,
38
+ createGrepToolDefinition,
39
+ createLsToolDefinition,
40
+ createReadToolDefinition,
41
+ createWriteToolDefinition,
42
+ } from "@earendil-works/pi-coding-agent";
43
+ import { armSessionLifecycle, debugEnabled, envAllowlist, teardownSandbox } from "../index.ts";
44
+ import {
45
+ createBashOps,
46
+ createEditOps,
47
+ createFindOps,
48
+ createLsOps,
49
+ createReadOps,
50
+ createWriteOps,
51
+ executeSandboxGrep,
52
+ } from "./operations.ts";
53
+ import { type ExecTransport, defaultProjectSandbox, resolveTransport } from "./transport.ts";
54
+
55
+ export default function (pi: ExtensionAPI) {
56
+ const localCwd = process.cwd();
57
+
58
+ // Settle the sandbox NAME synchronously, before any session event can compute
59
+ // (and memoise) the kernel's per-process fallback name. A stable, per-project
60
+ // name is what lets a later run reuse this project's sandbox instead of
61
+ // creating a new VM each time.
62
+ const projectSandbox = defaultProjectSandbox(localCwd);
63
+
64
+ // Built-in tool DEFINITIONS, kept to inherit schema, description, prompt
65
+ // snippet/guidelines and renderer. The *Definition* factories are used
66
+ // deliberately: `createXTool()` wraps and drops `promptSnippet` /
67
+ // `promptGuidelines`, and pi builds the system prompt's tool table from the
68
+ // registered tool objects — so an override built from `createXTool()` would
69
+ // silently delete the built-in guidance for that tool.
70
+ const localRead = createReadToolDefinition(localCwd);
71
+ const localWrite = createWriteToolDefinition(localCwd);
72
+ const localEdit = createEditToolDefinition(localCwd);
73
+ const localBash = createBashToolDefinition(localCwd);
74
+ const localLs = createLsToolDefinition(localCwd);
75
+ const localFind = createFindToolDefinition(localCwd);
76
+ const localGrep = createGrepToolDefinition(localCwd);
77
+
78
+ let transport: ExecTransport | undefined;
79
+ let starting: Promise<ExecTransport | undefined> | undefined;
80
+ let lastError: string | undefined;
81
+
82
+ /**
83
+ * Resolve (and memoize) the transport. Never throws: if sbx is missing or
84
+ * the sandbox cannot be provisioned, pi keeps working with its LOCAL tools
85
+ * and the degradation is reported — both to the user and in the system
86
+ * prompt, so the agent never believes it is sandboxed when it is not.
87
+ */
88
+ async function ensureTransport(ctx?: ExtensionContext): Promise<ExecTransport | undefined> {
89
+ if (transport) return transport;
90
+ if (!starting) {
91
+ starting = (async () => {
92
+ try {
93
+ ctx?.ui.setStatus("sbx", ctx.ui.theme.fg("accent", "sbx: starting"));
94
+ const resolved = await resolveTransport();
95
+ transport = resolved;
96
+ // Advertise the sandbox to sibling extensions (e.g. sbx-webdev
97
+ // running from a host pi), mirroring SANDBOX_ID in-sandbox.
98
+ process.env.PI_SBX_SANDBOX = resolved.target;
99
+ process.env.PI_SBX_BACKEND = resolved.kind;
100
+ ctx?.ui.setStatus(
101
+ "sbx",
102
+ ctx.ui.theme.fg("accent", `sbx: ${resolved.kind} ${resolved.target.slice(0, 24)}`),
103
+ );
104
+ return resolved;
105
+ } catch (err) {
106
+ lastError = err instanceof Error ? err.message : String(err);
107
+ ctx?.ui.setStatus("sbx", ctx.ui.theme.fg("error", "sbx: unavailable"));
108
+ ctx?.ui.notify(`sbx backend unavailable — running tools locally.\n${lastError}`, "warning");
109
+ return undefined;
110
+ } finally {
111
+ starting = undefined;
112
+ }
113
+ })();
114
+ }
115
+ return starting;
116
+ }
117
+
118
+ /**
119
+ * Route a tool to the sandbox, falling back to the local tool on failure.
120
+ * `ctx` is forwarded — the built-ins use it to inject PI_* session metadata
121
+ * into the bash environment, and dropping it would silently change behaviour.
122
+ */
123
+ function routed<T extends { execute: (...args: never[]) => unknown }>(
124
+ local: T,
125
+ build: (t: ExecTransport) => T,
126
+ ): T {
127
+ return {
128
+ ...local,
129
+ async execute(id: unknown, params: unknown, signal: unknown, onUpdate: unknown, ctx?: ExtensionContext) {
130
+ const t = await ensureTransport(ctx);
131
+ if (!t) return (local.execute as Function)(id, params, signal, onUpdate, ctx);
132
+ return (build(t).execute as Function)(id, params, signal, onUpdate, ctx);
133
+ },
134
+ } as T;
135
+ }
136
+
137
+ // Only PI_* session metadata reaches the sandbox shell, plus whatever the
138
+ // user opted into with DOCKER_SANDBOX_ENV_ALLOWLIST. pi's bash tool builds
139
+ // the child env from the FULL host environment, so passing it through
140
+ // unfiltered would copy host API keys into the sandbox.
141
+ const bashAllowEnv = (name: string) => name.startsWith("PI_") || envAllowlist().includes(name);
142
+
143
+ pi.on("session_start", async (_event, ctx) => {
144
+ // Routing file tools into the sandbox is incompatible with a read-only
145
+ // workspace mount: writes would fail. Say so instead of failing later.
146
+ if (/^(1|true|ro|yes|on)$/i.test((process.env.DOCKER_SANDBOX_WORKSPACE_RO ?? "").trim())) {
147
+ ctx.ui.notify(
148
+ "DOCKER_SANDBOX_WORKSPACE_RO is set: the sandbox mounts the project read-only, so write/edit/mkdir will fail there. Unset it to use the sbx execution backend.",
149
+ "warning",
150
+ );
151
+ }
152
+ // Do NOT await the transport here. Resolving it can mean booting or even
153
+ // CREATING a VM, and blocking pi's startup on that is exactly the
154
+ // multi-second wait this backend is trying to avoid. Tool calls await the
155
+ // same memoised promise, so the work overlaps with the user reading the
156
+ // prompt instead of gating it.
157
+ void ensureTransport(ctx)
158
+ .then((active) => (active?.kind === "sbx" ? armSessionLifecycle() : undefined))
159
+ .catch((err) => {
160
+ // Raw console writes land on the terminal the TUI is drawing, so cap
161
+ // the failure note behind the debug flag — the backend degrades to
162
+ // local tools either way (the `sbx` command reports live status).
163
+ if (debugEnabled()) console.error(`[sbx] session start failed: ${err instanceof Error ? err.message : String(err)}`);
164
+ });
165
+ });
166
+
167
+ pi.on("session_shutdown", async () => {
168
+ // Only an sbx sandbox is ours to reclaim; a container backend is a
169
+ // caller-supplied environment (docker_* owns its own sandbox lifecycle).
170
+ if (transport?.kind === "sbx") await teardownSandbox("session_shutdown");
171
+ });
172
+
173
+ pi.registerCommand("sbx", {
174
+ description: "Show the sbx execution backend status",
175
+ handler: async (_args, ctx) => {
176
+ const t = await ensureTransport(ctx);
177
+ ctx.ui.notify(
178
+ t
179
+ ? [
180
+ `sbx backend: ${t.kind}`,
181
+ `Target: ${t.target}`,
182
+ `Workspace: ${localCwd} (mounted at the same path)`,
183
+ projectSandbox ? `Per-project sandbox: ${projectSandbox} (reused across runs)` : "Ephemeral sandbox (SBX_EPHEMERAL=1)",
184
+ "",
185
+ "Tools routed into the sandbox: bash, read, write, edit, grep, find, ls",
186
+ ].join("\n")
187
+ : `sbx backend unavailable — tools run locally.\n${lastError ?? ""}`,
188
+ t ? "info" : "warning",
189
+ );
190
+ },
191
+ });
192
+
193
+ pi.registerTool(routed(localRead, (t) => createReadToolDefinition(localCwd, { operations: createReadOps(t) })));
194
+ pi.registerTool(routed(localWrite, (t) => createWriteToolDefinition(localCwd, { operations: createWriteOps(t) })));
195
+ pi.registerTool(routed(localEdit, (t) => createEditToolDefinition(localCwd, { operations: createEditOps(t) })));
196
+ pi.registerTool(routed(localBash, (t) => createBashToolDefinition(localCwd, { operations: createBashOps(t, { allowEnv: bashAllowEnv }) })));
197
+ pi.registerTool(routed(localLs, (t) => createLsToolDefinition(localCwd, { operations: createLsOps(t) })));
198
+ pi.registerTool(routed(localFind, (t) => createFindToolDefinition(localCwd, { operations: createFindOps(t) })));
199
+ // grep is replaced wholesale, not merely re-pointed: pi's grep tool spawns
200
+ // host ripgrep for match discovery regardless of custom operations, which
201
+ // would scan the host filesystem and require rg on the host. The sandbox
202
+ // implementation walks and matches over the transport instead.
203
+ pi.registerTool({
204
+ ...localGrep,
205
+ async execute(id, params, signal, onUpdate, ctx) {
206
+ const t = await ensureTransport(ctx);
207
+ if (!t) return localGrep.execute(id, params, signal, onUpdate, ctx);
208
+ return executeSandboxGrep(t, localCwd, params as GrepToolInput);
209
+ },
210
+ });
211
+
212
+ // The user's own `!` commands belong in the sandbox too, exactly as gondolin
213
+ // routes them — otherwise `!` would silently execute on the host.
214
+ pi.on("user_bash", async (_event, ctx) => {
215
+ const t = await ensureTransport(ctx);
216
+ if (!t) return undefined;
217
+ return { operations: createBashOps(t, { allowEnv: bashAllowEnv }) };
218
+ });
219
+
220
+ pi.on("before_agent_start", async (event, ctx) => {
221
+ const t = await ensureTransport(ctx);
222
+ const localLine = `Current working directory: ${localCwd}`;
223
+ const replacement = t
224
+ ? `Current working directory: ${localCwd} — commands run inside the ${t.kind} sandbox "${t.target}" ` +
225
+ `(the same absolute paths exist there; the host is not the execution environment)`
226
+ : `${localLine} (WARNING: the sbx sandbox is unavailable, so commands run directly on the host)`;
227
+ const systemPrompt = event.systemPrompt.includes(localLine)
228
+ ? event.systemPrompt.replace(localLine, replacement)
229
+ : `${event.systemPrompt}\n\n${replacement}`;
230
+ return { systemPrompt };
231
+ });
232
+ }