humanish 0.122.0 → 0.123.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +33 -6
  4. package/dist/analysis/automatic.js +2 -1
  5. package/dist/analysis/evidence.js +41 -21
  6. package/dist/cli/commands/study-run.js +9 -3
  7. package/dist/feedback/derivative-text.d.ts +12 -0
  8. package/dist/feedback/derivative-text.js +130 -0
  9. package/dist/feedback/export-bundle.js +38 -131
  10. package/dist/feedback/export.js +9 -1
  11. package/dist/observer/run-routes.js +61 -18
  12. package/dist/run/bundle-shape.js +2 -2
  13. package/dist/run/bundle.d.ts +5 -2
  14. package/dist/run/bundle.js +4 -1
  15. package/dist/run/detail.d.ts +6 -0
  16. package/dist/run/detail.js +14 -1
  17. package/dist/run/locate.js +3 -1
  18. package/dist/run/projection.d.ts +7 -0
  19. package/dist/run/projection.js +10 -0
  20. package/dist/run/run.js +7 -1
  21. package/dist/run/stored-runs.js +10 -4
  22. package/dist/run/stream-shape.d.ts +11 -1
  23. package/dist/run/stream-shape.js +26 -2
  24. package/dist/run/stream-trace.d.ts +68 -0
  25. package/dist/run/stream-trace.js +260 -0
  26. package/dist/run/streams.d.ts +20 -0
  27. package/dist/study/bundle-estimate.d.ts +48 -0
  28. package/dist/study/bundle-estimate.js +159 -0
  29. package/dist/study/doctor.js +4 -0
  30. package/dist/study/plan-types.d.ts +3 -0
  31. package/dist/study/plan.js +22 -2
  32. package/dist/study/preflight.js +8 -0
  33. package/dist/tui-app.js +87 -87
  34. package/dist/verify/artifacts.d.ts +17 -10
  35. package/dist/verify/artifacts.js +22 -13
  36. package/dist/verify/scan-limit.d.ts +6 -0
  37. package/dist/verify/scan-limit.js +25 -0
  38. package/dist/verify/verify.d.ts +1 -1
  39. package/dist/verify/verify.js +68 -18
  40. package/docs/architecture/github-feedback-loop.md +1 -1
  41. package/docs/contracts/README.md +1 -1
  42. package/docs/contracts/run-bundle.md +87 -6
  43. package/docs/contracts/schemas.md +14 -7
  44. package/package.json +6 -3
package/CHANGELOG.md CHANGED
@@ -8,6 +8,22 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.123.0: Run bundle v2 writes each trace once, bundle size estimate (2026-10-12)
12
+
13
+ humanish 0.123.0 writes each participant's trace once, outside run.json, so long studies stay
14
+ within the bundle's size limits, and estimates a study's bundle before it runs. Run bundles are now
15
+ `humanish.run-bundle.v2`: a participant's trace items live only in its trace file
16
+ (`actor.json`, `actors/<stream>.json` or `actor-<surface>.json`), and run.json indexes each one by
17
+ path, size and sha256. On a synthetic 8-participant, 1,000-turn run, run.json fell from 17 MB to
18
+ 74 KB. Every reader loads traces through one loader that checks the index and refuses a missing,
19
+ altered or shared trace; a refused trace shows as evidence unavailable, and an unfinished run
20
+ grades `local_only`. Bundles from earlier versions still load unchanged; npm 0.122.0 and earlier
21
+ refuse a v2 bundle by name. `study check` and `run` estimate the bundle's size and file count and
22
+ warn before any sandbox is created when it may pass the analysis or verify limit, and the
23
+ public-safety scan's file limit grows with the number of participants.
24
+
25
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.123.0)
26
+
11
27
  ## 0.122.0: Participants end on the clock, a harness error ends only its participant (2026-10-12)
12
28
 
13
29
  humanish 0.122.0 lets a participant end at a scheduled time and keeps one participant's harness
package/CONTEXT.md CHANGED
@@ -12,7 +12,7 @@ bundle and output fields keep older spellings; the second table maps them.
12
12
  | Term | Meaning | Where it lives in the code |
13
13
  | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
14
  | study | What a user designs and runs: one YAML file that declares the subject, the actor, the participants and the caps. The file lives at `humanish/studies/<id>.yaml` with schema `humanish.study.v3`, read by `humanish study` and `--study`. humanish refuses a 0.107 `humanish.lab.v2` file, and `humanish migrate` converts it (second table). | `src/study/`; `StudyConfig` in `src/study/types.ts`, parsed by `parseStudy` in `src/study/config.ts` |
15
- | run | One execution of a study, on disk: `.humanish/runs/<runId>/`, with `run.json` (`humanish.run-bundle.v1`) as the source of truth. | `src/run/`; `RunBundle` in `src/run/bundle.ts` |
15
+ | run | One execution of a study, on disk: `.humanish/runs/<runId>/`, with `run.json` (`humanish.run-bundle.v2`) as the source of truth and each participant's trace in its own file. | `src/run/`; `RunBundle` in `src/run/bundle.ts` |
16
16
  | dry run | A run with no live participant, from `--dry-run` or `mode: dry-run`. humanish writes a synthetic run bundle, review and Observer with no browser, model, keys or provider spend, and tests no product behavior. The `preview` route only dry-runs; "preview" names that route and nothing else. | `runDryRun` in `src/run/dry-run.ts`; `resolveStudyDryRun` in `src/study/plan.ts` |
17
17
  | participant | One simulated person, or agent, taking part in a study. The planner builds each one's record (id, persona id, assignment, labels and the route's own fields). Shared-world participants resolve the plan's records; computer-use participants rebuild the same records from the study config with `computerUseParticipants` (`src/study/plan-participants.ts`) and resolve those. `ResolvedParticipant` adds the bundle ids, persona and prompt. `run.json` records each one as a `simulations[]` entry with one or more `streams[]`. | `ResolvedParticipant` in `src/run/participant.ts`; the planned records in `src/study/plan-participants.ts`. Computer-use and shared-world participants run a `DesktopParticipantRun` (`src/routes/computer-use/types.ts`). The terminal session (`src/routes/terminal/session.ts`), scripted surfaces (`src/routes/scripted/plan.ts`) and fixture simulations (`src/run/dry-run.ts`) still build their own. |
18
18
  | persona | Who a participant is: traits, background, device. `humanish/personas/<id>.yaml`. | `src/study/persona.ts`, `src/study/persona-resolve.ts` |
package/README.md CHANGED
@@ -1,12 +1,23 @@
1
1
  # humanish
2
2
 
3
- Synthetic user research for apps, CLIs, and agent-facing product flows. AI participants with a persona and a task use your app on real desktops, and each run leaves evidence you can verify: what they did, where they got stuck, and what it cost.
3
+ [![npm version](https://img.shields.io/npm/v/humanish)](https://www.npmjs.com/package/humanish) [![npm downloads per month](https://img.shields.io/npm/dm/humanish)](https://www.npmjs.com/package/humanish) [![CI status on main](https://img.shields.io/github/actions/workflow/status/danielgwilson/humanish/ci.yml?branch=main&label=CI)](https://github.com/danielgwilson/humanish/actions/workflows/ci.yml?query=branch%3Amain) [![License: MIT](https://img.shields.io/npm/l/humanish)](LICENSE) [![Node.js version](https://img.shields.io/node/v/humanish)](https://nodejs.org/)
4
4
 
5
- [![The Observer grid of a saved eight-participant study: eight desktops in one multiplayer lobby, each tile a participant's live screen](https://humanish.dev/runs/lobby-0927/poster.jpg)](https://humanish.dev/demo)
5
+ Synthetic user research for apps, CLIs, and agent-facing product flows. AI participants with a persona and a task use your app on real desktops, and each run leaves evidence you can verify: what they did, where they got stuck, and what it cost.
6
6
 
7
- **[Watch a saved run](https://humanish.dev/demo).** Eight synthetic participants joined one
8
- lobby of a multiplayer game on its live deployment, each on its own hosted desktop. The page
9
- replays the real Observer; nothing runs from it.
7
+ <a href="https://humanish.dev/demo">
8
+ <picture>
9
+ <source media="(prefers-color-scheme: dark)" srcset="https://humanish.dev/runs/lobby-0927/observer-grid-dark.webp">
10
+ <img alt="The humanish Observer replaying a saved study five minutes into its 13-minute recording: a grid of eight participants' desktops in a multiplayer movie-guessing game, seven in the same round and one held at the join page, under tabs for 8 participants and 7 findings" src="https://humanish.dev/runs/lobby-0927/observer-grid-light.webp">
11
+ </picture>
12
+ </a>
13
+
14
+ **[Watch the replay](https://humanish.dev/demo).** The image is the Observer partway through a
15
+ saved study. Eight synthetic participants joined one lobby of a multiplayer movie-guessing game
16
+ on its live deployment, each on its own hosted desktop, and each tile shows one participant's
17
+ screen at the same moment of the recording. Six reached the final standings. Two were blocked,
18
+ among them Player six, who arrived after the game started and is still on the join page. The
19
+ Findings tab holds the seven findings the analysis wrote. The demo page replays this run in the
20
+ Observer, and nothing runs from it.
10
21
 
11
22
  ## Learn how a study works
12
23
 
@@ -19,6 +30,14 @@ disposable browser VM on your machine. On the computer-use route a model drives
19
30
  participant: every turn it reads a screenshot and answers with mouse and keyboard actions, until
20
31
  the participant finishes, gets stuck or reaches a limit.
21
32
 
33
+ <picture>
34
+ <source media="(prefers-color-scheme: dark)" srcset="https://humanish.dev/runs/lobby-0927/observer-player-dark.webp">
35
+ <img alt="The Observer with one participant open: its desktop during a round of a movie-guessing game, replay controls and a filmstrip of captures below, and on the right its trace, with the participant's reasoning and each TAB and ENTER keypress" src="https://humanish.dev/runs/lobby-0927/observer-player-light.webp">
36
+ </picture>
37
+
38
+ Player four from the run above, a keyboard-only participant: its screen, and beside it each
39
+ keypress and the reasoning it wrote before acting.
40
+
22
41
  The run writes its evidence to gitignored `.humanish/runs/<run id>/`: screenshots, every
23
42
  action, each participant's report and the estimated cost. The Observer replays it,
24
43
  `humanish review` prints the findings of the analysis that follows a live run, and
@@ -26,6 +45,14 @@ action, each participant's report and the estimated cost. The Observer replays i
26
45
  [How a study works](https://humanish.dev/docs/concepts) walks through one annotated study file,
27
46
  and the [study file reference](https://humanish.dev/docs/study-files) lists every field.
28
47
 
48
+ <picture>
49
+ <source media="(prefers-color-scheme: dark)" srcset="https://humanish.dev/runs/lobby-0927/observer-findings-dark.webp">
50
+ <img alt="The Observer's Findings tab: the first of seven findings, that three participants found keyboard guessing hard to track and one stopped mid-game, with the analysis's summary, what to check next, a cited capture and the timeline of evidence moments behind it" src="https://humanish.dev/runs/lobby-0927/observer-findings-light.webp">
51
+ </picture>
52
+
53
+ The first of the seven findings the analysis wrote for the same run, with the evidence moments
54
+ it cites.
55
+
29
56
  ## Try it without keys
30
57
 
31
58
  In a project directory, with Node.js 22.19 or newer:
@@ -308,7 +335,7 @@ and the library exports.
308
335
  - Breaking changes ship together in one minor release. Its release notes carry a migration
309
336
  table: each old form, its new form, and the change to make.
310
337
  - The study file and the run bundle carry versioned schemas, `humanish.study.v3` and
311
- `humanish.run-bundle.v1`. A breaking change to either one bumps its version.
338
+ `humanish.run-bundle.v2`. A breaking change to either one bumps its version.
312
339
 
313
340
  Pin the exact version in `package.json` and read [CHANGELOG.md](CHANGELOG.md) before upgrading.
314
341
 
@@ -4,6 +4,7 @@ import {} from "../run/bundle.js";
4
4
  import { refreshObserver, analyzeRun, readCompletedAnalysisSource, resolveAnalysisRun, } from "./service.js";
5
5
  import { preferLargerAnalysisOutput, ANALYSIS_PROMPT_VERSION } from "./execute.js";
6
6
  import { captureEvidence } from "./evidence.js";
7
+ import { loadRunTraces } from "../run/stream-trace.js";
7
8
  import { hashAnalysisValue } from "./validation.js";
8
9
  import { refusedCost } from "./admission.js";
9
10
  import { claimAutomaticAnalysis, readAutomaticAnalysisPrepared, requestAutomaticAnalysisCancellationPrepared, } from "./job.js";
@@ -88,7 +89,7 @@ async function readAutomaticSource(cwd, prepared, configInput, deps, hasKey) {
88
89
  let config = configInput;
89
90
  try {
90
91
  const bytes = await readCompletedAnalysisSource(cwd, prepared);
91
- const bundle = JSON.parse(bytes.toString("utf8"));
92
+ const bundle = await loadRunTraces(prepared, JSON.parse(bytes.toString("utf8")));
92
93
  if (bundle.streams.some((stream) => stream.actor?.stopCause === "harness_aborted"))
93
94
  return skipped("AUTOMATIC_ANALYSIS_ACTOR_CANCELLED");
94
95
  const participantEvidence = hasParticipantEvidence(bundle);
@@ -3,7 +3,8 @@ import { createHash } from "node:crypto";
3
3
  import { screenshotEvidenceError } from "../evidence/image.js";
4
4
  import { runIdOf } from "../run/paths.js";
5
5
  import { isRecord } from "../run/type-guards.js";
6
- import { RUN_BUNDLE_FILE } from "../run/bundle.js";
6
+ import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, } from "../run/bundle.js";
7
+ import { loadStreamTraces } from "../run/stream-trace.js";
7
8
  import { ACTION_CAPTURE_VERSION, } from "./types.js";
8
9
  import { digestAnalysisInput, hashAnalysisValue, validateAnalysisInputMetadata, } from "./validation.js";
9
10
  import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, sourceEntries, sourceOrder, impressionEventIds, } from "./evidence-sources.js";
@@ -29,7 +30,7 @@ function parseSource(prepared, bytes) {
29
30
  throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
30
31
  const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
31
32
  if (!isRecord(value) ||
32
- value.schema !== "humanish.run-bundle.v1" ||
33
+ (value.schema !== RUN_BUNDLE_SCHEMA && value.schema !== RUN_BUNDLE_V1_SCHEMA) ||
33
34
  value.runId !== runIdOf(prepared) ||
34
35
  !Array.isArray(value.streams) ||
35
36
  value.streams.length > EVIDENCE_LIMITS.participants ||
@@ -49,24 +50,8 @@ function parseSource(prepared, bytes) {
49
50
  throw new Error("ANALYSIS_SOURCE_INVALID");
50
51
  }
51
52
  ids.add(stream.id);
52
- const actor = stream.actor;
53
- if (actor !== undefined &&
54
- (!isRecord(actor) || !Array.isArray(actor.items) || actor.items.length > 100000)) {
53
+ if (stream.actor !== undefined && !isRecord(stream.actor))
55
54
  throw new Error("ANALYSIS_SOURCE_INVALID");
56
- }
57
- const eventIds = new Set();
58
- for (const item of isRecord(actor) ? actor.items : []) {
59
- if (!isRecord(item) ||
60
- typeof item.id !== "string" ||
61
- item.id.length === 0 ||
62
- item.id.length > 256 ||
63
- eventIds.has(item.id) ||
64
- typeof item.kind !== "string" ||
65
- typeof item.title !== "string" ||
66
- (item.text !== undefined && typeof item.text !== "string"))
67
- throw new Error("ANALYSIS_SOURCE_INVALID");
68
- eventIds.add(item.id);
69
- }
70
55
  }
71
56
  for (const event of value.events) {
72
57
  if (!isRecord(event) ||
@@ -77,6 +62,41 @@ function parseSource(prepared, bytes) {
77
62
  }
78
63
  return value;
79
64
  }
65
+ /**
66
+ * run.json's bundle with each stream's trace loaded. The source limit applies to each trace file
67
+ * as it does to run.json; run.json's digest, which the analysis record binds, binds each trace's
68
+ * sha256.
69
+ */
70
+ async function readSource(prepared, bytes, traceBytes) {
71
+ const stored = parseSource(prepared, bytes);
72
+ if (stored.streams.some((stream) => (stream.trace?.bytes ?? 0) > traceBytes))
73
+ throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
74
+ const loaded = await loadStreamTraces(prepared, stored, { maxBytes: traceBytes });
75
+ if (loaded.refused.length > 0)
76
+ throw new Error("ANALYSIS_SOURCE_INVALID");
77
+ for (const stream of loaded.bundle.streams)
78
+ validateTraceItems(stream.actor);
79
+ return loaded.bundle;
80
+ }
81
+ function validateTraceItems(actor) {
82
+ if (actor !== undefined &&
83
+ (!isRecord(actor) || !Array.isArray(actor.items) || actor.items.length > 100000)) {
84
+ throw new Error("ANALYSIS_SOURCE_INVALID");
85
+ }
86
+ const eventIds = new Set();
87
+ for (const item of isRecord(actor) ? actor.items : []) {
88
+ if (!isRecord(item) ||
89
+ typeof item.id !== "string" ||
90
+ item.id.length === 0 ||
91
+ item.id.length > 256 ||
92
+ eventIds.has(item.id) ||
93
+ typeof item.kind !== "string" ||
94
+ typeof item.title !== "string" ||
95
+ (item.text !== undefined && typeof item.text !== "string"))
96
+ throw new Error("ANALYSIS_SOURCE_INVALID");
97
+ eventIds.add(item.id);
98
+ }
99
+ }
80
100
  /** Select once from retained source. Models receive no filesystem or network resolver. */
81
101
  export async function captureEvidence(prepared, bundleBytes, requested = {}) {
82
102
  const limits = { ...EVIDENCE_LIMITS, ...requested };
@@ -92,7 +112,7 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
92
112
  const current = await readBoundedFile(prepared, RUN_BUNDLE_FILE, limits.sourceBytes);
93
113
  if (!current || !current.equals(bundleBytes))
94
114
  throw new Error("ANALYSIS_SOURCE_CHANGED");
95
- const bundle = parseSource(prepared, bundleBytes);
115
+ const bundle = await readSource(prepared, bundleBytes, limits.sourceBytes);
96
116
  const captureVersion = ACTION_CAPTURE_VERSION;
97
117
  if (bundle.streams.some((stream) => stream.liveActor !== undefined || UNFINISHED_STREAM_STATUSES.has(stream.status))) {
98
118
  throw new Error("ANALYSIS_RUN_UNFINISHED");
@@ -393,7 +413,7 @@ function packEvidence(selected, packings, textByEntry, evidence, images) {
393
413
  export async function validateAnalysisEvidence(prepared, artifact, bundleBytes) {
394
414
  if (artifact.captureVersion !== undefined && artifact.captureVersion !== ACTION_CAPTURE_VERSION)
395
415
  throw new Error("ANALYSIS_CAPTURE_VERSION_INVALID");
396
- const bundle = parseSource(prepared, bundleBytes);
416
+ const bundle = await readSource(prepared, bundleBytes, EVIDENCE_LIMITS.sourceBytes);
397
417
  if (artifact.runId !== bundle.runId || artifact.sourceRunSha256 !== sha256(bundleBytes))
398
418
  throw new Error("ANALYSIS_SOURCE_CHANGED");
399
419
  const included = new Set(artifact.coverage.includedStreamIds);
@@ -84,7 +84,14 @@ export async function runStudyCommand(args) {
84
84
  // the keys its plan reads. This comes after the option refusals above, which read no key, and
85
85
  // before the route's CLI setup and prepareStudy, which do.
86
86
  if (resolveStudyDryRun(config, args.options.dryRun, true) === false) {
87
- const announced = announcedKeyNames(config, args.options);
87
+ const planned = planStudy(config, { cwd: args.options.cwd, dryRun: false });
88
+ // The plan's warnings, such as a bundle estimated past a limit, before anything is created.
89
+ // The study file's own were printed above.
90
+ if (planned.ok)
91
+ for (const warning of planned.planned.plan.warnings ?? [])
92
+ if (!resolved.warnings.includes(warning))
93
+ warnAndQueue(args.command, args.io, warning);
94
+ const announced = announcedKeyNames(config, args.options, planned);
88
95
  await discoverCliKeys({
89
96
  io: args.io,
90
97
  cwd: args.options.cwd,
@@ -150,10 +157,9 @@ export async function runStudyCommand(args) {
150
157
  * change who runs, not which keys. Undefined, so every fill is printed, when a scorer is declared,
151
158
  * since its host code may read any key, and when the study does not plan.
152
159
  */
153
- function announcedKeyNames(config, options) {
160
+ function announcedKeyNames(config, options, planned) {
154
161
  if (options.scorer !== undefined || config.review?.scorer !== undefined)
155
162
  return undefined;
156
- const planned = planStudy(config, { cwd: options.cwd, dryRun: false });
157
163
  return planned.ok ? keyNamesOf(planned.planned.plan) : undefined;
158
164
  }
159
165
  /** Each route's CLI setup. It returns undefined when it has already written its own result. */
@@ -0,0 +1,12 @@
1
+ import type { RunBundle } from "../run/bundle.js";
2
+ /** A source file's path and bytes. */
3
+ interface TextFile {
4
+ path: string;
5
+ bytes: Buffer;
6
+ }
7
+ export declare function jsonBytes(value: unknown): Buffer;
8
+ export declare function transformText(file: TextFile, imagePaths: ReadonlySet<string>): Buffer;
9
+ export declare function assertRewritableNumbers(json: string): void;
10
+ /** The loaded source bundle as the copy records it: screenshots declared and labelled blurred. */
11
+ export declare function derivedBundle(bundle: RunBundle, imagePaths: ReadonlySet<string>): RunBundle;
12
+ export {};
@@ -0,0 +1,130 @@
1
+ // How a redacted copy rewrites a run's text: screenshots declared blurred, sandbox ids redacted,
2
+ // inline images and secret-shaped text refused. src/feedback/export-bundle.ts copies the files.
3
+ import path from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { ACTOR_TRACE_SCHEMA } from "../actors/contract.js";
6
+ import { containsSensitive, redactSandboxIds } from "../evidence/redaction.js";
7
+ import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
8
+ import { readPlainText } from "../evidence/plain-text.js";
9
+ export function jsonBytes(value) {
10
+ return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
11
+ }
12
+ function assertNoInlineRaster(text) {
13
+ // Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
14
+ // refused: copying a text extension must not smuggle untransformed pixels.
15
+ if (readingsOf(text).some(containsSensitive))
16
+ throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
17
+ const decoded = decodeEscapes(text);
18
+ if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
19
+ /iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
20
+ throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
21
+ }
22
+ }
23
+ function decodeText(file) {
24
+ const decoded = readPlainText(file.bytes);
25
+ if (!decoded.ok)
26
+ throw new Error(decoded.reason === "invalid-utf8"
27
+ ? "Source text contains invalid UTF-8 or binary bytes."
28
+ : "Source text contains binary control bytes.");
29
+ assertNoInlineRaster(decoded.text);
30
+ return decoded.text;
31
+ }
32
+ function updateScreenshotDeclarations(value, imagePaths) {
33
+ if (Array.isArray(value))
34
+ return value.map((entry) => updateScreenshotDeclarations(entry, imagePaths));
35
+ if (value === null || typeof value !== "object")
36
+ return value;
37
+ const object = value;
38
+ const result = Object.create(null);
39
+ for (const [key, entry] of Object.entries(object))
40
+ result[key] = updateScreenshotDeclarations(entry, imagePaths);
41
+ if (object.schema === ACTOR_TRACE_SCHEMA &&
42
+ object.redaction !== null &&
43
+ typeof object.redaction === "object") {
44
+ const original = object.redaction;
45
+ if (original.screenshots === "raw" || original.screenshots === "blurred") {
46
+ result.redaction = {
47
+ ...original,
48
+ screenshots: "blurred",
49
+ notes: `${typeof original.notes === "string" ? original.notes : ""} Exported copy: screenshots blurred during export; original capture posture: ${original.screenshots}. See derivation.json.`,
50
+ };
51
+ }
52
+ if (Array.isArray(result.items)) {
53
+ result.items = result.items.map((item) => {
54
+ if (item === null || typeof item !== "object")
55
+ return item;
56
+ const entry = item;
57
+ if (entry.screenshotRef === undefined)
58
+ return item;
59
+ if (entry.screenshotRef === null || typeof entry.screenshotRef !== "object")
60
+ throw new Error("Actor screenshot reference is malformed.");
61
+ const ref = entry.screenshotRef;
62
+ if (typeof ref.path !== "string" || !imagePaths.has(ref.path)) {
63
+ throw new Error("Actor screenshot reference must resolve to a PNG transformed from this run's inventory.");
64
+ }
65
+ return { ...entry, screenshotRef: { ...ref, redaction: "blurred" } };
66
+ });
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+ export function transformText(file, imagePaths) {
72
+ const text = decodeText(file);
73
+ const extension = path.extname(file.path).toLowerCase();
74
+ if (extension === ".json") {
75
+ const parsed = JSON.parse(text);
76
+ // Parsing also resolves escape sequences in string values before inspection.
77
+ assertNoInlineRaster(JSON.stringify(parsed));
78
+ // The copy names no sandbox: an id is local operational evidence, and the copy is shared.
79
+ const transformed = redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths));
80
+ if (JSON.stringify(transformed) === JSON.stringify(parsed))
81
+ return file.bytes;
82
+ assertRewritableNumbers(text);
83
+ return jsonBytes(transformed);
84
+ }
85
+ if (extension === ".ndjson" || extension === ".jsonl") {
86
+ const lines = text.split("\n");
87
+ const transformed = lines.map((line) => {
88
+ if (line.trim().length === 0)
89
+ return line;
90
+ const parsed = JSON.parse(line);
91
+ assertNoInlineRaster(JSON.stringify(parsed));
92
+ const updated = JSON.stringify(redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths)));
93
+ if (updated === JSON.stringify(parsed))
94
+ return line;
95
+ assertRewritableNumbers(line);
96
+ return updated;
97
+ });
98
+ return Buffer.from(transformed.join("\n"));
99
+ }
100
+ if (extension === ".yaml" || extension === ".yml") {
101
+ assertNoInlineRaster(JSON.stringify(parseYaml(text, { maxAliasCount: 100, logLevel: "silent" })) ?? "");
102
+ }
103
+ return file.bytes;
104
+ }
105
+ export function assertRewritableNumbers(json) {
106
+ // Opaque adapter JSON stays byte-identical. A known trace that actually needs
107
+ // rewriting must not silently round an identifier outside JS's integer range.
108
+ const unquoted = json.replace(/"(?:\\.|[^"\\])*"/g, '""');
109
+ for (const match of unquoted.matchAll(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g)) {
110
+ const number = Number(match[0]);
111
+ if (!Number.isFinite(number) || (Number.isInteger(number) && !Number.isSafeInteger(number))) {
112
+ throw new Error("A trace requiring redaction contains an unsafe numeric value; represent large identifiers as strings before capture.");
113
+ }
114
+ }
115
+ }
116
+ /** The loaded source bundle as the copy records it: screenshots declared and labelled blurred. */
117
+ export function derivedBundle(bundle, imagePaths) {
118
+ const transformed = redactSandboxIds(updateScreenshotDeclarations(bundle, imagePaths));
119
+ transformed.redaction = {
120
+ status: "passed",
121
+ notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
122
+ };
123
+ for (const stream of transformed.streams) {
124
+ for (const artifact of stream.artifacts) {
125
+ if (artifact.kind === "screenshot")
126
+ artifact.label = artifact.label.replace(/\((raw|blurred)\)/g, "(blurred at export)");
127
+ }
128
+ }
129
+ return transformed;
130
+ }
@@ -4,18 +4,18 @@ import { createHash } from "node:crypto";
4
4
  import { lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, rmdir } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { tmpdir } from "node:os";
7
- import { parse as parseYaml } from "yaml";
8
- import { ACTOR_TRACE_SCHEMA } from "../actors/contract.js";
9
7
  import { renderObserver } from "../observer/render.js";
10
8
  import { buildObserverData } from "../observer/data.js";
11
- import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
12
- import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
13
- import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
14
- import { readPlainText } from "../evidence/plain-text.js";
15
- import { MAX_RUN_ENTRIES, streamScreenshotPaths } from "../verify/artifacts.js";
9
+ import { inventorySandboxIds, publicSandboxView, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
10
+ import { splitStreamTraces } from "../run/stream-trace.js";
11
+ import { redactScreenshot, redactText } from "../evidence/redaction.js";
12
+ import { assertRewritableNumbers, derivedBundle, jsonBytes, transformText, } from "./derivative-text.js";
13
+ import { streamScreenshotPaths } from "../verify/artifacts.js";
14
+ import { runEntryLimit } from "../verify/scan-limit.js";
16
15
  import { verifyRunPrepared } from "../verify/verify.js";
17
- import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
18
- import {} from "../run/bundle.js";
16
+ import { loadRunBundlePrepared, readRunJsonIfExists, resolveRunPath, runJsonValue, } from "../run/locate.js";
17
+ import { RUN_BUNDLE_FILE } from "../run/bundle.js";
18
+ import { isRecord } from "../run/type-guards.js";
19
19
  import { runIdOf, isPathInside, prepareRunArtifactPaths, validatePreparedRunArtifactPaths, } from "../run/paths.js";
20
20
  import { isAnalysisRecordPath } from "../analysis/sharing.js";
21
21
  import { LOCAL_ONLY_RUN_FILES } from "../run/local-only-files.js";
@@ -43,9 +43,6 @@ const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
43
43
  function hash(bytes) {
44
44
  return createHash("sha256").update(bytes).digest("hex");
45
45
  }
46
- function jsonBytes(value) {
47
- return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
48
- }
49
46
  function omittedReason(relative) {
50
47
  if (relative.startsWith("analysis-automatic/") && isAnalysisRecordPath(relative))
51
48
  return "automatic execution intent and liveness do not transfer to a derivative";
@@ -63,14 +60,17 @@ async function inventory(root, maxBytes) {
63
60
  const files = [];
64
61
  let total = 0;
65
62
  let entriesSeen = 0;
63
+ // The scan's limit for the participants run.json records, so a run verify reads in full exports.
64
+ const stored = runJsonValue(await readRunJsonIfExists(root, RUN_BUNDLE_FILE));
65
+ const entryLimit = runEntryLimit(isRecord(stored) && Array.isArray(stored.simulations) ? stored.simulations.length : 0);
66
66
  const walk = async (relative) => {
67
67
  const directory = path.join(root.physicalRunRoot, relative);
68
68
  const before = await lstat(directory, { bigint: true });
69
69
  if (!before.isDirectory() || before.isSymbolicLink())
70
70
  throw new Error("Source contains an unsafe directory.");
71
71
  for (const name of (await readdir(directory)).sort()) {
72
- if (++entriesSeen > MAX_RUN_ENTRIES)
73
- throw new Error(`Source exceeds ${MAX_RUN_ENTRIES} inventory entries.`);
72
+ if (++entriesSeen > entryLimit)
73
+ throw new Error(`Source exceeds ${entryLimit} inventory entries.`);
74
74
  if (name.includes("\\") || name.includes("\0"))
75
75
  throw new Error("Source contains an unsafe path segment.");
76
76
  const rel = relative ? `${relative}/${name}` : name;
@@ -147,65 +147,6 @@ async function prospectivePhysicalPath(requested) {
147
147
  return path.join(await prospectivePhysicalPath(parent), path.basename(requested));
148
148
  }
149
149
  }
150
- function assertNoInlineRaster(text) {
151
- // Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
152
- // refused: copying a text extension must not smuggle untransformed pixels.
153
- if (readingsOf(text).some(containsSensitive))
154
- throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
155
- const decoded = decodeEscapes(text);
156
- if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
157
- /iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
158
- throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
159
- }
160
- }
161
- function decodeText(file) {
162
- const decoded = readPlainText(file.bytes);
163
- if (!decoded.ok)
164
- throw new Error(decoded.reason === "invalid-utf8"
165
- ? "Source text contains invalid UTF-8 or binary bytes."
166
- : "Source text contains binary control bytes.");
167
- assertNoInlineRaster(decoded.text);
168
- return decoded.text;
169
- }
170
- function updateScreenshotDeclarations(value, imagePaths) {
171
- if (Array.isArray(value))
172
- return value.map((entry) => updateScreenshotDeclarations(entry, imagePaths));
173
- if (value === null || typeof value !== "object")
174
- return value;
175
- const object = value;
176
- const result = Object.create(null);
177
- for (const [key, entry] of Object.entries(object))
178
- result[key] = updateScreenshotDeclarations(entry, imagePaths);
179
- if (object.schema === ACTOR_TRACE_SCHEMA &&
180
- object.redaction !== null &&
181
- typeof object.redaction === "object") {
182
- const original = object.redaction;
183
- if (original.screenshots === "raw" || original.screenshots === "blurred") {
184
- result.redaction = {
185
- ...original,
186
- screenshots: "blurred",
187
- notes: `${typeof original.notes === "string" ? original.notes : ""} Exported copy: screenshots blurred during export; original capture posture: ${original.screenshots}. See derivation.json.`,
188
- };
189
- }
190
- if (Array.isArray(result.items)) {
191
- result.items = result.items.map((item) => {
192
- if (item === null || typeof item !== "object")
193
- return item;
194
- const entry = item;
195
- if (entry.screenshotRef === undefined)
196
- return item;
197
- if (entry.screenshotRef === null || typeof entry.screenshotRef !== "object")
198
- throw new Error("Actor screenshot reference is malformed.");
199
- const ref = entry.screenshotRef;
200
- if (typeof ref.path !== "string" || !imagePaths.has(ref.path)) {
201
- throw new Error("Actor screenshot reference must resolve to a PNG transformed from this run's inventory.");
202
- }
203
- return { ...entry, screenshotRef: { ...ref, redaction: "blurred" } };
204
- });
205
- }
206
- }
207
- return result;
208
- }
209
150
  function assertFeedbackReferences(bundle, source) {
210
151
  const retained = new Set(source.files.filter((file) => omittedReason(file.path) === undefined).map((file) => file.path));
211
152
  retained.add("observer/index.html");
@@ -220,51 +161,6 @@ function assertFeedbackReferences(bundle, source) {
220
161
  }
221
162
  }
222
163
  }
223
- function transformText(file, imagePaths) {
224
- const text = decodeText(file);
225
- const extension = path.extname(file.path).toLowerCase();
226
- if (extension === ".json") {
227
- const parsed = JSON.parse(text);
228
- // Parsing also resolves escape sequences in string values before inspection.
229
- assertNoInlineRaster(JSON.stringify(parsed));
230
- // The copy names no sandbox: an id is local operational evidence, and the copy is shared.
231
- const transformed = redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths));
232
- if (JSON.stringify(transformed) === JSON.stringify(parsed))
233
- return file.bytes;
234
- assertRewritableNumbers(text);
235
- return jsonBytes(transformed);
236
- }
237
- if (extension === ".ndjson" || extension === ".jsonl") {
238
- const lines = text.split("\n");
239
- const transformed = lines.map((line) => {
240
- if (line.trim().length === 0)
241
- return line;
242
- const parsed = JSON.parse(line);
243
- assertNoInlineRaster(JSON.stringify(parsed));
244
- const updated = JSON.stringify(redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths)));
245
- if (updated === JSON.stringify(parsed))
246
- return line;
247
- assertRewritableNumbers(line);
248
- return updated;
249
- });
250
- return Buffer.from(transformed.join("\n"));
251
- }
252
- if (extension === ".yaml" || extension === ".yml") {
253
- assertNoInlineRaster(JSON.stringify(parseYaml(text, { maxAliasCount: 100, logLevel: "silent" })) ?? "");
254
- }
255
- return file.bytes;
256
- }
257
- function assertRewritableNumbers(json) {
258
- // Opaque adapter JSON stays byte-identical. A known trace that actually needs
259
- // rewriting must not silently round an identifier outside JS's integer range.
260
- const unquoted = json.replace(/"(?:\\.|[^"\\])*"/g, '""');
261
- for (const match of unquoted.matchAll(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g)) {
262
- const number = Number(match[0]);
263
- if (!Number.isFinite(number) || (Number.isInteger(number) && !Number.isSafeInteger(number))) {
264
- throw new Error("A trace requiring redaction contains an unsafe numeric value; represent large identifiers as strings before capture.");
265
- }
266
- }
267
- }
268
164
  /** PNG paths that run.json cites outside actor traces: feedback, adapter and stream artifacts. */
269
165
  function citedPngPaths(bundle) {
270
166
  const cited = [
@@ -304,6 +200,13 @@ async function writeDerivative(source, stagePaths, bundle) {
304
200
  const entries = [];
305
201
  const imagePaths = keptPngPaths(source, bundle);
306
202
  const sandboxIds = inventorySandboxIds(source.files);
203
+ // run.json and each trace file come from one rewritten bundle, so each trace's index binds the
204
+ // bytes written here. A v1 source is written as v2.
205
+ const derived = splitStreamTraces(publicSandboxView(derivedBundle(bundle, imagePaths), sandboxIds));
206
+ const derivedFiles = new Map([
207
+ ["run.json", jsonBytes(derived.bundle)],
208
+ ...derived.files.map((file) => [file.path, file.bytes]),
209
+ ]);
307
210
  let images = 0;
308
211
  for (const file of source.files) {
309
212
  const isPng = path.extname(file.path).toLowerCase() === ".png";
@@ -332,20 +235,11 @@ async function writeDerivative(source, stagePaths, bundle) {
332
235
  if (!TEXT_EXTENSIONS.has(extension))
333
236
  throw new Error(`Unsupported artifact format ${extension || "(no extension)"}; bundle export supports PNG and UTF-8 text only.`);
334
237
  bytes = transformText(file, imagePaths);
335
- if (file.path === "run.json") {
238
+ const rewritten = derivedFiles.get(file.path);
239
+ if (rewritten !== undefined) {
336
240
  assertRewritableNumbers(file.bytes.toString("utf8"));
337
- const transformed = redactSandboxIds(updateScreenshotDeclarations(bundle, imagePaths));
338
- transformed.redaction = {
339
- status: "passed",
340
- notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
341
- };
342
- for (const stream of transformed.streams) {
343
- for (const artifact of stream.artifacts) {
344
- if (artifact.kind === "screenshot")
345
- artifact.label = artifact.label.replace(/\((raw|blurred)\)/g, "(blurred at export)");
346
- }
347
- }
348
- bytes = jsonBytes(transformed);
241
+ bytes = rewritten;
242
+ derivedFiles.delete(file.path);
349
243
  }
350
244
  bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
351
245
  action = bytes.equals(file.bytes) ? "copied" : "updated";
@@ -353,6 +247,19 @@ async function writeDerivative(source, stagePaths, bundle) {
353
247
  await writeContainedOutputFile(stagePaths, file.path, bytes);
354
248
  entries.push({ path: file.path, sourceSha256: file.sha256, outputSha256: hash(bytes), action });
355
249
  }
250
+ // A v1 source stream with no actor trace file gets one; its content came from run.json.
251
+ const runJson = source.files.find((file) => file.path === "run.json");
252
+ for (const [relative, bytes] of derivedFiles) {
253
+ if (relative === "run.json" || runJson === undefined)
254
+ continue;
255
+ await writeContainedOutputFile(stagePaths, relative, bytes);
256
+ entries.push({
257
+ path: relative,
258
+ sourceSha256: runJson.sha256,
259
+ outputSha256: hash(bytes),
260
+ action: "updated",
261
+ });
262
+ }
356
263
  return { images, entries };
357
264
  }
358
265
  async function removeOwnedDirectory(root, strict = false) {