pi-crew 0.9.62 → 0.9.65

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +47 -1
  3. package/agents/critic.md +1 -1
  4. package/agents/explorer.md +1 -1
  5. package/agents/planner.md +1 -1
  6. package/agents/reviewer.md +1 -1
  7. package/agents/security-reviewer.md +1 -1
  8. package/agents/test-engineer.md +1 -1
  9. package/agents/writer.md +1 -1
  10. package/dist/index.mjs +478 -366
  11. package/package.json +7 -3
  12. package/scripts/analyze-run.mjs +1333 -0
  13. package/scripts/pty_probe.py +10 -8
  14. package/scripts/resource-sampler.mjs +482 -0
  15. package/skills/real-test-pi-crew/SKILL.md +6 -6
  16. package/src/agents/agent-config.ts +4 -0
  17. package/src/agents/agent-serializer.ts +1 -0
  18. package/src/agents/discover-agents.ts +8 -0
  19. package/src/config/role-tools.ts +49 -1
  20. package/src/observability/event-to-metric.ts +29 -0
  21. package/src/observability/metrics-primitives.ts +41 -3
  22. package/src/prompt/prompt-runtime.ts +6 -0
  23. package/src/prompt/scratchpad-lifecycle.ts +605 -0
  24. package/src/runtime/README.md +1 -1
  25. package/src/runtime/broker/crew-broker.ts +0 -16
  26. package/src/runtime/child-pi/child-pi-spawn.ts +42 -1
  27. package/src/runtime/child-pi/child-pi.ts +5 -0
  28. package/src/runtime/effectiveness.ts +23 -1
  29. package/src/runtime/merge-gate.ts +202 -0
  30. package/src/runtime/model/model-fallback.ts +11 -0
  31. package/src/runtime/model/pi-args.ts +1 -1
  32. package/src/runtime/model/provider-extensions.ts +31 -12
  33. package/src/runtime/output/progress-tracker.ts +3 -33
  34. package/src/runtime/recovery/crash-recovery.ts +1 -1
  35. package/src/runtime/scratchpad/README.md +184 -0
  36. package/src/runtime/scratchpad/engine.ts +648 -0
  37. package/src/runtime/scratchpad/guest.ts +360 -0
  38. package/src/runtime/scratchpad/index.ts +22 -0
  39. package/src/runtime/scratchpad/protocol.ts +88 -0
  40. package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
  41. package/src/runtime/scratchpad/snapshot-lookup.ts +74 -0
  42. package/src/runtime/scratchpad/transform.ts +363 -0
  43. package/src/runtime/task-runner/child-executor.ts +48 -31
  44. package/src/runtime/team-runner.ts +128 -203
  45. package/src/schema/team-tool-schema.ts +2 -0
  46. package/src/teams/discover-teams.ts +2 -0
  47. package/src/teams/team-config.ts +7 -0
  48. package/src/teams/team-serializer.ts +1 -0
  49. package/src/ui/mascot.ts +1 -14
  50. package/teams/default.team.md +1 -0
  51. package/teams/fast-fix.team.md +1 -0
  52. package/src/observability/event-bus.ts +0 -86
  53. package/src/plugins/plugin-define.ts +0 -6
  54. package/src/plugins/plugin-registry.ts +0 -32
  55. package/src/plugins/plugins/index.ts +0 -3
  56. package/src/plugins/plugins/nextjs.ts +0 -19
  57. package/src/plugins/plugins/vite.ts +0 -10
  58. package/src/plugins/plugins/vitest.ts +0 -9
  59. package/src/runtime/child-pi/child-pi-pool.ts +0 -68
  60. package/src/runtime/iteration-hooks.ts +0 -305
@@ -0,0 +1,360 @@
1
+ /**
2
+ * pi-rlm guest — Node port (spike).
3
+ *
4
+ * The persistent evaluator process for the pi-crew scratchpad spike. Owns the
5
+ * namespace and runs cells against it: each cell executes inside a `with`
6
+ * block over a proxy, so ordinary assignments become namespace entries and
7
+ * ordinary reads resolve against them. Writes are refused once the owning cell
8
+ * has been cancelled, which keeps a cancelled cell's still-running
9
+ * continuation from mutating state a later cell is using.
10
+ *
11
+ * Output is tagged with the cell that produced it; snapshot, restore and
12
+ * listing requests are served over the same protocol pipe.
13
+ *
14
+ * SPIKE deviations from pi-rlm's Bun guest:
15
+ * - NO Bun, NO Bun.$ guard, NO host bridge (rlm/tools handles). The spike
16
+ * only proves patterns 01 (namespace), 04 (transform), 05 (incremental
17
+ * bindings), 08 (snapshot) and 09 (revive) on Node.
18
+ * - bun:jsc serialize/deserialize -> node:v8 (serialize returns a Buffer in
19
+ * Node, so base64 is direct — no ArrayBuffer slicing).
20
+ * - Bun.inspect for cell results -> node:util inspect.
21
+ *
22
+ * Runs as: node --experimental-strip-types guest.ts (spawned by EngineManager)
23
+ *
24
+ * Protocol traffic leaves on fd 3 and carries a nonce, so cell output can be
25
+ * neither mistaken for nor forged into a protocol message.
26
+ */
27
+
28
+ import { AsyncLocalStorage } from "node:async_hooks";
29
+ import { writeSync } from "node:fs";
30
+ import { createInterface } from "node:readline";
31
+ import { format, inspect } from "node:util";
32
+ import { deserialize, serialize } from "node:v8";
33
+ import { decodeMessage, encodeMessage, type GuestToHostMessage, type HostToGuestMessage, NONCE_ENV, PROTOCOL_FD } from "./protocol.ts";
34
+ import { transformCell } from "./transform.ts";
35
+
36
+ // ── identity: nonce + unguessable internal names ─────────────────────────────
37
+ // The nonce is removed from the environment immediately so cell code cannot
38
+ // read it back and forge protocol traffic on fd 3.
39
+
40
+ const NONCE = process.env[NONCE_ENV] ?? "";
41
+ delete process.env[NONCE_ENV];
42
+ if (!NONCE) {
43
+ writeSync(2, "pi-rlm guest started without a protocol nonce\n");
44
+ process.exit(2);
45
+ }
46
+
47
+ const SCOPE_NAME = `__rlm_scope_${NONCE}`;
48
+ const CTX_NAME = `__rlm_ctx_${NONCE}`;
49
+ const INTERNAL_NAMES = new Set([SCOPE_NAME, CTX_NAME]);
50
+
51
+ // A pipe fd can be non-blocking: writeSync may write partially or throw EAGAIN
52
+ // when the host has not drained yet. Loop until the whole frame is out, or a
53
+ // half-written line would corrupt the protocol stream.
54
+ const backoff = new Int32Array(new SharedArrayBuffer(4));
55
+
56
+ function writeAllSync(fd: number, text: string): void {
57
+ const buffer = Buffer.from(text, "utf8");
58
+ let offset = 0;
59
+ while (offset < buffer.length) {
60
+ try {
61
+ offset += writeSync(fd, buffer, offset, buffer.length - offset);
62
+ } catch (error) {
63
+ const code = (error as NodeJS.ErrnoException).code;
64
+ if (code === "EAGAIN" || code === "EWOULDBLOCK") {
65
+ Atomics.wait(backoff, 0, 0, 1);
66
+ continue;
67
+ }
68
+ if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
69
+ try {
70
+ writeSync(2, "[guest] protocol pipe closed; exiting\n");
71
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: exit path — nothing left to report to.
72
+ } catch {}
73
+ // The host closed the protocol pipe (killed or disposed this engine).
74
+ // Nothing left to report to; exit quietly instead of crashing with an
75
+ // uncaught error the host would surface as a spurious failure.
76
+ process.exit(0);
77
+ }
78
+ throw error;
79
+ }
80
+ }
81
+ }
82
+
83
+ function send(message: GuestToHostMessage): void {
84
+ writeAllSync(PROTOCOL_FD, encodeMessage(message, NONCE));
85
+ }
86
+
87
+ // ── namespace, cell context ──────────────────────────────────────────────────
88
+
89
+ type Namespace = Record<string, unknown>;
90
+ const namespace: Namespace = Object.create(null);
91
+
92
+ /** D6 per-var restore cap (guest-side, NIT-5): a single decoded var larger than
93
+ * this is rejected → failed[] (bounds v8.deserialize amplification). Default
94
+ * 256 KiB — documented in README; pinned by P2-T5. */
95
+ const MAX_RESTORE_VAR_BYTES = 256 * 1024;
96
+
97
+ interface CellContext {
98
+ cellId: string;
99
+ /** Set when this cell is aborted; its later writes are discarded. */
100
+ aborted: boolean;
101
+ result?: { value: unknown };
102
+ setResult(value: unknown): void;
103
+ }
104
+
105
+ const cellStorage = new AsyncLocalStorage<CellContext>();
106
+ let activeCell: CellContext | undefined;
107
+
108
+ function makeCellContext(cellId: string): CellContext {
109
+ const ctx: CellContext = {
110
+ cellId,
111
+ aborted: false,
112
+ setResult(value: unknown) {
113
+ if (!ctx.aborted) ctx.result = { value };
114
+ },
115
+ };
116
+ return ctx;
117
+ }
118
+
119
+ function makeScopeProxy(ctx: CellContext): Namespace {
120
+ return new Proxy(namespace, {
121
+ has(_target, key) {
122
+ // Only the wrapper's own parameters are hidden, so user names — including
123
+ // __-prefixed ones — resolve and persist normally.
124
+ if (typeof key !== "string") return false;
125
+ return !INTERNAL_NAMES.has(key);
126
+ },
127
+ get(target, key) {
128
+ if (typeof key !== "string") return undefined;
129
+ if (key in target) return target[key];
130
+ return (globalThis as Record<string, unknown>)[key];
131
+ },
132
+ set(target, key, value) {
133
+ // Writes from an aborted cell's orphaned continuation are dropped;
134
+ // writes from cells that are merely older are not.
135
+ if (typeof key === "string" && !ctx.aborted) target[key] = value;
136
+ return true;
137
+ },
138
+ });
139
+ }
140
+
141
+ // ── user output capture ──────────────────────────────────────────────────────
142
+ // In Node, console methods route through process.stdout.write, but replacing
143
+ // them directly keeps the attribution identical to pi-rlm's. AsyncLocalStorage
144
+ // keeps attribution correct for output emitted by an orphaned continuation
145
+ // after its cell was aborted.
146
+
147
+ function emit(name: "stdout" | "stderr", text: string): void {
148
+ const owner = cellStorage.getStore() ?? activeCell;
149
+ send({ type: "stream", cellId: owner?.cellId ?? "", name, chunk: text });
150
+ }
151
+
152
+ function captureWrite(name: "stdout" | "stderr") {
153
+ return (chunk: unknown, ...rest: unknown[]): boolean => {
154
+ const text = typeof chunk === "string" ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : String(chunk);
155
+ emit(name, text);
156
+ const callback = rest.find((r) => typeof r === "function") as (() => void) | undefined;
157
+ callback?.();
158
+ return true;
159
+ };
160
+ }
161
+
162
+ process.stdout.write = captureWrite("stdout") as typeof process.stdout.write;
163
+ process.stderr.write = captureWrite("stderr") as typeof process.stderr.write;
164
+
165
+ function consoleWriter(name: "stdout" | "stderr") {
166
+ return (...args: unknown[]): void => {
167
+ emit(name, `${format(...args)}\n`);
168
+ };
169
+ }
170
+
171
+ const consoleOut = consoleWriter("stdout");
172
+ const consoleErr = consoleWriter("stderr");
173
+ console.log = consoleOut;
174
+ console.info = consoleOut;
175
+ console.debug = consoleOut;
176
+ console.dir = consoleOut;
177
+ console.warn = consoleErr;
178
+ console.error = consoleErr;
179
+ console.trace = consoleErr;
180
+
181
+ // ── bootstrap bindings ───────────────────────────────────────────────────────
182
+ // pi-rlm mounts rlm/tools/Bun handles here. The spike has no host bridge, so
183
+ // nothing is installed — but the hook is kept (and re-run after restore) for
184
+ // fidelity with the port plan: a later phase wires real bindings here.
185
+
186
+ const INTERNAL_BINDINGS = new Map<string, unknown>();
187
+
188
+ function installBootstrapBindings(): void {
189
+ // Spike: no host bridge, no Bun — nothing to mount.
190
+ }
191
+
192
+ installBootstrapBindings();
193
+
194
+ // ── cell execution ───────────────────────────────────────────────────────────
195
+
196
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: async arrow is only a constructor carrier.
197
+ const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>;
198
+
199
+ const liveCells = new Map<string, CellContext>();
200
+
201
+ async function runCell(cellId: string, code: string): Promise<void> {
202
+ const ctx = makeCellContext(cellId);
203
+ activeCell = ctx;
204
+ liveCells.set(cellId, ctx);
205
+
206
+ let done: GuestToHostMessage;
207
+ try {
208
+ const { body } = transformCell(code, { ctxName: CTX_NAME });
209
+ // Sloppy-mode wrapper so `with` is legal (AsyncFunction bodies are always
210
+ // sloppy, even inside a strict ESM strip-types module); async for
211
+ // top-level await.
212
+ const wrapper = new AsyncFunction(SCOPE_NAME, CTX_NAME, `with (${SCOPE_NAME}) { ${body}\n }`);
213
+ await cellStorage.run(ctx, () => wrapper(makeScopeProxy(ctx), ctx));
214
+ done = {
215
+ type: "done",
216
+ cellId,
217
+ status: ctx.aborted ? "aborted" : "ok",
218
+ result: !ctx.aborted && ctx.result && ctx.result.value !== undefined ? inspect(ctx.result.value) : undefined,
219
+ };
220
+ } catch (error) {
221
+ const err = error instanceof Error ? error : new Error(String(error));
222
+ done = {
223
+ type: "done",
224
+ cellId,
225
+ status: ctx.aborted ? "aborted" : "error",
226
+ error: { name: err.name, message: err.message, stack: (err.stack ?? "").split("\n") },
227
+ };
228
+ } finally {
229
+ if (activeCell === ctx) activeCell = undefined;
230
+ liveCells.delete(cellId);
231
+ }
232
+ send(done);
233
+ }
234
+
235
+ function abortCell(cellId: string): void {
236
+ const ctx = liveCells.get(cellId);
237
+ if (ctx) ctx.aborted = true;
238
+ }
239
+
240
+ // ── snapshot / restore / names ───────────────────────────────────────────────
241
+
242
+ function snapshotNamespace(): { vars: Record<string, string>; failed: { name: string; reason: string }[] } {
243
+ const vars: Record<string, string> = {};
244
+ const failed: { name: string; reason: string }[] = [];
245
+ for (const [name, value] of Object.entries(namespace)) {
246
+ if (INTERNAL_BINDINGS.get(name) === value) continue;
247
+ try {
248
+ // node:v8 serialize returns a Buffer — base64 direct, no slicing.
249
+ // Functions/classes throw ("could not be cloned"); they land in
250
+ // `failed` instead of crashing the snapshot.
251
+ vars[name] = serialize(value).toString("base64");
252
+ } catch (error) {
253
+ failed.push({ name, reason: error instanceof Error ? error.message : String(error) });
254
+ }
255
+ }
256
+ return { vars, failed };
257
+ }
258
+
259
+ function restoreNamespace(vars: Record<string, string>): {
260
+ restored: string[];
261
+ failed: { name: string; reason: string }[];
262
+ } {
263
+ const restored: string[] = [];
264
+ const failed: { name: string; reason: string }[] = [];
265
+ for (const [name, encoded] of Object.entries(vars)) {
266
+ // D4 (MAJOR-S2): a redacted secret arrives as the literal "***" (structural
267
+ // redaction in writeArtifact). base64 of a real value is never "***", so this
268
+ // special-case is collision-free — restore it as the placeholder so the model
269
+ // re-fetches, instead of failing (Buffer.from("***") → empty → deserialize
270
+ // throws → wrongly lands in failed).
271
+ if (encoded === "***") {
272
+ namespace[name] = "***";
273
+ restored.push(name);
274
+ continue;
275
+ }
276
+ try {
277
+ const buffer = Buffer.from(encoded, "base64");
278
+ // D6 per-var cap (256 KiB) — bounds v8 amplification per decoded var.
279
+ if (buffer.length > MAX_RESTORE_VAR_BYTES) {
280
+ failed.push({ name, reason: `size:${buffer.length}>${MAX_RESTORE_VAR_BYTES}` });
281
+ continue;
282
+ }
283
+ // D13 (MINOR-S4): flat redaction can inject "***" INTO a valid base64 payload
284
+ // (bytes matching sk-/eyJ patterns) → decode yields a DIFFERENT buffer →
285
+ // silent corruption. Round-trip check: a clean base64 re-encodes identically.
286
+ if (buffer.toString("base64") !== encoded) {
287
+ failed.push({ name, reason: "base64-corrupt" });
288
+ continue;
289
+ }
290
+ namespace[name] = deserialize(buffer);
291
+ restored.push(name);
292
+ } catch (error) {
293
+ failed.push({ name, reason: error instanceof Error ? error.message : String(error) });
294
+ }
295
+ }
296
+ // Bootstrap runs after restore: live handles would overwrite anything
297
+ // revived. The spike installs nothing, but the hook is kept for fidelity.
298
+ installBootstrapBindings();
299
+ return { restored, failed };
300
+ }
301
+
302
+ function listNames(): string[] {
303
+ return Object.keys(namespace).filter((name) => INTERNAL_BINDINGS.get(name) !== namespace[name]);
304
+ }
305
+
306
+ // ── resilience ───────────────────────────────────────────────────────────────
307
+ // A throw from a detached task (setTimeout, a floating promise) would otherwise
308
+ // kill the process and take the whole namespace with it. Report it as stderr on
309
+ // the owning cell and keep the evaluator alive.
310
+
311
+ function reportStrayError(kind: string, error: unknown): void {
312
+ const err = error instanceof Error ? error : new Error(String(error));
313
+ emit("stderr", `[${kind}] ${err.name}: ${err.message}\n`);
314
+ }
315
+
316
+ process.on("uncaughtException", (error) => reportStrayError("uncaught exception", error));
317
+ process.on("unhandledRejection", (reason) => reportStrayError("unhandled rejection", reason));
318
+
319
+ // ── message loop ─────────────────────────────────────────────────────────────
320
+
321
+ const readline = createInterface({ input: process.stdin });
322
+
323
+ readline.on("line", (line) => {
324
+ const message = decodeMessage<HostToGuestMessage>(line, NONCE);
325
+ if (!message) return;
326
+ switch (message.type) {
327
+ case "run":
328
+ void runCell(message.cellId, message.code);
329
+ break;
330
+ case "abort":
331
+ abortCell(message.cellId);
332
+ break;
333
+ case "ping":
334
+ send({ type: "pong", id: message.id });
335
+ break;
336
+ case "snapshot": {
337
+ const { vars, failed } = snapshotNamespace();
338
+ send({ type: "snapshot_result", id: message.id, vars, failed });
339
+ break;
340
+ }
341
+ case "restore": {
342
+ const { restored, failed } = restoreNamespace(message.vars);
343
+ send({ type: "restore_result", id: message.id, restored, failed });
344
+ break;
345
+ }
346
+ case "list_names":
347
+ send({ type: "names_result", id: message.id, names: listNames() });
348
+ break;
349
+ }
350
+ });
351
+
352
+ readline.on("close", () => {
353
+ try {
354
+ writeSync(2, "[guest] stdin closed; exiting\n");
355
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: best-effort write on teardown.
356
+ } catch {}
357
+ process.exit(0);
358
+ });
359
+
360
+ send({ type: "ready" });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Scratchpad spike — pi-rlm engine ported to Node.
3
+ *
4
+ * Barrel re-exporting the host engine and the shared transform/protocol
5
+ * surface. The spike proves patterns 01 (namespace), 04 (transform), 05
6
+ * (incremental bindings), 08 (snapshot) and 09 (revive) run on Node without
7
+ * Bun. See README.md in this directory for how to run and what was proven.
8
+ */
9
+
10
+ export type {
11
+ EngineExecuteError,
12
+ EngineOptions,
13
+ ExecuteOptions,
14
+ ExecuteResult,
15
+ RestoreResult,
16
+ SnapshotResult,
17
+ } from "./engine.ts";
18
+ export { EngineManager } from "./engine.ts";
19
+ export type { GuestToHostMessage, HostToGuestMessage } from "./protocol.ts";
20
+ export { decodeMessage, ENVELOPE_KEY, encodeMessage, NONCE_ENV, PROTOCOL_FD } from "./protocol.ts";
21
+ export type { TransformedCell, TransformOptions } from "./transform.ts";
22
+ export { transformCell } from "./transform.ts";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Wire protocol between EngineManager (host) and the guest process.
3
+ *
4
+ * Transport: line-delimited JSON on a dedicated pipe (fd 3), never on stdout.
5
+ *
6
+ * Two properties make the channel trustworthy, and both are load-bearing:
7
+ *
8
+ * 1. Separation. The guest's stdout and stderr carry only user output, so a
9
+ * cell that prints JSON cannot be parsed as protocol traffic. Sharing one
10
+ * channel would let ordinary output alter the engine's view of a cell.
11
+ * 2. Authentication. Every envelope carries a nonce the host mints at spawn
12
+ * and the guest erases from its environment before running any cell. Code
13
+ * inside a cell cannot recover it, so even a deliberate write to fd 3
14
+ * cannot forge a message.
15
+ *
16
+ * Together they make a cell unable to report its own outcome — it can only run.
17
+ */
18
+
19
+ export interface HostToGuest {
20
+ run: { type: "run"; cellId: string; code: string };
21
+ abort: { type: "abort"; cellId: string };
22
+ ping: { type: "ping"; id: string };
23
+ host_reply: {
24
+ type: "host_reply";
25
+ id: string;
26
+ status: "ok" | "error";
27
+ payload?: Record<string, unknown>;
28
+ error?: string;
29
+ };
30
+ snapshot: { type: "snapshot"; id: string };
31
+ restore: { type: "restore"; id: string; vars: Record<string, string> };
32
+ list_names: { type: "list_names"; id: string };
33
+ }
34
+
35
+ export type HostToGuestMessage = HostToGuest[keyof HostToGuest];
36
+
37
+ export interface GuestToHost {
38
+ ready: { type: "ready" };
39
+ stream: { type: "stream"; cellId: string; name: "stdout" | "stderr"; chunk: string };
40
+ done: {
41
+ type: "done";
42
+ cellId: string;
43
+ status: "ok" | "error" | "aborted";
44
+ result?: string;
45
+ error?: { name: string; message: string; stack: string[] };
46
+ };
47
+ pong: { type: "pong"; id: string };
48
+ host_request: { type: "host_request"; id: string; requestType: string; payload: Record<string, unknown> };
49
+ snapshot_result: {
50
+ type: "snapshot_result";
51
+ id: string;
52
+ vars: Record<string, string>;
53
+ failed: { name: string; reason: string }[];
54
+ };
55
+ restore_result: {
56
+ type: "restore_result";
57
+ id: string;
58
+ restored: string[];
59
+ failed: { name: string; reason: string }[];
60
+ };
61
+ names_result: { type: "names_result"; id: string; names: string[] };
62
+ }
63
+
64
+ export type GuestToHostMessage = GuestToHost[keyof GuestToHost];
65
+
66
+ export const ENVELOPE_KEY = "__rlm";
67
+ /** Env var carrying the per-process nonce to the guest. */
68
+ export const NONCE_ENV = "PI_RLM_NONCE";
69
+ /** Protocol pipe: guest → host. */
70
+ export const PROTOCOL_FD = 3;
71
+
72
+ export function encodeMessage(message: HostToGuestMessage | GuestToHostMessage, nonce?: string): string {
73
+ const envelope: Record<string, unknown> = { [ENVELOPE_KEY]: 1, ...message };
74
+ if (nonce) envelope.n = nonce;
75
+ return `${JSON.stringify(envelope)}\n`;
76
+ }
77
+
78
+ export function decodeMessage<T>(line: string, nonce?: string): T | null {
79
+ if (!line.includes(`"${ENVELOPE_KEY}":1`)) return null;
80
+ try {
81
+ const parsed = JSON.parse(line);
82
+ if (parsed?.[ENVELOPE_KEY] !== 1 || typeof parsed.type !== "string") return null;
83
+ if (nonce && parsed.n !== nonce) return null;
84
+ return parsed as T;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Snapshot HMAC helper — opt-in integrity for scratchpad snapshots.
3
+ *
4
+ * Closes the E.2 gap declared in docs/improvement-plan-2026-08-09.md and
5
+ * docs/runtime/scratchpad/README.md:120 ("v8.deserialize of restore content
6
+ * is unauthenticated (no HMAC)"). The threat model — a same-uid attacker
7
+ * plants a crafted V8 blob at the snapshot path to run deserialize gadgets
8
+ * in the guest — does not cross the existing same-uid boundary, but HMAC
9
+ * hardening is required before snapshots ever land in a shared/networked
10
+ * store.
11
+ *
12
+ * Migration window (see docs/decisions/2026-08-10-scratchpad-snapshot-hmac.md):
13
+ *
14
+ * Phase 1 (this module): HMAC sign-on-write + verify-on-read are OPT-IN
15
+ * via PI_CREW_SNAPSHOT_HMAC_KEY. When the key is unset, behaviour is
16
+ * unchanged (snapshots remain unsigned). When the key is set, writes
17
+ * attach a signature and reads verify it; an unsigned/failed snapshot
18
+ * is ACCEPTED with a warning so existing snapshots remain readable.
19
+ * Phase 2 (after one release): unsigned snapshots are REJECTED when the
20
+ * key is set (configurable via PI_CREW_SNAPSHOT_HMAC_STRICT=1).
21
+ * Phase 3 (after snapshots move to a shared store): the key becomes
22
+ * required and unsigned snapshots are always rejected.
23
+ *
24
+ * Wire-up into the actual write/read paths is intentionally deferred to a
25
+ * follow-up that audits the snapshot envelope format (V8 base64 vs raw
26
+ * bytes) and the writeArtifact redaction interaction. See ADR for the plan.
27
+ */
28
+ import { createHmac, timingSafeEqual } from "node:crypto";
29
+
30
+ /**
31
+ * Env var holding the HMAC secret. When unset, HMAC is disabled (Phase 0
32
+ * behaviour — snapshots unsigned). When set, sign-on-write and
33
+ * verify-on-read are enabled.
34
+ */
35
+ export const SNAPSHOT_HMAC_KEY_ENV = "PI_CREW_SNAPSHOT_HMAC_KEY";
36
+
37
+ /**
38
+ * Opt-in strict mode (Phase 2): when the key is set AND this is "1",
39
+ * unsigned or signature-mismatched snapshots are REJECTED on read instead
40
+ * of accepted with a warning.
41
+ */
42
+ export const SNAPSHOT_HMAC_STRICT_ENV = "PI_CREW_SNAPSHOT_HMAC_STRICT";
43
+
44
+ /**
45
+ * Header prefix for an inline signature. When snapshots are signed, the
46
+ * signature is prepended to the blob as `PI_CREW_SIG=<hex>\n` so a single
47
+ * read yields both the signature and the payload. (Sidecar files would
48
+ * require writeArtifact coordination that the envelope format does not
49
+ * currently support.)
50
+ */
51
+ export const SNAPSHOT_SIG_PREFIX = "PI_CREW_SIG=";
52
+
53
+ export function getSnapshotHmacKey(env: NodeJS.ProcessEnv = process.env): Buffer | undefined {
54
+ const raw = env[SNAPSHOT_HMAC_KEY_ENV];
55
+ if (!raw) return undefined;
56
+ // Accept hex-encoded keys directly; otherwise encode the string as utf8.
57
+ // A key shorter than 32 bytes is rejected to prevent trivial brute-force.
58
+ const buf = /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0 ? Buffer.from(raw, "hex") : Buffer.from(raw, "utf8");
59
+ if (buf.length < 32) {
60
+ throw new Error(
61
+ `${SNAPSHOT_HMAC_KEY_ENV} must be at least 32 bytes (got ${buf.length}); use a longer key or generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`,
62
+ );
63
+ }
64
+ return buf;
65
+ }
66
+
67
+ export function isSnapshotHmacStrict(env: NodeJS.ProcessEnv = process.env): boolean {
68
+ return env[SNAPSHOT_HMAC_STRICT_ENV] === "1" || env[SNAPSHOT_HMAC_STRICT_ENV] === "true";
69
+ }
70
+
71
+ /**
72
+ * Compute the HMAC-SHA256 signature of `content` under `key`. Returns a
73
+ * lowercase hex string.
74
+ */
75
+ export function signSnapshot(content: Buffer | string, key: Buffer): string {
76
+ return createHmac("sha256", key).update(content).digest("hex");
77
+ }
78
+
79
+ /**
80
+ * Constant-time signature comparison. Both signatures must be lowercase
81
+ * hex of the same length; anything else returns false without throwing.
82
+ */
83
+ export function snapshotSignatureMatches(content: Buffer | string, signature: string, key: Buffer): boolean {
84
+ const expected = signSnapshot(content, key);
85
+ const a = Buffer.from(expected);
86
+ const b = Buffer.from(signature);
87
+ if (a.length !== b.length) return false;
88
+ return timingSafeEqual(a, b);
89
+ }
90
+
91
+ /**
92
+ * Outcome of {@link verifySnapshotPayload}. Callers decide how to react
93
+ * based on the strict-mode flag.
94
+ */
95
+ export type SnapshotVerifyOutcome =
96
+ | { kind: "unsigned"; strict: boolean }
97
+ | { kind: "verified" }
98
+ | { kind: "mismatch"; strict: boolean }
99
+ | { kind: "hmac-disabled" };
100
+
101
+ /**
102
+ * Verify a snapshot payload that may or may not carry an inline signature.
103
+ *
104
+ * @param payload The raw bytes read from disk (possibly including the
105
+ * `PI_CREW_SIG=<hex>\n` prefix).
106
+ * @param key The HMAC key, or `undefined` when HMAC is disabled.
107
+ * @param strict When true, unsigned/mismatched payloads are reported as
108
+ * rejectable. When false (the Phase 1 default), the caller is expected
109
+ * to accept the payload with a warning.
110
+ */
111
+ export function verifySnapshotPayload(payload: Buffer, key: Buffer | undefined, strict: boolean): SnapshotVerifyOutcome {
112
+ if (!key) return { kind: "hmac-disabled" };
113
+ const prefixStr = SNAPSHOT_SIG_PREFIX;
114
+ if (payload.length < prefixStr.length + 1 || payload.subarray(0, prefixStr.length).toString("utf8") !== prefixStr) {
115
+ return { kind: "unsigned", strict };
116
+ }
117
+ const newlineIdx = payload.indexOf(0x0a, prefixStr.length);
118
+ if (newlineIdx < 0) return { kind: "unsigned", strict };
119
+ const sigHex = payload.subarray(prefixStr.length, newlineIdx).toString("utf8");
120
+ const body = payload.subarray(newlineIdx + 1);
121
+ return snapshotSignatureMatches(body, sigHex, key) ? { kind: "verified" } : { kind: "mismatch", strict };
122
+ }
123
+
124
+ /**
125
+ * Strip the inline signature prefix and return the bare payload. Returns
126
+ * the original buffer when no prefix is present. Used by read paths that
127
+ * have already called {@link verifySnapshotPayload} and decided to accept.
128
+ */
129
+ export function stripSnapshotSignature(payload: Buffer): Buffer {
130
+ if (payload.length < SNAPSHOT_SIG_PREFIX.length) return payload;
131
+ if (payload.subarray(0, SNAPSHOT_SIG_PREFIX.length).toString("utf8") !== SNAPSHOT_SIG_PREFIX) return payload;
132
+ const newlineIdx = payload.indexOf(0x0a, SNAPSHOT_SIG_PREFIX.length);
133
+ if (newlineIdx < 0) return payload;
134
+ return payload.subarray(newlineIdx + 1);
135
+ }
136
+
137
+ /**
138
+ * Attach an inline signature to a payload. Returns a new buffer shaped as
139
+ * `PI_CREW_SIG=<hex>\n<payload>`. Used by write paths that have HMAC
140
+ * enabled.
141
+ */
142
+ export function attachSnapshotSignature(payload: Buffer, key: Buffer): Buffer {
143
+ const sig = signSnapshot(payload, key);
144
+ return Buffer.concat([Buffer.from(`${SNAPSHOT_SIG_PREFIX}${sig}\n`, "utf8"), payload]);
145
+ }
146
+
147
+ /**
148
+ * Should the caller reject the snapshot based on the verify outcome?
149
+ * Centralises the strict-vs-migration decision so callers stay simple.
150
+ */
151
+ export function shouldRejectSnapshot(outcome: SnapshotVerifyOutcome): boolean {
152
+ switch (outcome.kind) {
153
+ case "hmac-disabled":
154
+ return false;
155
+ case "verified":
156
+ return false;
157
+ case "unsigned":
158
+ case "mismatch":
159
+ return outcome.strict;
160
+ }
161
+ }