humanish 0.136.1 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/actors/computer-use/actor.d.ts +2 -0
- package/dist/actors/computer-use/actor.js +1 -0
- package/dist/actors/computer-use/loop/observation.js +2 -0
- package/dist/actors/computer-use/loop/session.d.ts +3 -2
- package/dist/actors/computer-use/loop/session.js +8 -12
- package/dist/actors/computer-use/loop/types.d.ts +14 -1
- package/dist/evidence/browser-network.d.ts +108 -0
- package/dist/evidence/browser-network.js +516 -0
- package/dist/evidence/trace-page.d.ts +31 -1
- package/dist/evidence/trace-page.js +68 -2
- package/dist/observer-app.html +2 -2
- package/dist/routes/computer-use/bundle.js +1 -0
- package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
- package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
- package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
- package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
- package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
- package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
- package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
- package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
- package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
- package/dist/routes/computer-use/fanout-records.js +10 -0
- package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
- package/dist/routes/computer-use/participant-model.js +2 -1
- package/dist/routes/computer-use/plan.js +2 -1
- package/dist/routes/computer-use/single-bundle.d.ts +3 -0
- package/dist/routes/computer-use/single-bundle.js +4 -0
- package/dist/routes/computer-use/types.d.ts +7 -0
- package/dist/routes/scripted/plan.js +2 -2
- package/dist/routes/shared-world/plan.js +3 -2
- package/dist/routes/terminal/plan.js +2 -2
- package/dist/run/live-items.d.ts +20 -2
- package/dist/run/live-items.js +23 -14
- package/dist/run/streams.d.ts +7 -0
- package/dist/study/config.js +4 -1
- package/dist/study/keys.d.ts +3 -0
- package/dist/study/keys.js +2 -0
- package/dist/study/parse/comms.js +1 -20
- package/dist/study/parse/execution.js +30 -1
- package/dist/study/parse/values.d.ts +5 -0
- package/dist/study/parse/values.js +22 -0
- package/dist/study/plan-base.js +6 -1
- package/dist/study/plan-types.d.ts +1 -1
- package/dist/study/study-fields.d.ts +7 -1
- package/dist/study/study-fields.js +10 -0
- package/dist/study/types.d.ts +11 -0
- package/dist/study/validation.d.ts +8 -0
- package/dist/study/validation.js +31 -2
- package/dist/substrates/detached.d.ts +5 -0
- package/dist/substrates/detached.js +9 -0
- package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
- package/dist/substrates/e2b/cdp-network-capture.js +194 -0
- package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
- package/dist/substrates/e2b/cdp-probe.js +98 -34
- package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
- package/dist/substrates/e2b/desktop-cdp.js +87 -5
- package/dist/verify/network.d.ts +10 -0
- package/dist/verify/network.js +162 -0
- package/dist/verify/pages.js +1 -16
- package/dist/verify/verify.js +8 -2
- package/docs/contracts/run-bundle.md +86 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/verify/pages.js
CHANGED
|
@@ -1,23 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
package/dist/verify/verify.js
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|