@xaccefy/pi-casefile 0.8.2 → 0.9.0

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/src/poc-runner.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import {
3
4
  copyFileSync,
4
5
  existsSync,
6
+ mkdirSync,
5
7
  mkdtempSync,
6
8
  readFileSync,
7
9
  realpathSync,
@@ -11,6 +13,7 @@ import {
11
13
  import { tmpdir } from "node:os";
12
14
  import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
13
15
 
16
+ import { evidenceNonceMatches, type PoCEvidence, parsePoCEvidence } from "./evidence.ts";
14
17
  import { findWorkspaceRoot } from "./scratchpad.ts";
15
18
 
16
19
  export type PocRun = {
@@ -34,12 +37,28 @@ export type PocRun = {
34
37
  rawOutput?: string;
35
38
  /** True when `output` was truncated for display (rawOutput has more). */
36
39
  truncated?: boolean;
37
- /**
38
- * True when the run never started because of harness infrastructure
40
+ /** True iff child output capture was complete. False on maxBuffer/timeouts/spawn failures. */
41
+ outputComplete?: boolean;
42
+ /** Harness mode used for this run. */
43
+ mode?: string;
44
+ /** Harness target used for this run. */
45
+ target?: string;
46
+ /** True when the run never started because of harness infrastructure
39
47
  * failure (e.g. sandbox image pull failed) — set only by the runner,
40
- * never derived from PoC-controlled output text.
41
- */
48
+ * never derived from PoC-controlled output text. */
42
49
  infraError?: boolean;
50
+ /** Validated evidence.json written by the PoC to $PI_POC_EVIDENCE_DIR. */
51
+ evidence?: PoCEvidence;
52
+ /** SHA-256 of the evidence.json file (reproduction artifact). */
53
+ evidenceSha256?: string;
54
+ /** Absolute path of the PRESERVED copy of evidence.json (moved into the
55
+ * durable .pi/poc-evidence/ dir before the temp workspace is cleaned up) —
56
+ * lets the ledger's reproduction item stay artifact-backed and re-verifiable. */
57
+ evidencePath?: string;
58
+ /** Evidence contract failure (missing/invalid/nonce mismatch) — blocks the gate. */
59
+ evidenceError?: string;
60
+ /** The per-run nonce the evidence must be bound to (harness-generated). */
61
+ nonce?: string;
43
62
  };
44
63
 
45
64
  /**
@@ -106,13 +125,15 @@ const EXTENSION_MAP: Record<string, string> = {
106
125
  };
107
126
 
108
127
  const OUTPUT_MAX_CHARS = 4000;
109
- /** Sanitized output is kept whole (for marker checks) up to this size. */
110
- const RAW_OUTPUT_MAX_CHARS = 4 * 1024 * 1024;
111
128
  const TIMEOUT_MS = 30_000;
112
129
  /** Completion sentinel echoed after the PoC command inside the sandbox shell. */
113
130
  function makeSentinel(): string {
114
131
  return `__PI_POC_DONE_${Math.random().toString(36).slice(2, 12)}__`;
115
132
  }
133
+ /** Per-run random nonce the PoC must echo in evidence.json (binds evidence to its run). */
134
+ function makeNonce(): string {
135
+ return `poc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
136
+ }
116
137
  /** First-use image downloads are slow — pull outside the run timeout. */
117
138
  const PULL_TIMEOUT_MS = 300_000;
118
139
  const MAX_BUFFER = 8 * 1024 * 1024;
@@ -278,9 +299,73 @@ function sanitizeOutput(output: string): string {
278
299
 
279
300
  /** Split sanitized output into the raw (whole) and display (sliced) halves. */
280
301
  function splitOutput(raw: string): { rawOutput: string; output: string; truncated: boolean } {
281
- const rawOutput = raw.slice(0, RAW_OUTPUT_MAX_CHARS);
282
302
  const truncated = raw.length > OUTPUT_MAX_CHARS;
283
- return { rawOutput, output: raw.slice(0, OUTPUT_MAX_CHARS), truncated };
303
+ return { rawOutput: raw, output: raw.slice(0, OUTPUT_MAX_CHARS), truncated };
304
+ }
305
+
306
+ /**
307
+ * Read + validate the PoC's evidence.json from the harness-owned evidence
308
+ * dir. Missing, malformed, or nonce-mismatched evidence is a contract
309
+ * failure, not a verdict — surfaced as evidenceError on the run.
310
+ */
311
+ function readEvidence(
312
+ evidenceDir: string,
313
+ nonce: string,
314
+ ): { evidence?: PoCEvidence; evidenceSha256?: string; evidenceError?: string } {
315
+ const p = join(evidenceDir, "evidence.json");
316
+ if (!existsSync(p)) {
317
+ return {
318
+ evidenceError: `evidence.json missing in ${evidenceDir} — the PoC must write it to $PI_POC_EVIDENCE_DIR ({"nonce", "claim", "verify", "observations"})`,
319
+ };
320
+ }
321
+ let raw: unknown;
322
+ try {
323
+ raw = JSON.parse(readFileSync(p, "utf8"));
324
+ } catch (e) {
325
+ return { evidenceError: `evidence.json unparseable: ${(e as Error).message}` };
326
+ }
327
+ const parsed = parsePoCEvidence(raw);
328
+ if (!parsed.ok) return { evidenceError: parsed.error };
329
+ if (!evidenceNonceMatches(parsed.evidence, nonce)) {
330
+ return {
331
+ evidenceError:
332
+ "evidence.json nonce mismatch — the file was not written by this run (copy-pasted evidence fails here)",
333
+ };
334
+ }
335
+ return {
336
+ evidence: parsed.evidence,
337
+ evidenceSha256: createHash("sha256").update(readFileSync(p)).digest("hex"),
338
+ };
339
+ }
340
+
341
+ function runProvenance(env?: Record<string, string>): Pick<PocRun, "mode" | "target"> {
342
+ return { mode: env?.PI_POC_MODE, target: env?.PI_POC_TARGET };
343
+ }
344
+
345
+ /**
346
+ * Copy the run's evidence.json into a durable harness-owned dir BEFORE the
347
+ * temp workspace is deleted. The ledger's reproduction item stores the SHA-256
348
+ * of this exact file, so the file must survive the run — otherwise the
349
+ * "artifact-backed" evidence item hashes a file that no longer exists.
350
+ * Returns the preserved path, or undefined when the file is missing.
351
+ */
352
+ function preserveEvidence(evidenceDir: string, nonce: string): string | undefined {
353
+ const source = join(evidenceDir, "evidence.json");
354
+ if (!existsSync(source)) return undefined;
355
+ try {
356
+ const durableDir = join(getProjectRoot(), ".pi", "poc-evidence");
357
+ mkdirSync(durableDir, { recursive: true });
358
+ const dest = join(durableDir, `${nonce}.evidence.json`);
359
+ copyFileSync(source, dest);
360
+ return dest;
361
+ } catch {
362
+ // Best-effort: a preserved copy is an audit-trail improvement, not a gate.
363
+ return undefined;
364
+ }
365
+ }
366
+
367
+ function outputWasComplete(result: { error?: Error; signal: string | null }): boolean {
368
+ return !result.error && result.signal === null;
284
369
  }
285
370
 
286
371
  /** Reject control characters in harness-supplied PoC env values. */
@@ -412,6 +497,17 @@ function runSandboxed(
412
497
  // Named container so a timed-out / killed client can still be cleaned up —
413
498
  // `--rm` alone leaks the container when the CLI dies before the child exits.
414
499
  const containerName = `poc-runner-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
500
+ // Evidence contract: harness-owned dir + per-run nonce. The PoC writes
501
+ // evidence.json into $PI_POC_EVIDENCE_DIR (/workspace/evidence inside the
502
+ // container); the nonce binds the file to this run.
503
+ const nonce = makeNonce();
504
+ const evidenceDir = join(workspaceDir, "evidence");
505
+ mkdirSync(evidenceDir, { recursive: true });
506
+ const runEnv = {
507
+ ...env,
508
+ PI_POC_EVIDENCE_DIR: "/workspace/evidence",
509
+ PI_POC_NONCE: nonce,
510
+ };
415
511
 
416
512
  try {
417
513
  // Fail closed as a non-zero run (not a throw) when docker/images are
@@ -427,6 +523,8 @@ function runSandboxed(
427
523
  sandbox: true,
428
524
  completed: false,
429
525
  infraError: true,
526
+ outputComplete: false,
527
+ ...runProvenance(env),
430
528
  };
431
529
  }
432
530
  copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
@@ -434,15 +532,17 @@ function runSandboxed(
434
532
  const command = renderCommand(language.run, pocPath, true);
435
533
 
436
534
  // Completion sentinel: wrap the command so the shell echoes a unique token
437
- // AFTER the PoC exits, preserving its exit code. If the container is
438
- // killed, times out, or the run never starts, the sentinel is absent
439
- // callers can then tell "script ran and failed" from "script crashed".
535
+ // ONLY when the PoC exited normally (rc < 128). A child killed by a signal
536
+ // (segfault, OOM under the sandbox limits) yields rc = 128+N the sentinel
537
+ // is suppressed, so `completed` means "the script actually ran to
538
+ // completion", not merely "the container exited". Callers treat a missing
539
+ // sentinel as "not a verdict".
440
540
  const sentinel = makeSentinel();
441
- const wrapped = `${command}; rc=$?; echo '${sentinel}'; exit $rc`;
541
+ const wrapped = `${command}; rc=$?; if [ "$rc" -lt 128 ]; then echo '${sentinel}'; fi; exit $rc`;
442
542
 
443
543
  const result = spawnSync(
444
544
  "docker",
445
- buildDockerArgs(language.image, wrapped, workspaceDir, containerName, network, env),
545
+ buildDockerArgs(language.image, wrapped, workspaceDir, containerName, network, runEnv),
446
546
  {
447
547
  encoding: "utf8",
448
548
  timeout: TIMEOUT_MS,
@@ -454,6 +554,8 @@ function runSandboxed(
454
554
  const raw = (result.stdout ?? "") + (result.stderr ?? "") + spawnErr;
455
555
  const completed = raw.includes(sentinel);
456
556
  const { rawOutput, output, truncated } = splitOutput(sanitizeOutput(raw.replace(sentinel, "")));
557
+ const preserved = preserveEvidence(evidenceDir, nonce);
558
+ const evidence = readEvidence(evidenceDir, nonce);
457
559
  return {
458
560
  path: pocPath,
459
561
  exitCode: spawnExitCode(result),
@@ -463,6 +565,11 @@ function runSandboxed(
463
565
  ranAt,
464
566
  sandbox: true,
465
567
  completed,
568
+ outputComplete: outputWasComplete(result),
569
+ nonce,
570
+ ...evidence,
571
+ evidencePath: evidence.evidence ? preserved : undefined,
572
+ ...runProvenance(env),
466
573
  };
467
574
  } finally {
468
575
  // Best-effort: remove any container still running after a timeout/kill.
@@ -481,47 +588,70 @@ function runSandboxed(
481
588
 
482
589
  function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, string>): PocRun {
483
590
  const ranAt = new Date().toISOString();
484
-
485
- // The run template is `<interpreter> [flags...] {{file}}`. Split the static
486
- // template on whitespace FIRST (builtins only, not user input), then render
487
- // placeholders within each token. Passing the tokens to spawnSync with NO
488
- // shell keeps a space-containing PoC path as one arg and keeps extra flags
489
- // (e.g. `node --experimental-vm-modules {{file}}`) as separate args.
490
- // Splitting after rendering would re-split a space-containing path.
491
- const tokens = language.run.trim().split(/\s+/).filter(Boolean);
492
- const interpreter = tokens.shift() ?? language.run.trim();
493
- const args = tokens.map((tok) => renderCommand(tok, pocPath, false));
494
-
495
- const result = spawnSync(interpreter, args, {
496
- encoding: "utf8",
497
- timeout: TIMEOUT_MS,
498
- maxBuffer: MAX_BUFFER,
499
- // Host runs get the harness env contract merged over the operator env;
500
- // the spawn env is explicitly provided so PI_POC_MODE / PI_POC_TARGET
501
- // reach the script without leaking through a shell. Same control-char
502
- // rejection as the sandboxed path.
503
- env: env ? { ...process.env, ...sanitizePocEnv(env) } : undefined,
504
- });
505
-
506
- // Local runs stay shell-free (space-containing paths stay single args), so
507
- // there is no sentinel echo: "completed" is derived from the spawn result.
508
- // A spawn error (interpreter missing) or a signal kill (timeout, SIGKILL)
509
- // means the script never ran to completion — fail closed on those.
510
- const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
511
- const completed = !result.error && result.signal === null;
512
- const { rawOutput, output, truncated } = splitOutput(
513
- sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
514
- );
515
- return {
516
- path: pocPath,
517
- exitCode: spawnExitCode(result),
518
- output,
519
- rawOutput,
520
- truncated,
521
- ranAt,
522
- sandbox: false,
523
- completed,
591
+ // Evidence contract: harness-owned temp dir + per-run nonce, same as sandboxed.
592
+ const nonce = makeNonce();
593
+ const evidenceDir = mkdtempSync(join(tmpdir(), "poc-evidence-"));
594
+ const runEnv = {
595
+ ...env,
596
+ PI_POC_EVIDENCE_DIR: evidenceDir,
597
+ PI_POC_NONCE: nonce,
524
598
  };
599
+
600
+ try {
601
+ // The run template is `<interpreter> [flags...] {{file}}`. Split the static
602
+ // template on whitespace FIRST (builtins only, not user input), then render
603
+ // placeholders within each token. Passing the tokens to spawnSync with NO
604
+ // shell keeps a space-containing PoC path as one arg and keeps extra flags
605
+ // (e.g. `node --experimental-vm-modules {{file}}`) as separate args.
606
+ // Splitting after rendering would re-split a space-containing path.
607
+ const tokens = language.run.trim().split(/\s+/).filter(Boolean);
608
+ const interpreter = tokens.shift() ?? language.run.trim();
609
+ const args = tokens.map((tok) => renderCommand(tok, pocPath, false));
610
+
611
+ const result = spawnSync(interpreter, args, {
612
+ encoding: "utf8",
613
+ timeout: TIMEOUT_MS,
614
+ maxBuffer: MAX_BUFFER,
615
+ // Host runs get the harness env contract merged over the operator env;
616
+ // the spawn env is explicitly provided so PI_POC_MODE / PI_POC_TARGET
617
+ // reach the script without leaking through a shell. Same control-char
618
+ // rejection as the sandboxed path.
619
+ env: { ...process.env, ...sanitizePocEnv(runEnv) },
620
+ });
621
+
622
+ // Local runs stay shell-free (space-containing paths stay single args), so
623
+ // there is no sentinel echo: "completed" is derived from the spawn result.
624
+ // A spawn error (interpreter missing) or a signal kill (timeout, SIGKILL)
625
+ // means the script never ran to completion — fail closed on those.
626
+ const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
627
+ const completed = !result.error && result.signal === null;
628
+ const { rawOutput, output, truncated } = splitOutput(
629
+ sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
630
+ );
631
+ const preserved = preserveEvidence(evidenceDir, nonce);
632
+ const evidence = readEvidence(evidenceDir, nonce);
633
+ return {
634
+ path: pocPath,
635
+ exitCode: spawnExitCode(result),
636
+ output,
637
+ rawOutput,
638
+ truncated,
639
+ ranAt,
640
+ sandbox: false,
641
+ completed,
642
+ outputComplete: outputWasComplete(result),
643
+ nonce,
644
+ ...evidence,
645
+ evidencePath: evidence.evidence ? preserved : undefined,
646
+ ...runProvenance(env),
647
+ };
648
+ } finally {
649
+ try {
650
+ rmSync(evidenceDir, { recursive: true, force: true });
651
+ } catch {
652
+ // Best-effort cleanup.
653
+ }
654
+ }
525
655
  }
526
656
 
527
657
  /**
@@ -530,8 +660,7 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
530
660
  * Language detection (in order):
531
661
  * 1. Shebang line in the PoC file.
532
662
  * 2. File extension (a .py PoC in a Node repo still runs under python).
533
- * 3. Project type markers in the workspace root (e.g., package.json, requirements.txt).
534
- * 4. PI_POC_DEFAULT_LANGUAGE environment variable (a built-in language key).
663
+ * 3. PI_POC_DEFAULT_LANGUAGE environment variable (a built-in language key).
535
664
  *
536
665
  * Security:
537
666
  * - PoC paths must be absolute and under the project workspace by default.
@@ -544,20 +673,12 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
544
673
  * `PI_POC_ALLOW_LOCAL=1` and is used only when Docker is unavailable
545
674
  * (or when `PI_POC_FORCE_LOCAL=1` is also set). Without ALLOW the run
546
675
  * fails closed if Docker cannot start.
547
- *
548
- * Back-compat: `runPoc(path, true)` == sandboxed, `runPoc(path, false)` ==
549
- * `{ local: true }` (host-network sandbox; host if operator-gated).
550
676
  */
551
- export function runPoc(pocPath: string, options?: PocRunOptions | boolean): PocRun {
677
+ export function runPoc(pocPath: string, options?: PocRunOptions): PocRun {
552
678
  const normalized = validatePocPath(pocPath);
553
679
  const { language } = resolveLanguage(normalized);
554
680
 
555
- const opts: PocRunOptions =
556
- typeof options === "boolean"
557
- ? options
558
- ? { network: "none" }
559
- : { local: true }
560
- : (options ?? {});
681
+ const opts: PocRunOptions = options ?? {};
561
682
 
562
683
  // Host execution is gated by the OPERATOR, never by an agent-supplied flag.
563
684
  // `local: true` means "network access needed":
package/src/scratchpad.ts CHANGED
@@ -24,7 +24,16 @@
24
24
  * `--fresh` clears it via scratchpad_clear().
25
25
  */
26
26
 
27
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
+ import { createHash } from "node:crypto";
28
+ import {
29
+ existsSync,
30
+ mkdirSync,
31
+ readdirSync,
32
+ readFileSync,
33
+ renameSync,
34
+ rmSync,
35
+ writeFileSync,
36
+ } from "node:fs";
28
37
  import { dirname, join, resolve } from "node:path";
29
38
 
30
39
  // ── Types ────────────────────────────────────────────────────────────
@@ -118,7 +127,7 @@ export function findWorkspaceRoot(envNames: string[], markers: string[]): string
118
127
  }
119
128
 
120
129
  /** Detect the scratchpad workspace root (override, env, then walk up). */
121
- function detectWorkspaceRoot(): string {
130
+ export function detectWorkspaceRoot(): string {
122
131
  if (scratchpadRootOverride) return scratchpadRootOverride;
123
132
  return findWorkspaceRoot(
124
133
  ["XPI_SCRATCHPAD_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"],
@@ -152,9 +161,38 @@ function sanitizeName(name: string, label: string): string {
152
161
  return safe;
153
162
  }
154
163
 
164
+ function runDirName(runId: string): string {
165
+ const safe = sanitizeName(runId, "run_id");
166
+ if (safe === runId) return safe;
167
+ const suffix = createHash("sha256").update(runId).digest("hex").slice(0, 12);
168
+ return `${safe.slice(0, 80)}-${suffix}`;
169
+ }
170
+
171
+ /**
172
+ * Artifact names get the same disambiguation as run dirs: sanitization is
173
+ * lossy ("a/b" and "a_b" both become "a_b"), so a changed name gets a content
174
+ * hash suffix — distinct inputs can no longer silently overwrite each other's
175
+ * file. Reads use the same mapping, so round-trips stay consistent.
176
+ */
177
+ function artifactFileName(name: string): string {
178
+ const safe = sanitizeName(name, "artifact name");
179
+ if (safe === name) return safe;
180
+ const suffix = createHash("sha256").update(name).digest("hex").slice(0, 12);
181
+ return `${safe.slice(0, 80)}-${suffix}`;
182
+ }
183
+
184
+ /** Cap on a single scratchpad artifact (2 MiB) — a hallucinating or hostile
185
+ * subagent must not be able to fill the disk with unbounded writes. */
186
+ const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
187
+
188
+ /** Pre-hash-suffix naming used by older scratchpad versions (sanitize only). */
189
+ function legacyRunDirName(runId: string): string {
190
+ return sanitizeName(runId, "run_id");
191
+ }
192
+
155
193
  /** The directory for a specific run. */
156
194
  export function getRunDir(runId: string, projectRoot?: string): string {
157
- return join(getScratchpadRoot(projectRoot), sanitizeName(runId, "run_id"));
195
+ return join(getScratchpadRoot(projectRoot), runDirName(runId));
158
196
  }
159
197
 
160
198
  /** The state.json path for a run. */
@@ -187,23 +225,42 @@ function ensureRunDirs(runDir: string): void {
187
225
  function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheckpoint | null {
188
226
  const statePath = getStatePath(runId, projectRoot);
189
227
  if (!existsSync(statePath)) return null;
190
- try {
191
- const raw = readFileSync(statePath, "utf8");
192
- const cp = JSON.parse(raw) as ScratchpadCheckpoint;
193
- // Backfill maps for phases not yet checkpointed (defensive).
194
- if (!cp.phase_ids) cp.phase_ids = {} as Record<ScratchpadPhase, string[]>;
195
- if (!cp.phase_summaries) cp.phase_summaries = {} as Record<ScratchpadPhase, string>;
196
- return cp;
197
- } catch {
198
- return null;
228
+ const raw = readFileSync(statePath, "utf8");
229
+ const cp = JSON.parse(raw) as ScratchpadCheckpoint;
230
+ if (typeof cp !== "object" || cp === null || Array.isArray(cp)) {
231
+ throw new Error(`Corrupt scratchpad state for ${runId}: root must be an object`);
232
+ }
233
+ if (cp.run_id !== runId) {
234
+ throw new Error(`Corrupt scratchpad state for ${runId}: state belongs to ${cp.run_id}`);
235
+ }
236
+ if (!Array.isArray(cp.completed_phases)) {
237
+ throw new Error(`Corrupt scratchpad state for ${runId}: completed_phases must be an array`);
199
238
  }
239
+ for (const phase of cp.completed_phases) {
240
+ if (!PHASE_ORDER.includes(phase)) {
241
+ throw new Error(`Corrupt scratchpad state for ${runId}: invalid phase ${phase}`);
242
+ }
243
+ }
244
+ if (!cp.phase_ids || typeof cp.phase_ids !== "object" || Array.isArray(cp.phase_ids)) {
245
+ cp.phase_ids = {} as Record<ScratchpadPhase, string[]>;
246
+ }
247
+ if (
248
+ !cp.phase_summaries ||
249
+ typeof cp.phase_summaries !== "object" ||
250
+ Array.isArray(cp.phase_summaries)
251
+ ) {
252
+ cp.phase_summaries = {} as Record<ScratchpadPhase, string>;
253
+ }
254
+ return cp;
200
255
  }
201
256
 
202
257
  function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): void {
203
258
  cp.last_updated = new Date().toISOString();
204
259
  const statePath = getStatePath(cp.run_id, projectRoot);
205
260
  ensureRunDirs(getRunDir(cp.run_id, projectRoot));
206
- writeFileSync(statePath, JSON.stringify(cp, null, 2), "utf8");
261
+ const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
262
+ writeFileSync(tmp, JSON.stringify(cp, null, 2), "utf8");
263
+ renameSync(tmp, statePath);
207
264
  }
208
265
 
209
266
  // ── Public API ───────────────────────────────────────────────────────
@@ -241,8 +298,15 @@ export function scratchpad_write(
241
298
  const runDir = getRunDir(runId, root);
242
299
  ensureRunDirs(runDir);
243
300
 
244
- // Sanitize artifact name: no path traversal, no dot-only escape.
245
- const safeName = sanitizeName(artifactName, "artifact name");
301
+ if (Buffer.byteLength(content, "utf8") > MAX_ARTIFACT_BYTES) {
302
+ throw new Error(
303
+ `Artifact too large (${Buffer.byteLength(content, "utf8")} bytes; max ${MAX_ARTIFACT_BYTES}): ${artifactName}`,
304
+ );
305
+ }
306
+
307
+ // Sanitize + disambiguate artifact name: no path traversal, no dot-only
308
+ // escape, and lossy sanitization cannot collide two distinct names.
309
+ const safeName = artifactFileName(artifactName);
246
310
  const dir = join(runDir, PHASE_DIRS[phase]);
247
311
  const filePath = join(dir, safeName);
248
312
  writeFileSync(filePath, content, "utf8");
@@ -259,7 +323,7 @@ export function scratchpad_read(
259
323
  projectRoot?: string,
260
324
  ): string | null {
261
325
  const root = projectRoot ?? detectWorkspaceRoot();
262
- const safeName = sanitizeName(artifactName, "artifact name");
326
+ const safeName = artifactFileName(artifactName);
263
327
  const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
264
328
  if (!existsSync(filePath)) return null;
265
329
  return readFileSync(filePath, "utf8");
@@ -268,6 +332,32 @@ export function scratchpad_read(
268
332
  /**
269
333
  * List all artifacts written for a phase.
270
334
  */
335
+ export function scratchpad_runs(projectRoot?: string): string[] {
336
+ const root = getScratchpadRoot(projectRoot);
337
+ if (!existsSync(root)) return [];
338
+ const out: string[] = [];
339
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
340
+ if (!entry.isDirectory()) continue;
341
+ const state = join(root, entry.name, "state.json");
342
+ if (!existsSync(state)) continue;
343
+ try {
344
+ const cp = JSON.parse(readFileSync(state, "utf8")) as { run_id?: unknown };
345
+ if (typeof cp.run_id !== "string") continue;
346
+ if (getRunDir(cp.run_id, projectRoot) === join(root, entry.name)) {
347
+ out.push(cp.run_id);
348
+ } else if (join(root, legacyRunDirName(cp.run_id)) === join(root, entry.name)) {
349
+ // Runs created before the hash-suffix naming used sanitizeName(runId)
350
+ // as the directory; keep surfacing them in bundle discovery. Safe ids
351
+ // were never suffixed, so only legacy unsafe ids can land here.
352
+ out.push(cp.run_id);
353
+ }
354
+ } catch {
355
+ // Corrupt runs are ignored during report bundle discovery; direct resume still fails closed.
356
+ }
357
+ }
358
+ return out;
359
+ }
360
+
271
361
  export function scratchpad_list(
272
362
  runId: string,
273
363
  phase: ScratchpadPhase,