pi-crew 0.11.50 → 0.11.52
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 +143 -0
- package/dist/index.mjs +552 -545
- package/package.json +1 -1
- package/src/agents/agent-config.ts +58 -1
- package/src/config/config-validation.ts +18 -0
- package/src/config/env-allowlist-registry.ts +4 -0
- package/src/config/env-vars.ts +4 -0
- package/src/config/sanitize-project-config.ts +55 -0
- package/src/extension/management.ts +9 -0
- package/src/extension/registration/lifecycle-handlers.ts +28 -0
- package/src/extension/registration/subagent-tools.ts +5 -1
- package/src/extension/run-import.ts +40 -6
- package/src/extension/team-tool/api/agent-control.ts +49 -0
- package/src/extension/team-tool/api/heartbeat.ts +48 -1
- package/src/extension/team-tool/api/mailbox.ts +81 -2
- package/src/extension/team-tool/api/read.ts +22 -0
- package/src/extension/team-tool/api/task-claims.ts +134 -3
- package/src/extension/team-tool/cancel.ts +15 -1
- package/src/extension/team-tool/destructive-gate.ts +92 -8
- package/src/extension/team-tool/inspect.ts +8 -2
- package/src/extension/team-tool/lifecycle-actions.ts +9 -2
- package/src/extension/team-tool.ts +128 -27
- package/src/observability/exporters/otlp-exporter.ts +69 -19
- package/src/prompt/message-tool.ts +19 -8
- package/src/prompt/prompt-runtime.ts +160 -51
- package/src/runtime/background-runner.ts +101 -81
- package/src/runtime/broker/crew-broker-child.ts +32 -0
- package/src/runtime/broker/crew-broker-tokens.ts +24 -13
- package/src/runtime/broker/crew-broker.ts +12 -6
- package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
- package/src/runtime/child-pi/child-pi-spawn.ts +17 -0
- package/src/runtime/child-pi/child-pi-steering.ts +8 -1
- package/src/runtime/child-pi/inference-broker.ts +41 -7
- package/src/runtime/child-pi/pi-version.ts +31 -3
- package/src/runtime/child-pi/sandbox.ts +38 -1
- package/src/runtime/dwf-state-store.ts +193 -7
- package/src/runtime/foreground-control.ts +95 -1
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
- package/src/runtime/heartbeat/heartbeat-watcher.ts +32 -11
- package/src/runtime/live-session/live-session-runtime.ts +78 -3
- package/src/runtime/mcp-proxy.ts +65 -6
- package/src/runtime/model/pi-args.ts +16 -15
- package/src/runtime/plan-replan.ts +4 -1
- package/src/runtime/task-runner/child-executor.ts +34 -7
- package/src/runtime/task-runner/hybrid-tier.ts +17 -0
- package/src/runtime/task-runner/live-executor.ts +3 -0
- package/src/runtime/task-runner/post-execution.ts +5 -0
- package/src/runtime/task-runner/pre-execution.ts +13 -6
- package/src/runtime/task-runner.ts +14 -0
- package/src/runtime/team-runner.ts +26 -0
- package/src/runtime/verification/verification-worktree.ts +46 -29
- package/src/runtime/verification/verify-gate-memo.ts +211 -32
- package/src/runtime/verification/verify-gate.ts +266 -14
- package/src/runtime/workspace-lock.ts +95 -21
- package/src/schema/config-schema.ts +12 -2
- package/src/state/coordination/locks.ts +86 -19
- package/src/state/coordination/mailbox.ts +263 -22
- package/src/state/stores/manifest-io.ts +206 -7
- package/src/state/stores/state-store.ts +44 -15
- package/src/state/types.ts +9 -0
- package/src/ui/inline-panel/agent-transcript.ts +151 -11
- package/src/ui/settings-overlay.ts +8 -3
- package/src/ui/theme-discovery.ts +14 -2
- package/src/ui/tool-renderers/index.ts +26 -6
- package/src/utils/safe-terminal-text.ts +60 -0
- package/src/worktree/git-hardening.ts +78 -1
- package/src/worktree/worktree-manager.ts +34 -4
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
appendMailboxMessageIdempotentAsync,
|
|
45
45
|
type MailboxMessage,
|
|
46
46
|
registerMailboxAppendObserver,
|
|
47
|
+
signSteeringEntry,
|
|
47
48
|
} from "../../state/coordination/mailbox.ts";
|
|
48
49
|
import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
49
50
|
import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
|
|
@@ -1334,13 +1335,18 @@ export class CrewBroker {
|
|
|
1334
1335
|
try {
|
|
1335
1336
|
const steeringDir = `${loaded.manifest.artifactsRoot}/steering`;
|
|
1336
1337
|
const steeringPath = resolveRealContainedPath(loaded.manifest.artifactsRoot, `steering/${targetTaskId}.jsonl`);
|
|
1338
|
+
// w1a sender binding: the steering file is same-uid writable, so the
|
|
1339
|
+
// line must carry this (broker/writer) process's Ed25519 signature —
|
|
1340
|
+
// the worker's pollSteering drops unauthenticated lines.
|
|
1337
1341
|
const line =
|
|
1338
|
-
JSON.stringify(
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1342
|
+
JSON.stringify(
|
|
1343
|
+
signSteeringEntry({
|
|
1344
|
+
type: "steer",
|
|
1345
|
+
message: body,
|
|
1346
|
+
id: messageId,
|
|
1347
|
+
ts: new Date().toISOString(),
|
|
1348
|
+
}),
|
|
1349
|
+
) + "\n";
|
|
1344
1350
|
await fsp.mkdir(steeringDir, { recursive: true });
|
|
1345
1351
|
await fsp.appendFile(steeringPath, line, "utf-8");
|
|
1346
1352
|
} catch (fileErr) {
|
|
@@ -21,7 +21,7 @@ import type { TeamRunManifest, TeamTaskState } from "../../../state/types.ts";
|
|
|
21
21
|
import { logInternalError } from "../../../utils/internal-error.ts";
|
|
22
22
|
import type { ServerConnection } from "./connection-state.ts";
|
|
23
23
|
import { parseWaitResolveParams } from "./request-parsers.ts";
|
|
24
|
-
import { recordWaitPolicyRejection } from "./wait-auth.ts";
|
|
24
|
+
import { recordWaitPolicyRejection, waitAuthError } from "./wait-auth.ts";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* M3-6 (R21-M1, fix-wave 6): resolve ONE park without un-shielding the others.
|
|
@@ -110,6 +110,20 @@ export async function handleWaitResolve(
|
|
|
110
110
|
helpers.sendError(conn, id, "auth", "not authed");
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
|
+
// w1a (broker-bare-run-token-forged-task-identity): enforce the compound-token
|
|
114
|
+
// rule on wait.resolve — the SAME rule its wait.request sibling applies at
|
|
115
|
+
// crew-broker.ts:1488 (and delegate.request inline). Without this, a legacy
|
|
116
|
+
// bare-runId-token connection that forged a victim taskId at hello could
|
|
117
|
+
// resolve the victim's parked ask (waiting→running flip + false
|
|
118
|
+
// respond_delivered recorded under the victim's taskId). runId-fallback
|
|
119
|
+
// matches are rejected with the migrate hint; orchestrator connections are
|
|
120
|
+
// rejected by role. Placement mirrors handleWaitRequest: presence check →
|
|
121
|
+
// auth kind → params parse.
|
|
122
|
+
const authErr = waitAuthError(conn);
|
|
123
|
+
if (authErr) {
|
|
124
|
+
helpers.sendError(conn, id, authErr.code, authErr.message);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
113
127
|
const parsed = parseWaitResolveParams(params);
|
|
114
128
|
if (!parsed) {
|
|
115
129
|
helpers.sendError(conn, id, "bad-params", "wait.resolve: invalid params");
|
|
@@ -22,6 +22,7 @@ import * as path from "node:path";
|
|
|
22
22
|
import { childPiBaseAllowlist } from "../../config/env-allowlist-registry.ts";
|
|
23
23
|
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
24
24
|
import { isScratchpadEnabledForRole } from "../../config/role-tools.ts";
|
|
25
|
+
import { COORDINATION_VERIFY_KEY_ENV, getMailboxVerifyKeyPublic } from "../../state/coordination/mailbox.ts";
|
|
25
26
|
import { buildScopedAllowList, providerEnvKeys, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
|
|
26
27
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
27
28
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
@@ -491,6 +492,22 @@ export function prepareSpawnContext(
|
|
|
491
492
|
// stateRoot. Absent (non-team spawn) → var stays unset and the ask tool
|
|
492
493
|
// fast-fails with a structured notice instead of hanging.
|
|
493
494
|
if (input.eventsPath) built.env.PI_CREW_STATE_ROOT = path.dirname(input.eventsPath);
|
|
495
|
+
// w1a (coordination-durable-files-no-sender-binding): hand the child the
|
|
496
|
+
// PUBLIC Ed25519 verify key for the durable coordination channels (mailbox
|
|
497
|
+
// + steering JSONL). The signing PRIVATE key never leaves the writer process
|
|
498
|
+
// heap; this is a public key — non-secret, env-safe (control-namespace key).
|
|
499
|
+
// Key resolution for THIS spawn's run:
|
|
500
|
+
// 1. relay our own env key when we are a worker spawning a same-run
|
|
501
|
+
// grandchild (our key came from the same root writer),
|
|
502
|
+
// 2. else our own process keypair (we are the run's writer: root session
|
|
503
|
+
// or nested host — lazy-created once, heap-only).
|
|
504
|
+
// A child without this key drops every coordination row (fail-closed).
|
|
505
|
+
if (input.eventsPath) {
|
|
506
|
+
const relayedVerifyKey = getCrewEnv(COORDINATION_VERIFY_KEY_ENV);
|
|
507
|
+
const relayMatchesRun =
|
|
508
|
+
relayedVerifyKey !== undefined && input.runId !== undefined && getCrewEnv("PI_CREW_BROKER_RUN_ID") === input.runId;
|
|
509
|
+
built.env[COORDINATION_VERIFY_KEY_ENV] = relayMatchesRun ? relayedVerifyKey : getMailboxVerifyKeyPublic();
|
|
510
|
+
}
|
|
494
511
|
// Phase 0 inter-pi broker: inject socket path + token (control-namespace keys,
|
|
495
512
|
// safe under assertOnlyControlEnvKeys). Only when the parent broker issued
|
|
496
513
|
// credentials for this run — i.e. the broker is enabled AND this run is
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
|
+
import { signSteeringEntry } from "../../state/coordination/mailbox.ts";
|
|
16
17
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
17
18
|
import { killProcessTree } from "./child-pi-kill.ts";
|
|
18
19
|
|
|
@@ -74,7 +75,13 @@ export class ChildPiSteeringController {
|
|
|
74
75
|
// a failed write must NOT kill the worker.
|
|
75
76
|
if (steeringFile) {
|
|
76
77
|
try {
|
|
77
|
-
|
|
78
|
+
// w1a sender binding: sign the advisory line (steering file is
|
|
79
|
+
// same-uid writable; worker pollSteering drops unauthenticated lines).
|
|
80
|
+
fs.appendFileSync(
|
|
81
|
+
steeringFile,
|
|
82
|
+
`${JSON.stringify(signSteeringEntry({ type: "steer", message: STEER_WRAP_UP_MESSAGE }))}\n`,
|
|
83
|
+
"utf-8",
|
|
84
|
+
);
|
|
78
85
|
} catch (err) {
|
|
79
86
|
logInternalError("child-pi.steer-write-failed", err instanceof Error ? err : new Error(String(err)), `pid=${pid}`);
|
|
80
87
|
}
|
|
@@ -377,6 +377,10 @@ async function handleRequest(
|
|
|
377
377
|
res: http.ServerResponse,
|
|
378
378
|
ctx: { routes: Map<string, ProviderRoute>; workers: Map<string, WorkerEntry> },
|
|
379
379
|
): Promise<void> {
|
|
380
|
+
// Assigned AFTER concurrency admission (step 2b). The catch path releases
|
|
381
|
+
// the slot for body-read/upstream failures that bypass the normal
|
|
382
|
+
// response-close settlement; the release itself is idempotent.
|
|
383
|
+
let releaseSlot: (() => void) | undefined;
|
|
380
384
|
try {
|
|
381
385
|
// (0) Provider routing: the FIRST path segment names the provider
|
|
382
386
|
// ("/anthropic/v1/messages", "/openai/chat/completions"); the broker
|
|
@@ -411,12 +415,35 @@ async function handleRequest(
|
|
|
411
415
|
deny(res, 429, "too many concurrent inference requests for this worker");
|
|
412
416
|
return;
|
|
413
417
|
}
|
|
418
|
+
// (2b) Take the concurrency slot BEFORE the body read, and release it
|
|
419
|
+
// EXACTLY once on every post-admission exit path: the "close" handler
|
|
420
|
+
// covers normal completion, client aborts, and post-admission denies
|
|
421
|
+
// (deny() ends the response → close fires), and the idempotent guard
|
|
422
|
+
// makes the explicit catch-path call a no-op when close already fired.
|
|
423
|
+
// Without the matching increment the >= cap gate above was dead code
|
|
424
|
+
// (inFlight only ever walked negative after the first response close),
|
|
425
|
+
// so one valid worker token could hold unbounded concurrent requests —
|
|
426
|
+
// each buffering up to MAX_BODY_BYTES in parent-process memory — for
|
|
427
|
+
// the life of the token.
|
|
428
|
+
worker.inFlight += 1;
|
|
429
|
+
const usage = new UsageCounter();
|
|
430
|
+
let settled = false;
|
|
431
|
+
const settle = (): void => {
|
|
432
|
+
if (settled) return;
|
|
433
|
+
settled = true;
|
|
434
|
+
worker.inFlight -= 1;
|
|
435
|
+
worker.inputTokens += usage.input;
|
|
436
|
+
worker.outputTokens += usage.output;
|
|
437
|
+
};
|
|
438
|
+
releaseSlot = settle;
|
|
439
|
+
res.on("close", settle);
|
|
414
440
|
// (3) Read the request body (bounded).
|
|
415
441
|
const chunks: Buffer[] = [];
|
|
416
442
|
let size = 0;
|
|
417
443
|
for await (const chunk of req) {
|
|
418
444
|
size += (chunk as Buffer).length;
|
|
419
445
|
if (size > MAX_BODY_BYTES) {
|
|
446
|
+
settle();
|
|
420
447
|
deny(res, 413, "request body too large");
|
|
421
448
|
return;
|
|
422
449
|
}
|
|
@@ -445,13 +472,8 @@ async function handleRequest(
|
|
|
445
472
|
});
|
|
446
473
|
res.writeHead(upstreamRes.status, outHeaders);
|
|
447
474
|
worker.requests += 1;
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
worker.inFlight -= 1;
|
|
451
|
-
worker.inputTokens += usage.input;
|
|
452
|
-
worker.outputTokens += usage.output;
|
|
453
|
-
};
|
|
454
|
-
res.on("close", finish);
|
|
475
|
+
// Slot settlement + usage accounting were wired at admission (2b);
|
|
476
|
+
// "close" fires here on normal completion (res.end / pipe drain).
|
|
455
477
|
if (!upstreamRes.body) {
|
|
456
478
|
res.end();
|
|
457
479
|
return;
|
|
@@ -463,6 +485,10 @@ async function handleRequest(
|
|
|
463
485
|
});
|
|
464
486
|
nodeStream.pipe(usage.tee).pipe(res);
|
|
465
487
|
} catch (error) {
|
|
488
|
+
// Release the admitted concurrency slot for body-read/upstream failures
|
|
489
|
+
// (client abort mid-body, fetch throw, ...) — idempotent when the
|
|
490
|
+
// response close already settled it.
|
|
491
|
+
releaseSlot?.();
|
|
466
492
|
logInternalError("inference-broker.forward", undefined, error instanceof Error ? error.message : String(error), "warn");
|
|
467
493
|
if (!res.headersSent) deny(res, 502, "inference broker upstream failure");
|
|
468
494
|
else res.destroy();
|
|
@@ -648,3 +674,11 @@ export function __resetInferenceBrokerForTests(): void {
|
|
|
648
674
|
export function __inferenceBrokerTokenMapKeysForTests(): string[] {
|
|
649
675
|
return broker ? [...broker.workers.keys()] : [];
|
|
650
676
|
}
|
|
677
|
+
|
|
678
|
+
/** @internal TESTS ONLY — the live per-worker in-flight counter (looked up
|
|
679
|
+
* by worker key, never the raw token). Pins the MAX_INFLIGHT_PER_WORKER
|
|
680
|
+
* admission cap: incremented at admission, exactly one release per settled
|
|
681
|
+
* request (success, upstream failure, client abort, post-admission deny). */
|
|
682
|
+
export function __inferenceBrokerInFlightForTests(workerKey: string): number | undefined {
|
|
683
|
+
return broker?.byKey.get(workerKey)?.inFlight;
|
|
684
|
+
}
|
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
* a real child; the default seam wraps node:child_process execFile with a
|
|
20
20
|
* 5s timeout (utf8).
|
|
21
21
|
* - Single-flight memoized per process: the FIRST call creates the promise;
|
|
22
|
-
* every later call (any args) returns the same one.
|
|
23
|
-
*
|
|
22
|
+
* every later call (any args) returns the same one. SUCCESSFUL probes memoize
|
|
23
|
+
* forever; FAILED/unknown probes (null) re-arm after a short TTL
|
|
24
|
+
* (PROBE_FAILURE_RETRY_TTL_MS, 60s) — one transient cold-start failure must
|
|
25
|
+
* not permanently drop version-gated safety flags (--no-mcp) for the
|
|
26
|
+
* process lifetime (SEC-2026-11 N2: parity-spawn-mcp-cut-version-gated).
|
|
24
27
|
* - NEVER rejects: spawn error / nonzero exit / timeout / unparseable
|
|
25
28
|
* stdout all resolve null. The result is a hint, never a failure channel.
|
|
26
29
|
*
|
|
@@ -155,6 +158,15 @@ export function piVersionAtLeast(version: string | null | undefined, floor: stri
|
|
|
155
158
|
/** Memoized single-flight probe promise (null = unknown, failures included). */
|
|
156
159
|
let memoizedProbe: Promise<string | null> | null = null;
|
|
157
160
|
|
|
161
|
+
/**
|
|
162
|
+
* SEC-2026-11 N2: how long a FAILED/unknown probe stays memoized before a
|
|
163
|
+
* later spawn may retry. Successes memoize forever; failures must not —
|
|
164
|
+
* a single transient probe miss (e.g. 5s cold-start timeout) otherwise drops
|
|
165
|
+
* --no-mcp from every later parity spawn in the process, silently widening
|
|
166
|
+
* the MCP exposure window on hosts that DO support the flag.
|
|
167
|
+
*/
|
|
168
|
+
export const PROBE_FAILURE_RETRY_TTL_MS = 60_000;
|
|
169
|
+
|
|
158
170
|
async function runPiVersionProbe(exec: ExecPiVersionFn): Promise<string | null> {
|
|
159
171
|
const { command, args } = getPiSpawnCommand(["--version"]);
|
|
160
172
|
const { stdout } = await exec(command, args, { timeout: PI_VERSION_PROBE_TIMEOUT_MS, encoding: "utf8" });
|
|
@@ -170,7 +182,23 @@ async function runPiVersionProbe(exec: ExecPiVersionFn): Promise<string | null>
|
|
|
170
182
|
* matter what they pass. NEVER rejects; unknown resolves null.
|
|
171
183
|
*/
|
|
172
184
|
export function probePiVersion(exec: ExecPiVersionFn = defaultExec): Promise<string | null> {
|
|
173
|
-
if (!memoizedProbe)
|
|
185
|
+
if (!memoizedProbe) {
|
|
186
|
+
const memo = runPiVersionProbe(exec)
|
|
187
|
+
.catch(() => null)
|
|
188
|
+
.then((version) => {
|
|
189
|
+
if (version === null) {
|
|
190
|
+
// Failure/unknown: re-arm the memo after the TTL so a later spawn
|
|
191
|
+
// retries (see PROBE_FAILURE_RETRY_TTL_MS). Only clear OUR memo — a
|
|
192
|
+
// concurrent success must not be evicted.
|
|
193
|
+
const timer: NodeJS.Timeout = setTimeout(() => {
|
|
194
|
+
if (memoizedProbe === memo) memoizedProbe = null;
|
|
195
|
+
}, PROBE_FAILURE_RETRY_TTL_MS);
|
|
196
|
+
timer.unref();
|
|
197
|
+
}
|
|
198
|
+
return version;
|
|
199
|
+
});
|
|
200
|
+
memoizedProbe = memo;
|
|
201
|
+
}
|
|
174
202
|
return memoizedProbe;
|
|
175
203
|
}
|
|
176
204
|
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { type SpawnSyncOptions, type SpawnSyncReturns, spawnSync } from "node:child_process";
|
|
30
30
|
import * as fs from "node:fs";
|
|
31
|
+
import * as os from "node:os";
|
|
31
32
|
import * as path from "node:path";
|
|
32
33
|
|
|
33
34
|
export const SANDBOX_ENV = "PI_CREW_SANDBOX";
|
|
@@ -122,14 +123,33 @@ function toolchainBinds(spec: { command: string; args: string[] }, isPackageRoot
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
// --extension / --skill resources: bind the value + ancestors up to the
|
|
125
|
-
// nearest package root (extensions import across their package tree)
|
|
126
|
+
// nearest package root (extensions import across their package tree) —
|
|
127
|
+
// but NEVER at or above the user trust boundary. User-global skill roots
|
|
128
|
+
// (~/.pi/agent/skills — the default pi user-skill location) have NO
|
|
129
|
+
// package.json ancestor before $HOME, so an uncapped walk climbed all the
|
|
130
|
+
// way up and emitted `--ro-bind-try $HOME $HOME` (plus `~/.pi/agent`, the
|
|
131
|
+
// auth.json directory), mounting the whole host home — credentials
|
|
132
|
+
// included — read-only inside the sandbox. The walk now stops BELOW the
|
|
133
|
+
// pi agent dir / $HOME, and a value that would mount the home, the agent
|
|
134
|
+
// dir, or any ancestor containing either is SKIPPED entirely (fail-closed
|
|
135
|
+
// ENOENT inside the sandbox — never a HOME bind).
|
|
136
|
+
const home = os.homedir();
|
|
137
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(home, ".pi", "agent");
|
|
138
|
+
// Protect the DEFAULT agent dir too: a PI_CODING_AGENT_DIR override moves
|
|
139
|
+
// the effective dir, but ~/.pi/agent keeps holding real credentials on
|
|
140
|
+
// default installs — fail closed for both locations.
|
|
141
|
+
const trustBoundaries = [home, agentDir, path.join(home, ".pi", "agent")];
|
|
126
142
|
for (let i = 0; i < spec.args.length - 1; i++) {
|
|
127
143
|
const flag = spec.args[i];
|
|
128
144
|
if (flag !== "--extension" && flag !== "--skill") continue;
|
|
129
145
|
const value = spec.args[i + 1];
|
|
130
146
|
if (!value || !path.isAbsolute(value)) continue;
|
|
147
|
+
if (crossesUserTrustBoundary(value, trustBoundaries)) continue;
|
|
131
148
|
binds.add(value);
|
|
132
149
|
for (let dir = path.dirname(value); dir !== path.dirname(dir); dir = path.dirname(dir)) {
|
|
150
|
+
// never climb at/above the user trust boundary: the pi agent dir
|
|
151
|
+
// (auth.json!) and the home dir itself stay unmounted
|
|
152
|
+
if (crossesUserTrustBoundary(dir, trustBoundaries)) break;
|
|
133
153
|
binds.add(dir);
|
|
134
154
|
if (isPackageRoot(dir)) break;
|
|
135
155
|
}
|
|
@@ -137,6 +157,23 @@ function toolchainBinds(spec: { command: string; args: string[] }, isPackageRoot
|
|
|
137
157
|
return [...binds];
|
|
138
158
|
}
|
|
139
159
|
|
|
160
|
+
/**
|
|
161
|
+
* True when binding `dir` would mount a trust boundary itself or any
|
|
162
|
+
* ancestor CONTAINING one (e.g. `dir` == $HOME, ~/.pi, /home, or /) — the
|
|
163
|
+
* caller must skip/break (fail closed) instead of binding. Comparison goes
|
|
164
|
+
* through path.relative, which is case-insensitive on win32.
|
|
165
|
+
*/
|
|
166
|
+
function crossesUserTrustBoundary(dir: string, boundaries: string[]): boolean {
|
|
167
|
+
for (const boundary of boundaries) {
|
|
168
|
+
const rel = path.relative(dir, boundary);
|
|
169
|
+
// rel === "" → dir IS the boundary; a rel that stays inside dir (no
|
|
170
|
+
// ".." prefix, not absolute) → the boundary lives UNDER dir, so
|
|
171
|
+
// binding dir would mount it.
|
|
172
|
+
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
140
177
|
/** Default package-root probe: a directory carrying package.json (extensions import across their package tree). */
|
|
141
178
|
function defaultIsPackageRoot(dir: string): boolean {
|
|
142
179
|
try {
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* corrupt checkpoint is treated as a fresh run (load() returns undefined). If a crash
|
|
14
14
|
* happens mid-agent, that agent simply re-runs from scratch on resume — agent results
|
|
15
15
|
* are expected to be idempotent-ish.
|
|
16
|
+
*
|
|
17
|
+
* w3-dwf-trust (2026-10-29): the checkpoint path (<crewRoot>/state/runs/<runId>/) is
|
|
18
|
+
* writable by every WRITE-role child worker of the run (same OS uid), so the "sole
|
|
19
|
+
* writer is the runner" assumption is FALSE. load() therefore validates at the
|
|
20
|
+
* hydration boundary: runId binding (expectedRunId), numeric clamps on spent/
|
|
21
|
+
* agentCount, container-shape checks, and a producer-shape verdict allowlist over
|
|
22
|
+
* completedAgentCalls entries — forged or schema-invalid state fails closed to
|
|
23
|
+
* re-execution instead of replaying as trusted. See sanitizeCheckpoint below.
|
|
16
24
|
*/
|
|
17
25
|
|
|
18
26
|
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
@@ -59,6 +67,157 @@ export interface DwfCheckpointState {
|
|
|
59
67
|
updatedAt: string;
|
|
60
68
|
}
|
|
61
69
|
|
|
70
|
+
// ─── w3-dwf-trust (2026-10-29): hydration-boundary validation ─────────────
|
|
71
|
+
// The checkpoint file lives under the shared repo where WRITE-role child
|
|
72
|
+
// workers can write it, so load() revalidates instead of blind-casting. These
|
|
73
|
+
// helpers narrow the parsed JSON (typed as `unknown` — no `any`) into a
|
|
74
|
+
// DwfCheckpointState, dropping or resetting anything the runner could not
|
|
75
|
+
// have produced. Fail-closed policy: schema-invalid containers reject the
|
|
76
|
+
// whole checkpoint (fresh run); invalid per-call entries are dropped (call
|
|
77
|
+
// re-executes); invalid numeric accumulators clamp to 0.
|
|
78
|
+
|
|
79
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
80
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
81
|
+
|
|
82
|
+
/** String[] narrowing: non-array → undefined (reject), non-string elements filtered out. */
|
|
83
|
+
const sanitizeStringArray = (value: unknown): string[] | undefined => {
|
|
84
|
+
if (!Array.isArray(value)) return undefined;
|
|
85
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Numeric control accumulator clamp: finite non-negative numbers pass; anything else → 0
|
|
89
|
+
* (a forged negative `spent` inflated budget.remaining() past the dispatched cap, and a
|
|
90
|
+
* negative/reset `agentCount` re-armed the agent-call cap). */
|
|
91
|
+
const clampNonNegativeNumber = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0);
|
|
92
|
+
|
|
93
|
+
/** Optional finite non-negative number field (usage parts, durationMs): keep when valid, drop when not. */
|
|
94
|
+
const optionalNonNegativeNumber = (value: unknown): number | undefined =>
|
|
95
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
96
|
+
|
|
97
|
+
/** Validate one hydrated completedAgentCalls entry against the runner's producer shape.
|
|
98
|
+
* Verdict allowlist: the runner caches ONLY successful results (ok exactly boolean true,
|
|
99
|
+
* string text) — anything else was never produced by loadWorkflowModule's runner, so it
|
|
100
|
+
* is dropped (the call re-executes on resume) rather than replayed with ok forced true.
|
|
101
|
+
* Optional fields are kept only when correctly typed; `structured` is post-JSON-parse by
|
|
102
|
+
* construction and survives as-is (deep schema re-validation needs the call's schema,
|
|
103
|
+
* which the checkpoint format does not carry — the dispatch-side pin/runId binding is
|
|
104
|
+
* the load-bearing control for forged-but-well-formed entries). */
|
|
105
|
+
function sanitizeAgentResult(value: unknown): AgentResult | undefined {
|
|
106
|
+
if (!isPlainObject(value)) return undefined;
|
|
107
|
+
if (value.ok !== true) return undefined;
|
|
108
|
+
if (typeof value.text !== "string") return undefined;
|
|
109
|
+
const result: AgentResult = { ok: true, text: value.text };
|
|
110
|
+
if (value.structured !== undefined) result.structured = value.structured;
|
|
111
|
+
if (isPlainObject(value.usage)) {
|
|
112
|
+
const usage: AgentResult["usage"] = {};
|
|
113
|
+
const input = optionalNonNegativeNumber(value.usage.input);
|
|
114
|
+
const output = optionalNonNegativeNumber(value.usage.output);
|
|
115
|
+
const cost = optionalNonNegativeNumber(value.usage.cost);
|
|
116
|
+
const turns = optionalNonNegativeNumber(value.usage.turns);
|
|
117
|
+
if (input !== undefined) usage.input = input;
|
|
118
|
+
if (output !== undefined) usage.output = output;
|
|
119
|
+
if (cost !== undefined) usage.cost = cost;
|
|
120
|
+
if (turns !== undefined) usage.turns = turns;
|
|
121
|
+
if (Object.keys(usage).length > 0) result.usage = usage;
|
|
122
|
+
}
|
|
123
|
+
if (typeof value.runId === "string") result.runId = value.runId;
|
|
124
|
+
if (typeof value.taskId === "string") result.taskId = value.taskId;
|
|
125
|
+
if (typeof value.artifactPath === "string") result.artifactPath = value.artifactPath;
|
|
126
|
+
if (typeof value.error === "string") result.error = value.error;
|
|
127
|
+
const durationMs = optionalNonNegativeNumber(value.durationMs);
|
|
128
|
+
if (durationMs !== undefined) result.durationMs = durationMs;
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Bind + revalidate a version-guarded checkpoint record into a DwfCheckpointState.
|
|
133
|
+
* Returns undefined when the record is schema-invalid or bound to another run
|
|
134
|
+
* (fail closed to re-execution, never trusted replay). */
|
|
135
|
+
function sanitizeCheckpoint(record: Record<string, unknown>, expectedRunId: string | undefined): DwfCheckpointState | undefined {
|
|
136
|
+
// (1) RunId binding: the runner passes manifest.runId, so a checkpoint for a
|
|
137
|
+
// different (stale/planted/enumerated) run never hydrates as this run's state.
|
|
138
|
+
if (expectedRunId !== undefined && record.runId !== expectedRunId) {
|
|
139
|
+
logInternalError(
|
|
140
|
+
"dwf-state-store.load",
|
|
141
|
+
new Error("checkpoint runId mismatch — rejecting checkpoint, run restarts fresh"),
|
|
142
|
+
`expectedRunId=${expectedRunId}, checkpointRunId=${record.runId}`,
|
|
143
|
+
);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (typeof record.runId !== "string") return undefined;
|
|
147
|
+
const runId = record.runId;
|
|
148
|
+
|
|
149
|
+
// (2) Required-shape containers. Non-conforming shape → reject the whole
|
|
150
|
+
// checkpoint (schema-invalid state must never hydrate partially).
|
|
151
|
+
if (record.vars !== undefined && !isPlainObject(record.vars)) return undefined;
|
|
152
|
+
const phases = sanitizeStringArray(record.phases);
|
|
153
|
+
if (phases === undefined) return undefined;
|
|
154
|
+
const logs = sanitizeStringArray(record.logs);
|
|
155
|
+
if (logs === undefined) return undefined;
|
|
156
|
+
if (record.currentPhase !== undefined && typeof record.currentPhase !== "string") return undefined;
|
|
157
|
+
const currentPhase: string | undefined = record.currentPhase;
|
|
158
|
+
|
|
159
|
+
// (3) Numeric control accumulators clamp (never trust worker-writable arithmetic).
|
|
160
|
+
const spent = clampNonNegativeNumber(record.spent);
|
|
161
|
+
const agentCount = clampNonNegativeNumber(record.agentCount);
|
|
162
|
+
|
|
163
|
+
// (4) Per-call replay cache: drop schema-invalid / non-producer-reachable entries.
|
|
164
|
+
let completedAgentCalls: Record<string, AgentResult> | undefined;
|
|
165
|
+
if (record.completedAgentCalls !== undefined) {
|
|
166
|
+
if (!isPlainObject(record.completedAgentCalls)) {
|
|
167
|
+
logInternalError(
|
|
168
|
+
"dwf-state-store.load",
|
|
169
|
+
new Error("checkpoint completedAgentCalls is not an object — dropping the replay cache (calls re-execute on resume)"),
|
|
170
|
+
`runId=${runId}`,
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
completedAgentCalls = {};
|
|
174
|
+
let dropped = 0;
|
|
175
|
+
for (const [callId, entry] of Object.entries(record.completedAgentCalls)) {
|
|
176
|
+
const sanitized = sanitizeAgentResult(entry);
|
|
177
|
+
if (sanitized !== undefined) {
|
|
178
|
+
completedAgentCalls[callId] = sanitized;
|
|
179
|
+
} else {
|
|
180
|
+
dropped++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (dropped > 0) {
|
|
184
|
+
logInternalError(
|
|
185
|
+
"dwf-state-store.load",
|
|
186
|
+
new Error(
|
|
187
|
+
`${dropped} checkpoint agent-call entr${dropped === 1 ? "y" : "ies"} failed hydration validation and ${dropped === 1 ? "was" : "were"} dropped (the call${dropped === 1 ? " re-executes" : "s re-execute"} on resume)`,
|
|
188
|
+
),
|
|
189
|
+
`runId=${runId}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// (5) dispatchContext fingerprint: keep only correctly typed fields (the runner
|
|
196
|
+
// compares it against the current dispatch and emits a mismatch event).
|
|
197
|
+
let dispatchContext: DwfCheckpointState["dispatchContext"];
|
|
198
|
+
if (isPlainObject(record.dispatchContext)) {
|
|
199
|
+
dispatchContext = {};
|
|
200
|
+
if (typeof record.dispatchContext.modelOverride === "string") dispatchContext.modelOverride = record.dispatchContext.modelOverride;
|
|
201
|
+
if (typeof record.dispatchContext.team === "string") dispatchContext.team = record.dispatchContext.team;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// vars was shape-checked above (undefined or plain object); default {} for legacy absence.
|
|
205
|
+
const vars: Record<string, unknown> = isPlainObject(record.vars) ? record.vars : {};
|
|
206
|
+
return {
|
|
207
|
+
version: DWF_CHECKPOINT_VERSION,
|
|
208
|
+
runId,
|
|
209
|
+
vars,
|
|
210
|
+
phases,
|
|
211
|
+
currentPhase,
|
|
212
|
+
logs,
|
|
213
|
+
spent,
|
|
214
|
+
agentCount,
|
|
215
|
+
...(completedAgentCalls !== undefined ? { completedAgentCalls } : {}),
|
|
216
|
+
...(dispatchContext !== undefined ? { dispatchContext } : {}),
|
|
217
|
+
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
62
221
|
/**
|
|
63
222
|
* DwfStore — atomic CRUD for a single run's DWF checkpoint.
|
|
64
223
|
*
|
|
@@ -83,16 +242,42 @@ export class DwfStore {
|
|
|
83
242
|
return `${this.stateRoot}/dwf-checkpoint.json`;
|
|
84
243
|
}
|
|
85
244
|
|
|
86
|
-
/** Load the checkpoint for this
|
|
87
|
-
|
|
245
|
+
/** Load the checkpoint for this stateRoot.
|
|
246
|
+
*
|
|
247
|
+
* w3-dwf-trust (2026-10-29): the checkpoint file lives under the shared repo
|
|
248
|
+
* (<crewRoot>/state/runs/<runId>/dwf-checkpoint.json) where every WRITE-role
|
|
249
|
+
* child worker of the run can also write (same OS uid), so the file can no
|
|
250
|
+
* longer be treated as runner-only ground truth. Hydration now validates at
|
|
251
|
+
* this boundary instead of blind-casting:
|
|
252
|
+
*
|
|
253
|
+
* - `expectedRunId` (passed by dynamic-workflow-runner.ts as manifest.runId)
|
|
254
|
+
* binds the checkpoint to the resuming run — a foreign/stale runId is
|
|
255
|
+
* rejected (→ undefined → fresh run) instead of replayed.
|
|
256
|
+
* - Control accumulators are clamped: `spent`/`agentCount` must be finite
|
|
257
|
+
* non-negative numbers, else reset to 0 (a forged negative `spent` used to
|
|
258
|
+
* inflate budget.remaining() past the dispatched cap and a negative
|
|
259
|
+
* `agentCount` re-armed the SDD-3 G13 agent-call cap).
|
|
260
|
+
* - Schema-invalid containers (non-array phases/logs, non-object vars, a
|
|
261
|
+
* non-string currentPhase) reject the WHOLE checkpoint (fail closed to
|
|
262
|
+
* re-execution).
|
|
263
|
+
* - Hydrated `completedAgentCalls` entries are re-validated against the
|
|
264
|
+
* producer's shape: the runner caches ONLY successful results
|
|
265
|
+
* (`ok === true`, string `text`), so entries failing that verdict
|
|
266
|
+
* allowlist/shape are DROPPED (the call re-executes on resume) and
|
|
267
|
+
* optional fields are stripped when mistyped. Anomalies are logged via
|
|
268
|
+
* logInternalError so quarantines are observable (not silently swallowed).
|
|
269
|
+
*
|
|
270
|
+
* Returns undefined if missing/corrupt/tampered (fresh run). */
|
|
271
|
+
load(expectedRunId?: string): DwfCheckpointState | undefined {
|
|
88
272
|
const path = this.path;
|
|
89
273
|
try {
|
|
90
274
|
if (!existsSync(path)) return undefined;
|
|
91
275
|
const raw = readFileSync(path, "utf-8");
|
|
92
|
-
const parsed = JSON.parse(raw);
|
|
93
|
-
// Corrupt-guard: a valid checkpoint must be
|
|
94
|
-
// (mirrors GoalStore.load's typeof parsed.goalId !== "string"
|
|
95
|
-
|
|
276
|
+
const parsed: unknown = JSON.parse(raw);
|
|
277
|
+
// Corrupt-guard: a valid checkpoint must be a plain JSON object with a
|
|
278
|
+
// string runId (mirrors GoalStore.load's typeof parsed.goalId !== "string"
|
|
279
|
+
// check). Arrays and primitives are rejected outright.
|
|
280
|
+
if (!isPlainObject(parsed) || typeof parsed.runId !== "string") return undefined;
|
|
96
281
|
// R6-12: version guard, mirroring the recovery/checkpoint.ts runId-guard
|
|
97
282
|
// style (typed mismatch → treat as not-loadable). A checkpoint from an
|
|
98
283
|
// unknown FUTURE version is REJECTED (→ undefined → fresh run) rather than
|
|
@@ -110,7 +295,8 @@ export class DwfStore {
|
|
|
110
295
|
);
|
|
111
296
|
return undefined;
|
|
112
297
|
}
|
|
113
|
-
|
|
298
|
+
// w3-dwf-trust: bind + revalidate at the hydration boundary (never blind-cast).
|
|
299
|
+
return sanitizeCheckpoint(parsed, expectedRunId);
|
|
114
300
|
} catch {
|
|
115
301
|
return undefined;
|
|
116
302
|
}
|