pi-crew 0.11.35 → 0.11.38
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/CHANGELOG.md +51 -0
- package/agents/critic.md +13 -0
- package/agents/oracle.md +14 -0
- package/dist/index.mjs +534 -533
- package/package.json +3 -2
- package/skills/delegation-patterns/SKILL.md +16 -0
- package/skills/real-test-pi-crew/SKILL.md +4 -1
- package/src/config/env-allowlist-registry.ts +17 -0
- package/src/config/env-vars.ts +23 -0
- package/src/extension/registration/foreground-run-controller.ts +21 -1
- package/src/prompt/prompt-runtime.ts +67 -5
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -1
- package/src/runtime/child-pi/child-pi.ts +65 -5
- package/src/runtime/child-pi/inference-broker.ts +71 -6
- package/src/runtime/child-pi/sandbox.ts +224 -0
- package/src/runtime/foreground-watchdog.ts +394 -17
- package/src/runtime/model/model-fallback.ts +5 -0
- package/src/runtime/task-runner/child-executor.ts +3 -0
- package/src/runtime/task-runner/post-execution.ts +112 -1
- package/src/runtime/verification/verify-gate-memo.ts +127 -0
- package/src/runtime/verification/verify-gate.ts +127 -1
- package/src/state/coordination/mailbox.ts +68 -0
- package/src/state/types.ts +19 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.11.36 OS-boundary sandbox (Linux opt-in, bubblewrap).
|
|
3
|
+
*
|
|
4
|
+
* WHAT IT IS: an optional bwrap (bubblewrap) wrapper around headless child-pi
|
|
5
|
+
* workers that closes the HOST-FILE hole the env layer cannot — a sandboxed
|
|
6
|
+
* worker sees ONLY: the project cwd (read-write — it must edit files), its
|
|
7
|
+
* scoped inference agent dir, explicitly handed-in temp/scratch paths, and
|
|
8
|
+
* the OS runtime (/usr, /etc, /dev, /proc as read-only mounts + tmpfs /tmp,
|
|
9
|
+
* /run). Everything else on the host — ~/.pi/auth.json, ~/.ssh, dotfiles,
|
|
10
|
+
* browser profiles — is simply NOT MOUNTED into the namespace. Fail-closed:
|
|
11
|
+
* a path we forgot to bind yields ENOENT inside the sandbox, never silent
|
|
12
|
+
* host access.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IT IS NOT (honest boundary, pass 1):
|
|
15
|
+
* - Egress isolation: `PI_CREW_SANDBOX_NET=off` unshares the network stack
|
|
16
|
+
* entirely (nothing reachable — including the loopback inference broker),
|
|
17
|
+
* which is why net=off is REJECTED while the inference broker is active.
|
|
18
|
+
* The default `share` profile keeps host networking: a worker with a
|
|
19
|
+
* broker token can still open outbound connections to the internet.
|
|
20
|
+
* - TUI surface panes and the rpc transport are NOT sandboxed (pass 1): when
|
|
21
|
+
* the sandbox is enabled both branches are skipped so every worker takes
|
|
22
|
+
* the sandboxed headless path (fail-closed, never fail-open).
|
|
23
|
+
*
|
|
24
|
+
* FAIL-CLOSED CONTRACT: every misconfiguration throws BEFORE any spawn —
|
|
25
|
+
* unsupported value, non-Linux platform, missing bwrap binary, or the
|
|
26
|
+
* net=off + broker contradiction. A sandbox that cannot start must never
|
|
27
|
+
* silently degrade to an unsandboxed spawn.
|
|
28
|
+
*/
|
|
29
|
+
import { type SpawnSyncOptions, type SpawnSyncReturns, spawnSync } from "node:child_process";
|
|
30
|
+
import * as fs from "node:fs";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
|
|
33
|
+
export const SANDBOX_ENV = "PI_CREW_SANDBOX";
|
|
34
|
+
export const SANDBOX_NET_ENV = "PI_CREW_SANDBOX_NET";
|
|
35
|
+
|
|
36
|
+
export interface SandboxConfig {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
mode: "off" | "bwrap";
|
|
39
|
+
/** "share" keeps host networking (inference broker reachable); "off" unshares the net stack entirely. */
|
|
40
|
+
net: "share" | "off";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve + VALIDATE the sandbox knobs. Throws fail-closed on any misconfiguration (never silently unsandboxed). */
|
|
44
|
+
export function resolveSandboxConfig(env: NodeJS.ProcessEnv, opts: { platform: NodeJS.Platform; brokerActive: boolean }): SandboxConfig {
|
|
45
|
+
const raw = (env[SANDBOX_ENV] ?? "").trim();
|
|
46
|
+
if (raw === "" || raw === "0") return { enabled: false, mode: "off", net: "share" };
|
|
47
|
+
if (raw !== "bwrap") {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${SANDBOX_ENV}: unsupported value '${raw}' (supported: 'bwrap') — fail-closed: refusing to spawn with an unrecognized sandbox mode`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const netRaw = (env[SANDBOX_NET_ENV] ?? "share").trim().toLowerCase();
|
|
53
|
+
if (netRaw !== "share" && netRaw !== "off") {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`${SANDBOX_NET_ENV}: unsupported value '${netRaw}' (supported: 'share' | 'off') — fail-closed: refusing to spawn with an unrecognized network profile`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (netRaw === "off" && opts.brokerActive) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${SANDBOX_NET_ENV}=off is incompatible with the inference broker: a new network namespace has no loopback route to the 127.0.0.1 broker, so broker-token workers would be keyless AND netless. Either keep ${SANDBOX_NET_ENV}=share or unset ${SANDBOX_ENV}/PI_CREW_INFERENCE_BROKER.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return { enabled: true, mode: "bwrap", net: netRaw };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Binary/platform gate. Throws when a bwrap sandbox cannot be honored (fail-closed). */
|
|
67
|
+
export function assertSandboxAvailable(
|
|
68
|
+
config: SandboxConfig,
|
|
69
|
+
deps: {
|
|
70
|
+
platform: NodeJS.Platform;
|
|
71
|
+
spawnSync: (cmd: string, args: string[], opts?: SpawnSyncOptions) => SpawnSyncReturns<string> | { error: unknown; status: null };
|
|
72
|
+
} = {
|
|
73
|
+
platform: process.platform,
|
|
74
|
+
spawnSync: (cmd, args, opts) => spawnSync(cmd, args, opts) as SpawnSyncReturns<string>,
|
|
75
|
+
},
|
|
76
|
+
): void {
|
|
77
|
+
if (!config.enabled) return;
|
|
78
|
+
if (deps.platform !== "linux") {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${SANDBOX_ENV}=bwrap is Linux-only (bubblewrap user namespaces); current platform is ${deps.platform} — fail-closed: refusing to spawn unsandboxed`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const probe = deps.spawnSync("bwrap", ["--version"], { timeout: 5000 });
|
|
84
|
+
if (probe.error || probe.status !== 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`${SANDBOX_ENV}=bwrap requires the 'bwrap' binary (bubblewrap) on PATH — not runnable here (fail-closed: refusing to spawn unsandboxed). Install bubblewrap or unset ${SANDBOX_ENV}.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SandboxWrapInput {
|
|
92
|
+
/** Project dir — mounted read-write (the worker's whole job is editing it). */
|
|
93
|
+
cwd: string;
|
|
94
|
+
/** "share" keeps host networking (inference broker reachable); "off" unshares the net stack entirely. */
|
|
95
|
+
net: "share" | "off";
|
|
96
|
+
/** Scoped inference agent dir (models.json only) — mounted read-write (pi may write session state under it). */
|
|
97
|
+
agentDir?: string;
|
|
98
|
+
/** Parent-side paths the worker must READ (task.md tempDir, scratchpad snapshot/restore, steering file). */
|
|
99
|
+
roBindPaths?: string[];
|
|
100
|
+
/** Parent-side paths the worker must reach read-write (inter-pi broker socket — connect() needs write permission). */
|
|
101
|
+
rwBindPaths?: string[];
|
|
102
|
+
/** Package-root detector for resource walks (injectable to keep applySandboxToSpawnSpec pure in tests). */
|
|
103
|
+
isPackageRoot?: (dir: string) => boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Toolchain mounts a wrapped spec needs (deduped):
|
|
107
|
+
* - the runtime bin dir (nvm/homebrew node lives outside /usr),
|
|
108
|
+
* - for the entry script (.js arg 0): its dir + every ancestor `node_modules`
|
|
109
|
+
* (ESM/CJS resolution walks them for the SDK's external deps),
|
|
110
|
+
* - for every `--extension`/`--skill` value: the value + ancestor dirs up to
|
|
111
|
+
* and including the nearest package root (extensions import siblings
|
|
112
|
+
* across their package: prompt-runtime.ts reaches ../config, ../state …).
|
|
113
|
+
*/
|
|
114
|
+
function toolchainBinds(spec: { command: string; args: string[] }, isPackageRoot: (dir: string) => boolean): string[] {
|
|
115
|
+
const binds = new Set<string>();
|
|
116
|
+
if (path.isAbsolute(spec.command)) binds.add(path.dirname(spec.command));
|
|
117
|
+
const entry = spec.args[0];
|
|
118
|
+
if (entry && path.isAbsolute(entry) && entry.endsWith(".js")) {
|
|
119
|
+
binds.add(path.dirname(entry));
|
|
120
|
+
for (let dir = path.dirname(path.dirname(entry)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
|
|
121
|
+
if (path.basename(dir) === "node_modules") binds.add(dir);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// --extension / --skill resources: bind the value + ancestors up to the
|
|
125
|
+
// nearest package root (extensions import across their package tree).
|
|
126
|
+
for (let i = 0; i < spec.args.length - 1; i++) {
|
|
127
|
+
const flag = spec.args[i];
|
|
128
|
+
if (flag !== "--extension" && flag !== "--skill") continue;
|
|
129
|
+
const value = spec.args[i + 1];
|
|
130
|
+
if (!value || !path.isAbsolute(value)) continue;
|
|
131
|
+
binds.add(value);
|
|
132
|
+
for (let dir = path.dirname(value); dir !== path.dirname(dir); dir = path.dirname(dir)) {
|
|
133
|
+
binds.add(dir);
|
|
134
|
+
if (isPackageRoot(dir)) break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return [...binds];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Default package-root probe: a directory carrying package.json (extensions import across their package tree). */
|
|
141
|
+
function defaultIsPackageRoot(dir: string): boolean {
|
|
142
|
+
try {
|
|
143
|
+
return fs.statSync(path.join(dir, "package.json")).isFile();
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Wrap a spawn spec in a bwrap invocation. Unit-testable on every platform
|
|
151
|
+
* (inject isPackageRoot to avoid IO); ordering matters: --tmpfs /tmp precedes
|
|
152
|
+
* any bind under /tmp so parent temp paths surface at their REAL paths.
|
|
153
|
+
*/
|
|
154
|
+
export function applySandboxToSpawnSpec(
|
|
155
|
+
spec: { command: string; args: string[] },
|
|
156
|
+
wrap: SandboxWrapInput,
|
|
157
|
+
): { command: string; args: string[]; envOverrides: Record<string, string> } {
|
|
158
|
+
const args: string[] = [
|
|
159
|
+
"--unshare-all",
|
|
160
|
+
// net=share: re-share host networking AFTER --unshare-all (the
|
|
161
|
+
// inference broker lives on host loopback). net=off leaves the new
|
|
162
|
+
// netns fully isolated — nothing reachable, broker included.
|
|
163
|
+
...(wrap.net === "share" ? ["--share-net"] : []),
|
|
164
|
+
"--die-with-parent",
|
|
165
|
+
"--new-session",
|
|
166
|
+
"--ro-bind",
|
|
167
|
+
"/usr",
|
|
168
|
+
"/usr",
|
|
169
|
+
"--ro-bind-try",
|
|
170
|
+
"/etc",
|
|
171
|
+
"/etc",
|
|
172
|
+
"--ro-bind-try",
|
|
173
|
+
"/bin",
|
|
174
|
+
"/bin",
|
|
175
|
+
"--ro-bind-try",
|
|
176
|
+
"/lib",
|
|
177
|
+
"/lib",
|
|
178
|
+
"--ro-bind-try",
|
|
179
|
+
"/lib64",
|
|
180
|
+
"/lib64",
|
|
181
|
+
"--ro-bind-try",
|
|
182
|
+
"/sbin",
|
|
183
|
+
"/sbin",
|
|
184
|
+
// TOOLCHAIN: the wrapped command is usually process.execPath — which on
|
|
185
|
+
// developer machines lives OUTSIDE /usr (nvm: ~/.nvm/.../bin/node,
|
|
186
|
+
// homebrew: /opt/homebrew/...). Without mounting its directory the
|
|
187
|
+
// sandbox cannot even exec the runtime (bwrap: execvp ENOENT — found by
|
|
188
|
+
// the real-smoke test on this very machine). The node bin dir is
|
|
189
|
+
// toolchain, not user data — same class as /usr.
|
|
190
|
+
//
|
|
191
|
+
// The pi bundle entry (spec.args[0] when an absolute .js file) brings its
|
|
192
|
+
// OWN tree: the bundle dir has sibling chunks/runtime files, and ESM/CJS
|
|
193
|
+
// resolution walks every ancestor `node_modules` for the SDK's external
|
|
194
|
+
// deps (jiti/yaml/...). Bind the entry DIR + each ancestor named
|
|
195
|
+
// node_modules, READ-ONLY — dependency trees are toolchain, not user data.
|
|
196
|
+
...toolchainBinds(spec, wrap.isPackageRoot ?? defaultIsPackageRoot).flatMap((p) => ["--ro-bind-try", p, p]),
|
|
197
|
+
"--proc",
|
|
198
|
+
"/proc",
|
|
199
|
+
"--dev",
|
|
200
|
+
"/dev",
|
|
201
|
+
"--tmpfs",
|
|
202
|
+
"/tmp",
|
|
203
|
+
"--tmpfs",
|
|
204
|
+
"/run",
|
|
205
|
+
// Project dir read-write at its real path (mount order guarantees this
|
|
206
|
+
// wins over any ancestor tmpfs).
|
|
207
|
+
"--bind",
|
|
208
|
+
wrap.cwd,
|
|
209
|
+
wrap.cwd,
|
|
210
|
+
];
|
|
211
|
+
if (wrap.agentDir) {
|
|
212
|
+
args.push("--bind", wrap.agentDir, wrap.agentDir);
|
|
213
|
+
}
|
|
214
|
+
for (const p of wrap.roBindPaths ?? []) {
|
|
215
|
+
args.push("--ro-bind-try", p, p);
|
|
216
|
+
}
|
|
217
|
+
for (const p of wrap.rwBindPaths ?? []) {
|
|
218
|
+
args.push("--bind-try", p, p);
|
|
219
|
+
}
|
|
220
|
+
args.push("--chdir", wrap.cwd, "--", spec.command, ...spec.args);
|
|
221
|
+
// HOME/TMPDIR point at the sandbox tmpfs: the worker's writes land in
|
|
222
|
+
// scratch space, and ~/.pi (auth.json!) simply does not exist inside.
|
|
223
|
+
return { command: "bwrap", args, envOverrides: { HOME: "/tmp", TMPDIR: "/tmp" } };
|
|
224
|
+
}
|