pi-crew 0.11.28 → 0.11.29

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.29",
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,98 @@ 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;
327
+ }
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
+ /** MINOR-4 / R2-L-A7: post-spawn canary verification. The parent env carries
344
+ * {@link RPC_ENV_SPLICE_CANARY} while the window is open (open->close spans
345
+ * the spawn); the canary is NEVER in the allowlisted childEnv. If the child
346
+ * env shows it, the spawn did not use the spliced env — the child carries
347
+ * the FULL parent env (provider keys) — and the caller must kill it.
348
+ *
349
+ * Platform coverage: linux /proc, darwin ps -Eww. win32 has no
350
+ * cross-process environ API: instead of a silent PASS, emits a ONE-TIME
351
+ * explicit warn (via onUnobservable) so drift on Windows is at least
352
+ * visible in diagnostics. `opts` is injectable for unit tests.
353
+ */
354
+ export function verifyRpcSpawnEnvSplice(
355
+ proc: ChildProcess | null,
356
+ opts?: { reader?: SpliceEnvReader; platform?: NodeJS.Platform; onUnobservable?: (platform: NodeJS.Platform) => void },
357
+ ): boolean {
358
+ if (!proc?.pid) return true;
359
+ const platform = opts?.platform ?? process.platform;
360
+ const reader = opts?.reader ?? (platform === "linux" ? readProcEnviron : platform === "darwin" ? readPsEnviron : undefined);
361
+ if (!reader) {
362
+ // R2-L-A7: never a silent PASS — surface the observability gap once.
363
+ if (!unobservableWarned) {
364
+ unobservableWarned = true;
365
+ logInternalError(
366
+ "child-pi-rpc.env-splice-canary",
367
+ new Error(
368
+ `env-splice canary unobservable on ${platform} (no cross-process environ API) — SDK spawn-contract drift on this platform would go UNDETECTED`,
369
+ ),
370
+ "warn",
371
+ );
372
+ }
373
+ opts?.onUnobservable?.(platform);
374
+ return true;
311
375
  }
312
- const leaked = environ.split("\0").some((entry) => entry.startsWith(`${RPC_ENV_SPLICE_CANARY}=`));
376
+ const environ = reader(proc.pid);
377
+ if (environ === undefined) return true; // unobservable (raced exit, permissions) — best-effort
378
+ // /proc: NUL-separated entries (exact KEY match). ps: whitespace-separated
379
+ // after the command line (reserved name ⇒ substring is unambiguous — no
380
+ // legitimate argv contains PI_CREW_RPC_ENV_SPLICE_CANARY=).
381
+ const leaked =
382
+ platform === "linux"
383
+ ? environ.split("\0").some((entry) => entry.startsWith(`${RPC_ENV_SPLICE_CANARY}=`))
384
+ : environ.includes(`${RPC_ENV_SPLICE_CANARY}=`);
313
385
  return !leaked;
314
386
  }
315
387
 
@@ -354,6 +426,18 @@ export async function tryChildPiRpcRun(plan: ChildPiRpcRunPlan): Promise<ChildPi
354
426
  return runRpcSession(Ctor, plan, cliPath, pooled);
355
427
  }
356
428
 
429
+ /** R2-L-A7 (wave 8) TEST SEAM — runRpcSession with an INJECTED RpcClient ctor
430
+ * so unit tests can simulate an SDK spawn-contract drift (spawn moved past
431
+ * start()'s first await) and pin that the canary window still catches it.
432
+ * Production always goes through tryChildPiRpcRun (real RpcClient). */
433
+ export function __runRpcSessionWithCtorForTests(
434
+ RpcClientCtor: new (options: unknown) => RpcClientInstance,
435
+ plan: ChildPiRpcRunPlan,
436
+ cliPath: string,
437
+ ): Promise<ChildPiRunResult> {
438
+ return runRpcSession(RpcClientCtor, plan, cliPath, undefined);
439
+ }
440
+
357
441
  /** Local SEC-1 twin of child-pi.ts redactStderrExcerpt (cycle-free import). */
358
442
  function redactExcerpt(stderr: string, maxChars: number): string {
359
443
  return redactSecretString(stderr.slice(-maxChars));
@@ -401,6 +485,13 @@ async function runRpcSession(
401
485
  }
402
486
  }
403
487
  };
488
+ // R2-L-A7: window closer is a no-op for pooled reuse (no splice, no window);
489
+ // fresh spawns install the real closer below. Hoisted so every early-return
490
+ // path between open and verification can close the window (A5 discipline).
491
+ let windowClosed = true;
492
+ let closeWindowOnce: () => void = () => {
493
+ /* no-op unless a fresh spawn installs the real closer below */
494
+ };
404
495
  if (!pooled) {
405
496
  // Env-splice: RpcClient spawns with `{...process.env, ...options.env}`
406
497
  // (rpc-client.js start()) — WITHOUT this splice the child would inherit
@@ -409,13 +500,23 @@ async function runRpcSession(
409
500
  // start(), before its first await, so restoring before awaiting keeps
410
501
  // the splice invisible to every other reader of process.env (single
411
502
  // 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).
503
+ // MINOR-4 + R2-L-A7 (wave-8): the canary WINDOW now stays open through
504
+ // `await startPromise` until post-spawn verification — the wave-2b
505
+ // version deleted the canary synchronously after start() returned, so
506
+ // the drifted-spawn case it exists to catch inherited a canary-free env
507
+ // and could NEVER fire (even on Linux). Refcounted: concurrent RPC
508
+ // spawns share one window; the marker is inert for non-RPC children.
415
509
  const savedEnv = process.env;
416
510
  delete childEnv[RPC_ENV_SPLICE_CANARY]; // belt & braces — never allowlisted
417
- savedEnv[RPC_ENV_SPLICE_CANARY] = "1";
511
+ openRpcEnvSpliceWindow();
418
512
  let startPromise: Promise<void>;
513
+ windowClosed = false;
514
+ closeWindowOnce = (): void => {
515
+ if (!windowClosed) {
516
+ windowClosed = true;
517
+ closeRpcEnvSpliceWindow();
518
+ }
519
+ };
419
520
  try {
420
521
  process.env = childEnv as unknown as NodeJS.ProcessEnv;
421
522
  try {
@@ -423,12 +524,14 @@ async function runRpcSession(
423
524
  } finally {
424
525
  process.env = savedEnv;
425
526
  }
426
- } finally {
427
- delete savedEnv[RPC_ENV_SPLICE_CANARY];
527
+ } catch (error) {
528
+ closeWindowOnce();
529
+ throw error;
428
530
  }
429
531
  try {
430
532
  await startPromise;
431
533
  } catch (error) {
534
+ closeWindowOnce();
432
535
  const stderr = client.getStderr?.() ?? "";
433
536
  const message = `RPC child Pi failed to start: ${error instanceof Error ? error.message : String(error)}. Stderr: ${redactExcerpt(stderr, 500) || "(none)"}`;
434
537
  try {
@@ -470,6 +573,7 @@ async function runRpcSession(
470
573
  // runtime (rpc-client.js field) — hence the structural cast.
471
574
  const child = (client as unknown as { process: ChildProcess | null }).process;
472
575
  if (!child) {
576
+ closeWindowOnce();
473
577
  cleanupFreshTempDir();
474
578
  return {
475
579
  exitCode: null,
@@ -486,11 +590,15 @@ async function runRpcSession(
486
590
  },
487
591
  };
488
592
  }
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)) {
593
+ // MINOR-4 + R2-L-A7: post-spawn canary (start() resolved = the RPC wire
594
+ // answered = exec happened, so the child env is stable). A canary hit means
595
+ // the sync-spawn assumption broke and the child carries the FULL parent
596
+ // env — kill it immediately and fail loudly; never register or prompt it.
597
+ // The window closes here on EVERY path past this point: leaked (kill) or
598
+ // verified-clean.
599
+ const spliceVerified = pooled || verifyRpcSpawnEnvSplice(child);
600
+ closeWindowOnce();
601
+ if (!spliceVerified) {
494
602
  killProcessTree(child.pid, child);
495
603
  const message =
496
604
  "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") {