@stixxert/pi-docker-sandbox 1.0.1 → 1.1.0
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 +36 -0
- package/boundary.md +10 -0
- package/index.ts +53 -12
- package/package.json +7 -1
- package/sandbox/README.md +277 -0
- package/sandbox/e2e.mjs +467 -0
- package/sandbox/index.ts +229 -0
- package/sandbox/operations.ts +496 -0
- package/sandbox/package.json +11 -0
- package/sandbox/transport.ts +377 -0
- package/sandbox/try.sh +157 -0
- package/security.md +40 -1
- package/template/Dockerfile +34 -0
- package/template/README.md +92 -0
- package/template/build.sh +168 -0
- package/template/install.sh +77 -0
- package/test-loader.mjs +53 -0
package/sandbox/index.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
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, 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
|
+
console.error(`[sbx] session start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
pi.on("session_shutdown", async () => {
|
|
165
|
+
// Only an sbx sandbox is ours to reclaim; a container backend is a
|
|
166
|
+
// caller-supplied environment (docker_* owns its own sandbox lifecycle).
|
|
167
|
+
if (transport?.kind === "sbx") await teardownSandbox("session_shutdown");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
pi.registerCommand("sbx", {
|
|
171
|
+
description: "Show the sbx execution backend status",
|
|
172
|
+
handler: async (_args, ctx) => {
|
|
173
|
+
const t = await ensureTransport(ctx);
|
|
174
|
+
ctx.ui.notify(
|
|
175
|
+
t
|
|
176
|
+
? [
|
|
177
|
+
`sbx backend: ${t.kind}`,
|
|
178
|
+
`Target: ${t.target}`,
|
|
179
|
+
`Workspace: ${localCwd} (mounted at the same path)`,
|
|
180
|
+
projectSandbox ? `Per-project sandbox: ${projectSandbox} (reused across runs)` : "Ephemeral sandbox (SBX_EPHEMERAL=1)",
|
|
181
|
+
"",
|
|
182
|
+
"Tools routed into the sandbox: bash, read, write, edit, grep, find, ls",
|
|
183
|
+
].join("\n")
|
|
184
|
+
: `sbx backend unavailable — tools run locally.\n${lastError ?? ""}`,
|
|
185
|
+
t ? "info" : "warning",
|
|
186
|
+
);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pi.registerTool(routed(localRead, (t) => createReadToolDefinition(localCwd, { operations: createReadOps(t) })));
|
|
191
|
+
pi.registerTool(routed(localWrite, (t) => createWriteToolDefinition(localCwd, { operations: createWriteOps(t) })));
|
|
192
|
+
pi.registerTool(routed(localEdit, (t) => createEditToolDefinition(localCwd, { operations: createEditOps(t) })));
|
|
193
|
+
pi.registerTool(routed(localBash, (t) => createBashToolDefinition(localCwd, { operations: createBashOps(t, { allowEnv: bashAllowEnv }) })));
|
|
194
|
+
pi.registerTool(routed(localLs, (t) => createLsToolDefinition(localCwd, { operations: createLsOps(t) })));
|
|
195
|
+
pi.registerTool(routed(localFind, (t) => createFindToolDefinition(localCwd, { operations: createFindOps(t) })));
|
|
196
|
+
// grep is replaced wholesale, not merely re-pointed: pi's grep tool spawns
|
|
197
|
+
// host ripgrep for match discovery regardless of custom operations, which
|
|
198
|
+
// would scan the host filesystem and require rg on the host. The sandbox
|
|
199
|
+
// implementation walks and matches over the transport instead.
|
|
200
|
+
pi.registerTool({
|
|
201
|
+
...localGrep,
|
|
202
|
+
async execute(id, params, signal, onUpdate, ctx) {
|
|
203
|
+
const t = await ensureTransport(ctx);
|
|
204
|
+
if (!t) return localGrep.execute(id, params, signal, onUpdate, ctx);
|
|
205
|
+
return executeSandboxGrep(t, localCwd, params as GrepToolInput);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// The user's own `!` commands belong in the sandbox too, exactly as gondolin
|
|
210
|
+
// routes them — otherwise `!` would silently execute on the host.
|
|
211
|
+
pi.on("user_bash", async (_event, ctx) => {
|
|
212
|
+
const t = await ensureTransport(ctx);
|
|
213
|
+
if (!t) return undefined;
|
|
214
|
+
return { operations: createBashOps(t, { allowEnv: bashAllowEnv }) };
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
218
|
+
const t = await ensureTransport(ctx);
|
|
219
|
+
const localLine = `Current working directory: ${localCwd}`;
|
|
220
|
+
const replacement = t
|
|
221
|
+
? `Current working directory: ${localCwd} — commands run inside the ${t.kind} sandbox "${t.target}" ` +
|
|
222
|
+
`(the same absolute paths exist there; the host is not the execution environment)`
|
|
223
|
+
: `${localLine} (WARNING: the sbx sandbox is unavailable, so commands run directly on the host)`;
|
|
224
|
+
const systemPrompt = event.systemPrompt.includes(localLine)
|
|
225
|
+
? event.systemPrompt.replace(localLine, replacement)
|
|
226
|
+
: `${event.systemPrompt}\n\n${replacement}`;
|
|
227
|
+
return { systemPrompt };
|
|
228
|
+
});
|
|
229
|
+
}
|