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/CHANGELOG.md +26 -0
- package/dist/index.mjs +512 -512
- package/package.json +1 -1
- package/src/runtime/child-pi/child-pi-rpc.ts +187 -49
- package/src/runtime/classifier/classifier-service.ts +26 -4
package/package.json
CHANGED
|
@@ -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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
324
|
+
return fs.readFileSync(`/proc/${pid}/environ`, "utf-8");
|
|
309
325
|
} catch {
|
|
310
|
-
return
|
|
326
|
+
return undefined;
|
|
311
327
|
}
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
process.env = savedEnv;
|
|
548
|
+
windowClosed = false;
|
|
549
|
+
closeWindowOnce = (): void => {
|
|
550
|
+
if (!windowClosed) {
|
|
551
|
+
windowClosed = true;
|
|
552
|
+
closeRpcEnvSpliceWindow();
|
|
425
553
|
}
|
|
426
|
-
}
|
|
427
|
-
|
|
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
|
|
490
|
-
// exec happened, so
|
|
491
|
-
// sync-spawn assumption broke and the child carries the FULL parent
|
|
492
|
-
// kill it immediately and fail loudly; never register or prompt it.
|
|
493
|
-
|
|
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(
|
|
335
|
-
|
|
336
|
-
|
|
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") {
|