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/CHANGELOG.md +14 -0
- package/dist/index.mjs +512 -512
- package/package.json +1 -1
- package/src/runtime/child-pi/child-pi-rpc.ts +137 -29
- 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,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
|
-
|
|
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;
|
|
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
|
|
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
|
|
413
|
-
//
|
|
414
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|
|
427
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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") {
|