pi-crew 0.11.44 → 0.11.46
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 +67 -1
- package/dist/index.mjs +510 -510
- package/package.json +1 -1
- package/src/config/env-allowlist-registry.ts +4 -0
- package/src/config/env-vars.ts +4 -0
- package/src/extension/cross-extension-rpc.ts +15 -6
- package/src/prompt/prompt-runtime.ts +16 -3
- package/src/runtime/async-runner.ts +5 -7
- package/src/runtime/broker/crew-broker-child.ts +58 -0
- package/src/runtime/broker/crew-broker-delegate-request.ts +381 -0
- package/src/runtime/broker/crew-broker.ts +57 -327
- package/src/runtime/broker/socket-stop.ts +147 -0
- package/src/runtime/broker/stdin-handshake.ts +11 -0
- package/src/runtime/child-pi/child-pi-kill.ts +44 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +53 -4
- package/src/runtime/child-pi/child-pi.ts +32 -1
- package/src/runtime/child-pi/inference-broker.ts +40 -8
- package/src/runtime/goal-workflow/goal-achievement.ts +10 -1
- package/src/runtime/pi-spawn.ts +10 -1
- package/src/runtime/task-runner/post-execution.ts +36 -2
- package/src/utils/socket-path.ts +54 -0
- package/src/worktree/branch-freshness.ts +9 -22
- package/src/worktree/cleanup.ts +78 -65
- package/src/worktree/git-hardening.ts +507 -0
- package/src/worktree/worktree-manager.ts +176 -129
|
@@ -57,6 +57,11 @@ const identityByPid = new Map<number, ProcIdentity>();
|
|
|
57
57
|
// Wave 11a injection seam (same shape as zombie-scanner.ts `__test`): tests seed
|
|
58
58
|
// identity snapshots and swap the group reaper without a real child process.
|
|
59
59
|
let reapGroupImpl: typeof reapGroup = reapGroup;
|
|
60
|
+
// Wave 16 (E1): injectable /proc identity reader for killProcessPid's PID-recycle
|
|
61
|
+
// re-validation — tests simulate a recycled starttime without racing the OS for
|
|
62
|
+
// a real pid reuse. registerActiveChild deliberately keeps the REAL reader (its
|
|
63
|
+
// identityByPid snapshot is production /proc state, not a test fixture).
|
|
64
|
+
let readProcIdentityImpl: typeof readProcIdentity = readProcIdentity;
|
|
60
65
|
export const __test = {
|
|
61
66
|
/** Seed the exit-time identity snapshot for a pid (as registerActiveChild would). */
|
|
62
67
|
seedIdentity(pid: number, identity: ProcIdentity | undefined): void {
|
|
@@ -71,6 +76,10 @@ export const __test = {
|
|
|
71
76
|
setReapGroup(fn: typeof reapGroup | undefined): void {
|
|
72
77
|
reapGroupImpl = fn ?? reapGroup;
|
|
73
78
|
},
|
|
79
|
+
/** Replace the /proc identity reader used by killProcessPid's E1 re-validation (undefined restores the real one). */
|
|
80
|
+
setReadProcIdentity(fn: typeof readProcIdentity | undefined): void {
|
|
81
|
+
readProcIdentityImpl = fn ?? readProcIdentity;
|
|
82
|
+
},
|
|
74
83
|
};
|
|
75
84
|
|
|
76
85
|
// Periodic cleanup of dead child process entries to prevent memory leaks.
|
|
@@ -173,9 +182,24 @@ export function killProcessPid(pid: number): void {
|
|
|
173
182
|
void owned.dispose();
|
|
174
183
|
return;
|
|
175
184
|
}
|
|
185
|
+
// E1 (fix-wave 16): baseline the pid's /proc starttime (stat field 22) while
|
|
186
|
+
// it is alive at kill entry; the hard-kill timer below re-reads it and
|
|
187
|
+
// refuses to signal a CHANGED starttime — the original process exited during
|
|
188
|
+
// the grace window and an unrelated process recycled the pid. Effectively
|
|
189
|
+
// Linux-only: Windows has no /proc (readProcIdentity → undefined ⇒ no
|
|
190
|
+
// baseline ⇒ the timer keeps the pre-wave unconditional behavior — see the
|
|
191
|
+
// win32 branch note below), and /proc-less POSIX (macOS) degrades the same
|
|
192
|
+
// way. Deliberately NOT the registration-snapshot pattern (identityByPid):
|
|
193
|
+
// external pid-only callers — live-session agents, cancel paths on stale
|
|
194
|
+
// pids — have no registration to consult.
|
|
195
|
+
const baselineStarttime = process.platform === "win32" ? undefined : readProcIdentityImpl(pid)?.starttime;
|
|
176
196
|
try {
|
|
177
197
|
if (process.platform === "win32") {
|
|
178
198
|
// 3.8: Windows path uses taskkill /T /F (force kill the entire tree).
|
|
199
|
+
// E1 note: Windows has no /proc, so no starttime baseline/re-validation
|
|
200
|
+
// is possible here — behavior stays exactly as before. taskkill /T /F on
|
|
201
|
+
// a recycled pid fails harmlessly ("process not found"), so the residual
|
|
202
|
+
// recycle window is not actionable on this platform.
|
|
179
203
|
// taskkill itself can silently fail (PID gone, permission denied, etc.)
|
|
180
204
|
// so verify after 2s and log a warning if the process is still alive.
|
|
181
205
|
spawnTaskkillSafe(pid);
|
|
@@ -213,6 +237,26 @@ export function killProcessPid(pid: number): void {
|
|
|
213
237
|
}
|
|
214
238
|
clearHardKillTimer(pid);
|
|
215
239
|
const hardKillTimer = setTimeout(() => {
|
|
240
|
+
// E1: PID-recycle re-validation immediately before the hard kill —
|
|
241
|
+
// guards BOTH the group kill (-pid) below and the absolute-pid fallback
|
|
242
|
+
// inside its catch. Skip ONLY on a proven mismatch (baseline known AND
|
|
243
|
+
// current known AND different): an unreadable current value means the pid
|
|
244
|
+
// is already gone (the kill below reports ESRCH exactly as before) or
|
|
245
|
+
// /proc is unavailable on this platform (there was never a baseline to
|
|
246
|
+
// compare against).
|
|
247
|
+
const currentStarttime = readProcIdentityImpl(pid)?.starttime;
|
|
248
|
+
if (baselineStarttime !== undefined && currentStarttime !== undefined && currentStarttime !== baselineStarttime) {
|
|
249
|
+
logInternalError(
|
|
250
|
+
"child-pi.sigkill-pid-recycled",
|
|
251
|
+
new Error(
|
|
252
|
+
`starttime changed between SIGTERM and the hard-kill timer (${baselineStarttime} → ${currentStarttime}) — pid was recycled, skipping hard kill of the unrelated new occupant`,
|
|
253
|
+
),
|
|
254
|
+
`pid=${pid}`,
|
|
255
|
+
"warn",
|
|
256
|
+
);
|
|
257
|
+
childHardKillTimers.delete(pid);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
216
260
|
try {
|
|
217
261
|
process.kill(-pid, "SIGKILL");
|
|
218
262
|
} catch (error) {
|
|
@@ -20,10 +20,12 @@ import type { SpawnOptions } from "node:child_process";
|
|
|
20
20
|
import * as fs from "node:fs";
|
|
21
21
|
import * as path from "node:path";
|
|
22
22
|
import { childPiBaseAllowlist } from "../../config/env-allowlist-registry.ts";
|
|
23
|
+
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
23
24
|
import { isScratchpadEnabledForRole } from "../../config/role-tools.ts";
|
|
24
25
|
import { buildScopedAllowList, providerEnvKeys, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
|
|
25
26
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
26
27
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
28
|
+
import { buildBrokerStdinLine } from "../broker/stdin-handshake.ts";
|
|
27
29
|
import { buildPiWorkerArgs, createSafeTempDir, getPiTempBase } from "../model/pi-args.ts";
|
|
28
30
|
import { getPiSpawnCommand } from "../pi-spawn.ts";
|
|
29
31
|
import { findLatestScratchpadSnapshot } from "../scratchpad/snapshot-lookup.ts";
|
|
@@ -44,6 +46,19 @@ import {
|
|
|
44
46
|
// NOT set this (their sessions persist and benefit from long-lived cache).
|
|
45
47
|
export const PI_CACHE_RETENTION_ENV = "PI_CACHE_RETENTION";
|
|
46
48
|
|
|
49
|
+
/** LOW-1 residual closure (2026-10-27): when the inter-pi broker credentials
|
|
50
|
+
* are present, the per-task compound TOKEN travels over the spawn stdin pipe
|
|
51
|
+
* (one `buildBrokerStdinLine` payload, heap → pipe → heap — same shape as the
|
|
52
|
+
* F4 background-runner handshake) instead of the child env. Set
|
|
53
|
+
* PI_CREW_BROKER_STDIN=0 to roll back to the legacy env route (token in
|
|
54
|
+
* built.env, stdin stays "ignore"). Default ON. Only the headless JSON spawn
|
|
55
|
+
* branch consumes the payload: the RPC branch (stdin is the JSONL protocol
|
|
56
|
+
* channel) and the surface TUI panes keep the env route, so their env is
|
|
57
|
+
* untouched. */
|
|
58
|
+
export function resolveBrokerStdinHandshakeEnabled(): boolean {
|
|
59
|
+
return getCrewEnv("PI_CREW_BROKER_STDIN") !== "0";
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
// ── Env allowlist (base set always passed to children) ──────────────────
|
|
48
63
|
// Provider API keys are injected dynamically via buildScopedAllowList() only
|
|
49
64
|
// when a model is assigned to the task (per-task key scoping).
|
|
@@ -258,6 +273,7 @@ export function buildFinalChildPiSpawnOptions(
|
|
|
258
273
|
model?: string,
|
|
259
274
|
modelChain?: (string | undefined)[],
|
|
260
275
|
workerCacheSplit?: boolean,
|
|
276
|
+
stdinBrokerPayload?: string,
|
|
261
277
|
): SpawnOptions {
|
|
262
278
|
// (a) Canary: builtEnv must contain ONLY PI_CREW_*/PI_TEAMS_* keys.
|
|
263
279
|
assertOnlyControlEnvKeys(builtEnv);
|
|
@@ -287,7 +303,18 @@ export function buildFinalChildPiSpawnOptions(
|
|
|
287
303
|
if (workerCacheSplit) {
|
|
288
304
|
spawnOptions.env[PI_CACHE_RETENTION_ENV] = "short";
|
|
289
305
|
}
|
|
290
|
-
// (d)
|
|
306
|
+
// (d) LOW-1 closure: when the caller will hand the broker token to the child
|
|
307
|
+
// over the stdin pipe (headless JSON branch — it passes the payload it got
|
|
308
|
+
// from prepareSpawnContext), switch stdin from "ignore" to "pipe" and strip
|
|
309
|
+
// the secret from the env as defense-in-depth (prepareSpawnContext already
|
|
310
|
+
// omits it; the delete guards direct builtEnv constructions in tests/callers
|
|
311
|
+
// that still carry it). RPC branch / surface workerEnv snapshots call
|
|
312
|
+
// WITHOUT this param → env keeps the token and nothing else changes.
|
|
313
|
+
if (stdinBrokerPayload !== undefined) {
|
|
314
|
+
spawnOptions.stdio = ["pipe", "pipe", "pipe"];
|
|
315
|
+
delete spawnOptions.env.PI_CREW_BROKER_TOKEN;
|
|
316
|
+
}
|
|
317
|
+
// (e) Return the composed SpawnOptions for spawn(...).
|
|
291
318
|
return spawnOptions;
|
|
292
319
|
}
|
|
293
320
|
|
|
@@ -310,6 +337,12 @@ export interface SpawnContext {
|
|
|
310
337
|
builtEnv: Record<string, string | undefined>;
|
|
311
338
|
/** v0.11.31: inference-broker worker key that owns this spawn's token (cleanup/revoke bookkeeping). */
|
|
312
339
|
inferenceWorkerKey?: string;
|
|
340
|
+
/** LOW-1 closure: one-line broker credential payload to write to the
|
|
341
|
+
* child's stdin right after spawn (headless JSON branch ONLY — the RPC
|
|
342
|
+
* branch owns stdin for its JSONL protocol and surface panes are TTY).
|
|
343
|
+
* Present ⇒ PI_CREW_BROKER_TOKEN is NOT in builtEnv (token travels
|
|
344
|
+
* heap → pipe → heap instead). Undefined ⇒ legacy env route. */
|
|
345
|
+
brokerStdinPayload?: string;
|
|
313
346
|
}
|
|
314
347
|
|
|
315
348
|
/**
|
|
@@ -364,6 +397,9 @@ export function prepareSpawnContext(
|
|
|
364
397
|
// a per-spawn unique.
|
|
365
398
|
const inferenceSnapshot = requireInferenceBrokerIfEnabled();
|
|
366
399
|
let inferenceWorkerKey: string | undefined;
|
|
400
|
+
// LOW-1 closure: stdin handshake payload for the headless JSON branch (see
|
|
401
|
+
// the brokerSpawn block below). Undefined = env route (legacy/rollback).
|
|
402
|
+
let brokerStdinPayload: string | undefined;
|
|
367
403
|
if (inferenceSnapshot.active) {
|
|
368
404
|
// v0.11.36: the agentId derivation is SHARED with the exit-revoke wrapper
|
|
369
405
|
// (runChildPi finally) via inferenceWorkerKeyForAgent — both sides must
|
|
@@ -415,11 +451,23 @@ export function prepareSpawnContext(
|
|
|
415
451
|
// Phase 0 inter-pi broker: inject socket path + token (control-namespace keys,
|
|
416
452
|
// safe under assertOnlyControlEnvKeys). Only when the parent broker issued
|
|
417
453
|
// credentials for this run — i.e. the broker is enabled AND this run is
|
|
418
|
-
// eligible.
|
|
419
|
-
//
|
|
454
|
+
// eligible. NEVER persisted to disk.
|
|
455
|
+
// LOW-1 closure: by default the TOKEN leaves the env entirely — it rides the
|
|
456
|
+
// spawn stdin pipe as a single buildBrokerStdinLine payload (ctx
|
|
457
|
+
// .brokerStdinPayload; the headless JSON branch pipes stdin and writes it
|
|
458
|
+
// right after spawn). SOCKET/RUN_ID/TASK_ID stay in env (non-secret, needed
|
|
459
|
+
// by the worker bootstrap to bind + validate the stdin payload). Rollback
|
|
460
|
+
// gate PI_CREW_BROKER_STDIN=0 (or a legacy shape without runId/taskId to
|
|
461
|
+
// bind the payload to) keeps the env route bit-for-bit.
|
|
420
462
|
if (input.brokerSpawn?.socketPath && input.brokerSpawn.token) {
|
|
421
463
|
built.env.PI_CREW_BROKER_SOCKET = input.brokerSpawn.socketPath;
|
|
422
|
-
|
|
464
|
+
if (resolveBrokerStdinHandshakeEnabled() && input.runId && input.agentId) {
|
|
465
|
+
brokerStdinPayload = buildBrokerStdinLine(input.runId, input.brokerSpawn.socketPath, {
|
|
466
|
+
[input.agentId]: input.brokerSpawn.token,
|
|
467
|
+
});
|
|
468
|
+
} else {
|
|
469
|
+
built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
|
|
470
|
+
}
|
|
423
471
|
// runId/taskId are threaded unconditionally above (I5); nothing to add here.
|
|
424
472
|
}
|
|
425
473
|
// WP-9 (R9): the worker self-reporting channel env is UNCONDITIONAL —
|
|
@@ -499,6 +547,7 @@ export function prepareSpawnContext(
|
|
|
499
547
|
tempDir: built.tempDir,
|
|
500
548
|
builtEnv: built.env,
|
|
501
549
|
inferenceWorkerKey,
|
|
550
|
+
...(brokerStdinPayload !== undefined ? { brokerStdinPayload } : {}),
|
|
502
551
|
},
|
|
503
552
|
};
|
|
504
553
|
}
|
|
@@ -852,7 +852,18 @@ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult
|
|
|
852
852
|
// R6-8: modelChain threads the candidate chain for provider-key scoping.
|
|
853
853
|
// v0.11.38 W10-1: workerCacheSplit — headless single-run workers opt into
|
|
854
854
|
// short prompt-cache retention (see PI_CACHE_RETENTION_ENV).
|
|
855
|
-
|
|
855
|
+
// LOW-1 closure: ctx.brokerStdinPayload (when present) switches stdin to
|
|
856
|
+
// "pipe" + strips PI_CREW_BROKER_TOKEN from the child env — the token
|
|
857
|
+
// travels over the stdin pipe right after spawn instead (heap → pipe → heap).
|
|
858
|
+
const spawnOptions = buildFinalChildPiSpawnOptions(
|
|
859
|
+
input.cwd,
|
|
860
|
+
mergedEnv,
|
|
861
|
+
builtEnv,
|
|
862
|
+
input.model,
|
|
863
|
+
input.modelChain,
|
|
864
|
+
true,
|
|
865
|
+
spawnPrep.ctx.brokerStdinPayload,
|
|
866
|
+
);
|
|
856
867
|
// v0.11.36 OS boundary: when the sandbox is on, the headless spawn is
|
|
857
868
|
// wrapped in bwrap. Mounts: cwd RW (the worker edits the project), the
|
|
858
869
|
// scoped inference agent dir RW (pi writes session state under it), and
|
|
@@ -909,6 +920,26 @@ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult
|
|
|
909
920
|
ts: new Date().toISOString(),
|
|
910
921
|
});
|
|
911
922
|
}
|
|
923
|
+
// LOW-1 closure: hand the worker its broker credential over stdin — one
|
|
924
|
+
// buildBrokerStdinLine JSON line (v2, per-task compound token), then close
|
|
925
|
+
// the pipe. Mirror of the F4 async-runner handshake; the worker-side
|
|
926
|
+
// reader (crew-broker-child.ts via stdin-handshake.readStdinFirstLine)
|
|
927
|
+
// binds it by runId + taskId. Best-effort in BOTH directions: any write
|
|
928
|
+
// failure (EPIPE on a fast-exiting child, destroyed stream) leaves the
|
|
929
|
+
// worker creds-less exactly like a broker-less spawn — never throws out
|
|
930
|
+
// of the spawn path.
|
|
931
|
+
if (spawnPrep.ctx.brokerStdinPayload) {
|
|
932
|
+
try {
|
|
933
|
+
child.stdin?.write(spawnPrep.ctx.brokerStdinPayload);
|
|
934
|
+
} catch {
|
|
935
|
+
/* best-effort: worker proceeds creds-less */
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
child.stdin?.end();
|
|
939
|
+
} catch {
|
|
940
|
+
/* EPIPE: child already died — spawn error handling owns that */
|
|
941
|
+
}
|
|
942
|
+
}
|
|
912
943
|
// P0-1: O(1)-amortized bounded accumulators (segment ring) — replaces the O(n²)
|
|
913
944
|
// appendBoundedTail rebuild-per-line pattern that re-scanned 512 KiB every line.
|
|
914
945
|
const stdoutTail = new BoundedTail();
|
|
@@ -36,7 +36,11 @@
|
|
|
36
36
|
* Anthropic usage events; per-worker in-flight cap; revoke(workerKey) /
|
|
37
37
|
* revokeAll() kill-switch (revoked token → 401 on the NEXT request; in-flight
|
|
38
38
|
* requests are allowed to drain — killing sockets mid-stream corrupts
|
|
39
|
-
* transcripts without adding security, the token is already dead).
|
|
39
|
+
* transcripts without adding security, the token is already dead). The token
|
|
40
|
+
* map is keyed by the SHA-256 digest of each token, never the raw secret
|
|
41
|
+
* (constant-time discipline, same pattern as crew-broker-tokens.ts); after
|
|
42
|
+
* registerInferenceWorker hands the raw token to the caller, the broker
|
|
43
|
+
* retains only its digest.
|
|
40
44
|
*
|
|
41
45
|
* Honest boundary: the broker protects the REUSABLE CREDENTIAL channel only.
|
|
42
46
|
* A worker can still read other host files (~/.ssh, ~/.aws, ~/.pi/agent/
|
|
@@ -88,7 +92,10 @@ const STRIP_RESPONSE_HEADERS = new Set(["content-encoding", "content-length", "t
|
|
|
88
92
|
|
|
89
93
|
interface WorkerEntry {
|
|
90
94
|
workerKey: string;
|
|
91
|
-
token
|
|
95
|
+
/** SHA-256 hex digest of the worker token — the RAW secret is handed to
|
|
96
|
+
* the caller by registerInferenceWorker and never retained here
|
|
97
|
+
* (constant-time discipline, fix-wave TASK [5]). */
|
|
98
|
+
tokenDigest: string;
|
|
92
99
|
revoked: boolean;
|
|
93
100
|
requests: number;
|
|
94
101
|
inputTokens: number;
|
|
@@ -97,6 +104,15 @@ interface WorkerEntry {
|
|
|
97
104
|
maxTokens: number;
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
/** Map key for the per-worker token registry: the SHA-256 hex digest of the
|
|
108
|
+
* token, NEVER the raw secret — mirrors BrokerTokenRegistry.hashToken
|
|
109
|
+
* (runtime/broker/crew-broker-tokens.ts). Hashed locally with the identical
|
|
110
|
+
* implementation instead of importing the registry class, so this module's
|
|
111
|
+
* static import graph is untouched. */
|
|
112
|
+
function tokenKey(token: string): string {
|
|
113
|
+
return crypto.createHash("sha256").update(token, "utf8").digest("hex");
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
export interface InferenceBrokerSnapshot {
|
|
101
117
|
active: boolean;
|
|
102
118
|
baseUrl: string;
|
|
@@ -121,7 +137,7 @@ interface BrokerState {
|
|
|
121
137
|
server: http.Server;
|
|
122
138
|
port: number;
|
|
123
139
|
routes: Map<string, ProviderRoute>; // by path prefix ("/anthropic", "/openai")
|
|
124
|
-
workers: Map<string, WorkerEntry>; // by token
|
|
140
|
+
workers: Map<string, WorkerEntry>; // by SHA-256 token digest (never the raw secret)
|
|
125
141
|
byKey: Map<string, WorkerEntry>; // by workerKey
|
|
126
142
|
scopedAgentDir: string;
|
|
127
143
|
}
|
|
@@ -381,7 +397,7 @@ async function handleRequest(
|
|
|
381
397
|
}
|
|
382
398
|
// (1) Auth — fail-closed, generic errors (never echo tokens).
|
|
383
399
|
const token = extractToken(req);
|
|
384
|
-
const worker = token ? ctx.workers.get(token) : undefined;
|
|
400
|
+
const worker = token ? ctx.workers.get(tokenKey(token)) : undefined;
|
|
385
401
|
if (!worker || worker.revoked) {
|
|
386
402
|
deny(res, 401, "invalid or revoked inference token");
|
|
387
403
|
return;
|
|
@@ -535,13 +551,22 @@ export function registerInferenceWorker(workerKey: string): string {
|
|
|
535
551
|
const existing = broker.byKey.get(workerKey);
|
|
536
552
|
if (existing) {
|
|
537
553
|
existing.revoked = true;
|
|
538
|
-
broker.workers.delete(existing.
|
|
554
|
+
broker.workers.delete(existing.tokenDigest);
|
|
539
555
|
broker.byKey.delete(workerKey);
|
|
540
556
|
}
|
|
541
557
|
const token = crypto.randomBytes(32).toString("hex");
|
|
542
558
|
const maxTokens = getCrewEnvInt(INFERENCE_BUDGET_ENV) ?? 0;
|
|
543
|
-
const entry: WorkerEntry = {
|
|
544
|
-
|
|
559
|
+
const entry: WorkerEntry = {
|
|
560
|
+
workerKey,
|
|
561
|
+
tokenDigest: tokenKey(token),
|
|
562
|
+
revoked: false,
|
|
563
|
+
requests: 0,
|
|
564
|
+
inputTokens: 0,
|
|
565
|
+
outputTokens: 0,
|
|
566
|
+
inFlight: 0,
|
|
567
|
+
maxTokens,
|
|
568
|
+
};
|
|
569
|
+
broker.workers.set(entry.tokenDigest, entry);
|
|
545
570
|
broker.byKey.set(workerKey, entry);
|
|
546
571
|
return token;
|
|
547
572
|
}
|
|
@@ -574,7 +599,7 @@ export function revokeInferenceWorker(workerKey: string): boolean {
|
|
|
574
599
|
const entry = broker.byKey.get(workerKey);
|
|
575
600
|
if (!entry) return false;
|
|
576
601
|
entry.revoked = true;
|
|
577
|
-
broker.workers.delete(entry.
|
|
602
|
+
broker.workers.delete(entry.tokenDigest);
|
|
578
603
|
broker.byKey.delete(workerKey);
|
|
579
604
|
return true;
|
|
580
605
|
}
|
|
@@ -616,3 +641,10 @@ export function stopInferenceBroker(): void {
|
|
|
616
641
|
export function __resetInferenceBrokerForTests(): void {
|
|
617
642
|
stopInferenceBroker();
|
|
618
643
|
}
|
|
644
|
+
|
|
645
|
+
/** @internal TESTS ONLY — the live token-map keys. By construction these are
|
|
646
|
+
* SHA-256 digests (see tokenKey); the suite pins that no RAW token ever
|
|
647
|
+
* appears as a map key (constant-time discipline). */
|
|
648
|
+
export function __inferenceBrokerTokenMapKeysForTests(): string[] {
|
|
649
|
+
return broker ? [...broker.workers.keys()] : [];
|
|
650
|
+
}
|
|
@@ -24,6 +24,7 @@ import { spawnSync } from "node:child_process";
|
|
|
24
24
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
25
25
|
import { findRepoRoot } from "../../utils/paths.ts";
|
|
26
26
|
import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
|
|
27
|
+
import { buildGitSafeEnv, hardenGitArgs } from "../../worktree/git-hardening.ts";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Roles whose job is to EDIT project source/test files (changes that appear in
|
|
@@ -54,7 +55,15 @@ export function isGitWorkingTreeClean(cwd: string): { clean: boolean; repoRoot:
|
|
|
54
55
|
const repoRoot = findRepoRoot(cwd);
|
|
55
56
|
if (!repoRoot) return { clean: "unknown", repoRoot: undefined };
|
|
56
57
|
// `git status --porcelain` → empty stdout means a clean working tree.
|
|
57
|
-
|
|
58
|
+
// R-A5 (MED-1): previously spawnSync inherited the FULL process.env —
|
|
59
|
+
// API keys could leak to config-reachable helpers git spawns. Now the
|
|
60
|
+
// sanitized env + hardenGitArgs (cwd is the trusted manifest repo root,
|
|
61
|
+
// so no gitdir-pointer guard is needed here).
|
|
62
|
+
const res = spawnSync("git", hardenGitArgs(["-C", repoRoot, "status", "--porcelain"]), {
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
timeout: 5_000,
|
|
65
|
+
env: buildGitSafeEnv(),
|
|
66
|
+
});
|
|
58
67
|
if (res.error || res.status !== 0) return { clean: "unknown", repoRoot: undefined };
|
|
59
68
|
return { clean: res.stdout.trim().length === 0, repoRoot };
|
|
60
69
|
} catch {
|
package/src/runtime/pi-spawn.ts
CHANGED
|
@@ -260,7 +260,16 @@ function resolvePiCliScript(): string | undefined {
|
|
|
260
260
|
process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@earendil-works", "pi-coding-agent") : undefined,
|
|
261
261
|
process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@mariozechner", "pi-coding-agent") : undefined,
|
|
262
262
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
263
|
-
process.cwd()
|
|
263
|
+
// E2 (fix-wave 16): the process.cwd() root is deliberately REMOVED.
|
|
264
|
+
// findPiPackageJsonFrom matches package.json by NAME only (no version or
|
|
265
|
+
// signature check), so a booby-trapped working directory carrying a fake
|
|
266
|
+
// package.json (name=@earendil-works/pi-coding-agent + bin) would get its
|
|
267
|
+
// attacker binary spawned with the worker env — which carries the
|
|
268
|
+
// inference-broker token. Legitimate installs stay covered by the roots
|
|
269
|
+
// above (argv1 package root, `npm root -g`, %APPDATA%\npm, and the
|
|
270
|
+
// import.meta install-dir walk, which still scans every ancestor
|
|
271
|
+
// node_modules chain via findPiPackageJsonFrom) plus the bare-"pi" PATH
|
|
272
|
+
// fallback in getPiSpawnCommand.
|
|
264
273
|
].filter((entry): entry is string => Boolean(entry));
|
|
265
274
|
|
|
266
275
|
for (const root of roots) {
|
|
@@ -30,6 +30,8 @@ import type {
|
|
|
30
30
|
UsageState,
|
|
31
31
|
VerificationEvidence,
|
|
32
32
|
} from "../../state/types.ts";
|
|
33
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
34
|
+
import { buildGitSafeEnv, hardenGitArgs, worktreeTrustCheck } from "../../worktree/git-hardening.ts";
|
|
33
35
|
import { captureWorktreeDiffAsync, captureWorktreeDiffStatAsync } from "../../worktree/worktree-manager.ts";
|
|
34
36
|
import { appendTaskAttentionEvent } from "../attention-events.ts";
|
|
35
37
|
import { extractCommandTrace } from "../command-trace.ts";
|
|
@@ -150,16 +152,48 @@ const DRIFT_BLOCK_MARKER = /DRIFT-BLOCK\b/i;
|
|
|
150
152
|
* READ-ONLY lanes — any entry (modified OR untracked) is an unexpected
|
|
151
153
|
* mutation. Returns undefined when the tree is clean or git fails (fail-open:
|
|
152
154
|
* a broken probe must not break task finalization). Note-only, never a fail.
|
|
155
|
+
* R-A2 exception: a FAILED GITDIR-POINTER GUARD is not a broken probe but a
|
|
156
|
+
* live corruption signal — it reports a mutation note (see body).
|
|
153
157
|
*/
|
|
154
158
|
export async function detectReadLaneMutation(worktreePath: string): Promise<NonNullable<TeamTaskState["unexpectedMutation"]> | undefined> {
|
|
155
159
|
try {
|
|
160
|
+
// R-A2 (MED-1 follow-up): this probe used to be a raw execFile with the
|
|
161
|
+
// FULL process.env — API keys could leak to worker-planted fsmonitor /
|
|
162
|
+
// hook config reachable from the probed worktree. It now routes through
|
|
163
|
+
// the MED-1 hardening: hardenGitArgs config-injection overrides, the
|
|
164
|
+
// sanitized git env (no process.env inheritance), and the gitdir-pointer
|
|
165
|
+
// guard. Guard failure is fail-safe TOWARD REPORTING a mutation: a
|
|
166
|
+
// corrupted `.git` pointer in a READ-ONLY lane is itself a lane
|
|
167
|
+
// violation (the worker rewrote something it must not touch), so it
|
|
168
|
+
// surfaces as a note + internal log instead of a clean verdict.
|
|
169
|
+
const trust = worktreeTrustCheck(worktreePath);
|
|
170
|
+
if (trust.kind === "worktree" && !trust.ok) {
|
|
171
|
+
logInternalError(
|
|
172
|
+
"post-execution.readLane.gitdirMismatch",
|
|
173
|
+
new Error(`Refusing to run the mutation probe inside ${worktreePath}: ${trust.reason}`),
|
|
174
|
+
`worktreePath=${worktreePath}`,
|
|
175
|
+
);
|
|
176
|
+
return {
|
|
177
|
+
filesChanged: 0,
|
|
178
|
+
sample: [`worktree corrupted — gitdir pointer check failed (${trust.reason}); no git executed`],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
156
181
|
// W10-8 one-shot git probe per read-only task — child_process is
|
|
157
182
|
// already in the platform; dynamic keeps it off the static import graph.
|
|
158
183
|
// LAZY: see above
|
|
159
184
|
const { execFile } = await import("node:child_process");
|
|
160
185
|
const out: string = await new Promise((resolve, reject) => {
|
|
161
|
-
execFile(
|
|
162
|
-
|
|
186
|
+
execFile(
|
|
187
|
+
"git",
|
|
188
|
+
hardenGitArgs(["status", "--porcelain"]),
|
|
189
|
+
{
|
|
190
|
+
cwd: worktreePath,
|
|
191
|
+
env: buildGitSafeEnv(),
|
|
192
|
+
encoding: "utf-8",
|
|
193
|
+
timeout: 10_000,
|
|
194
|
+
windowsHide: true,
|
|
195
|
+
},
|
|
196
|
+
(err, stdout) => (err ? reject(err) : resolve(stdout)),
|
|
163
197
|
);
|
|
164
198
|
});
|
|
165
199
|
const entries = out
|
package/src/utils/socket-path.ts
CHANGED
|
@@ -122,11 +122,62 @@ export async function prepareBrokerSocketDir(sockPath: string): Promise<void> {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** Literal-escaping helper for building filesystem-name regexes. */
|
|
126
|
+
function escapeRegExp(literal: string): string {
|
|
127
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* B-LOW1: sweep `${sockPath}.stopping.<pid>.<hex>` shield corpses left by
|
|
132
|
+
* broker/socket-stop.ts. stopServerAndRemoveSocket renames a foreign/unpinned
|
|
133
|
+
* socket leaf aside across the libuv close-unlink window and renames it back
|
|
134
|
+
* afterwards; a crash between those renames strands the shield forever — the
|
|
135
|
+
* plain removeStaleBrokerSocket flow only ever looks at `sockPath` itself, so
|
|
136
|
+
* the corpses previously accumulated one per crashed stop.
|
|
137
|
+
*
|
|
138
|
+
* A corpse is removed ONLY when the pid embedded in its name is provably dead
|
|
139
|
+
* (process.kill(pid, 0) → ESRCH). EPERM/any other signal result means the pid
|
|
140
|
+
* is alive (or its liveness cannot be proven) — the shield is kept (fail-safe:
|
|
141
|
+
* a live broker may still restore it). Best-effort: never throws.
|
|
142
|
+
*/
|
|
143
|
+
async function sweepStoppingShields(sockPath: string): Promise<void> {
|
|
144
|
+
if (process.platform === "win32") return; // named pipes have no fs leaves
|
|
145
|
+
const dir = path.dirname(sockPath);
|
|
146
|
+
const pattern = new RegExp(`^${escapeRegExp(path.basename(sockPath))}\\.stopping\\.(\\d+)\\.[0-9a-f]{1,32}$`);
|
|
147
|
+
let entries: string[];
|
|
148
|
+
try {
|
|
149
|
+
entries = await fsp.readdir(dir);
|
|
150
|
+
} catch {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
const match = pattern.exec(entry);
|
|
155
|
+
if (!match) continue;
|
|
156
|
+
const pid = Number.parseInt(match[1]!, 10);
|
|
157
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
continue; // pid alive — keep the shield
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
163
|
+
if (code !== "ESRCH") continue; // cannot prove dead — keep
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
await fsp.unlink(path.join(dir, entry));
|
|
167
|
+
} catch {
|
|
168
|
+
/* best-effort */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
125
173
|
/** Connect-then-unlink stale socket (herdr pattern). If a live broker is
|
|
126
174
|
* listening, leave the endpoint intact (EADDRINUSE will surface on bind).
|
|
127
175
|
* If a stale file exists with no listener, remove it. If the path is a
|
|
128
176
|
* symlink, refuse rather than follow.
|
|
129
177
|
*
|
|
178
|
+
* Also sweeps `${sockPath}.stopping.*` shield corpses whose recorded pid is
|
|
179
|
+
* dead (B-LOW1 — see sweepStoppingShields).
|
|
180
|
+
*
|
|
130
181
|
* Returns "removed" when the stale entry was unlinked, "kept" when a live
|
|
131
182
|
* listener was detected, "absent" when no entry existed, "refused"
|
|
132
183
|
* when the entry is a symlink. */
|
|
@@ -138,6 +189,9 @@ export async function removeStaleBrokerSocket(
|
|
|
138
189
|
// the timeout branch without racing a real localhost connect.
|
|
139
190
|
netModule?: { createConnection: (path: string) => { once: (ev: string, fn: (arg?: unknown) => void) => void; destroy: () => void } },
|
|
140
191
|
): Promise<"removed" | "kept" | "absent" | "refused"> {
|
|
192
|
+
// B-LOW1: sweep stranded stop-shields first (best-effort, never affects the
|
|
193
|
+
// sockPath verdict below).
|
|
194
|
+
await sweepStoppingShields(sockPath);
|
|
141
195
|
// Reject symlinks outright.
|
|
142
196
|
let st: Awaited<ReturnType<typeof fsp.lstat>>;
|
|
143
197
|
try {
|
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
2
|
+
import { buildGitSafeEnv, hardenGitArgs } from "../worktree/git-hardening.ts";
|
|
4
3
|
|
|
5
|
-
// Read-only git operations only (rev-list, rev-parse, log).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"HOME",
|
|
12
|
-
"USER",
|
|
13
|
-
...WINDOWS_ESSENTIAL_ENV_VARS,
|
|
14
|
-
"SHELL",
|
|
15
|
-
"TERM",
|
|
16
|
-
"LANG",
|
|
17
|
-
"LC_ALL",
|
|
18
|
-
"GIT_CONFIG_GLOBAL",
|
|
19
|
-
"GIT_CONFIG_SYSTEM",
|
|
20
|
-
"GIT_EXEC_PATH",
|
|
21
|
-
],
|
|
22
|
-
});
|
|
4
|
+
// Read-only git operations only (rev-list, rev-parse, log). C-1 fix
|
|
5
|
+
// (code-review 2026-06-23): never inherit the full parent env — API keys
|
|
6
|
+
// must not reach git hooks/aliases/credential-helpers. R-A6 (MED-1
|
|
7
|
+
// uniformity): env + argv now come from the shared git-hardening helpers
|
|
8
|
+
// (sanitizeEnvSecrets allowList, GIT_CONFIG_NOSYSTEM=1, config-injection
|
|
9
|
+
// overrides) instead of a drifting local GIT_SAFE_ENV copy.
|
|
23
10
|
|
|
24
11
|
export type BranchFreshnessStatus = "fresh" | "stale" | "diverged" | "unknown";
|
|
25
12
|
export type StaleBranchPolicy = "warn" | "block" | "auto_rebase" | "auto_merge_forward";
|
|
@@ -36,11 +23,11 @@ export interface BranchFreshness {
|
|
|
36
23
|
}
|
|
37
24
|
|
|
38
25
|
function git(cwd: string, args: string[]): string {
|
|
39
|
-
return execFileSync("git", args, {
|
|
26
|
+
return execFileSync("git", hardenGitArgs(args), {
|
|
40
27
|
cwd,
|
|
28
|
+
env: buildGitSafeEnv(),
|
|
41
29
|
encoding: "utf-8",
|
|
42
30
|
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
-
env: GIT_SAFE_ENV,
|
|
44
31
|
windowsHide: true,
|
|
45
32
|
}).trim();
|
|
46
33
|
}
|