humanish 0.136.1 → 0.138.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 (99) hide show
  1. package/CHANGELOG.md +23 -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/cli/commands/runs.js +2 -2
  9. package/dist/cli/doctor.js +24 -0
  10. package/dist/evidence/browser-network.d.ts +108 -0
  11. package/dist/evidence/browser-network.js +516 -0
  12. package/dist/evidence/trace-page.d.ts +31 -1
  13. package/dist/evidence/trace-page.js +68 -2
  14. package/dist/keys/key-resolution.js +53 -2
  15. package/dist/observer-app.html +2 -2
  16. package/dist/routes/computer-use/bundle.js +1 -0
  17. package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
  18. package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
  19. package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
  20. package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
  21. package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
  22. package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
  23. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
  24. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  25. package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
  26. package/dist/routes/computer-use/fanout-records.js +10 -0
  27. package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
  28. package/dist/routes/computer-use/participant-model.js +2 -1
  29. package/dist/routes/computer-use/plan.js +2 -1
  30. package/dist/routes/computer-use/single-bundle.d.ts +3 -0
  31. package/dist/routes/computer-use/single-bundle.js +4 -0
  32. package/dist/routes/computer-use/types.d.ts +7 -0
  33. package/dist/routes/scripted/plan.js +2 -2
  34. package/dist/routes/shared-world/plan.js +3 -2
  35. package/dist/routes/terminal/live-sandbox.d.ts +2 -0
  36. package/dist/routes/terminal/live-sandbox.js +8 -3
  37. package/dist/routes/terminal/plan.js +51 -8
  38. package/dist/routes/terminal/session.js +8 -1
  39. package/dist/run/bundle.d.ts +2 -1
  40. package/dist/run/cost-summary.js +9 -4
  41. package/dist/run/live-items.d.ts +20 -2
  42. package/dist/run/live-items.js +23 -14
  43. package/dist/run/pricing.d.ts +11 -0
  44. package/dist/run/pricing.js +18 -5
  45. package/dist/run/reclaim-outcomes.d.ts +4 -1
  46. package/dist/run/reclaim-outcomes.js +8 -6
  47. package/dist/run/reclaim.d.ts +4 -1
  48. package/dist/run/reclaim.js +81 -34
  49. package/dist/run/sandbox-creates.d.ts +7 -4
  50. package/dist/run/sandbox-creates.js +17 -9
  51. package/dist/run/sandbox-receipts.d.ts +13 -5
  52. package/dist/run/sandbox-receipts.js +18 -6
  53. package/dist/run/streams.d.ts +7 -0
  54. package/dist/study/composition-rules.js +11 -10
  55. package/dist/study/config.js +4 -1
  56. package/dist/study/doctor.js +1 -1
  57. package/dist/study/keys.d.ts +3 -0
  58. package/dist/study/keys.js +2 -0
  59. package/dist/study/parse/comms.js +1 -20
  60. package/dist/study/parse/execution.js +35 -3
  61. package/dist/study/parse/values.d.ts +5 -0
  62. package/dist/study/parse/values.js +22 -0
  63. package/dist/study/plan-base.js +6 -1
  64. package/dist/study/plan-types.d.ts +5 -2
  65. package/dist/study/requirements.js +7 -2
  66. package/dist/study/routing.d.ts +1 -1
  67. package/dist/study/routing.js +1 -1
  68. package/dist/study/study-fields.d.ts +7 -1
  69. package/dist/study/study-fields.js +10 -0
  70. package/dist/study/types.d.ts +12 -1
  71. package/dist/study/validation.d.ts +8 -0
  72. package/dist/study/validation.js +31 -2
  73. package/dist/substrates/detached.d.ts +5 -0
  74. package/dist/substrates/detached.js +9 -0
  75. package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
  76. package/dist/substrates/e2b/cdp-network-capture.js +194 -0
  77. package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
  78. package/dist/substrates/e2b/cdp-probe.js +98 -34
  79. package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
  80. package/dist/substrates/e2b/desktop-cdp.js +87 -5
  81. package/dist/substrates/e2b/desktop-resources.d.ts +4 -2
  82. package/dist/substrates/e2b/desktop-resources.js +2 -2
  83. package/dist/substrates/e2b/peer-install.js +1 -1
  84. package/dist/substrates/e2b/sandbox.d.ts +3 -0
  85. package/dist/substrates/e2b/sandbox.js +5 -4
  86. package/dist/substrates/modal/image.d.ts +9 -0
  87. package/dist/substrates/modal/image.js +36 -0
  88. package/dist/substrates/modal/sandbox.d.ts +26 -0
  89. package/dist/substrates/modal/sandbox.js +367 -0
  90. package/dist/substrates/modal/sdk.d.ts +112 -0
  91. package/dist/substrates/modal/sdk.js +61 -0
  92. package/dist/verify/network.d.ts +10 -0
  93. package/dist/verify/network.js +162 -0
  94. package/dist/verify/pages.js +1 -16
  95. package/dist/verify/verify.js +8 -2
  96. package/docs/architecture/terminal-product-route.md +1 -1
  97. package/docs/contracts/run-bundle.md +90 -3
  98. package/docs/contracts/schemas.md +6 -5
  99. package/package.json +8 -2
@@ -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 = [
@@ -28,7 +28,7 @@ fail-closed cross-validation, and forward-declared warnings.
28
28
  | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
29
29
  | `subject.source` | `terminal-product` |
30
30
  | `subject.product` | `{ name, publicSurfaces[], install?, workdir?, upload? }`: the only world the agent sees |
31
- | `execution.target` | `e2b-terminal` (or absent → implied) |
31
+ | `execution.target` | `e2b-terminal` (or absent → implied), or `modal-terminal` for a Modal sandbox |
32
32
  | `execution.terminal` | `{ transport: exec-stream, stdin: disabled }` |
33
33
  | `execution.runtimeAuth` | `openai-egress` (default) or opt-in `openai-env`; names-only durable evidence |
34
34
  | `execution.runtime.version` | Optional exact `@openai/codex` version; observed before keyed execution |
@@ -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
@@ -621,8 +703,10 @@ Dry runs omit `cost`. A live run that spends nothing records an explicit zero: n
621
703
  `breakdown` lines, `estimatedTotalUsd: 0`, `ratesAsOf: null` and `fullyEstimated: true`.
622
704
  A local scripted run (no model request, no hosted desktop) is such a run; a scripted run on
623
705
  a provisioned clone records the clone's desktop as a `laneId: subject` line. A live bundle
624
- without `cost` has an unmeasured spend. A live terminal run's `cost` prices the E2B terminal
625
- sandbox as a `desktop-minutes` line from its acquired-to-cleanup span and `e2b.getInfo` size.
706
+ without `cost` has an unmeasured spend. A live terminal run's `cost` prices its shell sandbox
707
+ as a `desktop-minutes` line from its acquired-to-cleanup span and size: E2B's report
708
+ (`resourceSource: e2b.getInfo`), or the size humanish asked Modal for (`modal.create`), priced at
709
+ Modal's sandbox rate.
626
710
  Its Codex `model-tokens` line prices the agent's tokens from the model passed as `--model`
627
711
  (`actor.model`, else `gpt-5.6-sol`). Codex reports usage per turn, summed over the turn's
628
712
  requests, so the line prices every token at the base tier and carries
@@ -786,11 +870,14 @@ For run id `example-2026-06-02t10-00-00-000z-proof`, the core layout is:
786
870
  .humanish/runs/example-2026-06-02t10-00-00-000z-proof/observer/observer-data.json
787
871
  .humanish/runs/example-2026-06-02t10-00-00-000z-proof/events.ndjson
788
872
  .humanish/runs/example-2026-06-02t10-00-00-000z-proof/actors/stream-001.json
873
+ .humanish/runs/example-2026-06-02t10-00-00-000z-proof/network/p1.ndjson
789
874
  .humanish/runs/latest.json
790
875
  ```
791
876
 
792
877
  `actors/<stream>.json` (or `actor.json` for one participant) is a participant's trace file; see
793
- [Participant traces](#participant-traces).
878
+ [Participant traces](#participant-traces). `network/<participantId>.ndjson` is a participant's
879
+ browser network log, present only when its study set `execution.network`; see
880
+ [Browser network log](#browser-network-log).
794
881
 
795
882
  Absolute paths, traversal segments, remotes, hosted logs, and private artifact
796
883
  URLs are not part of the core layout.
@@ -336,10 +336,10 @@ The rest of this section names keys by their v3 spelling:
336
336
  - `subject.appUrl`: a loopback http(s) URL a computer-use actor drives, or (on the
337
337
  external-public shared-world route) the non-loopback public deployment used as the
338
338
  shared plane (with `publicTarget` + `allowPublicTargets`);
339
- - `execution`: where it runs (`local`, `e2b-desktop`, or `e2b-terminal`), plus
339
+ - `execution`: where it runs (`local`, `e2b-desktop`, `e2b-terminal` or `modal-terminal`), plus
340
340
  desktop device/resolution and timeouts. app-url subjects pair `e2b-desktop`
341
341
  or `local` with a computer-use actor, or `local` (or absent) with a
342
- scripted-browser actor; terminal-product subjects pair `e2b-terminal` (or absent → implied)
342
+ scripted-browser actor; terminal-product subjects pair `e2b-terminal` or `modal-terminal` (or absent → `e2b-terminal`)
343
343
  with a registered terminal actor. clone and local-tree subjects require an
344
344
  explicit `e2b-desktop`: any other target, or none, fails to parse, and the
345
345
  computer-use and scripted-browser routes refuse it when a library caller skips
@@ -1297,7 +1297,7 @@ items: []
1297
1297
  Reserved: `humanish.substrate.v1` is named here for layering intent but has
1298
1298
  never shipped; no code emits or validates it. Substrate truth today lives
1299
1299
  inside run bundles (per-stream transport and status) and study execution config
1300
- (`execution.target: local | e2b-desktop | e2b-terminal`). Do not emit this schema.
1300
+ (`execution.target: local | e2b-desktop | e2b-terminal | modal-terminal`). Do not emit this schema.
1301
1301
 
1302
1302
  ## Serve Result And Reserved Control-Plane Namespace
1303
1303
 
@@ -1578,12 +1578,13 @@ Three new `.v1` schema tags ship, all additive and optional so
1578
1578
  lines remain readable.
1579
1579
  A desktop line's optional `desktop` object records `minutes`,
1580
1580
  `durationBasis: host-acquired-to-cleanup`, observed `resources` (`cpuCount`,
1581
- `memoryMiB`), `resourceSource: e2b.getInfo`, and `usdPerSecond` when priceable.
1581
+ `memoryMiB`), `resourceSource` (`e2b.getInfo`, or `modal.create` for the size humanish asked
1582
+ Modal for), and `usdPerSecond` when priceable.
1582
1583
  Failed metadata reads record `resourceUnavailableReason`; no resource guess is used.
1583
1584
  Resource metadata and missing/unsupported rates produce a null line. A kept or
1584
1585
  unconfirmed allocation adds `desktop_lifetime_incomplete` as a second null line.
1585
1586
  Live terminal-product runs emit the same summary: one `desktop-minutes` line for
1586
- the E2B shell sandbox (its acquired-to-cleanup span and `e2b.getInfo` size) and
1587
+ the shell sandbox (its acquired-to-cleanup span and its `e2b.getInfo` or `modal.create` size) and
1587
1588
  one `model-tokens` line for the Codex participant, priced from the model the route passed
1588
1589
  to Codex (`gpt-5.6-sol` unless the study declares one) with `basis:
1589
1590
  aggregated_turns_base_rate`, or `null` with `no_token_usage`. Older terminal bundles record
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.136.1",
3
+ "version": "0.138.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",
@@ -109,6 +109,7 @@
109
109
  "parse5": "^8.0.1",
110
110
  "playwright-core": "^1.63.0",
111
111
  "pngjs": "^7.0.0",
112
+ "smol-toml": "^1.9.0",
112
113
  "undici": "^8.11.2",
113
114
  "yaml": "^2.9.1",
114
115
  "zod": "^4.6.5"
@@ -120,6 +121,7 @@
120
121
  "axe-core": "4.14.0",
121
122
  "fast-check": "catalog:",
122
123
  "knip": "6.41.0",
124
+ "modal": "^0.11.1",
123
125
  "oxc-parser": "catalog:",
124
126
  "oxfmt": "0.73.0",
125
127
  "oxlint": "1.88.0",
@@ -130,11 +132,15 @@
130
132
  "vitest": "catalog:"
131
133
  },
132
134
  "peerDependencies": {
133
- "@e2b/desktop": "^2.3.2"
135
+ "@e2b/desktop": "^2.3.2",
136
+ "modal": "^0.11.1"
134
137
  },
135
138
  "peerDependenciesMeta": {
136
139
  "@e2b/desktop": {
137
140
  "optional": true
141
+ },
142
+ "modal": {
143
+ "optional": true
138
144
  }
139
145
  },
140
146
  "engines": {