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.
- package/CHANGELOG.md +23 -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/cli/commands/runs.js +2 -2
- package/dist/cli/doctor.js +24 -0
- 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/keys/key-resolution.js +53 -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/live-sandbox.d.ts +2 -0
- package/dist/routes/terminal/live-sandbox.js +8 -3
- package/dist/routes/terminal/plan.js +51 -8
- package/dist/routes/terminal/session.js +8 -1
- package/dist/run/bundle.d.ts +2 -1
- package/dist/run/cost-summary.js +9 -4
- package/dist/run/live-items.d.ts +20 -2
- package/dist/run/live-items.js +23 -14
- package/dist/run/pricing.d.ts +11 -0
- package/dist/run/pricing.js +18 -5
- package/dist/run/reclaim-outcomes.d.ts +4 -1
- package/dist/run/reclaim-outcomes.js +8 -6
- package/dist/run/reclaim.d.ts +4 -1
- package/dist/run/reclaim.js +81 -34
- package/dist/run/sandbox-creates.d.ts +7 -4
- package/dist/run/sandbox-creates.js +17 -9
- package/dist/run/sandbox-receipts.d.ts +13 -5
- package/dist/run/sandbox-receipts.js +18 -6
- package/dist/run/streams.d.ts +7 -0
- package/dist/study/composition-rules.js +11 -10
- package/dist/study/config.js +4 -1
- package/dist/study/doctor.js +1 -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 +35 -3
- 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 +5 -2
- package/dist/study/requirements.js +7 -2
- package/dist/study/routing.d.ts +1 -1
- package/dist/study/routing.js +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 +12 -1
- 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/substrates/e2b/desktop-resources.d.ts +4 -2
- package/dist/substrates/e2b/desktop-resources.js +2 -2
- package/dist/substrates/e2b/peer-install.js +1 -1
- package/dist/substrates/e2b/sandbox.d.ts +3 -0
- package/dist/substrates/e2b/sandbox.js +5 -4
- package/dist/substrates/modal/image.d.ts +9 -0
- package/dist/substrates/modal/image.js +36 -0
- package/dist/substrates/modal/sandbox.d.ts +26 -0
- package/dist/substrates/modal/sandbox.js +367 -0
- package/dist/substrates/modal/sdk.d.ts +112 -0
- package/dist/substrates/modal/sdk.js +61 -0
- 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/architecture/terminal-product-route.md +1 -1
- package/docs/contracts/run-bundle.md +90 -3
- package/docs/contracts/schemas.md +6 -5
- package/package.json +8 -2
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { setImmediate as nextTurn } from "node:timers/promises";
|
|
3
|
+
import { assertSafeOutputPathSegment } from "../run/contained-output.js";
|
|
4
|
+
import { scrubSandboxIds } from "../run/sandbox-ids.js";
|
|
5
|
+
import { isNonNegativeSafeInteger, isRecord } from "../run/type-guards.js";
|
|
6
|
+
import { originKeepsNoWithheldLabel, pageProblem, pathKeepsNoWithheldSegment, tracePage, } from "./trace-page.js";
|
|
7
|
+
// A participant's browser network log: the requests its browser made, one record per request hop,
|
|
8
|
+
// each tagged with the turn it happened in. The in-sandbox holder appends a short line per
|
|
9
|
+
// DevTools event (src/substrates/e2b/cdp-network-capture.ts). This module joins those lines into
|
|
10
|
+
// records, redacts each address as a trace page is redacted, and binds the file it writes to the
|
|
11
|
+
// stream index run.json carries. verify reads the file with networkRecordProblem, the check the
|
|
12
|
+
// writer applies before it writes a record.
|
|
13
|
+
export const BROWSER_NETWORK_SCHEMA = "humanish.browser-network.v1";
|
|
14
|
+
/** Why records are missing from a participant's network log, as the index's `reason` says. */
|
|
15
|
+
export const BROWSER_NETWORK_GAPS = [
|
|
16
|
+
"no-capture",
|
|
17
|
+
"holder-stopped",
|
|
18
|
+
"capped",
|
|
19
|
+
"not-collected",
|
|
20
|
+
];
|
|
21
|
+
/** The first records a log keeps, then the failed, pending and 400-or-above records past them. */
|
|
22
|
+
const FIRST_RECORDS = 20_000;
|
|
23
|
+
const NOTABLE_RECORDS = 5_000;
|
|
24
|
+
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
25
|
+
/** The lowercase CDP ResourceType names; any other type is written as `other`. */
|
|
26
|
+
const RESOURCE_TYPES = new Set([
|
|
27
|
+
"document",
|
|
28
|
+
"stylesheet",
|
|
29
|
+
"image",
|
|
30
|
+
"media",
|
|
31
|
+
"font",
|
|
32
|
+
"script",
|
|
33
|
+
"texttrack",
|
|
34
|
+
"xhr",
|
|
35
|
+
"fetch",
|
|
36
|
+
"prefetch",
|
|
37
|
+
"eventsource",
|
|
38
|
+
"websocket",
|
|
39
|
+
"manifest",
|
|
40
|
+
"signedexchange",
|
|
41
|
+
"ping",
|
|
42
|
+
"cspviolationreport",
|
|
43
|
+
"preflight",
|
|
44
|
+
"fedcm",
|
|
45
|
+
"other",
|
|
46
|
+
]);
|
|
47
|
+
const CACHES = new Set([
|
|
48
|
+
"memory",
|
|
49
|
+
"disk",
|
|
50
|
+
"prefetch",
|
|
51
|
+
"service-worker",
|
|
52
|
+
"back-forward",
|
|
53
|
+
]);
|
|
54
|
+
const RECORD_KEYS = new Set([
|
|
55
|
+
"at",
|
|
56
|
+
"turn",
|
|
57
|
+
"method",
|
|
58
|
+
"origin",
|
|
59
|
+
"path",
|
|
60
|
+
"type",
|
|
61
|
+
"status",
|
|
62
|
+
"ms",
|
|
63
|
+
"redirect",
|
|
64
|
+
"cache",
|
|
65
|
+
"failed",
|
|
66
|
+
"pending",
|
|
67
|
+
]);
|
|
68
|
+
/** The HTTP methods a record names; a page can send any token as a method, written as `OTHER`. */
|
|
69
|
+
const METHODS = new Set([
|
|
70
|
+
"GET",
|
|
71
|
+
"HEAD",
|
|
72
|
+
"POST",
|
|
73
|
+
"PUT",
|
|
74
|
+
"DELETE",
|
|
75
|
+
"OPTIONS",
|
|
76
|
+
"PATCH",
|
|
77
|
+
"CONNECT",
|
|
78
|
+
"TRACE",
|
|
79
|
+
"OTHER",
|
|
80
|
+
]);
|
|
81
|
+
const FAILED = /^ERR_[A-Z0-9_]{1,64}$/;
|
|
82
|
+
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
83
|
+
/** Whether `value` is an ISO time as `Date.prototype.toISOString` writes one. */
|
|
84
|
+
export function isIsoTime(value) {
|
|
85
|
+
if (typeof value !== "string" || !ISO_TIME.test(value))
|
|
86
|
+
return false;
|
|
87
|
+
const time = Date.parse(value);
|
|
88
|
+
return Number.isFinite(time) && new Date(time).toISOString() === value;
|
|
89
|
+
}
|
|
90
|
+
/** The run-relative path of a participant's network log. */
|
|
91
|
+
export function networkLogPath(participantId) {
|
|
92
|
+
return `network/${participantId}.ndjson`;
|
|
93
|
+
}
|
|
94
|
+
const optional = (value, valid) => value === undefined || valid(value);
|
|
95
|
+
/**
|
|
96
|
+
* What is wrong with one network log record, or undefined when it holds only the schema's keys and
|
|
97
|
+
* values. The writer leaves out a record this names; verify reports one as a finding. Never says
|
|
98
|
+
* what the record holds.
|
|
99
|
+
*/
|
|
100
|
+
export function networkRecordProblem(record) {
|
|
101
|
+
if (!isRecord(record))
|
|
102
|
+
return "is not a JSON object";
|
|
103
|
+
if (Object.keys(record).some((key) => !RECORD_KEYS.has(key)))
|
|
104
|
+
return "has a key outside the schema";
|
|
105
|
+
if (!isIsoTime(record.at))
|
|
106
|
+
return "has a start time that is not an ISO time";
|
|
107
|
+
if (!optional(record.turn, isNonNegativeSafeInteger))
|
|
108
|
+
return "has a turn that is not a whole number from 0";
|
|
109
|
+
if (!optional(record.method, (method) => typeof method === "string" && METHODS.has(method)))
|
|
110
|
+
return "has a method outside the HTTP methods and `OTHER`";
|
|
111
|
+
const page = pageProblem({ origin: record.origin, path: record.path });
|
|
112
|
+
if (page !== undefined)
|
|
113
|
+
return page;
|
|
114
|
+
if (!pathKeepsNoWithheldSegment(String(record.path)))
|
|
115
|
+
return "has a path segment the trace page rule withholds";
|
|
116
|
+
if (!originKeepsNoWithheldLabel(String(record.origin)))
|
|
117
|
+
return "has a host label the trace page rule withholds";
|
|
118
|
+
if (typeof record.type !== "string" || !RESOURCE_TYPES.has(record.type))
|
|
119
|
+
return "has a resource type outside the list";
|
|
120
|
+
if (!optional(record.status, (status) => Number.isInteger(status) && Number(status) >= 100 && Number(status) <= 599))
|
|
121
|
+
return "has a status outside 100 to 599";
|
|
122
|
+
if (!optional(record.ms, isNonNegativeSafeInteger))
|
|
123
|
+
return "has a duration that is not a whole number of milliseconds from 0";
|
|
124
|
+
if (!optional(record.cache, (cache) => typeof cache === "string" && CACHES.has(cache)))
|
|
125
|
+
return "has a cache value outside the list";
|
|
126
|
+
if (!optional(record.failed, (failed) => typeof failed === "string" && FAILED.test(failed)))
|
|
127
|
+
return "has error text that is not ERR_ then capital letters, digits or underscores";
|
|
128
|
+
if (!optional(record.redirect, (flag) => flag === true))
|
|
129
|
+
return "has a redirect flag other than true";
|
|
130
|
+
if (!optional(record.pending, (flag) => flag === true))
|
|
131
|
+
return "has a pending flag other than true";
|
|
132
|
+
if (record.pending === true && (record.ms !== undefined || record.failed !== undefined))
|
|
133
|
+
return "has a duration or a failure on a request with no recorded end";
|
|
134
|
+
if (record.cache === "back-forward" &&
|
|
135
|
+
(record.type !== "document" ||
|
|
136
|
+
["method", "status", "ms", "failed", "pending", "redirect"].some((key) => key in record)))
|
|
137
|
+
return "is a back-forward restore, which sends no request, with request fields or a type other than document";
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
/** The longest the reduction runs before it lets other work on the event loop go first. */
|
|
141
|
+
const SLICE_MS = 20;
|
|
142
|
+
/**
|
|
143
|
+
* The index of a participant whose events were never read: capture did not start, or the file
|
|
144
|
+
* could not be read at teardown. No file goes with it.
|
|
145
|
+
*/
|
|
146
|
+
export function networkIndexWithoutEvents(capture, reason) {
|
|
147
|
+
return {
|
|
148
|
+
schema: BROWSER_NETWORK_SCHEMA,
|
|
149
|
+
origins: indexOrigins(capture.origins, withSandboxScrub(capture)),
|
|
150
|
+
complete: false,
|
|
151
|
+
reason,
|
|
152
|
+
omitted: { otherOrigin: 0, overCap: 0, invalid: 0 },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** The capture's redaction, then the sandbox id scrub, so tracePage withholds a segment naming one. */
|
|
156
|
+
function withSandboxScrub(capture) {
|
|
157
|
+
const ids = (capture.sandboxIds ?? []).filter((id) => id !== "");
|
|
158
|
+
return ids.length === 0 ? capture.redact : (text) => scrubSandboxIds(capture.redact(text), ids);
|
|
159
|
+
}
|
|
160
|
+
/** The allowed origins as the index lists them: each redacted as a trace page's origin is. */
|
|
161
|
+
function indexOrigins(origins, redact) {
|
|
162
|
+
return [
|
|
163
|
+
...new Set(origins.flatMap((origin) => {
|
|
164
|
+
const page = tracePage(origin, redact);
|
|
165
|
+
return page === undefined ? [] : [page.origin];
|
|
166
|
+
})),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The network log of one participant: the records the holder's events give, joined by request id
|
|
171
|
+
* into one per hop, redacted, tagged with turns, checked and capped, then the index that binds
|
|
172
|
+
* them. A redirect gives the 3xx hop's record, then one with `redirect: true`. A request's turn is
|
|
173
|
+
* the N of the first mark at or after its start, or the last mark's N plus 1 after every mark.
|
|
174
|
+
* Capture that started and kept no record writes no file. Only a hop the caps still take is
|
|
175
|
+
* redacted, and the work yields to the event loop every 20 ms, so the other participants of a
|
|
176
|
+
* fan-out keep running while a 32 MiB events file is reduced. Throws only for a participant id
|
|
177
|
+
* that is not one path segment.
|
|
178
|
+
*/
|
|
179
|
+
export async function reduceBrowserNetwork(capture) {
|
|
180
|
+
const { participantId } = capture;
|
|
181
|
+
assertSafeOutputPathSegment(participantId, "Network log participant");
|
|
182
|
+
const redact = withSandboxScrub(capture);
|
|
183
|
+
const origins = indexOrigins(capture.origins, redact);
|
|
184
|
+
let sliceStart = performance.now();
|
|
185
|
+
const pause = async () => {
|
|
186
|
+
if (performance.now() - sliceStart < SLICE_MS)
|
|
187
|
+
return;
|
|
188
|
+
await nextTurn();
|
|
189
|
+
sliceStart = performance.now();
|
|
190
|
+
};
|
|
191
|
+
const read = await joinHops(capture.events, capture.origins, pause);
|
|
192
|
+
const omitted = { otherOrigin: read.otherOrigin, overCap: 0, invalid: 0 };
|
|
193
|
+
if (read.startedAt === undefined)
|
|
194
|
+
return {
|
|
195
|
+
index: { ...networkIndexWithoutEvents(capture, "no-capture"), omitted },
|
|
196
|
+
warnings: [],
|
|
197
|
+
};
|
|
198
|
+
const pageOf = cachedPages(redact);
|
|
199
|
+
const turnOf = turnRule(capture.marks);
|
|
200
|
+
const lines = [];
|
|
201
|
+
const kept = { failed: 0, pending: 0 };
|
|
202
|
+
const room = {
|
|
203
|
+
first: FIRST_RECORDS,
|
|
204
|
+
notable: NOTABLE_RECORDS,
|
|
205
|
+
bytes: MAX_FILE_BYTES,
|
|
206
|
+
full: false,
|
|
207
|
+
};
|
|
208
|
+
for (const hop of read.hops) {
|
|
209
|
+
await pause();
|
|
210
|
+
if (isoOf(hop.wallTime) === undefined) {
|
|
211
|
+
omitted.invalid += 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (slotFor(hop, room) === undefined) {
|
|
215
|
+
omitted.overCap += 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const record = recordOf(hop, pageOf(hop.url), turnOf);
|
|
219
|
+
if (networkRecordProblem(record) !== undefined) {
|
|
220
|
+
omitted.invalid += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
224
|
+
if (!fits(hop, Buffer.byteLength(line, "utf8"), room)) {
|
|
225
|
+
omitted.overCap += 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
lines.push(line);
|
|
229
|
+
if (record.failed !== undefined)
|
|
230
|
+
kept.failed += 1;
|
|
231
|
+
if (record.pending === true)
|
|
232
|
+
kept.pending += 1;
|
|
233
|
+
}
|
|
234
|
+
const reason = read.capped || omitted.overCap > 0
|
|
235
|
+
? "capped"
|
|
236
|
+
: read.stopped || capture.holderExited === true
|
|
237
|
+
? "holder-stopped"
|
|
238
|
+
: undefined;
|
|
239
|
+
const gap = { complete: reason === undefined, ...(reason === undefined ? {} : { reason }) };
|
|
240
|
+
if (lines.length === 0)
|
|
241
|
+
return {
|
|
242
|
+
index: {
|
|
243
|
+
schema: BROWSER_NETWORK_SCHEMA,
|
|
244
|
+
startedAt: read.startedAt,
|
|
245
|
+
origins,
|
|
246
|
+
...gap,
|
|
247
|
+
omitted,
|
|
248
|
+
},
|
|
249
|
+
warnings: [],
|
|
250
|
+
};
|
|
251
|
+
const text = lines.join("");
|
|
252
|
+
const bytes = Buffer.from(text, "utf8");
|
|
253
|
+
return {
|
|
254
|
+
index: {
|
|
255
|
+
schema: BROWSER_NETWORK_SCHEMA,
|
|
256
|
+
path: networkLogPath(participantId),
|
|
257
|
+
records: lines.length,
|
|
258
|
+
...kept,
|
|
259
|
+
bytes: bytes.length,
|
|
260
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
261
|
+
startedAt: read.startedAt,
|
|
262
|
+
origins,
|
|
263
|
+
...gap,
|
|
264
|
+
omitted,
|
|
265
|
+
},
|
|
266
|
+
text,
|
|
267
|
+
warnings: capture.marks.length === 0
|
|
268
|
+
? [
|
|
269
|
+
`Participant ${participantId}'s network log has no turn numbers: no checkpoint screenshot recorded a browser-state read, so its requests could not be matched to turns. Each record's time (at) still places it.`,
|
|
270
|
+
]
|
|
271
|
+
: [],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The page a request address is written as. tracePage runs once per address the holder wrote,
|
|
276
|
+
* which is a raw origin and path: a page polling one endpoint repeats the same address thousands
|
|
277
|
+
* of times, and an uncached call costs a few hundred microseconds.
|
|
278
|
+
*/
|
|
279
|
+
function cachedPages(redact) {
|
|
280
|
+
const pages = new Map();
|
|
281
|
+
return (url) => {
|
|
282
|
+
let page = pages.get(url);
|
|
283
|
+
if (page === undefined) {
|
|
284
|
+
page = tracePage(url, redact) ?? null;
|
|
285
|
+
pages.set(url, page);
|
|
286
|
+
}
|
|
287
|
+
return page ?? undefined;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
291
|
+
/**
|
|
292
|
+
* The holder's lines joined by request id, each hop kept only when its address is on one of
|
|
293
|
+
* `allowedOrigins`. A last line with no line end is still being written.
|
|
294
|
+
*/
|
|
295
|
+
async function joinHops(events, allowedOrigins, pause) {
|
|
296
|
+
const allowed = new Set(allowedOrigins.flatMap((origin) => URL.parse(origin)?.origin ?? []));
|
|
297
|
+
// The holder writes only allowed addresses; the host decides what is written.
|
|
298
|
+
const allowedUrl = (url) => typeof url === "string" && allowed.has(URL.parse(url)?.origin ?? "") ? url : undefined;
|
|
299
|
+
const joined = {
|
|
300
|
+
hops: [],
|
|
301
|
+
startedAt: undefined,
|
|
302
|
+
otherOrigin: 0,
|
|
303
|
+
capped: false,
|
|
304
|
+
stopped: false,
|
|
305
|
+
};
|
|
306
|
+
const open = new Map();
|
|
307
|
+
const whole = events.slice(0, events.lastIndexOf("\n") + 1);
|
|
308
|
+
for (const text of whole.split("\n")) {
|
|
309
|
+
await pause();
|
|
310
|
+
if (text === "")
|
|
311
|
+
continue;
|
|
312
|
+
let line;
|
|
313
|
+
try {
|
|
314
|
+
line = JSON.parse(text);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (!isRecord(line))
|
|
320
|
+
continue;
|
|
321
|
+
const id = typeof line.id === "string" ? line.id : "";
|
|
322
|
+
const hop = open.get(id);
|
|
323
|
+
switch (line.e) {
|
|
324
|
+
case "start":
|
|
325
|
+
joined.startedAt ??= isoOf(finite(line.wallTime));
|
|
326
|
+
break;
|
|
327
|
+
case "cap":
|
|
328
|
+
joined.capped = true;
|
|
329
|
+
break;
|
|
330
|
+
case "exit":
|
|
331
|
+
joined.stopped = true;
|
|
332
|
+
break;
|
|
333
|
+
case "request": {
|
|
334
|
+
const redirected = isRecord(line.redirectResponse);
|
|
335
|
+
if (hop !== undefined && redirected) {
|
|
336
|
+
answer(hop, line.redirectResponse);
|
|
337
|
+
close(hop, line.timestamp);
|
|
338
|
+
}
|
|
339
|
+
open.delete(id);
|
|
340
|
+
const url = allowedUrl(line.url);
|
|
341
|
+
if (url === undefined) {
|
|
342
|
+
joined.otherOrigin += 1;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
const next = {
|
|
346
|
+
wallTime: finite(line.wallTime),
|
|
347
|
+
timestamp: finite(line.timestamp),
|
|
348
|
+
url,
|
|
349
|
+
method: typeof line.method === "string" ? line.method : undefined,
|
|
350
|
+
type: typeof line.type === "string" ? line.type : "",
|
|
351
|
+
redirect: redirected,
|
|
352
|
+
done: false,
|
|
353
|
+
};
|
|
354
|
+
joined.hops.push(next);
|
|
355
|
+
open.set(id, next);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "response":
|
|
359
|
+
if (hop !== undefined)
|
|
360
|
+
answer(hop, line);
|
|
361
|
+
break;
|
|
362
|
+
case "servedFromCache":
|
|
363
|
+
if (hop !== undefined)
|
|
364
|
+
hop.cache = "memory";
|
|
365
|
+
break;
|
|
366
|
+
case "finished":
|
|
367
|
+
case "failed":
|
|
368
|
+
if (hop === undefined)
|
|
369
|
+
break;
|
|
370
|
+
if (line.e === "failed")
|
|
371
|
+
hop.failed = failedOf(line.errorText);
|
|
372
|
+
close(hop, line.timestamp);
|
|
373
|
+
open.delete(id);
|
|
374
|
+
break;
|
|
375
|
+
case "backForward": {
|
|
376
|
+
// A restore from the back-forward cache sends no request.
|
|
377
|
+
const url = allowedUrl(line.url);
|
|
378
|
+
if (url === undefined)
|
|
379
|
+
joined.otherOrigin += 1;
|
|
380
|
+
else
|
|
381
|
+
joined.hops.push({
|
|
382
|
+
wallTime: finite(line.wallTime),
|
|
383
|
+
timestamp: undefined,
|
|
384
|
+
url,
|
|
385
|
+
method: undefined,
|
|
386
|
+
type: "document",
|
|
387
|
+
redirect: false,
|
|
388
|
+
cache: "back-forward",
|
|
389
|
+
done: true,
|
|
390
|
+
});
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
default:
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Sorted by start; a hop with no start time sorts last and is counted invalid.
|
|
398
|
+
joined.hops.sort((left, right) => (left.wallTime ?? Number.POSITIVE_INFINITY) - (right.wallTime ?? Number.POSITIVE_INFINITY));
|
|
399
|
+
return joined;
|
|
400
|
+
}
|
|
401
|
+
/** A response's status and cache flags onto its hop. A memory-cache serve outranks the flags. */
|
|
402
|
+
function answer(hop, response) {
|
|
403
|
+
if (!isRecord(response))
|
|
404
|
+
return;
|
|
405
|
+
const status = response.status;
|
|
406
|
+
if (Number.isInteger(status) && Number(status) >= 100 && Number(status) <= 599)
|
|
407
|
+
hop.status = Number(status);
|
|
408
|
+
if (hop.cache === "memory")
|
|
409
|
+
return;
|
|
410
|
+
if (response.fromDiskCache === true)
|
|
411
|
+
hop.cache = "disk";
|
|
412
|
+
else if (response.fromPrefetchCache === true)
|
|
413
|
+
hop.cache = "prefetch";
|
|
414
|
+
else if (response.fromServiceWorker === true)
|
|
415
|
+
hop.cache = "service-worker";
|
|
416
|
+
}
|
|
417
|
+
function close(hop, timestamp) {
|
|
418
|
+
const end = finite(timestamp);
|
|
419
|
+
if (end !== undefined)
|
|
420
|
+
hop.end = end;
|
|
421
|
+
hop.done = true;
|
|
422
|
+
}
|
|
423
|
+
/** `loadingFailed.errorText` without its `net::` prefix, or ERR_OTHER for any other text. */
|
|
424
|
+
function failedOf(errorText) {
|
|
425
|
+
const text = typeof errorText === "string" ? errorText.replace(/^net::/, "") : "";
|
|
426
|
+
return FAILED.test(text) ? text : "ERR_OTHER";
|
|
427
|
+
}
|
|
428
|
+
function isoOf(wallTime) {
|
|
429
|
+
if (wallTime === undefined)
|
|
430
|
+
return undefined;
|
|
431
|
+
const date = new Date(wallTime * 1000);
|
|
432
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* The turn rule over `marks`: the N of the first mark at or after a start time, the last mark's N
|
|
436
|
+
* plus 1 after every mark, and no turn when there are no marks.
|
|
437
|
+
*/
|
|
438
|
+
function turnRule(marks) {
|
|
439
|
+
const sorted = marks
|
|
440
|
+
.filter((mark) => Number.isFinite(mark.wallTime) && isNonNegativeSafeInteger(mark.turn))
|
|
441
|
+
.toSorted((left, right) => left.wallTime - right.wallTime);
|
|
442
|
+
const last = sorted.at(-1);
|
|
443
|
+
if (last === undefined)
|
|
444
|
+
return () => undefined;
|
|
445
|
+
return (wallTime) => {
|
|
446
|
+
if (wallTime === undefined)
|
|
447
|
+
return undefined;
|
|
448
|
+
let low = 0;
|
|
449
|
+
let high = sorted.length;
|
|
450
|
+
while (low < high) {
|
|
451
|
+
const middle = (low + high) >>> 1;
|
|
452
|
+
if (sorted[middle].wallTime < wallTime)
|
|
453
|
+
low = middle + 1;
|
|
454
|
+
else
|
|
455
|
+
high = middle;
|
|
456
|
+
}
|
|
457
|
+
return low < sorted.length ? sorted[low].turn : last.turn + 1;
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The record of a hop whose address traced to `page`; an address tracePage gave no page for gets
|
|
462
|
+
* an origin and path the record check refuses. A hop whose events say it ended before it started,
|
|
463
|
+
* as a cross-origin preflight's do, gets no `ms`.
|
|
464
|
+
*/
|
|
465
|
+
function recordOf(hop, page, turnOf) {
|
|
466
|
+
const turn = turnOf(hop.wallTime);
|
|
467
|
+
const type = hop.type.toLowerCase();
|
|
468
|
+
const ms = hop.end !== undefined && hop.timestamp !== undefined && hop.end >= hop.timestamp
|
|
469
|
+
? Math.round((hop.end - hop.timestamp) * 1000)
|
|
470
|
+
: undefined;
|
|
471
|
+
return {
|
|
472
|
+
at: isoOf(hop.wallTime),
|
|
473
|
+
...(turn === undefined ? {} : { turn }),
|
|
474
|
+
...(hop.cache === "back-forward"
|
|
475
|
+
? {}
|
|
476
|
+
: { method: hop.method !== undefined && METHODS.has(hop.method) ? hop.method : "OTHER" }),
|
|
477
|
+
origin: page?.origin ?? "",
|
|
478
|
+
path: page?.path ?? "",
|
|
479
|
+
type: RESOURCE_TYPES.has(type) ? type : "other",
|
|
480
|
+
...(hop.status === undefined ? {} : { status: hop.status }),
|
|
481
|
+
...(ms === undefined ? {} : { ms }),
|
|
482
|
+
...(hop.redirect ? { redirect: true } : {}),
|
|
483
|
+
...(hop.cache === undefined ? {} : { cache: hop.cache }),
|
|
484
|
+
...(hop.failed === undefined ? {} : { failed: hop.failed }),
|
|
485
|
+
...(hop.done ? {} : { pending: true }),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* The slot a hop's record would take: one of the first records, or a notable one past them when
|
|
490
|
+
* it failed, is pending or has status 400 or above. Undefined when the caps take no more.
|
|
491
|
+
*/
|
|
492
|
+
function slotFor(hop, room) {
|
|
493
|
+
if (room.full)
|
|
494
|
+
return undefined;
|
|
495
|
+
if (room.first > 0)
|
|
496
|
+
return "first";
|
|
497
|
+
const notable = hop.failed !== undefined || !hop.done || (hop.status ?? 0) >= 400;
|
|
498
|
+
return notable && room.notable > 0 ? "notable" : undefined;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Whether a hop's record of `size` bytes still fits: a slot is free and the byte cap is not
|
|
502
|
+
* passed. After the first line that would pass the byte cap, no later line is written, so a reader
|
|
503
|
+
* knows the file ends where the cap fell.
|
|
504
|
+
*/
|
|
505
|
+
function fits(hop, size, room) {
|
|
506
|
+
const slot = slotFor(hop, room);
|
|
507
|
+
if (slot === undefined)
|
|
508
|
+
return false;
|
|
509
|
+
if (size > room.bytes) {
|
|
510
|
+
room.full = true;
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
room.bytes -= size;
|
|
514
|
+
room[slot] -= 1;
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
import type { TracePage } from "../actors/contract.js";
|
|
2
|
+
/**
|
|
3
|
+
* A computer-use trace's text redaction, which its narration and its pages go through: the run's
|
|
4
|
+
* known-value scrub, then `redactText`, then `redactEncodedText`, then the scrub again.
|
|
5
|
+
* - A value the model transcribes (a DB password it read on screen) has no shape, so `redactText`
|
|
6
|
+
* alone cannot catch it; the scrub closes that.
|
|
7
|
+
* - The participant receives its instructions as written, so it can repeat a key the study wrote
|
|
8
|
+
* base64- or percent-encoded, which verify decodes; `redactEncodedText` removes that one too.
|
|
9
|
+
* - The scrub runs again last: a marker written before other text can spell a known value, as
|
|
10
|
+
* `[REDACTED_SECRET] and` spells `SECRET] and`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function traceRedaction(scrubText: (text: string) => string, redactText: (text: string) => string): (text: string) => string;
|
|
2
13
|
/**
|
|
3
14
|
* The page of an observed URL, or undefined when there is none or the URL parser rejects it.
|
|
4
|
-
* `redact` is the trace's text redaction (
|
|
15
|
+
* `redact` is the trace's text redaction (traceRedaction).
|
|
5
16
|
*/
|
|
6
17
|
export declare function tracePage(url: string | undefined, redact: (text: string) => string): TracePage | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Whether no segment of a recorded path is one tracePage withholds by its shape alone: an encoding
|
|
20
|
+
* that hides text, an address or a token. A segment the run's redaction changed is the marker
|
|
21
|
+
* already, and the text scan finds secret shapes. verify holds each network log record's path to
|
|
22
|
+
* it, as the writer does.
|
|
23
|
+
*/
|
|
24
|
+
export declare function pathKeepsNoWithheldSegment(path: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Whether no host label of a recorded origin is one tracePage withholds: a token-shaped label in
|
|
27
|
+
* any reading, apart from the text redaction itself writes. verify holds each network log origin to it, as the
|
|
28
|
+
* writer does.
|
|
29
|
+
*/
|
|
30
|
+
export declare function originKeepsNoWithheldLabel(origin: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* What is wrong with a recorded page, or undefined when it holds an origin and a path only, as
|
|
33
|
+
* tracePage writes one. verify applies it to each recorded page and the browser network log's
|
|
34
|
+
* record check to each record's origin and path.
|
|
35
|
+
*/
|
|
36
|
+
export declare function pageProblem(page: unknown): string | undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isRecord } from "../run/type-guards.js";
|
|
2
|
+
import { readingsOf, redactEncodedText, scanEncodedText } from "./encoded-text.js";
|
|
2
3
|
import { REDACTION_MARKERS } from "./redaction.js";
|
|
3
4
|
// The page a computer-use observation was on, as a trace records it: the URL's origin and path,
|
|
4
5
|
// with no query string, fragment or user info, and any path segment that can carry a credential or
|
|
@@ -67,9 +68,22 @@ function redactOrigin(url, redact) {
|
|
|
67
68
|
.join(".");
|
|
68
69
|
return redact(`${url.protocol}//${host}${url.port === "" ? "" : `:${url.port}`}`);
|
|
69
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* A computer-use trace's text redaction, which its narration and its pages go through: the run's
|
|
73
|
+
* known-value scrub, then `redactText`, then `redactEncodedText`, then the scrub again.
|
|
74
|
+
* - A value the model transcribes (a DB password it read on screen) has no shape, so `redactText`
|
|
75
|
+
* alone cannot catch it; the scrub closes that.
|
|
76
|
+
* - The participant receives its instructions as written, so it can repeat a key the study wrote
|
|
77
|
+
* base64- or percent-encoded, which verify decodes; `redactEncodedText` removes that one too.
|
|
78
|
+
* - The scrub runs again last: a marker written before other text can spell a known value, as
|
|
79
|
+
* `[REDACTED_SECRET] and` spells `SECRET] and`.
|
|
80
|
+
*/
|
|
81
|
+
export function traceRedaction(scrubText, redactText) {
|
|
82
|
+
return (text) => scrubText(redactEncodedText(redactText(scrubText(text))));
|
|
83
|
+
}
|
|
70
84
|
/**
|
|
71
85
|
* The page of an observed URL, or undefined when there is none or the URL parser rejects it.
|
|
72
|
-
* `redact` is the trace's text redaction (
|
|
86
|
+
* `redact` is the trace's text redaction (traceRedaction).
|
|
73
87
|
*/
|
|
74
88
|
export function tracePage(url, redact) {
|
|
75
89
|
if (url === undefined)
|
|
@@ -88,3 +102,55 @@ export function tracePage(url, redact) {
|
|
|
88
102
|
return { origin: REDACTION_MARKERS.urlPart, path: REDACTION_MARKERS.urlPart };
|
|
89
103
|
return { origin, path };
|
|
90
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Whether no segment of a recorded path is one tracePage withholds by its shape alone: an encoding
|
|
107
|
+
* that hides text, an address or a token. A segment the run's redaction changed is the marker
|
|
108
|
+
* already, and the text scan finds secret shapes. verify holds each network log record's path to
|
|
109
|
+
* it, as the writer does.
|
|
110
|
+
*/
|
|
111
|
+
export function pathKeepsNoWithheldSegment(path) {
|
|
112
|
+
return path
|
|
113
|
+
.split("/")
|
|
114
|
+
.every((segment) => segment === REDACTION_MARKERS.urlPart || !withheld(segment, (text) => text));
|
|
115
|
+
}
|
|
116
|
+
// Text redaction writes into a host: a marker, or a sandbox id's label with its digest.
|
|
117
|
+
const WRITTEN_BY_REDACTION = new RegExp([
|
|
118
|
+
...Object.values(REDACTION_MARKERS).map((marker) => marker.replace(/[[\]]/g, "\\$&")),
|
|
119
|
+
"\\[redacted-sandbox-id [0-9a-f]{16}\\]",
|
|
120
|
+
].join("|"), "g");
|
|
121
|
+
/**
|
|
122
|
+
* Whether no host label of a recorded origin is one tracePage withholds: a token-shaped label in
|
|
123
|
+
* any reading, apart from the text redaction itself writes. verify holds each network log origin to it, as the
|
|
124
|
+
* writer does.
|
|
125
|
+
*/
|
|
126
|
+
export function originKeepsNoWithheldLabel(origin) {
|
|
127
|
+
// An IPv6 literal is kept as the URL wrote it, and a marker origin or a scheme alone has no host.
|
|
128
|
+
if (/^[a-z][a-z0-9+.-]*:\/\/\[[0-9a-f:.]*\](?::\d+)?$/i.test(origin))
|
|
129
|
+
return true;
|
|
130
|
+
const host = /^[a-z][a-z0-9+.-]*:\/\/([^/:]*)/i.exec(origin)?.[1];
|
|
131
|
+
if (host === undefined)
|
|
132
|
+
return true;
|
|
133
|
+
// Each reading, as for a path segment: an escape can split a token below the length matched.
|
|
134
|
+
return host
|
|
135
|
+
.split(".")
|
|
136
|
+
.every((label) => !readingsOf(label.replace(WRITTEN_BY_REDACTION, ".")).some(tokenShaped));
|
|
137
|
+
}
|
|
138
|
+
// The markers that can stand for a whole origin, such as a withheld sandbox host.
|
|
139
|
+
const MARKERS = new Set(Object.values(REDACTION_MARKERS));
|
|
140
|
+
// `scheme:` alone (a non-web URL) or `scheme://host[:port]`, with nothing after the host. The URL
|
|
141
|
+
// parser reads a backslash there as a slash, so a backslash counts as a path too.
|
|
142
|
+
const ORIGIN = /^[a-z][a-z0-9+.-]*:(?:\/\/[^/\\?#@]*)?$/i;
|
|
143
|
+
/**
|
|
144
|
+
* What is wrong with a recorded page, or undefined when it holds an origin and a path only, as
|
|
145
|
+
* tracePage writes one. verify applies it to each recorded page and the browser network log's
|
|
146
|
+
* record check to each record's origin and path.
|
|
147
|
+
*/
|
|
148
|
+
export function pageProblem(page) {
|
|
149
|
+
if (!isRecord(page) || typeof page.origin !== "string" || typeof page.path !== "string")
|
|
150
|
+
return "is not an origin and a path";
|
|
151
|
+
if (/[?#]/.test(page.path))
|
|
152
|
+
return "has a query string or fragment in its path";
|
|
153
|
+
if (!MARKERS.has(page.origin) && !ORIGIN.test(page.origin))
|
|
154
|
+
return "has an origin with user info, a path, a query string or a fragment";
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|