pi-crew 0.11.29 → 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.29",
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",
@@ -340,6 +340,42 @@ function readPsEnviron(pid: number): string | undefined {
340
340
 
341
341
  let unobservableWarned = false;
342
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
+
343
379
  /** MINOR-4 / R2-L-A7: post-spawn canary verification. The parent env carries
344
380
  * {@link RPC_ENV_SPLICE_CANARY} while the window is open (open->close spans
345
381
  * the spawn); the canary is NEVER in the allowlisted childEnv. If the child
@@ -452,11 +488,7 @@ async function runRpcSession(
452
488
  const { input, effectiveTask, ctx } = plan;
453
489
  const poolKey = poolKeyFor(input);
454
490
  const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, ctx.mergedEnv, ctx.builtEnv, input.model);
455
- // RpcClient types env as Record<string,string> — drop undefined entries.
456
- const childEnv: Record<string, string> = {};
457
- for (const [k, v] of Object.entries(spawnOptions.env ?? {})) {
458
- if (v !== undefined) childEnv[k] = v;
459
- }
491
+ const childEnv = buildExactEnvShadowOptions(spawnOptions.env);
460
492
 
461
493
  let client: RpcClientInstance;
462
494
  if (pooled) {
@@ -493,21 +525,24 @@ async function runRpcSession(
493
525
  /* no-op unless a fresh spawn installs the real closer below */
494
526
  };
495
527
  if (!pooled) {
496
- // Env-splice: RpcClient spawns with `{...process.env, ...options.env}`
497
- // (rpc-client.js start()) — WITHOUT this splice the child would inherit
498
- // the FULL parent env, silently regressing the allowlist/per-task key
499
- // scoping model. The spawn happens in the SYNCHRONOUS prefix of
500
- // start(), before its first await, so restoring before awaiting keeps
501
- // the splice invisible to every other reader of process.env (single
502
- // JS thread — no interleaving is possible inside this sync block).
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.
509
- const savedEnv = process.env;
510
- delete childEnv[RPC_ENV_SPLICE_CANARY]; // belt & braces — never allowlisted
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;
511
546
  openRpcEnvSpliceWindow();
512
547
  let startPromise: Promise<void>;
513
548
  windowClosed = false;
@@ -518,12 +553,7 @@ async function runRpcSession(
518
553
  }
519
554
  };
520
555
  try {
521
- process.env = childEnv as unknown as NodeJS.ProcessEnv;
522
- try {
523
- startPromise = client.start();
524
- } finally {
525
- process.env = savedEnv;
526
- }
556
+ startPromise = client.start();
527
557
  } catch (error) {
528
558
  closeWindowOnce();
529
559
  throw error;