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/CHANGELOG.md +16 -0
- package/dist/index.mjs +535 -535
- package/package.json +1 -1
- package/src/config/env-allowlist-registry.ts +9 -0
- package/src/config/env-vars.ts +12 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +7 -1
- package/src/runtime/child-pi/child-pi.ts +62 -4
- package/src/runtime/child-pi/inference-broker.ts +71 -6
- package/src/runtime/child-pi/sandbox.ts +224 -0
package/package.json
CHANGED
|
@@ -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" },
|
package/src/config/env-vars.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|