humanish 0.136.0 → 0.137.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 (63) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/actors/computer-use/actor.d.ts +2 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/observation.js +2 -0
  5. package/dist/actors/computer-use/loop/session.d.ts +3 -2
  6. package/dist/actors/computer-use/loop/session.js +8 -12
  7. package/dist/actors/computer-use/loop/types.d.ts +14 -1
  8. package/dist/evidence/browser-network.d.ts +108 -0
  9. package/dist/evidence/browser-network.js +516 -0
  10. package/dist/evidence/trace-page.d.ts +31 -1
  11. package/dist/evidence/trace-page.js +68 -2
  12. package/dist/observer-app.html +4 -4
  13. package/dist/routes/computer-use/bundle.js +1 -0
  14. package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
  15. package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
  16. package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
  17. package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
  18. package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
  19. package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
  20. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
  21. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  22. package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
  23. package/dist/routes/computer-use/fanout-records.js +10 -0
  24. package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
  25. package/dist/routes/computer-use/participant-model.js +2 -1
  26. package/dist/routes/computer-use/plan.js +2 -1
  27. package/dist/routes/computer-use/single-bundle.d.ts +3 -0
  28. package/dist/routes/computer-use/single-bundle.js +4 -0
  29. package/dist/routes/computer-use/types.d.ts +7 -0
  30. package/dist/routes/scripted/plan.js +2 -2
  31. package/dist/routes/shared-world/plan.js +3 -2
  32. package/dist/routes/terminal/plan.js +2 -2
  33. package/dist/run/live-items.d.ts +20 -2
  34. package/dist/run/live-items.js +23 -14
  35. package/dist/run/streams.d.ts +7 -0
  36. package/dist/study/config.js +4 -1
  37. package/dist/study/keys.d.ts +3 -0
  38. package/dist/study/keys.js +2 -0
  39. package/dist/study/parse/comms.js +1 -20
  40. package/dist/study/parse/execution.js +30 -1
  41. package/dist/study/parse/values.d.ts +5 -0
  42. package/dist/study/parse/values.js +22 -0
  43. package/dist/study/plan-base.js +6 -1
  44. package/dist/study/plan-types.d.ts +1 -1
  45. package/dist/study/study-fields.d.ts +7 -1
  46. package/dist/study/study-fields.js +10 -0
  47. package/dist/study/types.d.ts +11 -0
  48. package/dist/study/validation.d.ts +8 -0
  49. package/dist/study/validation.js +31 -2
  50. package/dist/substrates/detached.d.ts +5 -0
  51. package/dist/substrates/detached.js +9 -0
  52. package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
  53. package/dist/substrates/e2b/cdp-network-capture.js +194 -0
  54. package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
  55. package/dist/substrates/e2b/cdp-probe.js +100 -36
  56. package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
  57. package/dist/substrates/e2b/desktop-cdp.js +87 -5
  58. package/dist/verify/network.d.ts +10 -0
  59. package/dist/verify/network.js +162 -0
  60. package/dist/verify/pages.js +1 -16
  61. package/dist/verify/verify.js +8 -2
  62. package/docs/contracts/run-bundle.md +86 -1
  63. package/package.json +6 -6
@@ -1,5 +1,3 @@
1
- // Chromium DevTools on a hosted E2B desktop, reached from inside the sandbox: browser state reads
2
- // and mobile emulation.
3
1
  import { chromeCdpProbeCommand, parseChromeCdpProbeOutput, } from "./cdp-probe.js";
4
2
  import { failureTail, toErrorMessage } from "../../evidence/redaction.js";
5
3
  import { readDetachedLog, startDetachedProcess } from "../detached.js";
@@ -18,7 +16,7 @@ import { e2bShell } from "./shell.js";
18
16
  * url/text criteria are not being measured, instead of letting the funnel report 0/N.
19
17
  */
20
18
  export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, options = {}) {
21
- const { targetId, onUnavailable, drift } = options;
19
+ const { targetId, onUnavailable, drift, onClock } = options;
22
20
  let reported = false;
23
21
  let drifted = false;
24
22
  const checkedTargets = new Set(drift === undefined ? [] : [drift.emulatedTargetId]);
@@ -68,6 +66,7 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
68
66
  : `the participant drove a page target other than the emulated launch tab and that page reports a ${fidelity.innerWidth} px viewport where ${drift.expectedWidth} px was requested (DPR ${fidelity.devicePixelRatio}); the mobile user agent and touch events are browser-wide, the viewport override was not re-applied to it`);
69
67
  };
70
68
  return async () => {
69
+ onClock?.(undefined);
71
70
  let result;
72
71
  try {
73
72
  result = await shell.run(chromeCdpProbeCommand({
@@ -85,6 +84,7 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
85
84
  return unavailable(`probe exited ${result.exitCode}: ${failureTail(result.stderr || result.stdout)}`);
86
85
  }
87
86
  const parsed = parseChromeCdpProbeOutput(result.stdout);
87
+ onClock?.(parsed.now);
88
88
  if (parsed.unavailable !== undefined)
89
89
  return unavailable(parsed.unavailable);
90
90
  if (parsed.targetId !== undefined)
@@ -103,16 +103,19 @@ export const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_
103
103
  * Apply mobile emulation to the participant's launch page and read back what the page reports.
104
104
  * Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
105
105
  * the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
106
- * (the emulation was applied; only the proof is missing).
106
+ * (the emulation was applied; only the proof is missing). With `network`, the same holder also
107
+ * captures requests, and a capture that could not start is reported in `network` without failing
108
+ * the emulation.
107
109
  */
108
110
  export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint, request, options = {}) {
109
- const { targetId } = options;
111
+ const { targetId, network } = options;
110
112
  const command = (mode) => chromeCdpProbeCommand({
111
113
  ...endpoint,
112
114
  ...(targetId === undefined ? {} : { targetId }),
113
115
  prefer: "pinned",
114
116
  mode,
115
117
  emulation: request,
118
+ ...(mode === "hold" && network !== undefined ? { network } : {}),
116
119
  });
117
120
  const shell = e2bShell(desktop);
118
121
  const read = async () => {
@@ -178,6 +181,7 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
178
181
  userAgent: request.userAgent,
179
182
  };
180
183
  const emulatedTargetId = applied.targetId ?? fidelityRead.targetId;
184
+ const capture = applied.network === undefined ? {} : { network: applied.network };
181
185
  if (fidelityRead.fidelity === undefined) {
182
186
  warnings.push(`Mobile emulation was applied but the page's own report could not be read (${fidelityRead.unavailable ?? "no fidelity read"}); desktopGeometry.fidelity carries the request without a resolved block.`);
183
187
  return {
@@ -185,6 +189,7 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
185
189
  warnings,
186
190
  holderName,
187
191
  ...(emulatedTargetId === undefined ? {} : { targetId: emulatedTargetId }),
192
+ ...capture,
188
193
  };
189
194
  }
190
195
  const resolved = { ...fidelityRead.fidelity, source: "cdp" };
@@ -207,5 +212,82 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
207
212
  warnings,
208
213
  holderName,
209
214
  ...(emulatedTargetId === undefined ? {} : { targetId: emulatedTargetId }),
215
+ ...capture,
210
216
  };
211
217
  }
218
+ /** The holder stops writing its events file at 32 MiB, so a read stops there too. */
219
+ const NETWORK_EVENTS_MAX_BYTES = 32 * 1024 * 1024;
220
+ /** How many times the capture holder's log is read for its announce, and how far apart. */
221
+ const NETWORK_ANNOUNCE_READS = 6;
222
+ const NETWORK_ANNOUNCE_EVERY_MS = 500;
223
+ /**
224
+ * Start a holder that records the launch page's requests, and every later tab's, to
225
+ * `network.eventsPath`, for a participant without mobile emulation; applyMobileEmulation passes
226
+ * the request to the holder it starts. Sends no Emulation command and no reload. Returns the
227
+ * holder's process name, which readNetworkCaptureStart reads.
228
+ */
229
+ export async function startNetworkCapture(desktop, requestTimeoutMs, endpoint, network) {
230
+ const holderName = `network-capture-${Date.now().toString(36)}`;
231
+ await startDetachedProcess(e2bShell(desktop), {
232
+ name: holderName,
233
+ command: chromeCdpProbeCommand({ ...endpoint, prefer: "pinned", mode: "hold", network }),
234
+ requestTimeoutMs,
235
+ });
236
+ return holderName;
237
+ }
238
+ /**
239
+ * What the capture holder announced: when capture started on the launch page, on the sandbox
240
+ * clock, or why it did not. Reads the holder's log up to 6 times, half a second apart by
241
+ * `desktop.wait`, and reports a holder that printed nothing as unavailable.
242
+ */
243
+ export async function readNetworkCaptureStart(desktop, requestTimeoutMs, holderName) {
244
+ const shell = e2bShell(desktop);
245
+ for (let attempt = 0; attempt < NETWORK_ANNOUNCE_READS; attempt += 1) {
246
+ if (attempt > 0)
247
+ await desktop.wait(NETWORK_ANNOUNCE_EVERY_MS).catch(() => undefined);
248
+ const log = await readDetachedLog(shell, holderName, requestTimeoutMs).catch(() => "");
249
+ const line = log.split("\n").find((candidate) => candidate.trim().startsWith("{"));
250
+ if (line === undefined)
251
+ continue;
252
+ const announced = parseChromeCdpProbeOutput(line);
253
+ return (announced.network ?? {
254
+ unavailable: announced.unavailable ?? "the holder announced without starting capture",
255
+ });
256
+ }
257
+ return {
258
+ unavailable: `the in-sandbox holder printed nothing within ${(NETWORK_ANNOUNCE_READS * NETWORK_ANNOUNCE_EVERY_MS) / 1000} s of the desktop settling`,
259
+ };
260
+ }
261
+ /**
262
+ * The capture holder's events file as text: whole lines only, from at most the first 32 MiB.
263
+ * Throws when the sandbox cannot stream files or the file cannot be read.
264
+ */
265
+ export async function readNetworkEvents(desktop, eventsPath, requestTimeoutMs) {
266
+ if (desktop.files.read === undefined)
267
+ throw new Error("this E2B SDK cannot stream a sandbox file to the host");
268
+ const stream = await desktop.files.read(eventsPath, {
269
+ format: "stream",
270
+ requestTimeoutMs,
271
+ streamIdleTimeoutMs: requestTimeoutMs,
272
+ signal: AbortSignal.timeout(requestTimeoutMs),
273
+ });
274
+ const reader = stream.getReader();
275
+ const chunks = [];
276
+ let total = 0;
277
+ try {
278
+ while (total < NETWORK_EVENTS_MAX_BYTES) {
279
+ const { done, value } = await reader.read();
280
+ if (done)
281
+ break;
282
+ const chunk = Buffer.from(value.subarray(0, NETWORK_EVENTS_MAX_BYTES - total));
283
+ chunks.push(chunk);
284
+ total += chunk.length;
285
+ }
286
+ }
287
+ finally {
288
+ await reader.cancel().catch(() => undefined);
289
+ }
290
+ const bytes = Buffer.concat(chunks);
291
+ // A line still being written, or one the cap cut, is dropped.
292
+ return bytes.subarray(0, bytes.lastIndexOf(0x0a) + 1).toString("utf8");
293
+ }
@@ -0,0 +1,10 @@
1
+ import type { RunBundle } from "../run/bundle.js";
2
+ import type { PreparedRunArtifactPaths } from "../run/paths.js";
3
+ /**
4
+ * One finding per problem with the run's browser network logs: a stream index without the
5
+ * schema's shape or naming a file other than network/<its participant id>.ndjson, a file that is
6
+ * missing, unreadable or does not match its index's byte count, sha256 and record count, a record
7
+ * outside the schema or on an origin its index does not list, and a file under network/ that no
8
+ * index names. Each finding names the file and the line, never what the line holds.
9
+ */
10
+ export declare function networkFindings(bundle: RunBundle, runPaths: PreparedRunArtifactPaths): Promise<string[]>;
@@ -0,0 +1,162 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { BROWSER_NETWORK_GAPS, BROWSER_NETWORK_SCHEMA, isIsoTime, networkLogPath, networkRecordProblem, } from "../evidence/browser-network.js";
4
+ import { REDACTION_MARKERS } from "../evidence/redaction.js";
5
+ import { originKeepsNoWithheldLabel, pageProblem } from "../evidence/trace-page.js";
6
+ import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "../run/contained-output.js";
7
+ import { boundJsonLines } from "../run/live-items.js";
8
+ import { streamParticipantIdOf } from "../run/participant-records.js";
9
+ import { isNonNegativeSafeInteger, isRecord } from "../run/type-guards.js";
10
+ // A participant's browser network log is a public-safety surface: a header, a cookie or a query
11
+ // string in it would have no shape the text scan matches. So verify reads each log a stream index
12
+ // names, binds it to the index as a live file is bound, and holds every record to the schema the
13
+ // writer holds it to. A file under network/ that no index names was not written by humanish.
14
+ const NETWORK_FOLDER = "network";
15
+ const RECORD = { one: "a record", many: "records" };
16
+ /** The record findings one file reports by line; the rest are counted in one more finding. */
17
+ const LINE_FINDINGS = 10;
18
+ /** Every key of the index type, so a key added to the type is one verify admits. */
19
+ const INDEX_KEYS = new Set(Object.keys({
20
+ schema: true,
21
+ path: true,
22
+ records: true,
23
+ failed: true,
24
+ pending: true,
25
+ bytes: true,
26
+ sha256: true,
27
+ startedAt: true,
28
+ origins: true,
29
+ complete: true,
30
+ reason: true,
31
+ omitted: true,
32
+ }));
33
+ const REASONS = new Set(BROWSER_NETWORK_GAPS);
34
+ /** What is wrong with a stream's network index, or undefined when it has the schema's shape. */
35
+ function networkIndexProblem(index) {
36
+ if (!isRecord(index))
37
+ return "is not an object";
38
+ if (Object.keys(index).some((key) => !INDEX_KEYS.has(key)))
39
+ return "has a key outside the schema";
40
+ if (index.schema !== BROWSER_NETWORK_SCHEMA)
41
+ return `is not ${BROWSER_NETWORK_SCHEMA}`;
42
+ const file = [index.records, index.failed, index.pending, index.bytes, index.sha256];
43
+ if (index.path === undefined) {
44
+ if (file.some((value) => value !== undefined))
45
+ return "binds a file it gives no path for";
46
+ }
47
+ else if (typeof index.path !== "string" ||
48
+ !isNonNegativeSafeInteger(index.records) ||
49
+ !isNonNegativeSafeInteger(index.failed) ||
50
+ !isNonNegativeSafeInteger(index.pending) ||
51
+ !isNonNegativeSafeInteger(index.bytes) ||
52
+ typeof index.sha256 !== "string" ||
53
+ !/^[0-9a-f]{64}$/.test(index.sha256))
54
+ return "names a file without its record, failed and pending counts, byte count and sha256";
55
+ if (index.startedAt !== undefined && !isIsoTime(index.startedAt))
56
+ return "has a start time that is not an ISO time";
57
+ if (!Array.isArray(index.origins) ||
58
+ index.origins.some((origin) => pageProblem({ origin, path: "" }) !== undefined ||
59
+ !originKeepsNoWithheldLabel(String(origin))))
60
+ return "has an origin that is not an origin alone, or a host label the trace page rule withholds";
61
+ if (typeof index.complete !== "boolean")
62
+ return "does not say whether it is complete";
63
+ if (index.complete ? index.reason !== undefined : !REASONS.has(index.reason))
64
+ return "has a reason that does not match whether it is complete";
65
+ const { omitted } = index;
66
+ if (!isRecord(omitted) ||
67
+ Object.keys(omitted).length !== 3 ||
68
+ !isNonNegativeSafeInteger(omitted.otherOrigin) ||
69
+ !isNonNegativeSafeInteger(omitted.overCap) ||
70
+ !isNonNegativeSafeInteger(omitted.invalid))
71
+ return "does not count what it left out as otherOrigin, overCap and invalid";
72
+ return undefined;
73
+ }
74
+ /**
75
+ * One finding per problem with the run's browser network logs: a stream index without the
76
+ * schema's shape or naming a file other than network/<its participant id>.ndjson, a file that is
77
+ * missing, unreadable or does not match its index's byte count, sha256 and record count, a record
78
+ * outside the schema or on an origin its index does not list, and a file under network/ that no
79
+ * index names. Each finding names the file and the line, never what the line holds.
80
+ */
81
+ export async function networkFindings(bundle, runPaths) {
82
+ const findings = [];
83
+ const named = new Set();
84
+ for (const stream of bundle.streams) {
85
+ const index = stream.network;
86
+ if (index === undefined)
87
+ continue;
88
+ const problem = networkIndexProblem(index);
89
+ if (problem !== undefined) {
90
+ findings.push(`${stream.id} network index ${problem}`);
91
+ continue;
92
+ }
93
+ if (!isRecord(index) || typeof index.path !== "string")
94
+ continue;
95
+ named.add(index.path);
96
+ const participantId = streamParticipantIdOf(stream);
97
+ if (participantId === undefined || index.path !== networkLogPath(participantId)) {
98
+ findings.push(`${stream.id} network index path is not network/<its participant id>.ndjson`);
99
+ continue;
100
+ }
101
+ findings.push(...(await fileFindings(runPaths, {
102
+ path: index.path,
103
+ origins: new Set(Array.isArray(index.origins) ? index.origins : []),
104
+ lines: Number(index.records),
105
+ failed: Number(index.failed),
106
+ pending: Number(index.pending),
107
+ bytes: Number(index.bytes),
108
+ sha256: String(index.sha256),
109
+ })));
110
+ }
111
+ // No folder means no file to name; one the walk cannot list is unscanned there.
112
+ const listed = await readdir(path.join(runPaths.physicalRunRoot, NETWORK_FOLDER)).catch(() => []);
113
+ for (const name of listed.toSorted((left, right) => left.localeCompare(right))) {
114
+ const file = `${NETWORK_FOLDER}/${name}`;
115
+ if (!named.has(file))
116
+ findings.push(`${file} is under network/ and no stream index names it`);
117
+ }
118
+ return findings;
119
+ }
120
+ /**
121
+ * The findings for one indexed log: its read and binding, its records by line, each record's
122
+ * origin against the index's, then its failed and pending counts against the index's, which a
123
+ * reader such as the Observer shows unchecked. A record's origin can also be the marker tracePage
124
+ * writes for a page withheld whole.
125
+ */
126
+ async function fileFindings(runPaths, index) {
127
+ const read = await readContainedRegularFile(runPaths, index.path, RUN_ARTIFACT_MAX_BYTES);
128
+ if (read.status === "missing")
129
+ return [`${index.path} is missing`];
130
+ if (read.status === "refused")
131
+ return [refusalText(index.path, read)];
132
+ const bound = boundJsonLines(read.bytes, index, RECORD);
133
+ if ("refused" in bound)
134
+ return [bound.refused];
135
+ const findings = [];
136
+ let more = 0;
137
+ const counted = { failed: 0, pending: 0 };
138
+ bound.lines.forEach((record, at) => {
139
+ if (isRecord(record) && record.failed !== undefined)
140
+ counted.failed += 1;
141
+ if (isRecord(record) && record.pending !== undefined)
142
+ counted.pending += 1;
143
+ const problem = networkRecordProblem(record) ??
144
+ (isRecord(record) &&
145
+ !index.origins.has(record.origin) &&
146
+ record.origin !== REDACTION_MARKERS.urlPart
147
+ ? "has an origin its index does not list"
148
+ : undefined);
149
+ if (problem === undefined)
150
+ return;
151
+ if (findings.length < LINE_FINDINGS)
152
+ findings.push(`${index.path} line ${at + 1} ${problem}`);
153
+ else
154
+ more += 1;
155
+ });
156
+ if (more > 0)
157
+ findings.push(`${index.path} has ${more} more lines outside the schema`);
158
+ for (const key of ["failed", "pending"])
159
+ if (counted[key] !== index[key])
160
+ findings.push(`${index.path} holds ${counted[key]} ${key} records where its index says ${index[key]}`);
161
+ return findings;
162
+ }
@@ -1,23 +1,8 @@
1
- import { REDACTION_MARKERS } from "../evidence/redaction.js";
1
+ import { pageProblem } from "../evidence/trace-page.js";
2
2
  import { isRecord } from "../run/type-guards.js";
3
3
  // A recorded page (`items[].page`, `finalPage`) keeps a URL's origin and path only. The text scan
4
4
  // finds secret shapes in it as in any trace text, but a query string, a fragment or user info can
5
5
  // carry a short token no pattern matches, so a page holding one is a public-safety finding.
6
- // The markers that can stand for a whole origin, such as a withheld sandbox host.
7
- const MARKERS = new Set(Object.values(REDACTION_MARKERS));
8
- // `scheme:` alone (a non-web URL) or `scheme://host[:port]`, with nothing after the host. The URL
9
- // parser reads a backslash there as a slash, so a backslash counts as a path too.
10
- const ORIGIN = /^[a-z][a-z0-9+.-]*:(?:\/\/[^/\\?#@]*)?$/i;
11
- /** What is wrong with a recorded page, or undefined when it holds an origin and a path only. */
12
- function pageProblem(page) {
13
- if (!isRecord(page) || typeof page.origin !== "string" || typeof page.path !== "string")
14
- return "is not an origin and a path";
15
- if (/[?#]/.test(page.path))
16
- return "has a query string or fragment in its path";
17
- if (!MARKERS.has(page.origin) && !ORIGIN.test(page.origin))
18
- return "has an origin with user info, a path, a query string or a fragment";
19
- return undefined;
20
- }
21
6
  /**
22
7
  * One finding per recorded page that holds more than an origin and a path, in each finished
23
8
  * trace's items and summary and each running participant's items. A finding names where the page
@@ -17,6 +17,7 @@ import { runNotFoundMessage } from "../run/run-not-found.js";
17
17
  import { actorVerdictConsistencyFindings, noEngagementActorFindings, validateCodexAppServerEvidence, validateTerminalProductEvidence, } from "./actor.js";
18
18
  import { invalidRunEvidenceReferences, MAX_REPORTED_FINDINGS, missingLocalEvidenceArtifacts, rawScreenshotPostureWarnings, rawScreenshotStreamIds, redactedShapeFramePaths, scanRunPublicSafetyArtifacts, scanSourceEvidence, streamScreenshotPaths, unscannedMessage, } from "./artifacts.js";
19
19
  import { flatContextWarnings } from "./context-growth.js";
20
+ import { networkFindings } from "./network.js";
20
21
  import { pageFindings } from "./pages.js";
21
22
  import { runNotFinished } from "./liveness.js";
22
23
  import { costAndReceiptFindings } from "./costs.js";
@@ -242,6 +243,7 @@ async function loadTraces(runPaths, stored) {
242
243
  pastReadLimit: new Set(),
243
244
  ...scanned,
244
245
  pages: [],
246
+ network: [],
245
247
  };
246
248
  // Each trace is source evidence: scanned by the bytes it was joined from, apart from the
247
249
  // directory walk and any path exemption the walk makes.
@@ -271,6 +273,7 @@ async function loadTraces(runPaths, stored) {
271
273
  pastReadLimit: new Set(unread.map((error) => error.file)),
272
274
  ...scanned,
273
275
  pages: pageFindings(bundle),
276
+ network: await networkFindings(bundle, runPaths),
274
277
  };
275
278
  }
276
279
  /** Whether the run file at `file` holds more than RUN_ARTIFACT_MAX_BYTES, read only when it does not. */
@@ -280,7 +283,10 @@ async function pastReadLimit(runPaths, file) {
280
283
  }
281
284
  /** The continuous recordings the bundle registers, which CONTINUOUS_MEDIA grades. */
282
285
  const recordingPaths = (bundle) => new Set(bundle?.streams.flatMap((stream) => (stream.recording ? [stream.recording.path] : [])) ?? []);
283
- /** The trace scan's results, beside the walk's: each file named once, then each page finding. */
286
+ /**
287
+ * The trace scan's results, beside the walk's: each file named once, then each page finding and
288
+ * each network log finding.
289
+ */
284
290
  function addTraceScan(traces, findings, unscanned) {
285
291
  for (const file of traces.sensitive)
286
292
  if (!findings.includes(`sensitive text ${file}`))
@@ -288,7 +294,7 @@ function addTraceScan(traces, findings, unscanned) {
288
294
  for (const file of traces.opaque)
289
295
  if (!unscanned.files.includes(file))
290
296
  unscanned.files.push(file);
291
- findings.push(...traces.pages);
297
+ findings.push(...traces.pages, ...traces.network);
292
298
  }
293
299
  /** The top-level run.json sections the shape guard requires. */
294
300
  const REQUIRED_SECTIONS = [
@@ -515,6 +515,88 @@ entry without loading the whole video into memory. Raw continuous media makes th
515
515
  run `local_only` regardless of screenshot redaction. Analysis input remains text
516
516
  and screenshots. See [desktop recording](https://humanish.dev/docs/desktop-recording).
517
517
 
518
+ ## Browser network log
519
+
520
+ A hosted computer-use participant whose study sets `execution.network` and whose browser is
521
+ Chrome or Chromium carries `streams[].network`:
522
+
523
+ ```yaml
524
+ network:
525
+ schema: humanish.browser-network.v1
526
+ path: network/p1.ndjson
527
+ records: 412
528
+ failed: 3 # records with `failed`
529
+ pending: 2 # records with `pending`
530
+ bytes: 66104
531
+ sha256: "<hex digest of the file>"
532
+ startedAt: "2026-10-16T10:00:07.812Z"
533
+ origins: [https://shop.example]
534
+ complete: true
535
+ omitted: { otherOrigin: 1203, overCap: 0, invalid: 0 }
536
+ ```
537
+
538
+ When capture kept at least one request the stream also has `network/<participantId>.ndjson`,
539
+ registered in `streams[].artifacts` with kind `log`. The folder is `network/` because "request"
540
+ in this contract already means a model request. `path`, `records`, `failed`, `pending`, `bytes`
541
+ and `sha256` are absent when no file was written: capture did not start, the file could not be
542
+ read, or capture started and kept no request, which is the case with `startedAt` present. They
543
+ bind the file as a trace index binds its trace, and verify checks each one. `origins` are
544
+ redacted as `items[].page` origins are, and verify reports a record whose origin is not one of
545
+ them or the `[REDACTED_URL_PART]` marker. `startedAt` is when capture answered on the launch
546
+ page, on the desktop's clock.
547
+
548
+ Each line of the file is one request hop as a JSON object, sorted by `at`:
549
+
550
+ | Key | Meaning |
551
+ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
552
+ | `at` | When the request started, as an ISO time on the desktop's clock |
553
+ | `turn` | The turn the request fell in, by the rule below. Absent when no checkpoint screenshot recorded a browser-state read |
554
+ | `method` | `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `OPTIONS`, `PATCH`, `CONNECT` or `TRACE`; any other method is `OTHER`. Absent on a back-forward record |
555
+ | `origin`, `path` | The address as `items[].page` records it: no query, fragment or user info, and token-shaped or secret-bearing path segments become `[REDACTED_URL_PART]` |
556
+ | `type` | The lowercase CDP resource type: `document`, `fetch`, `xhr`, `prefetch`, `script`, `image` and the rest of the documented list. Any other value is `other` |
557
+ | `status` | 100 to 599. Absent when no response arrived |
558
+ | `ms` | Start to finish, failure or the next hop, in whole milliseconds. Absent when `pending`, on a back-forward record, and when the events arrived after the request finished, as a cross-origin preflight's do |
559
+ | `redirect` | `true`: a 3xx response started this hop. The 3xx itself is the hop before it |
560
+ | `cache` | `memory`, `disk`, `prefetch`, `service-worker` or `back-forward`. A `back-forward` record is a page restored from the back-forward cache: `type: document` and no `status`, since no request was sent |
561
+ | `failed` | The load's error text without its `net::` prefix, such as `ERR_CONNECTION_REFUSED`. `failed` with no `status` means the request failed before any response |
562
+ | `pending` | `true`: no end was recorded. The request was still open when the file was read, or capture stopped first (`reason: capped` or `holder-stopped`) |
563
+
564
+ A request's `turn` is the N of the first `turn-NN` or `turn-NN-after-dwell` screenshot whose
565
+ browser-state read came after the request started; requests after the last screenshot carry the
566
+ last N plus 1. The read and `at` use the desktop's clock, so a request that turn N's actions
567
+ started carries N: the actions run before that screenshot's read. A held turn and a reply cut
568
+ off by the output limit take no screenshot, so their requests carry the next screenshot's N.
569
+
570
+ The file holds no bodies, cookies, header names or values, query strings, fragments or user
571
+ info. Requests to origins outside `origins` are counted in `omitted.otherOrigin` and not
572
+ written. Absent from the file:
573
+
574
+ - requests that started before `startedAt`. A desktop participant's capture starts once its
575
+ browser's DevTools answer, several seconds after launch, so most of its first page load is
576
+ missing; a phone-emulated participant's launch page reloads under capture;
577
+ - requests from workers, service workers and cross-site iframes, and WebSocket frames;
578
+ - a new tab's first request, which can be missing.
579
+
580
+ Path segments are redacted as `items[].page` redacts them. A run of 16 or more characters is
581
+ withheld only when a piece of it mixes letters and digits, so a short code, a long all-digit
582
+ invite code or a long all-letter token in a path stays in the file. A segment that names a
583
+ sandbox id the run journaled is withheld too, and verify reports a record whose path keeps a
584
+ segment these rules withhold. The in-sandbox holder cuts a path longer than 4,096 characters at
585
+ its last whole segment within them, and the file keeps at most 1,024 characters of the redacted
586
+ path, as `items[].page` does, ending in `[TRUNCATED]` when cut. The file keeps the first 20,000
587
+ records, then up to 5,000 more that failed, are pending or have status 400 or above, and stops
588
+ before 8 MiB.
589
+
590
+ `complete: false` comes with `reason`: `no-capture` (capture did not start: a browser other than
591
+ Chrome or Chromium, or a holder that could not start it), `not-collected` (the file could not be
592
+ read at teardown), `capped` (records past the file's caps, counted in `omitted.overCap`; or the
593
+ holder's events file reached 32 MiB, after which requests are absent and counted nowhere) or
594
+ `holder-stopped` (the in-sandbox holder stopped during the session: it wrote its exit line, or
595
+ its process had exited by teardown, as a holder killed by a signal has). A stream with no `network`
596
+ field had capture off, or its participant never started: a dry run, or one skipped before its
597
+ desktop was created. Example: `jq -c 'select(.turn==7 and (.failed or .pending or
598
+ .status>=400))' network/p1.ndjson` lists what failed in turn 7.
599
+
518
600
  ## Hosted Desktop Geometry
519
601
 
520
602
  Hosted browser streams may carry additive `desktopGeometry` evidence. Its fields keep five
@@ -786,11 +868,14 @@ For run id `example-2026-06-02t10-00-00-000z-proof`, the core layout is:
786
868
  .humanish/runs/example-2026-06-02t10-00-00-000z-proof/observer/observer-data.json
787
869
  .humanish/runs/example-2026-06-02t10-00-00-000z-proof/events.ndjson
788
870
  .humanish/runs/example-2026-06-02t10-00-00-000z-proof/actors/stream-001.json
871
+ .humanish/runs/example-2026-06-02t10-00-00-000z-proof/network/p1.ndjson
789
872
  .humanish/runs/latest.json
790
873
  ```
791
874
 
792
875
  `actors/<stream>.json` (or `actor.json` for one participant) is a participant's trace file; see
793
- [Participant traces](#participant-traces).
876
+ [Participant traces](#participant-traces). `network/<participantId>.ndjson` is a participant's
877
+ browser network log, present only when its study set `execution.network`; see
878
+ [Browser network log](#browser-network-log).
794
879
 
795
880
  Absolute paths, traversal segments, remotes, hosted logs, and private artifact
796
881
  URLs are not part of the core layout.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.136.0",
3
+ "version": "0.137.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",
@@ -119,12 +119,12 @@
119
119
  "@types/pngjs": "^6.0.5",
120
120
  "axe-core": "4.14.0",
121
121
  "fast-check": "catalog:",
122
- "knip": "6.40.0",
122
+ "knip": "6.41.0",
123
123
  "oxc-parser": "catalog:",
124
- "oxfmt": "0.72.0",
125
- "oxlint": "1.87.0",
126
- "oxlint-tsgolint": "7.0.2003",
127
- "skills": "1.7.1",
124
+ "oxfmt": "0.73.0",
125
+ "oxlint": "1.88.0",
126
+ "oxlint-tsgolint": "7.0.2004",
127
+ "skills": "1.7.2",
128
128
  "tsx": "^4.23.15",
129
129
  "typescript": "catalog:",
130
130
  "vitest": "catalog:"