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
|
@@ -86,7 +86,12 @@ function isLockHolderAlive(filePath: string): boolean {
|
|
|
86
86
|
* `heldByLiveInProcess: true` when the stored token belongs to a run-lock
|
|
87
87
|
* acquisition of THIS process that is still inside its critical section — the
|
|
88
88
|
* async acquire loop then WAITS (timer-based retry) instead of throwing or
|
|
89
|
-
* stealing.
|
|
89
|
+
* stealing. W6 (steal-rm-dual-holder) additionally returns `holderToken` —
|
|
90
|
+
* the snapshotted corpse's token (undefined for a token-less legacy payload,
|
|
91
|
+
* a vanished file, or an unreadable snapshot) — so the acquire loop's
|
|
92
|
+
* destructive removal can be INSTANCE-BOUND to the verified corpse
|
|
93
|
+
* (removeLockIfTokenMatches) instead of deleting whatever currently occupies
|
|
94
|
+
* the path.
|
|
90
95
|
*
|
|
91
96
|
* ## EPERM Handling (Accepted Risk)
|
|
92
97
|
*
|
|
@@ -115,7 +120,7 @@ function readLockSnapshot(
|
|
|
115
120
|
filePath: string,
|
|
116
121
|
staleMs: number,
|
|
117
122
|
options?: { treatOwnPidAsStealable?: boolean; activeHolderTokens?: ReadonlySet<string> },
|
|
118
|
-
): { canSteal: boolean; heldByLiveInProcess: boolean } {
|
|
123
|
+
): { canSteal: boolean; heldByLiveInProcess: boolean; holderToken?: string } {
|
|
119
124
|
const treatOwnPidAsStealable = options?.treatOwnPidAsStealable === true;
|
|
120
125
|
let stat: fs.Stats | undefined;
|
|
121
126
|
let raw: string | undefined;
|
|
@@ -129,11 +134,11 @@ function readLockSnapshot(
|
|
|
129
134
|
// "locked" error.
|
|
130
135
|
const code = (error as NodeJS.ErrnoException).code;
|
|
131
136
|
if (code === "ENOENT") {
|
|
132
|
-
return { canSteal: true, heldByLiveInProcess: false };
|
|
137
|
+
return { canSteal: true, heldByLiveInProcess: false, holderToken: undefined };
|
|
133
138
|
}
|
|
134
139
|
// Transient I/O error — be conservative (don't steal, retry the read on
|
|
135
140
|
// next attempt).
|
|
136
|
-
return { canSteal: false, heldByLiveInProcess: false };
|
|
141
|
+
return { canSteal: false, heldByLiveInProcess: false, holderToken: undefined };
|
|
137
142
|
}
|
|
138
143
|
// Staleness from a single snapshot.
|
|
139
144
|
let createdAt = parseCreatedAtFromLock(raw);
|
|
@@ -185,6 +190,7 @@ function readLockSnapshot(
|
|
|
185
190
|
return {
|
|
186
191
|
canSteal: isStale || !isAlive || ownPidStealable,
|
|
187
192
|
heldByLiveInProcess: holderIsLiveInProcess,
|
|
193
|
+
holderToken,
|
|
188
194
|
};
|
|
189
195
|
}
|
|
190
196
|
|
|
@@ -250,6 +256,39 @@ function readLockToken(filePath: string): string | undefined {
|
|
|
250
256
|
}
|
|
251
257
|
}
|
|
252
258
|
|
|
259
|
+
/**
|
|
260
|
+
* W6 (steal-rm-dual-holder): INSTANCE-BOUND steal removal.
|
|
261
|
+
*
|
|
262
|
+
* Removes the lock file at `filePath` ONLY when its currently stored token
|
|
263
|
+
* still equals `corpseToken` — the token of the stale/dead holder that the
|
|
264
|
+
* caller's single-snapshot read verified as stealable. This binds the
|
|
265
|
+
* destructive removal to the exact file instance that was verified: a
|
|
266
|
+
* contender that created a FRESH lock between the snapshot and this removal
|
|
267
|
+
* carries a different randomUUID token (or ANY token at all when the corpse
|
|
268
|
+
* was a legacy token-less payload), which never matches → no removal, and the
|
|
269
|
+
* caller's retry loop re-snapshots cleanly instead of deleting a live
|
|
270
|
+
* holder's lock (the dual-holder window the un-tokened `fs.rmSync` created).
|
|
271
|
+
* Symlinks are refused via readLockToken (undefined for a symlink).
|
|
272
|
+
*
|
|
273
|
+
* Exported for unit tests of the steal-race fix; production callers are the
|
|
274
|
+
* steal branches of acquireLockWithRetry / acquireLockWithRetryAsync.
|
|
275
|
+
*/
|
|
276
|
+
export function removeLockIfTokenMatches(filePath: string, corpseToken: string | undefined): boolean {
|
|
277
|
+
const currentToken = readLockToken(filePath);
|
|
278
|
+
const matches =
|
|
279
|
+
corpseToken === undefined
|
|
280
|
+
? currentToken === undefined
|
|
281
|
+
: currentToken !== undefined && timingSafeTokenMatch(currentToken, corpseToken);
|
|
282
|
+
if (!matches) return false;
|
|
283
|
+
try {
|
|
284
|
+
fs.rmSync(filePath, { force: true });
|
|
285
|
+
return true;
|
|
286
|
+
} catch {
|
|
287
|
+
/* race — let the caller's loop retry */
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
253
292
|
/**
|
|
254
293
|
* Release a lock file, but ONLY if the stored token matches. This prevents
|
|
255
294
|
* the "losing contender wipes winner's lock" race that occurs when:
|
|
@@ -456,7 +495,6 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
|
|
|
456
495
|
const token = randomUUID();
|
|
457
496
|
try {
|
|
458
497
|
writeLockFile(filePath, token, kind);
|
|
459
|
-
return token;
|
|
460
498
|
} catch (error) {
|
|
461
499
|
const code = (error as NodeJS.ErrnoException).code;
|
|
462
500
|
if (!isLockContention(code)) throw error;
|
|
@@ -467,19 +505,37 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
|
|
|
467
505
|
// separate stale + alive reads (which could race stale→fresh).
|
|
468
506
|
// treatOwnPidAsStealable=false (this is the sync file-lock path used
|
|
469
507
|
// by withFileLockSync — never steal a fresh lock even if it has our pid).
|
|
470
|
-
const { canSteal } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: false });
|
|
508
|
+
const { canSteal, holderToken: corpseToken } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: false });
|
|
471
509
|
if (!canSteal) {
|
|
472
510
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
473
511
|
}
|
|
474
|
-
// Stale or dead holder —
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
512
|
+
// Stale or dead holder — remove it. W6 (steal-rm-dual-holder): the
|
|
513
|
+
// removal is INSTANCE-BOUND — re-read the current holder's token and
|
|
514
|
+
// remove only if it still equals the snapshotted corpse token. Without
|
|
515
|
+
// this binding, a contender that created a FRESH lock between our
|
|
516
|
+
// snapshot and this removal would lose its lock to our rmSync, our
|
|
517
|
+
// create would then succeed, and BOTH of us would enter the critical
|
|
518
|
+
// section (dual holders, no post-create verification). On mismatch:
|
|
519
|
+
// no removal — the retry loop re-snapshots cleanly.
|
|
520
|
+
removeLockIfTokenMatches(filePath, corpseToken);
|
|
480
521
|
sleepSync(Math.min(250, 25 * 2 ** attempt));
|
|
481
522
|
attempt++;
|
|
523
|
+
continue;
|
|
482
524
|
}
|
|
525
|
+
// W6 (steal-rm-dual-holder): post-claim verification, mirroring
|
|
526
|
+
// workspace-lock.ts claimLock/tryCreateVerified. O_EXCL proved WE created
|
|
527
|
+
// the file, but a concurrent stealer can remove our fresh file and claim
|
|
528
|
+
// its own in the microseconds before we return the token. Re-read: the
|
|
529
|
+
// on-disk token must be OURS before the acquire is considered successful.
|
|
530
|
+
// On mismatch: release best-effort (token-guarded → no-op against the
|
|
531
|
+
// foreign holder) and retry instead of returning a token we cannot prove.
|
|
532
|
+
if (readLockToken(filePath) === token) return token;
|
|
533
|
+
releaseLock(filePath, token);
|
|
534
|
+
if (Date.now() > deadline) {
|
|
535
|
+
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
536
|
+
}
|
|
537
|
+
sleepSync(Math.min(250, 25 * 2 ** attempt));
|
|
538
|
+
attempt++;
|
|
483
539
|
}
|
|
484
540
|
}
|
|
485
541
|
|
|
@@ -494,7 +550,6 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
|
|
|
494
550
|
const token = randomUUID();
|
|
495
551
|
try {
|
|
496
552
|
writeLockFile(filePath, token, kind);
|
|
497
|
-
return token;
|
|
498
553
|
} catch (error) {
|
|
499
554
|
const code = (error as NodeJS.ErrnoException).code;
|
|
500
555
|
if (!isLockContention(code)) throw error;
|
|
@@ -550,16 +605,28 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
|
|
|
550
605
|
"warn",
|
|
551
606
|
);
|
|
552
607
|
}
|
|
553
|
-
// Stale or dead holder —
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
608
|
+
// Stale or dead holder — remove it. W6 (steal-rm-dual-holder): the
|
|
609
|
+
// removal is INSTANCE-BOUND to the snapshotted corpse token (see the
|
|
610
|
+
// sync twin above) — a contender's fresh lock created between our
|
|
611
|
+
// snapshot and this removal never matches and is NOT deleted; the
|
|
612
|
+
// retry loop re-snapshots instead of leaving two live holders.
|
|
613
|
+
removeLockIfTokenMatches(filePath, verdict.holderToken);
|
|
559
614
|
const delay = Math.min(250, 25 * 2 ** attempt);
|
|
560
615
|
await sleep(delay);
|
|
561
616
|
attempt++;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
// W6 (steal-rm-dual-holder): post-claim verification — same protocol as
|
|
620
|
+
// the sync twin above (mirrors workspace-lock.ts tryCreateVerified): the
|
|
621
|
+
// returned token is only trusted when the on-disk token is still OURS.
|
|
622
|
+
if (readLockToken(filePath) === token) return token;
|
|
623
|
+
releaseLock(filePath, token);
|
|
624
|
+
if (Date.now() > deadline) {
|
|
625
|
+
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
562
626
|
}
|
|
627
|
+
const delay = Math.min(250, 25 * 2 ** attempt);
|
|
628
|
+
await sleep(delay);
|
|
629
|
+
attempt++;
|
|
563
630
|
}
|
|
564
631
|
}
|
|
565
632
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { KeyObject } from "node:crypto";
|
|
2
|
+
import { createHash, createPublicKey, sign as ed25519Sign, verify as ed25519Verify, generateKeyPairSync, randomUUID } from "node:crypto";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { DEFAULT_MAILBOX } from "../../config/defaults.ts";
|
|
6
|
+
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
5
7
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
6
8
|
import { redactSecrets } from "../../utils/redaction.ts";
|
|
7
9
|
import { atomicWriteFile } from "../atomic-write.ts";
|
|
@@ -22,6 +24,212 @@ export type MailboxDeliveryMode = "interrupt" | "next_turn";
|
|
|
22
24
|
*/
|
|
23
25
|
export type MailboxReason = "need_decision" | "progress_update" | "interview_request";
|
|
24
26
|
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Coordination-row sender binding (w1a, coordination-durable-files-no-sender-binding).
|
|
29
|
+
//
|
|
30
|
+
// The durable coordination channels (mailbox JSONL + steering JSONL) are
|
|
31
|
+
// same-uid-writable by design: every WRITE-role child holds absolute paths to
|
|
32
|
+
// them (PI_CREW_STATE_ROOT / PI_CREW_STEERING_FILE). Before this section, every
|
|
33
|
+
// consumer trusted the file's caller-declared from/kind/to/taskId fields, so any
|
|
34
|
+
// worker could forge orchestrator-authority steers and leader/human-attributed
|
|
35
|
+
// messages with plain fs.appendFileSync.
|
|
36
|
+
//
|
|
37
|
+
// Fix: the parent/broker process SIGNS every row it writes with an Ed25519
|
|
38
|
+
// per-process keypair. The PRIVATE key is heap-only in the writer process
|
|
39
|
+
// (never persisted, never serialized, never placed in any child env). Children
|
|
40
|
+
// receive ONLY the public verify key (PI_CREW_MAILBOX_VERIFY_KEY, base64 SPKI
|
|
41
|
+
// DER — a public key, safe in env) set by child-pi-spawn at spawn time.
|
|
42
|
+
// Delivery-side readers (parseMailboxFile — the shared path under readMailbox /
|
|
43
|
+
// readAllMailboxMessages / pollWorkerInbox / replay) verify the signature over
|
|
44
|
+
// the identity-bearing fields and treat unverifiable rows as NOISE (dropped,
|
|
45
|
+
// not delivered). The key-possession constraint is respected: a shared HMAC
|
|
46
|
+
// secret distributed to children would let a malicious child forge valid rows;
|
|
47
|
+
// an Ed25519 public key cannot sign.
|
|
48
|
+
//
|
|
49
|
+
// Verification policy (delivery reads):
|
|
50
|
+
// - verify key available (env key in a child, or the in-process keypair in a
|
|
51
|
+
// writer) → a row is delivered ONLY with a valid signature; unsigned
|
|
52
|
+
// (legacy or forged) rows are noise. This is the fail-closed default the
|
|
53
|
+
// fix brief sanctions — including resume/replay: rows signed by a
|
|
54
|
+
// predecessor process verify against the NEW process keypair only if that
|
|
55
|
+
// key is the same, which it never is (heap-only per-process key), so
|
|
56
|
+
// pre-restart pending rows are dropped on replay. Documented tradeoff.
|
|
57
|
+
// - NO verify key available (standalone reader that never signed anything
|
|
58
|
+
// and carries no env key — e.g. offline tooling/test harnesses) → rows are
|
|
59
|
+
// kept without verification (inert-reader mode). Such a process is not a
|
|
60
|
+
// delivery sink: production children always carry the env key and
|
|
61
|
+
// production writer processes create their keypair at install/first-sign.
|
|
62
|
+
//
|
|
63
|
+
// validateMailbox()/updateMailboxMessageReply() deliberately keep using the
|
|
64
|
+
// shape-only parseMailboxMessage and preserve original line bytes on repair so
|
|
65
|
+
// repair tooling can neither drop nor forge authentication.
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/** Child env var carrying the parent's base64 SPKI DER Ed25519 public verify
|
|
69
|
+
* key (set by child-pi-spawn). A PUBLIC key — non-secret, env-safe. */
|
|
70
|
+
export const COORDINATION_VERIFY_KEY_ENV = "PI_CREW_MAILBOX_VERIFY_KEY";
|
|
71
|
+
|
|
72
|
+
interface MailboxSigningKeypair {
|
|
73
|
+
privateKey: KeyObject;
|
|
74
|
+
publicKey: KeyObject;
|
|
75
|
+
publicBase64: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let signingKeypair: MailboxSigningKeypair | undefined;
|
|
79
|
+
|
|
80
|
+
function getMailboxSigningKeypair(): MailboxSigningKeypair {
|
|
81
|
+
if (!signingKeypair) {
|
|
82
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
83
|
+
const der = publicKey.export({ type: "spki", format: "der" });
|
|
84
|
+
signingKeypair = { privateKey, publicKey, publicBase64: Buffer.from(der).toString("base64") };
|
|
85
|
+
// A freshly created keypair changes the verification verdict for rows
|
|
86
|
+
// parsed before it existed (keyless → keyed). Drop memoized parses so no
|
|
87
|
+
// cached pre-key verdict survives the transition.
|
|
88
|
+
mailboxParseCache.clear();
|
|
89
|
+
}
|
|
90
|
+
return signingKeypair;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Public verify key (base64 SPKI DER) of this process's coordination signing
|
|
94
|
+
* keypair, creating the keypair on first use. Called by the writer process at
|
|
95
|
+
* install/spawn time so children can be handed the key, and at every sign. */
|
|
96
|
+
export function getMailboxVerifyKeyPublic(): string {
|
|
97
|
+
return getMailboxSigningKeypair().publicBase64;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether any verify key is resolvable in THIS process (child env key or an
|
|
101
|
+
* already-created in-process keypair). Keyless processes are inert readers. */
|
|
102
|
+
export function hasCoordinationVerifyKeys(): boolean {
|
|
103
|
+
return resolveCoordinationVerifyKeys().length > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveCoordinationVerifyKeys(): string[] {
|
|
107
|
+
const envKey = getCrewEnv(COORDINATION_VERIFY_KEY_ENV);
|
|
108
|
+
if (envKey && envKey.length > 0) return [envKey];
|
|
109
|
+
return signingKeypair ? [signingKeypair.publicBase64] : [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Canonical payload string over the identity-bearing mailbox-row fields —
|
|
113
|
+
* fixed field order, undefined → null. `sig` itself is never included. `data`
|
|
114
|
+
* rides as its deterministic JSON serialization (writer and verifier both
|
|
115
|
+
* derive it from the same written file bytes). */
|
|
116
|
+
function canonicalMailboxPayload(row: Record<string, unknown>): string {
|
|
117
|
+
const pick = (key: string): unknown => (row[key] === undefined ? null : row[key]);
|
|
118
|
+
return JSON.stringify([
|
|
119
|
+
pick("id"),
|
|
120
|
+
pick("runId"),
|
|
121
|
+
pick("direction"),
|
|
122
|
+
pick("from"),
|
|
123
|
+
pick("to"),
|
|
124
|
+
pick("body"),
|
|
125
|
+
pick("createdAt"),
|
|
126
|
+
pick("status"),
|
|
127
|
+
pick("kind"),
|
|
128
|
+
pick("priority"),
|
|
129
|
+
pick("deliveryMode"),
|
|
130
|
+
pick("taskId"),
|
|
131
|
+
pick("questionId"),
|
|
132
|
+
pick("requestId"),
|
|
133
|
+
pick("reason"),
|
|
134
|
+
pick("replyTo"),
|
|
135
|
+
pick("replyFrom"),
|
|
136
|
+
pick("replyDeadline"),
|
|
137
|
+
pick("acknowledgedAt"),
|
|
138
|
+
row.data !== undefined && row.data !== null && typeof row.data === "object" ? JSON.stringify(row.data) : pick("data"),
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function coordinationPayloadHash(canonical: string): string {
|
|
143
|
+
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function signPayloadHash(payloadHash: string): string {
|
|
147
|
+
const { privateKey } = getMailboxSigningKeypair();
|
|
148
|
+
return ed25519Sign(null, Buffer.from(payloadHash, "utf8"), privateKey).toString("base64");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Verify a mailbox row's Ed25519 signature against explicit base64 SPKI keys
|
|
152
|
+
* (default: the keys resolvable in this process). False for missing/invalid
|
|
153
|
+
* signatures and malformed keys — never throws. */
|
|
154
|
+
export function verifyMailboxRowSig(row: Record<string, unknown>, verifyKeys?: string[]): boolean {
|
|
155
|
+
const sig = row.sig;
|
|
156
|
+
if (typeof sig !== "string" || sig.length === 0) return false;
|
|
157
|
+
const keys = verifyKeys ?? resolveCoordinationVerifyKeys();
|
|
158
|
+
if (keys.length === 0) return false;
|
|
159
|
+
const payloadHash = coordinationPayloadHash(canonicalMailboxPayload(row));
|
|
160
|
+
let sigBuf: Buffer;
|
|
161
|
+
try {
|
|
162
|
+
sigBuf = Buffer.from(sig, "base64");
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
for (const key of keys) {
|
|
167
|
+
try {
|
|
168
|
+
const pub = createPublicKey({ key: Buffer.from(key, "base64"), format: "der", type: "spki" });
|
|
169
|
+
if (ed25519Verify(null, Buffer.from(payloadHash, "utf8"), pub, sigBuf)) return true;
|
|
170
|
+
} catch {
|
|
171
|
+
/* malformed key material — try the next */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Stamp this process's Ed25519 signature onto a mailbox message. Called at
|
|
178
|
+
* every durable mailbox write (append + reply rewrite) in the writer process. */
|
|
179
|
+
export function signMailboxMessage(message: MailboxMessage): MailboxMessage & { sig: string } {
|
|
180
|
+
// Spread into an anonymous object literal (assignable to Record<string, unknown>
|
|
181
|
+
// without a cast — named interfaces lack implicit index signatures, anonymous
|
|
182
|
+
// object types do not) so the canonical payload sees every MailboxMessage field.
|
|
183
|
+
const row: Record<string, unknown> = { ...message };
|
|
184
|
+
return { ...message, sig: signPayloadHash(coordinationPayloadHash(canonicalMailboxPayload(row))) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Steering-JSONL line shape shared by every writer (team-tool, broker
|
|
188
|
+
* steer.push, child-executor seed, wrap-up advisories). */
|
|
189
|
+
export interface CoordinationSteerEntry {
|
|
190
|
+
type?: string;
|
|
191
|
+
message?: string;
|
|
192
|
+
id?: string;
|
|
193
|
+
ts?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Canonical payload for a steering line — fixed field order (type, message,
|
|
197
|
+
* id, ts); `sig` never included. */
|
|
198
|
+
function canonicalSteeringPayload(entry: CoordinationSteerEntry): string {
|
|
199
|
+
return JSON.stringify([entry.type ?? null, entry.message ?? null, entry.id ?? null, entry.ts ?? null]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Stamp this process's Ed25519 signature onto a steering JSONL entry. */
|
|
203
|
+
export function signSteeringEntry<T extends CoordinationSteerEntry>(entry: T): T & { sig: string } {
|
|
204
|
+
return { ...entry, sig: signPayloadHash(coordinationPayloadHash(canonicalSteeringPayload(entry))) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Verify a steering line's signature against this process's resolvable keys.
|
|
208
|
+
* False for missing/invalid signatures; never throws. */
|
|
209
|
+
export function verifySteeringEntrySig(entry: unknown): boolean {
|
|
210
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
211
|
+
const sig = (entry as Record<string, unknown>).sig;
|
|
212
|
+
if (typeof sig !== "string" || sig.length === 0) return false;
|
|
213
|
+
const keys = resolveCoordinationVerifyKeys();
|
|
214
|
+
if (keys.length === 0) return false;
|
|
215
|
+
const payloadHash = coordinationPayloadHash(canonicalSteeringPayload(entry as CoordinationSteerEntry));
|
|
216
|
+
let sigBuf: Buffer;
|
|
217
|
+
try {
|
|
218
|
+
sigBuf = Buffer.from(sig, "base64");
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
for (const key of keys) {
|
|
223
|
+
try {
|
|
224
|
+
const pub = createPublicKey({ key: Buffer.from(key, "base64"), format: "der", type: "spki" });
|
|
225
|
+
if (ed25519Verify(null, Buffer.from(payloadHash, "utf8"), pub, sigBuf)) return true;
|
|
226
|
+
} catch {
|
|
227
|
+
/* malformed key material — try the next */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
25
233
|
// ============================================================================
|
|
26
234
|
// Phase 1.3: post-append observer (single notification point)
|
|
27
235
|
// ============================================================================
|
|
@@ -139,6 +347,14 @@ function mailboxDir(manifest: TeamRunManifest): string {
|
|
|
139
347
|
return path.join(manifest.stateRoot, "mailbox");
|
|
140
348
|
}
|
|
141
349
|
|
|
350
|
+
/** w1a: durable serialize + Ed25519 sign of a mailbox row. Signature is
|
|
351
|
+
* computed over the REDACTED row (the exact bytes that hit the file), so a
|
|
352
|
+
* verifier recomputing the canonical payload from the parsed line matches. */
|
|
353
|
+
function serializeMailboxRow(message: MailboxMessage): string {
|
|
354
|
+
const prepared = redactSecrets(message) as MailboxMessage;
|
|
355
|
+
return JSON.stringify({ ...prepared, sig: signMailboxMessage(prepared).sig });
|
|
356
|
+
}
|
|
357
|
+
|
|
142
358
|
function safeMailboxDir(manifest: TeamRunManifest, create = false): string {
|
|
143
359
|
const dir = mailboxDir(manifest);
|
|
144
360
|
if (create) {
|
|
@@ -352,13 +568,30 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
|
|
|
352
568
|
}
|
|
353
569
|
|
|
354
570
|
/** Raw read+parse of one mailbox JSONL file (extracted verbatim from the old
|
|
355
|
-
* readMailboxFile body). Callers go through cachedMailboxRead instead.
|
|
571
|
+
* readMailboxFile body). Callers go through cachedMailboxRead instead.
|
|
572
|
+
*
|
|
573
|
+
* w1a sender-binding GATE: this is THE shared delivery-read path (readMailbox,
|
|
574
|
+
* readAllMailboxMessages → pollWorkerInbox / msg.inbox / replay). When this
|
|
575
|
+
* process can verify coordination signatures (child env key or an existing
|
|
576
|
+
* writer keypair), a row is delivered ONLY with a valid Ed25519 signature —
|
|
577
|
+
* unsigned (legacy/forged) rows and tampered rows are dropped as noise.
|
|
578
|
+
* Keyless processes (no env key, keypair never created) keep rows without
|
|
579
|
+
* verification — inert-reader mode, never a delivery sink in production.
|
|
580
|
+
* Shape-only parsing (validateMailbox / reply rewrite) deliberately bypasses
|
|
581
|
+
* this gate via parseMailboxMessage. */
|
|
356
582
|
function parseMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
|
|
357
583
|
const messages: MailboxMessage[] = [];
|
|
584
|
+
const verifyKeys = resolveCoordinationVerifyKeys();
|
|
358
585
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
359
586
|
for (const line of raw.split(/\r?\n/).filter(Boolean)) {
|
|
360
587
|
try {
|
|
361
|
-
const
|
|
588
|
+
const parsedLine = JSON.parse(line) as unknown;
|
|
589
|
+
if (verifyKeys.length > 0) {
|
|
590
|
+
if (!parsedLine || typeof parsedLine !== "object" || Array.isArray(parsedLine)) continue;
|
|
591
|
+
const row = parsedLine as Record<string, unknown>;
|
|
592
|
+
if (typeof row.sig !== "string" || !verifyMailboxRowSig(row, verifyKeys)) continue;
|
|
593
|
+
}
|
|
594
|
+
const message = parseMailboxMessage(parsedLine, direction);
|
|
362
595
|
if (message) messages.push(message);
|
|
363
596
|
} catch {
|
|
364
597
|
// Invalid mailbox lines are reported by validateMailbox().
|
|
@@ -840,14 +1073,16 @@ function buildMailboxMessage(manifest: TeamRunManifest, message: MailboxAppendIn
|
|
|
840
1073
|
/**
|
|
841
1074
|
* Append a message to a run's or task's mailbox.
|
|
842
1075
|
*
|
|
843
|
-
* SECURITY NOTE
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
1076
|
+
* SECURITY NOTE (updated w1a — coordination-durable-files-no-sender-binding):
|
|
1077
|
+
* The `from` field is caller-declared within the pi-crew process; every row
|
|
1078
|
+
* this module writes is stamped with the writer process's Ed25519 signature
|
|
1079
|
+
* over the identity-bearing fields (see the sender-binding section above), and
|
|
1080
|
+
* every DELIVERY-side consumer (readMailbox / readAllMailboxMessages →
|
|
1081
|
+
* pollWorkerInbox, msg.inbox, replay) drops rows without a valid signature.
|
|
1082
|
+
* Workers and same-uid processes can still APPEND bytes to the JSONL files,
|
|
1083
|
+
* but those rows no longer deliver: they verify against no trusted key. The
|
|
1084
|
+
* private signing key never leaves the parent process heap; children hold only
|
|
1085
|
+
* the public verify key (PI_CREW_MAILBOX_VERIFY_KEY).
|
|
851
1086
|
*/
|
|
852
1087
|
export function appendMailboxMessage(manifest: TeamRunManifest, message: MailboxAppendInput): MailboxMessage {
|
|
853
1088
|
if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
|
|
@@ -866,11 +1101,7 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Mailbox
|
|
|
866
1101
|
// with appends — otherwise a concurrent append between rename and recreate can
|
|
867
1102
|
// be truncated to an empty file.
|
|
868
1103
|
withFileLockSync(mailboxFile(manifest, complete.direction, complete.taskId), () => {
|
|
869
|
-
fs.appendFileSync(
|
|
870
|
-
mailboxFile(manifest, complete.direction, complete.taskId),
|
|
871
|
-
`${JSON.stringify(redactSecrets(complete))}\n`,
|
|
872
|
-
"utf-8",
|
|
873
|
-
);
|
|
1104
|
+
fs.appendFileSync(mailboxFile(manifest, complete.direction, complete.taskId), `${serializeMailboxRow(complete)}\n`, "utf-8");
|
|
874
1105
|
// 3.3 — rotate mailbox file if it has grown past 10 MB. Cheap stat check;
|
|
875
1106
|
// rotates at most once per append.
|
|
876
1107
|
rotateMailboxFileIfNeeded(mailboxFile(manifest, complete.direction, complete.taskId));
|
|
@@ -958,7 +1189,7 @@ export async function appendMailboxMessageAsync(manifest: TeamRunManifest, messa
|
|
|
958
1189
|
const complete = buildMailboxMessage(manifest, message, createdAt);
|
|
959
1190
|
const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
|
|
960
1191
|
await withFileLockAsync(mbFile, async () => {
|
|
961
|
-
await fs.promises.appendFile(mbFile, `${
|
|
1192
|
+
await fs.promises.appendFile(mbFile, `${serializeMailboxRow(complete)}\n`, "utf-8");
|
|
962
1193
|
rotateMailboxFileIfNeeded(mbFile);
|
|
963
1194
|
});
|
|
964
1195
|
// R1 review fix / plan §5 #6: delivery RMW uses the cross-process sync
|
|
@@ -1086,7 +1317,7 @@ export function appendMailboxMessageIdempotent(
|
|
|
1086
1317
|
stored = existing;
|
|
1087
1318
|
return;
|
|
1088
1319
|
}
|
|
1089
|
-
fs.appendFileSync(mbFile, `${
|
|
1320
|
+
fs.appendFileSync(mbFile, `${serializeMailboxRow(complete)}\n`, "utf-8");
|
|
1090
1321
|
rotateMailboxFileIfNeeded(mbFile);
|
|
1091
1322
|
});
|
|
1092
1323
|
if (stored) return { message: stored, deduped: true };
|
|
@@ -1115,7 +1346,7 @@ export async function appendMailboxMessageIdempotentAsync(
|
|
|
1115
1346
|
stored = existing;
|
|
1116
1347
|
return;
|
|
1117
1348
|
}
|
|
1118
|
-
await fs.promises.appendFile(mbFile, `${
|
|
1349
|
+
await fs.promises.appendFile(mbFile, `${serializeMailboxRow(complete)}\n`, "utf-8");
|
|
1119
1350
|
rotateMailboxFileIfNeeded(mbFile);
|
|
1120
1351
|
});
|
|
1121
1352
|
if (stored) return { message: stored, deduped: true };
|
|
@@ -1327,7 +1558,9 @@ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMes
|
|
|
1327
1558
|
if (msg && msg.id === originalMessageId) {
|
|
1328
1559
|
msg.repliedAt = new Date().toISOString();
|
|
1329
1560
|
msg.replyContent = replyContent;
|
|
1330
|
-
|
|
1561
|
+
// w1a: the rewrite mutates signed fields — re-sign so the
|
|
1562
|
+
// row stays authentic under the writer process key.
|
|
1563
|
+
updatedLines.push(serializeMailboxRow(msg));
|
|
1331
1564
|
localFound = true;
|
|
1332
1565
|
} else {
|
|
1333
1566
|
updatedLines.push(line);
|
|
@@ -1361,7 +1594,9 @@ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMes
|
|
|
1361
1594
|
// Recreate the live file if it went missing — the archives in this
|
|
1362
1595
|
// directory prove the mailbox itself is valid.
|
|
1363
1596
|
if (!fs.existsSync(filePath)) atomicWriteFile(filePath, "");
|
|
1364
|
-
|
|
1597
|
+
// w1a: compensating records are NEW durable rows — sign them like any
|
|
1598
|
+
// append, or the delivery-read gate would drop them as unauthenticated.
|
|
1599
|
+
fs.appendFileSync(filePath, `${serializeMailboxRow(compensating)}\n`, "utf-8");
|
|
1365
1600
|
// The append may itself trigger rotation — safe: the compensating
|
|
1366
1601
|
// record then lands in the NEWEST archive, which sorts after the
|
|
1367
1602
|
// original's archive in the merge order, so it still wins by id.
|
|
@@ -1431,7 +1666,13 @@ export function validateMailbox(
|
|
|
1431
1666
|
const parsed = JSON.parse(line) as unknown;
|
|
1432
1667
|
const message = parseMailboxMessage(parsed, direction);
|
|
1433
1668
|
if (!message) throw new Error("invalid message schema");
|
|
1434
|
-
|
|
1669
|
+
// w1a: preserve the ORIGINAL line bytes for valid rows. The old
|
|
1670
|
+
// re-serialization here stripped Ed25519 signatures from every
|
|
1671
|
+
// valid line on a repair pass (repair only rewrites when at least
|
|
1672
|
+
// one line was invalid), silently de-authenticating the whole
|
|
1673
|
+
// file. Repair must never drop NOR forge authentication — keep
|
|
1674
|
+
// the row exactly as written.
|
|
1675
|
+
validLines.push(line);
|
|
1435
1676
|
} catch (error) {
|
|
1436
1677
|
const message = error instanceof Error ? error.message : String(error);
|
|
1437
1678
|
issues.push({ level: "error", path: filePath, message });
|