pi-crew 0.11.49 → 0.11.51
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 +116 -0
- package/dist/index.mjs +551 -544
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +2 -0
- package/src/agents/agent-config.ts +58 -1
- package/src/config/config-validation.ts +18 -0
- package/src/config/env-allowlist-registry.ts +5 -1
- package/src/config/env-vars.ts +5 -1
- 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/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/broker/crew-broker-child.ts +55 -17
- 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 +36 -10
- package/src/runtime/child-pi/child-pi-steering.ts +8 -1
- package/src/runtime/child-pi/child-pi.ts +2 -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/goal-workflow/dynamic-workflow-context.ts +48 -7
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
- 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 +11 -5
- package/src/runtime/task-runner/hybrid-tier.ts +17 -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
|
@@ -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
|
}
|
|
@@ -281,7 +281,8 @@ export interface ChildPiRunInput {
|
|
|
281
281
|
* in the parent's in-memory `Map<runId, token>` and in the child's env.
|
|
282
282
|
* NEVER persisted to manifest, events, mailbox, or any run-dir file.
|
|
283
283
|
*
|
|
284
|
-
*
|
|
284
|
+
* DEFAULT-ON fd3 route (PI_CREW_BROKER_STDIN — '0' opts out; DEFAULT-ON
|
|
285
|
+
* flip 2026-10-28, FIX-1 2026-10-28): the token
|
|
285
286
|
* is still written into built.env for every brokered spawn — RPC/surface
|
|
286
287
|
* branches compose WITHOUT the payload and need the env credential, but
|
|
287
288
|
* the headless JSON branch composes WITH ctx.brokerStdinPayload and
|
|
@@ -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
|
}
|
|
@@ -26,7 +26,8 @@ import { randomBytes } from "node:crypto";
|
|
|
26
26
|
import type { TSchema } from "@sinclair/typebox";
|
|
27
27
|
import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
28
28
|
import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
|
|
29
|
-
import {
|
|
29
|
+
import { renderInboxMessage } from "../../prompt/prompt-runtime.ts";
|
|
30
|
+
import { appendMailboxMessage, type MailboxMessage, readMailbox } from "../../state/coordination/mailbox.ts";
|
|
30
31
|
import { appendEvent } from "../../state/event-log/event-log.ts";
|
|
31
32
|
import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
32
33
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
@@ -383,6 +384,27 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
|
|
|
383
384
|
// `while(true){review; if accept break; retry}` loop replay round-1's
|
|
384
385
|
// verdict forever (stale-verdict hazard, docs/dynamic-workflows.md).
|
|
385
386
|
const hydratedCallIds = new Set<string>(Object.keys(opts.resumedState?.completedAgentCalls ?? {}));
|
|
387
|
+
// W1b binding (security fix gatherreplies-replyto-only-binding): messageId →
|
|
388
|
+
// expected addressee, recorded when ctx.mail() issues each mail. gatherReplies
|
|
389
|
+
// only accepts a reply row when its broker-authenticated sender (m.from)
|
|
390
|
+
// equals the expected addressee of the mail the reply claims (m.replyTo) — a
|
|
391
|
+
// forged replyTo from any other task cannot enter the reply stream.
|
|
392
|
+
const mailedAddressees = new Map<string, string>();
|
|
393
|
+
const unresolvedMailLookups = new Set<string>();
|
|
394
|
+
// Resolve the expected addressee for a mailed id, falling back to the run
|
|
395
|
+
// outbox ONCE per unknown id (resume path: mails sent by a previous process
|
|
396
|
+
// of the same workflow are not in the in-memory map; their outbox rows are
|
|
397
|
+
// host-written — workers cannot append to the outbox lane — so the recorded
|
|
398
|
+
// `to` is a trusted binding source. Inbox rows are never consulted).
|
|
399
|
+
const expectedReplyAddressee = (messageId: string): string | undefined => {
|
|
400
|
+
const known = mailedAddressees.get(messageId);
|
|
401
|
+
if (known !== undefined) return known;
|
|
402
|
+
if (unresolvedMailLookups.has(messageId)) return undefined;
|
|
403
|
+
unresolvedMailLookups.add(messageId);
|
|
404
|
+
const mailed = readMailbox(manifest, "outbox").find((m) => m.id === messageId && m.from === "dynamic-workflow" && m.to);
|
|
405
|
+
if (mailed?.to) mailedAddressees.set(messageId, mailed.to);
|
|
406
|
+
return mailedAddressees.get(messageId);
|
|
407
|
+
};
|
|
386
408
|
// round-14 P1-2: frozen budget surface. The closures read wfState.spent so the
|
|
387
409
|
// object stays live after Object.freeze(ctx). total is a snapshot primitive.
|
|
388
410
|
const budget = Object.freeze({
|
|
@@ -852,18 +874,37 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
|
|
|
852
874
|
replyTo: mailOpts?.replyTo,
|
|
853
875
|
replyDeadline: mailOpts?.replyDeadline,
|
|
854
876
|
});
|
|
877
|
+
// W1b binding: record the expected addressee so gatherReplies can verify
|
|
878
|
+
// that a reply row's authenticated sender (from) is the task this mail
|
|
879
|
+
// was addressed to (replyTo target ownership).
|
|
880
|
+
mailedAddressees.set(msg.id, to);
|
|
855
881
|
return msg.id;
|
|
856
882
|
},
|
|
857
883
|
async gatherReplies(messageIds: string[], deadlineMs: number): Promise<unknown[]> {
|
|
884
|
+
// W1b binding (security fix gatherreplies-replyto-only-binding): a reply
|
|
885
|
+
// row counts ONLY when (a) it targets the run-level parent lane
|
|
886
|
+
// (to === "parent" — the lane this gather polls), (b) its replyTo is one
|
|
887
|
+
// of the awaited ids, and (c) its broker-authenticated sender (from)
|
|
888
|
+
// equals the addressee recorded for that id at mail time. Duplicate rows
|
|
889
|
+
// from one sender no longer satisfy a multi-id gather (distinct
|
|
890
|
+
// (replyTo, from) pairs are counted toward messageIds.length), and every
|
|
891
|
+
// delivered body is fenced through renderInboxMessage (same seam as
|
|
892
|
+
// worker inbox delivery: control-char strip, closing-tag neutralization,
|
|
893
|
+
// 8192-char cap) before it enters workflow variables.
|
|
894
|
+
const collectBoundReplies = (): MailboxMessage[] =>
|
|
895
|
+
readMailbox(manifest, "inbox").filter((m) => {
|
|
896
|
+
if (!m.replyTo || !messageIds.includes(m.replyTo) || m.to !== "parent") return false;
|
|
897
|
+
return expectedReplyAddressee(m.replyTo) === m.from;
|
|
898
|
+
});
|
|
899
|
+
const distinctReplyPairs = (rows: MailboxMessage[]): number => new Set(rows.map((m) => `${m.replyTo}\u0000${m.from}`)).size;
|
|
900
|
+
const fenceReplyRow = (m: MailboxMessage): MailboxMessage => ({ ...m, body: renderInboxMessage(m) });
|
|
858
901
|
const deadline = Date.now() + deadlineMs;
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
if (
|
|
902
|
+
for (;;) {
|
|
903
|
+
const got = collectBoundReplies();
|
|
904
|
+
if (distinctReplyPairs(got) >= messageIds.length) return got.map(fenceReplyRow);
|
|
905
|
+
if (Date.now() >= deadline || ctxSignal.aborted) return got.map(fenceReplyRow);
|
|
863
906
|
await new Promise((r) => setTimeout(r, 500));
|
|
864
|
-
if (ctxSignal.aborted) return inbox.filter((m) => m.replyTo && messageIds.includes(m.replyTo));
|
|
865
907
|
}
|
|
866
|
-
return readMailbox(manifest, "inbox").filter((m) => m.replyTo && messageIds.includes(m.replyTo));
|
|
867
908
|
},
|
|
868
909
|
renderTemplate(name: string, vars: Record<string, string>): unknown {
|
|
869
910
|
return renderPlanTemplate(name, vars);
|