humanish 0.124.0 → 0.125.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 (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +2 -2
  4. package/dist/analysis/evidence.js +2 -2
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +1 -0
  7. package/dist/observer/pinned-files.d.ts +3 -1
  8. package/dist/observer/pinned-files.js +10 -7
  9. package/dist/observer/run-routes.js +2 -1
  10. package/dist/routes/computer-use/live-flush.d.ts +9 -4
  11. package/dist/routes/computer-use/live-flush.js +16 -3
  12. package/dist/run/bundle-shape.js +2 -2
  13. package/dist/run/bundle.d.ts +10 -3
  14. package/dist/run/bundle.js +11 -2
  15. package/dist/run/contained-output.d.ts +22 -0
  16. package/dist/run/contained-output.js +97 -1
  17. package/dist/run/evidence-files.d.ts +12 -1
  18. package/dist/run/evidence-files.js +70 -40
  19. package/dist/run/live-items.d.ts +48 -0
  20. package/dist/run/live-items.js +242 -0
  21. package/dist/run/locate.d.ts +10 -0
  22. package/dist/run/locate.js +10 -0
  23. package/dist/run/run.d.ts +3 -2
  24. package/dist/run/run.js +29 -8
  25. package/dist/run/sandbox-ids.d.ts +0 -5
  26. package/dist/run/sandbox-ids.js +0 -7
  27. package/dist/run/stream-trace.d.ts +18 -9
  28. package/dist/run/stream-trace.js +113 -22
  29. package/dist/run/streams.d.ts +17 -2
  30. package/dist/study/parse/actors.js +23 -6
  31. package/dist/study/parse/execution.js +6 -0
  32. package/dist/study/plan-base.js +4 -1
  33. package/dist/verify/artifacts.js +39 -51
  34. package/dist/verify/screenshot-reads.d.ts +21 -0
  35. package/dist/verify/screenshot-reads.js +46 -0
  36. package/dist/verify/verify.js +7 -6
  37. package/docs/architecture/github-feedback-loop.md +1 -1
  38. package/docs/contracts/README.md +1 -1
  39. package/docs/contracts/run-bundle.md +75 -13
  40. package/docs/contracts/schemas.md +38 -37
  41. package/package.json +3 -1
@@ -1,10 +1,14 @@
1
1
  // Where a participant's trace lives. A v1 run.json holds every stream's trace items and screenshot
2
2
  // entries inline. From v2 the run writer moves them into one file per stream and leaves an index
3
- // in run.json, so run.json stays a summary whatever the length of the sessions. Every reader that
4
- // needs the items loads them here, which accepts both versions and checks a file against its index.
3
+ // in run.json, so run.json stays a summary whatever the length of the sessions. From v3 a running
4
+ // participant's items so far are in a live file run.json indexes too (src/run/live-items.ts).
5
+ // Every reader that needs the items loads them here, which accepts every version and checks each
6
+ // file against its index.
5
7
  import { createHash } from "node:crypto";
6
- import { RUN_BUNDLE_SCHEMA } from "./bundle.js";
7
- import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
8
+ import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V2_SCHEMA, } from "./bundle.js";
9
+ import { isRunBundle } from "./bundle-shape.js";
10
+ import { readContainedPrefix, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
11
+ import { isLiveItemsIndex, LIVE_ACTOR_SCHEMA, LIVE_ACTOR_V1_SCHEMA, liveItemsFromBytes, } from "./live-items.js";
8
12
  import { isRunStreamArtifact, isStreamTraceIndex, isStreamTracePath } from "./stream-shape.js";
9
13
  import { isRecord } from "./type-guards.js";
10
14
  /** A trace that does not match its index, or cannot be read. The message names the stream. */
@@ -83,8 +87,10 @@ export function splitStreamTraces(bundle) {
83
87
  return { bundle: { ...bundle, schema: RUN_BUNDLE_SCHEMA, streams }, files };
84
88
  }
85
89
  function containedReader(runPaths) {
86
- return async (relativePath, maxBytes) => {
87
- const read = await readContainedRegularFile(runPaths, relativePath, maxBytes);
90
+ return async (relativePath, maxBytes, length) => {
91
+ const read = length === undefined
92
+ ? await readContainedRegularFile(runPaths, relativePath, maxBytes)
93
+ : await readContainedPrefix(runPaths, relativePath, length);
88
94
  if (read.status === "missing")
89
95
  return { refused: `${relativePath} is missing` };
90
96
  if (read.status === "refused")
@@ -136,15 +142,64 @@ function assertIndex(stream) {
136
142
  if (stream.actor.items !== undefined)
137
143
  throw new StreamTraceRefusedError(stream.id, "run.json holds items beside its trace index");
138
144
  }
139
- /** The stream with its trace joined, and the bytes it was joined from when it has an index. */
140
- async function readStreamTrace(read, stream, maxBytes) {
141
- if (stream.trace === undefined)
145
+ /** A live actor whose items run.json holds inline: humanish.live-actor.v1, or one with no schema. */
146
+ const isInlineLiveActor = (live) => live.schema === LIVE_ACTOR_V1_SCHEMA || live.schema === undefined;
147
+ /** A live file that could not be read, which a writer that moved on may have removed. */
148
+ class LiveFileUnreadableError extends StreamTraceRefusedError {
149
+ }
150
+ /**
151
+ * The stream with its live actor's items joined from the indexed prefix of its live file, and the
152
+ * file's path and those bytes. A live actor with inline items (humanish.live-actor.v1) is returned
153
+ * as it is; bundleRefusal refuses one in a v3 run.json.
154
+ */
155
+ async function readLiveItems(read, stream, maxBytes) {
156
+ const live = stream.liveActor;
157
+ if (live === undefined || isInlineLiveActor(live))
142
158
  return { stream };
143
- assertIndex(stream);
144
- const file = await read(stream.trace.path, maxBytes);
159
+ const refuse = (detail) => {
160
+ throw new StreamTraceRefusedError(stream.id, detail);
161
+ };
162
+ if (live.schema !== LIVE_ACTOR_SCHEMA)
163
+ refuse(`run.json records live actor schema ${String(live.schema)}, which this version does not read`);
164
+ const index = live.itemsFile;
165
+ if (!isLiveItemsIndex(index))
166
+ return refuse("run.json records a malformed live items index");
167
+ if (live.items !== undefined)
168
+ refuse("run.json holds in-progress items beside their live file");
169
+ if (index.bytes > maxBytes)
170
+ refuse(`${index.path} is indexed at ${index.bytes} bytes, more than the ${maxBytes} read`);
171
+ const file = await read(index.path, maxBytes, index.bytes);
145
172
  if ("refused" in file)
146
- throw new StreamTraceRefusedError(stream.id, file.refused);
147
- return { stream: joinTrace(stream, file.bytes), bytes: file.bytes };
173
+ throw new LiveFileUnreadableError(stream.id, file.refused);
174
+ // Only the indexed prefix: a reader that ignores `length` hands over the bytes appended since.
175
+ const joined = liveItemsFromBytes(file.bytes.subarray(0, index.bytes), index);
176
+ if ("refused" in joined)
177
+ return refuse(joined.refused);
178
+ const { itemsFile: _index, ...summary } = live;
179
+ return {
180
+ stream: { ...stream, liveActor: { ...summary, items: joined.items } },
181
+ live: { path: index.path, bytes: file.bytes.subarray(0, index.bytes) },
182
+ };
183
+ }
184
+ /**
185
+ * The stream with its trace and live items joined, and each file's path and the bytes it was
186
+ * joined from.
187
+ */
188
+ async function readStreamTrace(read, stream, maxBytes) {
189
+ const files = [];
190
+ let joined = stream;
191
+ if (stream.trace !== undefined) {
192
+ assertIndex(stream);
193
+ const file = await read(stream.trace.path, maxBytes);
194
+ if ("refused" in file)
195
+ throw new StreamTraceRefusedError(stream.id, file.refused);
196
+ joined = joinTrace(stream, file.bytes);
197
+ files.push({ path: stream.trace.path, bytes: file.bytes });
198
+ }
199
+ const live = await readLiveItems(read, joined, maxBytes);
200
+ if (live.live !== undefined)
201
+ files.push(live.live);
202
+ return { stream: live.stream, files };
148
203
  }
149
204
  /**
150
205
  * The stream as a v1 run.json held it: its actor with every item and every artifact entry. A
@@ -163,8 +218,10 @@ export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_
163
218
  * case-insensitive file system compares them, is refused. In v2 the writer
164
219
  * indexes every actor on every write, mid-run flushes included, so a v2 actor without an index lost
165
220
  * its trace file, and inline items never come from the writer. A run still writing carries its
166
- * participants' items so far as `liveActor` (humanish.live-actor.v1), which no index binds; a
167
- * run.json whose outcome says the run finished never does, since the final write replaces it.
221
+ * participants' items so far as `liveActor`: inline in v2 (humanish.live-actor.v1), in a live file
222
+ * from v3 (humanish.live-actor.v2), so a v3 run.json with inline live items did not come from the
223
+ * writer. A run.json whose outcome says the run finished never carries `liveActor`, since the
224
+ * final write replaces it.
168
225
  */
169
226
  function bundleRefusal(bundle, stream, owners) {
170
227
  const shared = isRecord(stream.trace)
@@ -172,12 +229,16 @@ function bundleRefusal(bundle, stream, owners) {
172
229
  : undefined;
173
230
  if (shared !== undefined && shared.length > 1)
174
231
  return `run.json indexes ${String(stream.trace.path)} for participants ${shared.join(", ")} (paths that differ only in case name one file on some file systems), and a trace file belongs to one participant`;
175
- if (bundle.schema !== RUN_BUNDLE_SCHEMA)
232
+ if (bundle.schema !== RUN_BUNDLE_SCHEMA && bundle.schema !== RUN_BUNDLE_V2_SCHEMA)
176
233
  return undefined;
177
234
  if (stream.actor !== undefined && stream.trace === undefined)
178
235
  return "run.json holds its actor and names no trace file";
179
236
  if (stream.liveActor !== undefined && bundle.outcome?.state === "finished")
180
237
  return "run.json records a finished run and still holds in-progress items";
238
+ if (bundle.schema === RUN_BUNDLE_SCHEMA &&
239
+ stream.liveActor !== undefined &&
240
+ isInlineLiveActor(stream.liveActor))
241
+ return "run.json v3 holds in-progress items inline, which the writer keeps in a live file";
181
242
  return undefined;
182
243
  }
183
244
  /**
@@ -185,11 +246,41 @@ function bundleRefusal(bundle, stream, owners) {
185
246
  * of each stream that could not be (see bundleRefusal for the rules that need the whole bundle).
186
247
  * Each refused stream is kept as run.json holds it; a reader that shows the run passes the result
187
248
  * to displayedTraces. `source` is the run's prepared paths, or a TraceFileReader. `onTrace`
188
- * receives each trace file's path and the bytes the stream was joined from, after they matched
189
- * the index, so a caller scans exactly what it reads.
249
+ * receives each trace and live file's path and the bytes the stream was joined from, after they
250
+ * matched the index, so a caller scans exactly what it reads.
251
+ *
252
+ * A live file that cannot be read may have been removed by the writer after `bundle` was read: it
253
+ * removes a superseded generation once run.json names the next one, and every live file once the
254
+ * run finished. The loader then reads run.json again, once, through the same reader, and loads
255
+ * that bundle instead when it is a run bundle of the same run.
190
256
  */
191
257
  export async function loadStreamTraces(source, bundle, options = {}) {
192
258
  const read = typeof source === "function" ? source : containedReader(source);
259
+ const maxBytes = options.maxBytes ?? RUN_ARTIFACT_MAX_BYTES;
260
+ let loaded = await loadEveryStream(read, bundle, maxBytes);
261
+ if (loaded.refused.some((error) => error instanceof LiveFileUnreadableError)) {
262
+ const current = await rereadBundle(read, bundle.runId);
263
+ if (current !== undefined)
264
+ loaded = await loadEveryStream(read, current, maxBytes);
265
+ }
266
+ for (const file of loaded.files)
267
+ options.onTrace?.(file.path, file.bytes);
268
+ return { bundle: loaded.bundle, refused: loaded.refused };
269
+ }
270
+ /** run.json as it is now, when it is a run bundle of run `runId`. */
271
+ async function rereadBundle(read, runId) {
272
+ const file = await read(RUN_BUNDLE_FILE, RUN_ARTIFACT_MAX_BYTES);
273
+ if ("refused" in file)
274
+ return undefined;
275
+ try {
276
+ const value = JSON.parse(file.bytes.toString("utf8"));
277
+ return isRunBundle(value) && value.runId === runId ? value : undefined;
278
+ }
279
+ catch {
280
+ return undefined;
281
+ }
282
+ }
283
+ async function loadEveryStream(read, bundle, maxBytes) {
193
284
  const owners = new Map();
194
285
  for (const stream of bundle.streams)
195
286
  if (isRecord(stream.trace)) {
@@ -198,14 +289,14 @@ export async function loadStreamTraces(source, bundle, options = {}) {
198
289
  }
199
290
  const streams = [];
200
291
  const refused = [];
292
+ const files = [];
201
293
  for (const stream of bundle.streams) {
202
294
  try {
203
295
  const reason = bundleRefusal(bundle, stream, owners);
204
296
  if (reason !== undefined)
205
297
  throw new StreamTraceRefusedError(stream.id, reason);
206
- const loaded = await readStreamTrace(read, stream, options.maxBytes ?? RUN_ARTIFACT_MAX_BYTES);
207
- if (loaded.bytes !== undefined)
208
- options.onTrace?.(stream.trace.path, loaded.bytes);
298
+ const loaded = await readStreamTrace(read, stream, maxBytes);
299
+ files.push(...loaded.files);
209
300
  streams.push(loaded.stream);
210
301
  }
211
302
  catch (error) {
@@ -215,7 +306,7 @@ export async function loadStreamTraces(source, bundle, options = {}) {
215
306
  streams.push(stream);
216
307
  }
217
308
  }
218
- return { bundle: { ...bundle, streams }, refused };
309
+ return { bundle: { ...bundle, streams }, refused, files };
219
310
  }
220
311
  /**
221
312
  * A loaded bundle as a reader that shows a run presents it. A refused stream keeps run.json's
@@ -154,6 +154,18 @@ export interface StreamTraceIndex {
154
154
  items: number;
155
155
  screenshots: number;
156
156
  }
157
+ /**
158
+ * A v3 run.json's index of a running participant's live file (src/run/live-items.ts): its path
159
+ * `live/<position>.<generation>.ndjson`, the generation, and the prefix written so far, as its item
160
+ * (line) count, byte count and sha256. The file only grows, so the prefix stays as indexed.
161
+ */
162
+ export interface LiveItemsIndex {
163
+ path: string;
164
+ generation: number;
165
+ items: number;
166
+ bytes: number;
167
+ sha256: string;
168
+ }
157
169
  /** The original participant-facing assignment, before runtime access/coordination details. */
158
170
  export interface RunParticipantAssignment {
159
171
  mission: string;
@@ -260,10 +272,11 @@ export interface RunStream {
260
272
  * grow. It is deliberately not an ActorTrace: a running participant has no final status,
261
273
  * completionReason, or completedAt, and this shape cannot claim them. Present only on
262
274
  * `inProgress` bundles; the final write replaces it with the real `actor` and never
263
- * carries it.
275
+ * carries it. A v3 run.json holds `humanish.live-actor.v2` with `itemsFile` in place of `items`;
276
+ * the trace loader (src/run/stream-trace.ts) reads the file and puts the items back.
264
277
  */
265
278
  liveActor?: {
266
- schema: "humanish.live-actor.v1";
279
+ schema: "humanish.live-actor.v1" | "humanish.live-actor.v2";
267
280
  executionProfile?: ActorTrace["executionProfile"];
268
281
  providerRequests?: ActorTrace["providerRequests"];
269
282
  historyTurnsOmitted?: number;
@@ -273,6 +286,8 @@ export interface RunStream {
273
286
  /** When this flush was written (ISO-8601). */
274
287
  updatedAt: string;
275
288
  items: ActorTraceItem[];
289
+ /** In a v3 run.json, in place of `items`: the live file that holds them. */
290
+ itemsFile?: LiveItemsIndex;
276
291
  };
277
292
  completion?: RunStreamCompletion;
278
293
  recording?: RunDesktopRecording;
@@ -7,6 +7,7 @@ import { entryCredentialReason, urlCredentialReason } from "./url-credentials.js
7
7
  import { registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, } from "../routing.js";
8
8
  import { invalid, str } from "./values.js";
9
9
  import { isRecord } from "../../run/type-guards.js";
10
+ import { scanEncodedText } from "../../evidence/encoded-text.js";
10
11
  import { isStartAfterMs, startAfterMessage, windowReason } from "../arrivals.js";
11
12
  // A participant id interpolates into its evidence paths (screenshots/<id>/, actors/<id>.json), so
12
13
  // it must be a public-safe path token, same shape as a study id.
@@ -36,15 +37,29 @@ export function parseActorType(entry, path) {
36
37
  }
37
38
  return { ok: true, value: type };
38
39
  }
40
+ /**
41
+ * A persona id. It names the persona file `humanish/personas/<id>.yaml`, and the run records it, so
42
+ * text verify would flag, as written or encoded, is refused here: redacting it would name a
43
+ * different file.
44
+ */
45
+ function parsePersonaId(raw, path) {
46
+ const persona = str(raw);
47
+ if (persona !== undefined && scanEncodedText(persona).sensitive) {
48
+ return invalid(`\`${path}\` looks like a secret or a local path, and the run records it. A persona id names the persona file humanish/personas/<id>.yaml: use a plain file name such as skeptical-shopper, and put the persona's details in that file.`);
49
+ }
50
+ return { ok: true, value: persona };
51
+ }
39
52
  /**
40
53
  * Every actor key other than `type`. The parser reads them after the participant entries, so a file
41
54
  * with errors in both reports the entry first.
42
55
  */
43
56
  export function parseActorFields(entry, path) {
44
57
  const actor = {};
45
- const persona = str(entry.persona);
46
- if (persona)
47
- actor.persona = persona;
58
+ const persona = parsePersonaId(entry.persona, `${path}.persona`);
59
+ if (!persona.ok)
60
+ return persona;
61
+ if (persona.value !== undefined)
62
+ actor.persona = persona.value;
48
63
  const mission = str(entry.mission);
49
64
  if (mission)
50
65
  actor.mission = mission;
@@ -141,9 +156,11 @@ export function parseParticipantEntries(raw, paths) {
141
156
  }
142
157
  parsedEntry.device = device;
143
158
  }
144
- const persona = str(entry.persona);
145
- if (persona !== undefined)
146
- parsedEntry.persona = persona;
159
+ const persona = parsePersonaId(entry.persona, `${paths.entry(entryIndex)}.persona`);
160
+ if (!persona.ok)
161
+ return persona;
162
+ if (persona.value !== undefined)
163
+ parsedEntry.persona = persona.value;
147
164
  const actorType = parseEntryMetadata(entry.actorType, `${paths.entry(entryIndex)}.actorType`);
148
165
  if (!actorType.ok)
149
166
  return actorType;
@@ -3,6 +3,7 @@ import { DEVICE_PRESET_NAMES, isDevicePresetName } from "../device-presets.js";
3
3
  import { isExactRuntimeVersion } from "../../routes/terminal/runtime.js";
4
4
  import { invalid, nonNegNumber, posInt, str } from "./values.js";
5
5
  import { isRecord } from "../../run/type-guards.js";
6
+ import { scanEncodedText } from "../../evidence/encoded-text.js";
6
7
  export function parseExecution(raw) {
7
8
  if (raw === undefined) {
8
9
  return { ok: true, value: undefined };
@@ -154,11 +155,16 @@ function parseDesktop(raw) {
154
155
  // A custom E2B desktop template name or ID. Trimmed non-empty when present; deliberately not
155
156
  // allowlisted (any string is a valid template name/id; over-restricting would reject real
156
157
  // adopter images). An explicitly-set but blank/whitespace value is a mistake, not a template.
158
+ // The run records the template it launched on, so text verify would flag, as written or
159
+ // encoded, is refused: redacting it in the record would name an image other than the one that ran.
157
160
  if (raw.template !== undefined) {
158
161
  const template = str(raw.template);
159
162
  if (template === undefined) {
160
163
  return invalid("`execution.desktop.template` must be a non-empty E2B desktop template name or ID when it is set. Any name is accepted.");
161
164
  }
165
+ if (scanEncodedText(template).sensitive) {
166
+ return invalid("`execution.desktop.template` looks like a secret or a local path, and the run records the template it launched on. Set it to the E2B template's name or ID only.");
167
+ }
162
168
  desktop.template = template;
163
169
  }
164
170
  if (typeof raw.codexAppServer === "boolean")
@@ -1,6 +1,7 @@
1
1
  // What every route planner shares: the plan fields common to all routes, the frozen residual config,
2
2
  // and the requirement and subject helpers.
3
3
  import { DEFAULT_OPENAI_CU_MODEL } from "../actors/computer-use/openai-provider.js";
4
+ import { redactText } from "../evidence/redaction.js";
4
5
  // A YAML alias can make a parsed record contain itself (an inline persona that names its own
5
6
  // anchor), and structuredClone keeps the cycle, so each object is frozen once.
6
7
  function deepFreeze(value, frozen = new WeakSet()) {
@@ -110,7 +111,9 @@ export function planBase(config, input) {
110
111
  const analysis = input.analysis?.config;
111
112
  return {
112
113
  studyId: config.id,
113
- ...(config.title === undefined ? {} : { title: config.title }),
114
+ // Every bundle writer takes the title from here, and review.md, run.json and the Observer show
115
+ // it, so a key or sandbox URL in it would make verify grade the run blocked.
116
+ ...(config.title === undefined ? {} : { title: redactText(config.title) }),
114
117
  residual: residualOf(config),
115
118
  dryRun: input.dryRun,
116
119
  ...(analysis === undefined
@@ -1,6 +1,5 @@
1
1
  import { lstat, readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { isRedactedFrameShape, screenshotEvidenceError } from "../evidence/image.js";
4
3
  import { isAnalysisRecordPath } from "../analysis/sharing.js";
5
4
  import { scanEncodedTextCached } from "../evidence/encoded-text.js";
6
5
  import { readPlainText } from "../evidence/plain-text.js";
@@ -9,12 +8,14 @@ import { isLocalEvidenceArtifactPath, isRiskyPublicArtifactPath, validatePrepare
9
8
  import { openContainedRegularFile, RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
10
9
  import { holdsKeyedSandboxId } from "../run/sandbox-ids.js";
11
10
  import { SANDBOX_RECEIPTS_ARTIFACT } from "../run/sandbox-receipts.js";
11
+ import { mapWithConcurrency } from "../run/concurrency.js";
12
12
  import { runEntryLimit } from "./scan-limit.js";
13
13
  import { readSafeRunArtifact, readSafeRunArtifactBytes, readSafeRunArtifactJson, } from "../run/locate.js";
14
14
  import { isRecord } from "../run/type-guards.js";
15
15
  import { TERMINAL_EVENTS_ARTIFACT } from "../run/terminal-contract.js";
16
16
  import { plural } from "../run/text.js";
17
17
  import { isZeroEventTerminalTrace } from "./actor.js";
18
+ import { readScreenshot } from "./screenshot-reads.js";
18
19
  /** Public-safety and evidence-reference findings stop at this many per list. */
19
20
  export const MAX_REPORTED_FINDINGS = 50;
20
21
  // The UNSCANNED_ARTIFACT reason names this many paths of each kind and counts the rest.
@@ -108,44 +109,38 @@ export async function missingLocalEvidenceArtifacts(runPaths, bundle) {
108
109
  }
109
110
  }
110
111
  }
111
- const missing = [];
112
- for (const [artifactPath, requirements] of requiredPaths) {
113
- const recording = recordings.get(artifactPath);
114
- if (recording) {
115
- const handle = await openContainedRegularFile(runPaths, artifactPath);
116
- try {
117
- if (!handle || (await handle.stat()).size !== recording.bytes)
118
- missing.push(artifactPath);
119
- else {
120
- const header = Buffer.alloc(12);
121
- const read = await handle.read(header, 0, header.length, 0);
122
- if (read.bytesRead !== header.length || header.toString("ascii", 4, 8) !== "ftyp")
123
- missing.push(`${artifactPath} (invalid MP4 header)`);
124
- }
125
- }
126
- finally {
127
- await handle?.close();
128
- }
129
- continue;
130
- }
131
- const artifact = await readSafeRunArtifact(runPaths, artifactPath);
132
- if (artifact.state === "limit") {
133
- missing.push(`${artifactPath} (larger than the ${RUN_ARTIFACT_MAX_BYTES} bytes verify reads)`);
134
- continue;
135
- }
136
- if (artifact.state === "unavailable" ||
137
- (artifact.bytes.length === 0 && !requirements.allowEmpty)) {
138
- missing.push(artifactPath);
139
- continue;
140
- }
141
- if (requirements.screenshot) {
142
- const imageError = screenshotEvidenceError(artifactPath, artifact.bytes);
143
- if (imageError) {
144
- missing.push(`${artifactPath} (${imageError})`);
145
- }
112
+ // Reads overlap, so one frame's file system calls run while another frame's PNG is checked.
113
+ const found = await mapWithConcurrency([...requiredPaths], EVIDENCE_READ_CONCURRENCY, ([artifactPath, requirements]) => missingArtifact(runPaths, artifactPath, requirements, recordings.get(artifactPath)));
114
+ return found.filter((entry) => entry !== null);
115
+ }
116
+ // Evidence files verify reads at once. Each read holds at most RUN_ARTIFACT_MAX_BYTES.
117
+ const EVIDENCE_READ_CONCURRENCY = 8;
118
+ /** Why one required path fails its requirements, or null when it meets them. */
119
+ async function missingArtifact(runPaths, artifactPath, requirements, recording) {
120
+ if (recording) {
121
+ const handle = await openContainedRegularFile(runPaths, artifactPath);
122
+ try {
123
+ if (!handle || (await handle.stat()).size !== recording.bytes)
124
+ return artifactPath;
125
+ const header = Buffer.alloc(12);
126
+ const read = await handle.read(header, 0, header.length, 0);
127
+ return read.bytesRead !== header.length || header.toString("ascii", 4, 8) !== "ftyp"
128
+ ? `${artifactPath} (invalid MP4 header)`
129
+ : null;
130
+ }
131
+ finally {
132
+ await handle?.close();
146
133
  }
147
134
  }
148
- return missing;
135
+ const read = requirements.screenshot
136
+ ? await readScreenshot(runPaths, artifactPath)
137
+ : await readSafeRunArtifact(runPaths, artifactPath);
138
+ if (read.state === "limit")
139
+ return `${artifactPath} (larger than the ${RUN_ARTIFACT_MAX_BYTES} bytes verify reads)`;
140
+ if (read.state === "unavailable" ||
141
+ (("size" in read ? read.size : read.bytes.length) === 0 && !requirements.allowEmpty))
142
+ return artifactPath;
143
+ return "error" in read && read.error !== null ? `${artifactPath} (${read.error})` : null;
149
144
  }
150
145
  function declaredActorScreenshotReferences(stream) {
151
146
  const references = [];
@@ -270,20 +265,13 @@ export function rawScreenshotPostureWarnings(bundle, redactedShapeFrames) {
270
265
  * (isRedactedFrameShape). A frame that is missing or cannot be read is not in the set.
271
266
  */
272
267
  export async function redactedShapeFramePaths(runPaths, bundle) {
273
- const shaped = new Set();
274
- for (const stream of bundle.streams) {
275
- for (const reference of declaredActorScreenshotReferences(stream)) {
276
- if (!isRunRootEvidenceReference(reference.path))
277
- continue;
278
- const normalized = normalizedFramePath(reference.path);
279
- if (shaped.has(normalized))
280
- continue;
281
- const bytes = await readSafeRunArtifactBytes(runPaths, normalized).catch(() => null);
282
- if (bytes !== null && isRedactedFrameShape(bytes))
283
- shaped.add(normalized);
284
- }
285
- }
286
- return shaped;
268
+ const frames = new Set();
269
+ for (const stream of bundle.streams)
270
+ for (const reference of declaredActorScreenshotReferences(stream))
271
+ if (isRunRootEvidenceReference(reference.path))
272
+ frames.add(normalizedFramePath(reference.path));
273
+ const reads = await mapWithConcurrency([...frames], EVIDENCE_READ_CONCURRENCY, (frame) => readScreenshot(runPaths, frame));
274
+ return new Set([...frames].filter((_, index) => reads[index].state === "read" && reads[index].redactedShape));
287
275
  }
288
276
  function normalizedFramePath(value) {
289
277
  return path.posix.normalize(value.replace(/\\/g, "/"));
@@ -0,0 +1,21 @@
1
+ import type { PreparedRunArtifactPaths } from "../run/paths.js";
2
+ /** What verify needs from a registered screenshot, from one read of its bytes. */
3
+ type ScreenshotRead = {
4
+ state: "read";
5
+ size: number;
6
+ /** screenshotEvidenceError for the path the caller gave. */
7
+ error: string | null;
8
+ redactedShape: boolean;
9
+ } | {
10
+ state: "limit";
11
+ } | {
12
+ state: "unavailable";
13
+ };
14
+ /**
15
+ * A registered screenshot's facts: its size, why it is not PNG evidence (null when it is), and
16
+ * whether it has the redactor's output shape. `relativePath` may use `\`, read as `/`. A frame read
17
+ * before through the same `runPaths` is not read again while it is still the same file with the
18
+ * same size and times, and its last change was at least 2 s before that read started.
19
+ */
20
+ export declare function readScreenshot(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<ScreenshotRead>;
21
+ export {};
@@ -0,0 +1,46 @@
1
+ import { isRedactedFrameShape, screenshotEvidenceError } from "../evidence/image.js";
2
+ import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
3
+ import { isUnchangedFile, readUnchangedFile } from "../run/evidence-files.js";
4
+ // A stamp from a read that started within this long of the file's last change may not show a
5
+ // later write: a filesystem with coarse timestamps gives both writes the same times.
6
+ const RACY_WINDOW_NS = 2000000000n;
7
+ /**
8
+ * Facts per prepared run, by the path the caller gave. A finished study verifies its run up to five
9
+ * times through one prepared object (the Observer render, the analysis gate, the analysis and two
10
+ * refreshes), and decoding every frame each time took most of each verify on a long run. The
11
+ * entries live as long as the prepared object.
12
+ */
13
+ const known = new WeakMap();
14
+ /**
15
+ * A registered screenshot's facts: its size, why it is not PNG evidence (null when it is), and
16
+ * whether it has the redactor's output shape. `relativePath` may use `\`, read as `/`. A frame read
17
+ * before through the same `runPaths` is not read again while it is still the same file with the
18
+ * same size and times, and its last change was at least 2 s before that read started.
19
+ */
20
+ export async function readScreenshot(runPaths, relativePath) {
21
+ const filePath = relativePath.replace(/\\/g, "/");
22
+ // Created before the first await: reads of one run overlap, and each must add to the same map.
23
+ let entries = known.get(runPaths);
24
+ if (entries === undefined)
25
+ known.set(runPaths, (entries = new Map()));
26
+ const entry = entries.get(relativePath);
27
+ if (entry !== undefined && (await isUnchangedFile(runPaths, filePath, entry.stamp)))
28
+ return entry.facts;
29
+ const startedNs = BigInt(Date.now()) * 1000000n;
30
+ const read = await readUnchangedFile(runPaths, filePath, RUN_ARTIFACT_MAX_BYTES);
31
+ if (read.state !== "read") {
32
+ entries.delete(relativePath);
33
+ return read.state === "limit" ? { state: "limit" } : read;
34
+ }
35
+ const facts = {
36
+ state: "read",
37
+ size: read.bytes.length,
38
+ error: screenshotEvidenceError(relativePath, read.bytes),
39
+ redactedShape: isRedactedFrameShape(read.bytes),
40
+ };
41
+ if (read.stamp.ctimeNs <= startedNs - RACY_WINDOW_NS)
42
+ entries.set(relativePath, { stamp: read.stamp, facts });
43
+ else
44
+ entries.delete(relativePath);
45
+ return facts;
46
+ }
@@ -5,7 +5,7 @@ import { analysisSharingProblems } from "../analysis/sharing.js";
5
5
  import { containsSensitive, REDACTED_SANDBOX_ID } from "../evidence/redaction.js";
6
6
  import { keyedSandboxIds, readRunSandboxIds } from "../run/sandbox-ids.js";
7
7
  import { validatePreparedRunArtifactPaths } from "../run/paths.js";
8
- import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, } from "../run/bundle.js";
8
+ import { isReadRunBundleSchema, RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, RUN_BUNDLE_V2_SCHEMA, } from "../run/bundle.js";
9
9
  import { ContainedReadRefusedError, refusalText } from "../run/contained-output.js";
10
10
  import { isCleanupResult, isRunBundle } from "../run/bundle-shape.js";
11
11
  import { loadStreamTraces } from "../run/stream-trace.js";
@@ -272,7 +272,7 @@ function bundlePresenceChecks(bundleRead, review) {
272
272
  const bundle = runJsonValue(bundleRead);
273
273
  const record = isRecord(bundle) ? bundle : undefined;
274
274
  const schema = record?.schema;
275
- const schemaOk = schema === RUN_BUNDLE_SCHEMA || schema === RUN_BUNDLE_V1_SCHEMA;
275
+ const schemaOk = isReadRunBundleSchema(schema);
276
276
  const redactionRecord = record?.redaction;
277
277
  const redaction = isRecord(redactionRecord) ? redactionRecord.status : undefined;
278
278
  const missingSections = REQUIRED_SECTIONS.filter((key) => record?.[key] === undefined);
@@ -305,7 +305,7 @@ function bundlePresenceChecks(bundleRead, review) {
305
305
  ? `run.json declares ${String(schema)}`
306
306
  : record === undefined
307
307
  ? "no readable run.json, so no schema"
308
- : `run.json declares ${typeof schema === "string" ? schema : "no schema"}; verify reads ${RUN_BUNDLE_SCHEMA} and ${RUN_BUNDLE_V1_SCHEMA}`,
308
+ : `run.json declares ${typeof schema === "string" ? schema : "no schema"}; verify reads ${RUN_BUNDLE_SCHEMA}, ${RUN_BUNDLE_V2_SCHEMA} and ${RUN_BUNDLE_V1_SCHEMA}`,
309
309
  },
310
310
  {
311
311
  name: "run bundle shape",
@@ -439,10 +439,11 @@ function buildShareSafety(args) {
439
439
  const unscanned = unscannedMessage(args.unscannedArtifacts);
440
440
  if (unscanned !== undefined)
441
441
  reasons.push({ code: "UNSCANNED_ARTIFACT", message: unscanned });
442
- // A v2 run.json binds each finished participant's items by its trace index. Items written while
443
- // a participant was still working (liveActor) have no index, so such a run stays local.
442
+ // From v2, run.json binds each finished participant's items by its trace index. Items written
443
+ // while a participant was still working (liveActor) belong to no finished trace, so such a run
444
+ // stays local.
444
445
  const live = args.bundle.streams.filter((stream) => stream.liveActor !== undefined);
445
- if (args.bundle.schema === RUN_BUNDLE_SCHEMA && live.length > 0)
446
+ if (args.bundle.schema !== RUN_BUNDLE_V1_SCHEMA && live.length > 0)
446
447
  reasons.push({
447
448
  code: "RUN_NOT_FINISHED",
448
449
  message: `The run did not finish: run.json holds in-progress items for ${live.map((stream) => stream.id).join(", ")}, which no trace file binds. Review it locally; rerun the study for evidence you can share.`,
@@ -129,7 +129,7 @@ humanish_swarm:
129
129
  - .env*
130
130
  - .github/workflows/**
131
131
  - infra/**
132
- artifact_schema_version: humanish.run-bundle.v2
132
+ artifact_schema_version: humanish.run-bundle.v3
133
133
  credential_manifest: []
134
134
  network_policy: no_network
135
135
  spend_policy: no_spend
@@ -8,7 +8,7 @@ disagree.
8
8
  (`RunStudyOptions`) and the exports removed in 0.107.0.
9
9
  - [core.md](core.md): the records a run writes under `.humanish/runs/<id>/` and how each route
10
10
  starts a run.
11
- - [run-bundle.md](run-bundle.md): `humanish.run-bundle.v2`, the evidence contract that `verify`
11
+ - [run-bundle.md](run-bundle.md): `humanish.run-bundle.v3`, the evidence contract that `verify`
12
12
  checks.
13
13
  - [policy.md](policy.md): credential, network, spend, redaction, sharing and assisted-run
14
14
  boundaries.