pi-crew 0.11.35 → 0.11.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.35",
3
+ "version": "0.11.37",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -216,6 +216,15 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
216
216
  },
217
217
  PI_CREW_INFERENCE_UPSTREAM_ANTHROPIC: { kind: "test-only", reason: "v0.11.34 anthropic-only broker upstream override — TESTS ONLY" },
218
218
  PI_CREW_INFERENCE_UPSTREAM_OPENAI: { kind: "test-only", reason: "v0.11.34 openai-only broker upstream override — TESTS ONLY" },
219
+ PI_CREW_INFERENCE_UPSTREAM_GOOGLE: { kind: "test-only", reason: "v0.11.36 google-only broker upstream override — TESTS ONLY" },
220
+ PI_CREW_SANDBOX: {
221
+ kind: "gap",
222
+ reason: "v0.11.36 OS-boundary knob read in the PARENT spawn path only (bwrap wrap decision) — deliberately never forwarded to children",
223
+ },
224
+ PI_CREW_SANDBOX_NET: {
225
+ kind: "gap",
226
+ reason: "v0.11.36 sandbox network profile (share|off) read in the PARENT spawn path only — deliberately never forwarded to children",
227
+ },
219
228
  PI_CREW_STEERING_FILE: { kind: "child-write", reason: "child-pi-spawn per-run steering JSONL" },
220
229
  PI_CREW_MAX_OUTPUT: { kind: "child-write", reason: "pi-args emits agent.maxTokens into built.env" },
221
230
  PI_CREW_RPC_SECRET: { kind: "child-write", reason: "shared HMAC secret handed to the child at spawn — secret name, never allowlisted" },
@@ -520,6 +520,18 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
520
520
  name: "PI_CREW_INFERENCE_UPSTREAM_OPENAI",
521
521
  doc: "v0.11.34 openai-only broker upstream override (beats PI_CREW_INFERENCE_UPSTREAM). TESTS ONLY — local mock; production must never set it",
522
522
  },
523
+ PI_CREW_INFERENCE_UPSTREAM_GOOGLE: {
524
+ name: "PI_CREW_INFERENCE_UPSTREAM_GOOGLE",
525
+ doc: "v0.11.36 google-only broker upstream override (beats PI_CREW_INFERENCE_UPSTREAM; default https://generativelanguage.googleapis.com, native google-generative-ai wire, x-goog-api-key auth). TESTS ONLY — local mock; production must never set it",
526
+ },
527
+ PI_CREW_SANDBOX: {
528
+ name: "PI_CREW_SANDBOX",
529
+ doc: "v0.11.36 OS-boundary sandbox, Linux opt-in: 'bwrap' wraps every headless child-pi worker in bubblewrap — the worker sees ONLY the project cwd (rw), the scoped inference agent dir, explicitly handed-in temp/scratch paths, and read-only OS runtime; ~/.pi (auth.json!), ~/.ssh and every other host path are NOT MOUNTED (fail-closed: forgotten paths yield ENOENT inside, never host access). Fail-closed knob: unsupported value / non-Linux / missing bwrap binary / net=off+broker all THROW before any spawn (never silently unsandboxed). Pass-1 boundary: TUI surface panes and the rpc transport are SKIPPED while on (fail-closed branch selection); PI_CREW_SANDBOX_NET=share keeps host networking — egress is NOT isolated unless net=off. Sandbox+broker-off = workers have no credentials at all (by isolation) — pair with PI_CREW_INFERENCE_BROKER=1 for sandboxed inference (sandbox.ts)",
530
+ },
531
+ PI_CREW_SANDBOX_NET: {
532
+ name: "PI_CREW_SANDBOX_NET",
533
+ doc: "v0.11.36 sandbox network profile: 'share' (default — host networking, loopback inference broker reachable, egress NOT isolated) or 'off' (unshare the net stack entirely: no egress, no loopback — REJECTED while the inference broker is active because broker-token workers would be keyless AND netless) (sandbox.ts)",
534
+ },
523
535
  PI_CREW_CLASSIFIER_ENABLED: {
524
536
  name: "PI_CREW_CLASSIFIER_ENABLED",
525
537
  parser: "boolean",
@@ -31,6 +31,7 @@ import type { ChildPiRunInput, ChildPiRunResult } from "./child-pi.ts";
31
31
  import {
32
32
  getInferenceBrokerSnapshot,
33
33
  INFERENCE_TOKEN_ENV,
34
+ inferenceWorkerKeyForAgent,
34
35
  PI_AGENT_DIR_ENV,
35
36
  registerInferenceWorker,
36
37
  requireInferenceBrokerIfEnabled,
@@ -344,7 +345,12 @@ export function prepareSpawnContext(
344
345
  const inferenceSnapshot = requireInferenceBrokerIfEnabled();
345
346
  let inferenceWorkerKey: string | undefined;
346
347
  if (inferenceSnapshot.active) {
347
- inferenceWorkerKey = input.agentId ? `inference:${input.agentId}` : `inference:spawn:${process.pid}:${Date.now().toString(36)}`;
348
+ // v0.11.36: the agentId derivation is SHARED with the exit-revoke wrapper
349
+ // (runChildPi finally) via inferenceWorkerKeyForAgent — both sides must
350
+ // compute the SAME key or revoke-on-exit silently misses.
351
+ inferenceWorkerKey = input.agentId
352
+ ? inferenceWorkerKeyForAgent(input.agentId)
353
+ : `inference:spawn:${process.pid}:${Date.now().toString(36)}`;
348
354
  built.env[INFERENCE_TOKEN_ENV] = registerInferenceWorker(inferenceWorkerKey);
349
355
  }
350
356
  // Pass steering file path to child for real-time steer injection
@@ -36,8 +36,9 @@ import { ChildPiSteeringController } from "./child-pi-steering.ts";
36
36
  import { ChildPiLineObserver, isFinalAssistantEvent } from "./child-pi-streams.ts";
37
37
  // Phase 2.3: the six timer constructs moved to child-pi-timers.ts (pure motion).
38
38
  import { createChildPiTimers } from "./child-pi-timers.ts";
39
- import { maybeStartInferenceBroker } from "./inference-broker.ts";
39
+ import { getInferenceBrokerSnapshot, maybeStartInferenceBroker, revokeInferenceWorkerForAgent } from "./inference-broker.ts";
40
40
  import { runMockChildPi } from "./mock-fixtures.ts";
41
+ import { applySandboxToSpawnSpec, assertSandboxAvailable, resolveSandboxConfig, type SandboxConfig } from "./sandbox.ts";
41
42
  // W2 (session-file recovery): deterministic worker session flags + crash-path
42
43
  // tail-replay of the session JSONL (see session-recovery.ts header).
43
44
  import {
@@ -703,6 +704,21 @@ async function trySurfaceBranch(
703
704
  export const __test__trySurfaceBranch = trySurfaceBranch;
704
705
 
705
706
  export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResult> {
707
+ // v0.11.36 exit-revoke WRAPPER: whatever the inner run does — normal exit,
708
+ // crash, B5 pre-spawn abort, surface/rpc branch, even a thrown error — the
709
+ // task's inference token dies with the run. prepareSpawnContext registers
710
+ // `inference:<agentId>` BEFORE its B5 abort check, so an aborted run that
711
+ // never spawned still leaves a token behind unless revoked HERE. Retries
712
+ // are safe: they call runChildPi again and re-register a FRESH token
713
+ // (register-replace semantics) after this revoke already ran.
714
+ try {
715
+ return await runChildPiInner(input);
716
+ } finally {
717
+ revokeInferenceWorkerForAgent(input.agentId);
718
+ }
719
+ }
720
+
721
+ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult> {
706
722
  // Phase 1 (live-session parity): prepend parent context when inheritContext is true.
707
723
  // This mirrors the effectivePrompt logic in live-session-runtime.ts so that
708
724
  // child-process workers receive the same inherited-context treatment.
@@ -781,6 +797,14 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
781
797
  // v0.11.31: start the inference broker (flag-gated, fail-closed) BEFORE the
782
798
  // sync spawn-prep path — http listen needs an event-loop turn.
783
799
  await maybeStartInferenceBroker();
800
+ // v0.11.36 OS boundary (bwrap, Linux opt-in): resolve + VALIDATE + probe
801
+ // BEFORE any spawn. Every misconfiguration throws here (fail-closed — a
802
+ // sandbox that cannot start must never degrade to an unsandboxed spawn).
803
+ const sandbox: SandboxConfig = resolveSandboxConfig(process.env, {
804
+ platform: process.platform,
805
+ brokerActive: getInferenceBrokerSnapshot().active,
806
+ });
807
+ assertSandboxAvailable(sandbox);
784
808
  const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...versionedInput, brokerSpawn } : versionedInput, effectiveTask, depthEnv);
785
809
  if (spawnPrep.kind === "aborted") return spawnPrep.result;
786
810
  const { spawnSpec, mergedEnv, tempDir, builtEnv, builtArgs } = spawnPrep.ctx;
@@ -805,7 +829,10 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
805
829
  // MuxSurface A1 (spec §13.1): attempt booting the worker in a mux pane BEFORE
806
830
  // spawning a stdio pipe. Returns a finished result in surface mode (pane exit
807
831
  // awaited) or null → fall through to the classic headless spawn below.
808
- const surfaceResult = await trySurfaceBranch(input, depthEnv, builtArgs, mergedEnv, builtEnv, tempDir);
832
+ // v0.11.36: TUI panes are NOT sandboxed (pass 1) — when the sandbox is on
833
+ // the branch is SKIPPED so a sandboxed request can never land in an
834
+ // unsandboxed pane (fail-closed branch selection).
835
+ const surfaceResult = sandbox.enabled ? null : await trySurfaceBranch(input, depthEnv, builtArgs, mergedEnv, builtEnv, tempDir);
809
836
  if (surfaceResult) return surfaceResult;
810
837
  // U14 (child-pi protocol v2 — upgrade-spec 2026-10-09): `--mode rpc`
811
838
  // transport, opt-in via PI_CREW_CHILD_PI_MODE=rpc (json remains the
@@ -814,7 +841,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
814
841
  // json branch below stays the fail-safe path. Surface TUI panes are
815
842
  // unaffected — trySurfaceBranch consumed builtArgs above and never
816
843
  // reaches this point in surface mode.
817
- if (resolveChildPiRpcMode()) {
844
+ if (!sandbox.enabled && resolveChildPiRpcMode()) {
818
845
  const rpcResult = await tryChildPiRpcRun({ input, effectiveTask, ctx: spawnPrep.ctx, workerSession, sessionRecoveryEnabled });
819
846
  if (rpcResult) return rpcResult;
820
847
  }
@@ -824,7 +851,38 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
824
851
  // owned by buildFinalChildPiSpawnOptions (see child-pi-spawn.ts, BLOCKER 2 / S5).
825
852
  // R6-8: modelChain threads the candidate chain for provider-key scoping.
826
853
  const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain);
827
- const child = spawn(spawnSpec.command, spawnSpec.args, spawnOptions);
854
+ // v0.11.36 OS boundary: when the sandbox is on, the headless spawn is
855
+ // wrapped in bwrap. Mounts: cwd RW (the worker edits the project), the
856
+ // scoped inference agent dir RW (pi writes session state under it), and
857
+ // the parent-side paths this run handed the worker via env/argv
858
+ // (task.md tempDir, scratchpad snapshot/restore, steering file) READ-ONLY
859
+ // plus the inter-pi broker socket RW (connect() needs write perm). A
860
+ // forgotten path fails CLOSED inside the namespace (ENOENT) — it never
861
+ // silently reaches the host.
862
+ let spawnCommand = spawnSpec.command;
863
+ let spawnArgs = spawnSpec.args;
864
+ if (sandbox.enabled) {
865
+ const inferenceSnap = getInferenceBrokerSnapshot();
866
+ const wrapped = applySandboxToSpawnSpec(
867
+ { command: spawnSpec.command, args: spawnSpec.args },
868
+ {
869
+ cwd: input.cwd,
870
+ net: sandbox.net,
871
+ agentDir: inferenceSnap.active ? inferenceSnap.scopedAgentDir : undefined,
872
+ roBindPaths: [
873
+ tempDir,
874
+ builtEnv.PI_CREW_SCRATCHPAD_SNAPSHOT,
875
+ builtEnv.PI_CREW_SCRATCHPAD_RESTORE,
876
+ builtEnv.PI_CREW_STEERING_FILE,
877
+ ].filter((p): p is string => typeof p === "string" && p.length > 0),
878
+ rwBindPaths: [input.brokerSpawn?.socketPath].filter((p): p is string => typeof p === "string" && p.length > 0),
879
+ },
880
+ );
881
+ spawnCommand = wrapped.command;
882
+ spawnArgs = wrapped.args;
883
+ spawnOptions.env = { ...spawnOptions.env, ...wrapped.envOverrides };
884
+ }
885
+ const child = spawn(spawnCommand, spawnArgs, spawnOptions);
828
886
  if (child.pid) {
829
887
  registerActiveChild(child.pid, child);
830
888
  input.onSpawn?.(child.pid);
@@ -106,11 +106,15 @@ export interface InferenceBrokerSnapshot {
106
106
  providers: string[];
107
107
  }
108
108
 
109
- /** One provider's upstream route: base URL + substituted auth header. */
109
+ /** One provider's upstream route: base URL + substituted auth header. The
110
+ * upstream DEFAULT carries the provider's version path (openai …/v1, google
111
+ * …/v1beta) exactly like the built-in models' baseUrls do — the scoped
112
+ * models.json replaces the WHOLE baseUrl with a bare /<provider> prefix, so
113
+ * the version path must never be duplicated on the broker side. */
110
114
  interface ProviderRoute {
111
- name: "anthropic" | "openai";
115
+ name: "anthropic" | "openai" | "google";
112
116
  upstream: string;
113
- auth: { header: "x-api-key" | "authorization"; value: string };
117
+ auth: { header: "x-api-key" | "authorization" | "x-goog-api-key"; value: string };
114
118
  }
115
119
 
116
120
  interface BrokerState {
@@ -186,6 +190,29 @@ function resolveProviderRoutes(env: NodeJS.ProcessEnv): { routes: ProviderRoute[
186
190
  routes.push({ name: "openai", upstream: openaiUpstream, auth: { header: "authorization", value: `Bearer ${openaiStored.key}` } });
187
191
  }
188
192
 
193
+ // v0.11.36 google (native google-generative-ai wire): the built-in google
194
+ // provider speaks `x-goog-api-key` and paths under /v1beta (verified against
195
+ // the pi catalog: ids like "gemini-2.5-pro", baseUrl ...
196
+ // /v1beta). Credential precedence mirrors the others: env FIRST
197
+ // (GOOGLE_API_KEY, then GEMINI_API_KEY — both conventional), then auth.json:google.
198
+ const googleUpstream =
199
+ getCrewEnv("PI_CREW_INFERENCE_UPSTREAM_GOOGLE", env) || genericUpstream || "https://generativelanguage.googleapis.com/v1beta";
200
+ const googleApiKey = env.GOOGLE_API_KEY;
201
+ const geminiApiKey = env.GEMINI_API_KEY;
202
+ const googleStored = authJson.google;
203
+ consulted.push("env GOOGLE_API_KEY", "env GEMINI_API_KEY", "auth.json:google");
204
+ const googleCredential =
205
+ googleApiKey && googleApiKey.length > 0
206
+ ? googleApiKey
207
+ : geminiApiKey && geminiApiKey.length > 0
208
+ ? geminiApiKey
209
+ : googleStored?.type === "api_key" && typeof googleStored.key === "string" && googleStored.key.length > 0
210
+ ? googleStored.key
211
+ : undefined;
212
+ if (googleCredential) {
213
+ routes.push({ name: "google", upstream: googleUpstream, auth: { header: "x-goog-api-key", value: googleCredential } });
214
+ }
215
+
189
216
  return { routes, trace: consulted.join(", ") };
190
217
  }
191
218
 
@@ -212,7 +239,7 @@ export async function maybeStartInferenceBroker(env: NodeJS.ProcessEnv = process
212
239
  const { routes, trace } = resolveProviderRoutes(env);
213
240
  if (routes.length === 0) {
214
241
  throw new Error(
215
- `${INFERENCE_BROKER_ENABLE_ENV}=1 but no provider credential resolved for the inference broker (consulted: ${trace}) — fail-closed: workers would otherwise receive no usable credential, or worse, fall back to real provider keys. Provide ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY in the parent env, an api_key entry for anthropic/openai in the parent's auth.json, or unset ${INFERENCE_BROKER_ENABLE_ENV}.`,
242
+ `${INFERENCE_BROKER_ENABLE_ENV}=1 but no provider credential resolved for the inference broker (consulted: ${trace}) — fail-closed: workers would otherwise receive no usable credential, or worse, fall back to real provider keys. Provide ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY / GOOGLE_API_KEY / GEMINI_API_KEY in the parent env, an api_key entry for anthropic/openai/google in the parent's auth.json, or unset ${INFERENCE_BROKER_ENABLE_ENV}.`,
216
243
  );
217
244
  }
218
245
  broker = await createBrokerServer(routes);
@@ -312,10 +339,12 @@ function buildScopedAgentDir(port: number, routes: ProviderRoute[]): string {
312
339
  return dir;
313
340
  }
314
341
 
315
- /** Extract the caller token from x-api-key or Authorization: Bearer. */
342
+ /** Extract the caller token from x-api-key, x-goog-api-key, or Authorization: Bearer. */
316
343
  function extractToken(req: http.IncomingMessage): string | undefined {
317
344
  const apiKey = req.headers["x-api-key"];
318
345
  if (typeof apiKey === "string" && apiKey.length > 0) return apiKey;
346
+ const googKey = req.headers["x-goog-api-key"];
347
+ if (typeof googKey === "string" && googKey.length > 0) return googKey;
319
348
  const auth = req.headers.authorization;
320
349
  if (typeof auth === "string" && auth.startsWith("Bearer ")) return auth.slice("Bearer ".length);
321
350
  return undefined;
@@ -347,7 +376,7 @@ async function handleRequest(
347
376
  }
348
377
  }
349
378
  if (!route) {
350
- deny(res, 404, "unknown provider route (expected /anthropic/... or /openai/...)");
379
+ deny(res, 404, "unknown provider route (expected /anthropic/..., /openai/..., or /google/...)");
351
380
  return;
352
381
  }
353
382
  // (1) Auth — fail-closed, generic errors (never echo tokens).
@@ -458,9 +487,23 @@ class UsageCounter {
458
487
  // SSE message_delta: evt.usage.output_tokens is CUMULATIVE (take max).
459
488
  // Non-stream JSON body: top-level evt.usage, both absolute.
460
489
  const usage = evt?.message?.usage ?? evt?.usage;
490
+ // Google native (google-generative-ai): usageMetadata with
491
+ // promptTokenCount / candidatesTokenCount (+ thoughtsTokenCount) —
492
+ // CUMULATIVE per stream, so max() below lands on the final totals.
493
+ const meta = evt?.usageMetadata;
494
+ if (meta && typeof meta === "object") {
495
+ const prompt = typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : 0;
496
+ const candidates = typeof meta.candidatesTokenCount === "number" ? meta.candidatesTokenCount : 0;
497
+ const thoughts = typeof meta.thoughtsTokenCount === "number" ? meta.thoughtsTokenCount : 0;
498
+ this.input = Math.max(this.input, prompt);
499
+ this.output = Math.max(this.output, candidates + thoughts);
500
+ }
461
501
  if (!usage || typeof usage !== "object") continue;
462
502
  // Anthropic: input_tokens / output_tokens. OpenAI chat-completions:
463
503
  // prompt_tokens / completion_tokens (final usage chunk, absolute).
504
+ // Google native (google-generative-ai): usageMetadata with
505
+ // promptTokenCount / candidatesTokenCount (+ thoughtsTokenCount) —
506
+ // CUMULATIVE per stream, so max() below lands on the final totals.
464
507
  const inTok =
465
508
  typeof usage.input_tokens === "number"
466
509
  ? usage.input_tokens
@@ -503,6 +546,28 @@ export function registerInferenceWorker(workerKey: string): string {
503
546
  return token;
504
547
  }
505
548
 
549
+ /** Deterministic worker key for a task-owned worker — SHARED by the spawn side (prepareSpawnContext) and the exit-revoke wrapper (runChildPi finally). */
550
+ export function inferenceWorkerKeyForAgent(agentId: string): string {
551
+ return `inference:${agentId}`;
552
+ }
553
+
554
+ /**
555
+ * Non-throwing exit hook (v0.11.36): revoke a task worker's inference token
556
+ * when its runChildPi call settles — normal exit, crash, or abort. Anonymous
557
+ * (agentId-less) spawns use per-spawn unique keys that only the spawn path
558
+ * knows; their tokens die at register-replacement or broker stop. Broker off
559
+ * → no-op. NEVER throws (must not mask a worker result).
560
+ */
561
+ export function revokeInferenceWorkerForAgent(agentId: string | undefined): boolean {
562
+ if (!agentId) return false;
563
+ try {
564
+ if (!getInferenceBrokerSnapshot().active) return false;
565
+ return revokeInferenceWorker(inferenceWorkerKeyForAgent(agentId));
566
+ } catch {
567
+ return false;
568
+ }
569
+ }
570
+
506
571
  /** Kill-switch for one worker: its token dies on the NEXT request. */
507
572
  export function revokeInferenceWorker(workerKey: string): boolean {
508
573
  if (!broker) return false;
@@ -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
+ }