@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/README.md +4 -3
- package/package.json +3 -5
- package/skills/casefile/SKILL.md +4 -3
- package/src/index.ts +294 -225
- package/src/ledger.ts +591 -396
- package/src/pipeline-submit.ts +279 -26
- package/src/poc-runner.ts +186 -65
- package/src/scratchpad.ts +106 -16
- package/src/workflow.ts +79 -16
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
|
-
|
|
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
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
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,
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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.
|
|
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
|
|
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 {
|
|
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),
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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 =
|
|
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,
|