pi-crew 0.11.28 → 0.11.30

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.28",
3
+ "version": "0.11.30",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -61,6 +61,7 @@
61
61
  */
62
62
 
63
63
  import type { ChildProcess } from "node:child_process";
64
+ import { execFileSync } from "node:child_process";
64
65
  import * as fs from "node:fs";
65
66
  import * as path from "node:path";
66
67
  import { getCrewEnv, getCrewEnvBool } from "../../config/env-vars.ts";
@@ -289,27 +290,134 @@ function attachPoolExitHook(poolKey: string, proc: ChildProcess): void {
289
290
  /** MINOR-4 marker var — see verifyRpcSpawnEnvSplice (never allowlisted). */
290
291
  const RPC_ENV_SPLICE_CANARY = "PI_CREW_RPC_ENV_SPLICE_CANARY";
291
292
 
292
- /**
293
- * MINOR-4 (wave-2 review): the env-splice contract below assumes RpcClient
294
- * start() spawns in its SYNCHRONOUS prefix. If a future SDK delays the spawn
295
- * past its first await, process.env has already been RESTORED and the child
296
- * silently inherits the FULL parent env — regressing the allowlist/per-task
297
- * key scoping model. Post-spawn canary: the parent env temporarily carries a
298
- * marker var that is ABSENT from the allowlisted childEnv; if /proc/<pid>/environ
299
- * (Linux only — best-effort elsewhere) still shows it AFTER start() resolved
300
- * (the RPC wire answered, so exec has happened and environ is stable), the
301
- * splice did not cover the actual spawn → the caller kills the child and
302
- * fails loudly instead of leaking. Exported for unit tests.
303
- */
304
- export function verifyRpcSpawnEnvSplice(proc: ChildProcess | null): boolean {
305
- if (!proc?.pid || process.platform !== "linux") return true;
306
- let environ: string;
293
+ // ── R2-L-A7 (wave-8): refcounted canary window ───────────────────────────
294
+ // The wave-2b MINOR-4 canary had a DEAD WINDOW: the canary was deleted from
295
+ // the saved parent env synchronously after client.start() returned — BEFORE
296
+ // `await startPromise`. A drifted SDK (the exact failure MINOR-4 guards:
297
+ // spawn moved past start()'s first await) would spawn AFTER the delete, so
298
+ // the child inherited the full parent env WITHOUT the canary — undetectable
299
+ // even on Linux. The window must stay open until post-spawn verification
300
+ // completes. Refcounted so overlapping RPC spawns (broker concurrency) don't
301
+ // close each other's window. Cost: unrelated children spawned mid-window
302
+ // inherit an inert reserved-name marker — acceptable; the name is
303
+ // pi-crew-namespaced and no code reads it.
304
+ let spliceWindowDepth = 0;
305
+
306
+ /** Open one canary window (sets the marker on the parent env when the first
307
+ * overlapping window opens). Paired with exactly one closeRpcEnvSpliceWindow
308
+ * on every path — including exceptions — between open and verification. */
309
+ export function openRpcEnvSpliceWindow(): void {
310
+ if (spliceWindowDepth++ === 0) process.env[RPC_ENV_SPLICE_CANARY] = "1";
311
+ }
312
+
313
+ /** Close one canary window (deletes the marker when the last closes). */
314
+ export function closeRpcEnvSpliceWindow(): void {
315
+ if (spliceWindowDepth > 0 && --spliceWindowDepth === 0) delete process.env[RPC_ENV_SPLICE_CANARY];
316
+ }
317
+
318
+ /** Read the raw environment blob of an arbitrary pid (best-effort). Returns
319
+ * undefined when unobservable (raced exit, permissions, no OS API). */
320
+ export type SpliceEnvReader = (pid: number) => string | undefined;
321
+
322
+ function readProcEnviron(pid: number): string | undefined {
307
323
  try {
308
- environ = fs.readFileSync(`/proc/${proc.pid}/environ`, "utf-8");
324
+ return fs.readFileSync(`/proc/${pid}/environ`, "utf-8");
309
325
  } catch {
310
- return true; // unobservable (raced exit, permissions) — best-effort
326
+ return undefined;
311
327
  }
312
- const leaked = environ.split("\0").some((entry) => entry.startsWith(`${RPC_ENV_SPLICE_CANARY}=`));
328
+ }
329
+
330
+ function readPsEnviron(pid: number): string | undefined {
331
+ // macOS (BSD ps): `ps -Eww -o command= -p PID` appends the environment
332
+ // (KEY=VALUE, whitespace-separated) after the command line. We only need
333
+ // presence of the reserved-name marker, not a full parse.
334
+ try {
335
+ return execFileSync("ps", ["-Eww", "-o", "command=", "-p", String(pid)], { timeout: 2000 }).toString();
336
+ } catch {
337
+ return undefined;
338
+ }
339
+ }
340
+
341
+ let unobservableWarned = false;
342
+
343
+ /** S* (v0.11.30, supersedes the wave-8 process.env swap): build the
344
+ * RpcClient `env` option that yields an EXACT env for the child despite the
345
+ * SDK's internal merge `{...process.env, ...options.env}` (rpc-client.js).
346
+ *
347
+ * Mechanism (both halves experiment-verified 2026-10-20):
348
+ * 1. `{...A, ...{K: undefined}}` — a later key with an `undefined` value
349
+ * OVERRIDES the earlier key (spread semantics).
350
+ * 2. Node's spawn DROPS env entries whose value is `undefined` (the child
351
+ * sees the key as ABSENT, not "undefined"/"").
352
+ * Together: every parent key NOT in the intended set is shadowed to
353
+ * `undefined` in options.env → the merge yields `undefined` for it → Node
354
+ * omits it → the child receives EXACTLY the intended set, on every OS,
355
+ * deterministically — regardless of WHEN the spawn actually happens. The
356
+ * wave-2b/8 sync-prefix assumption and the process.env swap both die as
357
+ * requirements: even a drifted (post-await) spawn merges the same shadowed
358
+ * options.env and still lands on the exact set.
359
+ *
360
+ * Forward-compat: if upstream ever removes the process.env spread, the
361
+ * undefined entries are still dropped by Node → same exact env. If a future
362
+ * SDK validates env values strictly, start() throws LOUD (fail-loud, the
363
+ * right direction). The post-spawn canary (verifyRpcSpawnEnvSplice) stays as
364
+ * a PRODUCTION TRIPWIRE proving "child env == intended set".
365
+ *
366
+ * Runtime typing note: RpcClient's .d.ts says Record<string,string>, but the
367
+ * runtime only spreads the object into spawn (single usage, rpc-client.js)
368
+ * — undefined values flow through harmlessly. Exported for unit tests. */
369
+ export function buildExactEnvShadowOptions(
370
+ intendedEnv: NodeJS.ProcessEnv | Record<string, string | undefined> | undefined,
371
+ ): Record<string, string | undefined> {
372
+ const shadow: Record<string, string | undefined> = { ...(intendedEnv ?? {}) };
373
+ for (const key of Object.keys(process.env)) {
374
+ if (!(key in shadow)) shadow[key] = undefined;
375
+ }
376
+ return shadow;
377
+ }
378
+
379
+ /** MINOR-4 / R2-L-A7: post-spawn canary verification. The parent env carries
380
+ * {@link RPC_ENV_SPLICE_CANARY} while the window is open (open->close spans
381
+ * the spawn); the canary is NEVER in the allowlisted childEnv. If the child
382
+ * env shows it, the spawn did not use the spliced env — the child carries
383
+ * the FULL parent env (provider keys) — and the caller must kill it.
384
+ *
385
+ * Platform coverage: linux /proc, darwin ps -Eww. win32 has no
386
+ * cross-process environ API: instead of a silent PASS, emits a ONE-TIME
387
+ * explicit warn (via onUnobservable) so drift on Windows is at least
388
+ * visible in diagnostics. `opts` is injectable for unit tests.
389
+ */
390
+ export function verifyRpcSpawnEnvSplice(
391
+ proc: ChildProcess | null,
392
+ opts?: { reader?: SpliceEnvReader; platform?: NodeJS.Platform; onUnobservable?: (platform: NodeJS.Platform) => void },
393
+ ): boolean {
394
+ if (!proc?.pid) return true;
395
+ const platform = opts?.platform ?? process.platform;
396
+ const reader = opts?.reader ?? (platform === "linux" ? readProcEnviron : platform === "darwin" ? readPsEnviron : undefined);
397
+ if (!reader) {
398
+ // R2-L-A7: never a silent PASS — surface the observability gap once.
399
+ if (!unobservableWarned) {
400
+ unobservableWarned = true;
401
+ logInternalError(
402
+ "child-pi-rpc.env-splice-canary",
403
+ new Error(
404
+ `env-splice canary unobservable on ${platform} (no cross-process environ API) — SDK spawn-contract drift on this platform would go UNDETECTED`,
405
+ ),
406
+ "warn",
407
+ );
408
+ }
409
+ opts?.onUnobservable?.(platform);
410
+ return true;
411
+ }
412
+ const environ = reader(proc.pid);
413
+ if (environ === undefined) return true; // unobservable (raced exit, permissions) — best-effort
414
+ // /proc: NUL-separated entries (exact KEY match). ps: whitespace-separated
415
+ // after the command line (reserved name ⇒ substring is unambiguous — no
416
+ // legitimate argv contains PI_CREW_RPC_ENV_SPLICE_CANARY=).
417
+ const leaked =
418
+ platform === "linux"
419
+ ? environ.split("\0").some((entry) => entry.startsWith(`${RPC_ENV_SPLICE_CANARY}=`))
420
+ : environ.includes(`${RPC_ENV_SPLICE_CANARY}=`);
313
421
  return !leaked;
314
422
  }
315
423
 
@@ -354,6 +462,18 @@ export async function tryChildPiRpcRun(plan: ChildPiRpcRunPlan): Promise<ChildPi
354
462
  return runRpcSession(Ctor, plan, cliPath, pooled);
355
463
  }
356
464
 
465
+ /** R2-L-A7 (wave 8) TEST SEAM — runRpcSession with an INJECTED RpcClient ctor
466
+ * so unit tests can simulate an SDK spawn-contract drift (spawn moved past
467
+ * start()'s first await) and pin that the canary window still catches it.
468
+ * Production always goes through tryChildPiRpcRun (real RpcClient). */
469
+ export function __runRpcSessionWithCtorForTests(
470
+ RpcClientCtor: new (options: unknown) => RpcClientInstance,
471
+ plan: ChildPiRpcRunPlan,
472
+ cliPath: string,
473
+ ): Promise<ChildPiRunResult> {
474
+ return runRpcSession(RpcClientCtor, plan, cliPath, undefined);
475
+ }
476
+
357
477
  /** Local SEC-1 twin of child-pi.ts redactStderrExcerpt (cycle-free import). */
358
478
  function redactExcerpt(stderr: string, maxChars: number): string {
359
479
  return redactSecretString(stderr.slice(-maxChars));
@@ -368,11 +488,7 @@ async function runRpcSession(
368
488
  const { input, effectiveTask, ctx } = plan;
369
489
  const poolKey = poolKeyFor(input);
370
490
  const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, ctx.mergedEnv, ctx.builtEnv, input.model);
371
- // RpcClient types env as Record<string,string> — drop undefined entries.
372
- const childEnv: Record<string, string> = {};
373
- for (const [k, v] of Object.entries(spawnOptions.env ?? {})) {
374
- if (v !== undefined) childEnv[k] = v;
375
- }
491
+ const childEnv = buildExactEnvShadowOptions(spawnOptions.env);
376
492
 
377
493
  let client: RpcClientInstance;
378
494
  if (pooled) {
@@ -401,34 +517,51 @@ async function runRpcSession(
401
517
  }
402
518
  }
403
519
  };
520
+ // R2-L-A7: window closer is a no-op for pooled reuse (no splice, no window);
521
+ // fresh spawns install the real closer below. Hoisted so every early-return
522
+ // path between open and verification can close the window (A5 discipline).
523
+ let windowClosed = true;
524
+ let closeWindowOnce: () => void = () => {
525
+ /* no-op unless a fresh spawn installs the real closer below */
526
+ };
404
527
  if (!pooled) {
405
- // Env-splice: RpcClient spawns with `{...process.env, ...options.env}`
406
- // (rpc-client.js start()) — WITHOUT this splice the child would inherit
407
- // the FULL parent env, silently regressing the allowlist/per-task key
408
- // scoping model. The spawn happens in the SYNCHRONOUS prefix of
409
- // start(), before its first await, so restoring before awaiting keeps
410
- // the splice invisible to every other reader of process.env (single
411
- // JS thread — no interleaving is possible inside this sync block).
412
- // MINOR-4: the canary below pins that assumption — if a future SDK
413
- // moves the spawn past its first await, verifyRpcSpawnEnvSplice()
414
- // detects the leaked full-parent-env child post-spawn (see below).
415
- const savedEnv = process.env;
416
- delete childEnv[RPC_ENV_SPLICE_CANARY]; // belt & braces — never allowlisted
417
- savedEnv[RPC_ENV_SPLICE_CANARY] = "1";
528
+ // S* (v0.11.30): NO process.env swap anymore. The RpcClient-internal
529
+ // merge `{...process.env, ...options.env}` is neutralized BY the
530
+ // undefined-shadow options.env (buildExactEnvShadowOptions) — the child
531
+ // receives exactly the intended allowlisted set no matter when the spawn
532
+ // fires (sync prefix or drifted past an await). The wave-2b..8 global
533
+ // `process.env = childEnv` splice is deleted — global mutable state gone.
534
+ //
535
+ // The canary WINDOW below is now purely a VERIFICATION aid: the marker
536
+ // must sit in the parent env at snapshot time so verifyRpcSpawnEnvSplice
537
+ // has something to detect with. It must span the await because the
538
+ // child's env snapshot is taken at its (possibly delayed) spawn; the
539
+ // shadow makes the marker harmless to the child either way. Refcounted:
540
+ // concurrent RPC spawns share one window.
541
+ // Belt & braces: SHADOW the canary to undefined (do NOT delete — a
542
+ // delete lets the parent-env marker win the merge; undefined overrides
543
+ // it). Covers the ordering gap: the shadow snapshot was taken before the
544
+ // window opened, so the marker was not in process.env then.
545
+ childEnv[RPC_ENV_SPLICE_CANARY] = undefined;
546
+ openRpcEnvSpliceWindow();
418
547
  let startPromise: Promise<void>;
419
- try {
420
- process.env = childEnv as unknown as NodeJS.ProcessEnv;
421
- try {
422
- startPromise = client.start();
423
- } finally {
424
- process.env = savedEnv;
548
+ windowClosed = false;
549
+ closeWindowOnce = (): void => {
550
+ if (!windowClosed) {
551
+ windowClosed = true;
552
+ closeRpcEnvSpliceWindow();
425
553
  }
426
- } finally {
427
- delete savedEnv[RPC_ENV_SPLICE_CANARY];
554
+ };
555
+ try {
556
+ startPromise = client.start();
557
+ } catch (error) {
558
+ closeWindowOnce();
559
+ throw error;
428
560
  }
429
561
  try {
430
562
  await startPromise;
431
563
  } catch (error) {
564
+ closeWindowOnce();
432
565
  const stderr = client.getStderr?.() ?? "";
433
566
  const message = `RPC child Pi failed to start: ${error instanceof Error ? error.message : String(error)}. Stderr: ${redactExcerpt(stderr, 500) || "(none)"}`;
434
567
  try {
@@ -470,6 +603,7 @@ async function runRpcSession(
470
603
  // runtime (rpc-client.js field) — hence the structural cast.
471
604
  const child = (client as unknown as { process: ChildProcess | null }).process;
472
605
  if (!child) {
606
+ closeWindowOnce();
473
607
  cleanupFreshTempDir();
474
608
  return {
475
609
  exitCode: null,
@@ -486,11 +620,15 @@ async function runRpcSession(
486
620
  },
487
621
  };
488
622
  }
489
- // MINOR-4: post-spawn canary (start() resolved = the RPC wire answered =
490
- // exec happened, so /proc/<pid>/environ is stable). A canary hit means the
491
- // sync-spawn assumption broke and the child carries the FULL parent env —
492
- // kill it immediately and fail loudly; never register or prompt it.
493
- if (!pooled && !verifyRpcSpawnEnvSplice(child)) {
623
+ // MINOR-4 + R2-L-A7: post-spawn canary (start() resolved = the RPC wire
624
+ // answered = exec happened, so the child env is stable). A canary hit means
625
+ // the sync-spawn assumption broke and the child carries the FULL parent
626
+ // env — kill it immediately and fail loudly; never register or prompt it.
627
+ // The window closes here on EVERY path past this point: leaked (kill) or
628
+ // verified-clean.
629
+ const spliceVerified = pooled || verifyRpcSpawnEnvSplice(child);
630
+ closeWindowOnce();
631
+ if (!spliceVerified) {
494
632
  killProcessTree(child.pid, child);
495
633
  const message =
496
634
  "RPC env allowlist splice failed post-spawn verification (child inherited the full parent env — SDK spawn contract drifted); process killed";
@@ -26,6 +26,7 @@
26
26
  import { getCrewEnvBool, getCrewEnvString } from "../../config/env-vars.ts";
27
27
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
28
28
  import { logInternalError } from "../../utils/internal-error.ts";
29
+ import { redactSecrets } from "../../utils/redaction.ts";
29
30
  import { modelStringFromUnknown } from "../model/model-fallback.ts";
30
31
 
31
32
  /** Default classifier model (runtime.classifierModel default). */
@@ -294,6 +295,24 @@ interface ClassifyPipelineFallback {
294
295
  errorMessage: string | undefined;
295
296
  }
296
297
 
298
+ /** R2-L-B8 (wave-8): CHOKE-POINT redaction — every payload crossing to the
299
+ * host classifier endpoint is serialized through redactSecrets first. The
300
+ * wave-2b fix covered only the ambient-noise gate; the retry-triage consumer
301
+ * (child-executor) sent the raw failure summary (≤2000 chars, may carry
302
+ * provider keys echoed in stderr) to the same endpoint. Redacting HERE (not
303
+ * per-consumer) covers all current and future classify consumers — the
304
+ * pre-gate verdict state, retry triage, noise gate (double-redact harmless)
305
+ * — and any added later. Serialization failure degrades to the original
306
+ * payload: classify is a never-rejects pipeline and redaction must not turn
307
+ * a redaction bug into a task failure. */
308
+ function redactClassifyPayload<T>(payload: T): T {
309
+ try {
310
+ return JSON.parse(JSON.stringify(redactSecrets(payload))) as T;
311
+ } catch {
312
+ return payload;
313
+ }
314
+ }
315
+
297
316
  async function runClassifyQuestion(input: ClassifyPipelineInput): Promise<ClassifyPipelineOk | ClassifyPipelineFallback> {
298
317
  const registry = asClassifierRegistry(input.modelRegistry);
299
318
  if (!registry || typeof registry.getAvailableOfType !== "function" || typeof registry.classify !== "function") {
@@ -331,10 +350,13 @@ async function runClassifyQuestion(input: ClassifyPipelineInput): Promise<Classi
331
350
  const chosen = available[chosenIndex];
332
351
  const usedModel = normalized[chosenIndex] ?? requested;
333
352
  try {
334
- const result = await registry.classify(chosen, {
335
- state: input.state,
336
- questions: { [input.questionKey]: input.question },
337
- });
353
+ const result = await registry.classify(
354
+ chosen,
355
+ redactClassifyPayload({
356
+ state: input.state,
357
+ questions: { [input.questionKey]: input.question },
358
+ }),
359
+ );
338
360
  const stopReason = typeof result?.stopReason === "string" ? result.stopReason : undefined;
339
361
  const errorMessage = typeof result?.errorMessage === "string" ? result.errorMessage : undefined;
340
362
  if (stopReason !== "stop") {