humanish 0.134.0 → 0.136.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 +30 -0
- package/dist/actors/computer-use/loop/session.js +7 -2
- package/dist/actors/scripted-browser/actor.js +3 -3
- package/dist/actors/scripted-browser/steps.d.ts +9 -2
- package/dist/actors/scripted-browser/steps.js +17 -12
- package/dist/analysis/automatic-config.js +5 -3
- package/dist/analysis/evidence-sources.js +4 -7
- package/dist/analysis/evidence.js +4 -4
- package/dist/analysis/execute.js +2 -1
- package/dist/analysis/service.js +2 -1
- package/dist/analysis/source.d.ts +7 -4
- package/dist/analysis/source.js +22 -4
- package/dist/cli/commands/setup.js +7 -10
- package/dist/evidence/encoded-text.d.ts +13 -3
- package/dist/evidence/encoded-text.js +88 -25
- package/dist/evidence/image.d.ts +2 -2
- package/dist/evidence/image.js +47 -32
- package/dist/observer/data.d.ts +7 -3
- package/dist/observer/page-data.d.ts +6 -0
- package/dist/observer/page-data.js +34 -7
- package/dist/observer/participant-items.d.ts +15 -1
- package/dist/observer/participant-items.js +4 -3
- package/dist/observer/render.js +2 -4
- package/dist/observer/run-routes.js +4 -4
- package/dist/observer-app.html +4 -4
- package/dist/routes/computer-use/fanout-bundle.js +3 -3
- package/dist/routes/computer-use/participant-execution.js +1 -1
- package/dist/routes/computer-use/participant-runs.js +2 -2
- package/dist/routes/computer-use/single-bundle.js +3 -3
- package/dist/routes/scripted/setup.js +62 -17
- package/dist/routes/shared-world/bundle.js +3 -3
- package/dist/routes/shared-world/participant-specs.js +2 -2
- package/dist/routes/terminal/bundle.js +2 -2
- package/dist/run/bundle.d.ts +5 -1
- package/dist/run/locate.d.ts +13 -11
- package/dist/run/locate.js +20 -13
- package/dist/run/paths.js +38 -28
- package/dist/run/run.js +4 -3
- package/dist/run/stream-trace.d.ts +2 -2
- package/dist/run/stream-trace.js +24 -11
- package/dist/run/streams.d.ts +18 -3
- package/dist/run-study.d.ts +3 -3
- package/dist/run-study.js +3 -3
- package/dist/study/bundle-estimate.d.ts +21 -3
- package/dist/study/bundle-estimate.js +65 -45
- package/dist/study/config.d.ts +4 -3
- package/dist/study/config.js +8 -5
- package/dist/study/parse/actors.d.ts +6 -0
- package/dist/study/parse/actors.js +14 -0
- package/dist/study/parse/subject-state.js +4 -4
- package/dist/study/participant-assignment.js +2 -2
- package/dist/study/persona.d.ts +7 -1
- package/dist/study/persona.js +13 -5
- package/dist/study/plan-base.js +4 -3
- package/dist/tui-app.js +23 -23
- package/dist/verify/screenshot-reads.js +1 -1
- package/docs/architecture/observer.md +24 -7
- package/docs/architecture/state-driven-executor.md +4 -3
- package/docs/contracts/run-bundle.md +3 -0
- package/docs/contracts/study-analysis.md +7 -5
- package/docs/decisions/0002-observer-is-one-self-contained-file.md +3 -3
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { isUtf8 } from "node:buffer";
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { readPlainText } from "./plain-text.js";
|
|
8
|
-
import { containsSensitive } from "./redaction.js";
|
|
8
|
+
import { containsSensitive, REDACTION_MARKERS, redactText } from "./redaction.js";
|
|
9
9
|
// HTML5 named references that stand for printable ASCII. Letters and digits have only numeric
|
|
10
10
|
// references, which decodeEscapes handles.
|
|
11
11
|
const ASCII_ENTITIES = new Map([
|
|
@@ -97,37 +97,71 @@ function decodePercentBytes(run) {
|
|
|
97
97
|
export function decodeEscapes(text) {
|
|
98
98
|
return decodeWith(text, decodePercentBytes);
|
|
99
99
|
}
|
|
100
|
-
/** The readings a scan matches, with the
|
|
100
|
+
/** The readings a scan matches, with the three that scanEncodedText also searches for runs. */
|
|
101
101
|
function readings(text) {
|
|
102
|
-
|
|
102
|
+
// Decoding references with the other escapes joins a letter reference to the escaped key after
|
|
103
|
+
// it, `A\u0073k-` to `Ask-`, where a pattern's word boundary fails. With references left as
|
|
104
|
+
// written, the `;` before the key keeps that boundary. A key that ends in a reference before an
|
|
105
|
+
// escaped letter, `l\u0041`, keeps the boundary after it when only references are decoded.
|
|
106
|
+
// The reading with references left as written is also read with transfer escapes expanded, as
|
|
107
|
+
// the decoded one is, for a key that a quoted-printable soft break splits.
|
|
108
|
+
const unescaped = decodeCodeEscapes(text, decodePercentBytes);
|
|
109
|
+
const decoded = decodeReferences(unescaped);
|
|
103
110
|
// The UTF-8 reading differs only where a percent escape holds a byte of 0x80 or more, which
|
|
104
111
|
// decodeEscapes writes as a character from U+0080 to U+00FF. Testing the decoded text also
|
|
105
112
|
// catches a `%` that a `\u0025` wrote.
|
|
106
113
|
const utf8 = HIGH_BYTE.test(decoded) ? decodeWith(text, decodePercentRun) : decoded;
|
|
107
114
|
const expanded = decodeTransferEscapes(decoded);
|
|
115
|
+
// Without references in the text, or without code escapes, each of these two readings is one
|
|
116
|
+
// already decoded above, so it reuses that string. On a real 2 MiB Observer page, decoding both
|
|
117
|
+
// again took 12 ms of the 60 that readingsOf took.
|
|
118
|
+
const unescapedExpanded = unescaped === decoded ? expanded : decodeTransferEscapes(unescaped);
|
|
119
|
+
const referencesDecoded = text === unescaped ? decoded : decodeReferences(text);
|
|
108
120
|
const recovered = [text, decoded].filter((reading) => HIGH_BYTE.test(reading));
|
|
109
121
|
return {
|
|
122
|
+
all: [
|
|
123
|
+
...new Set([
|
|
124
|
+
text,
|
|
125
|
+
decoded,
|
|
126
|
+
utf8,
|
|
127
|
+
expanded,
|
|
128
|
+
...recovered.map(latin1RunsAsUtf8),
|
|
129
|
+
unescaped,
|
|
130
|
+
unescapedExpanded,
|
|
131
|
+
referencesDecoded,
|
|
132
|
+
]),
|
|
133
|
+
],
|
|
110
134
|
decoded,
|
|
111
135
|
expanded,
|
|
112
|
-
|
|
136
|
+
unescapedExpanded,
|
|
113
137
|
};
|
|
114
138
|
}
|
|
115
139
|
/**
|
|
116
140
|
* Every reading of the text a scan matches, each once: as written, decodeEscapes, with percent
|
|
117
|
-
* escapes read as UTF-8, decodeEscapes with transfer escapes expanded,
|
|
141
|
+
* escapes read as UTF-8, decodeEscapes with transfer escapes expanded, the text as written and
|
|
118
142
|
* decoded with each run of U+0080 to U+00FF characters read as UTF-8 bytes, which undoes text that
|
|
119
|
-
* a reader already decoded one byte per character
|
|
120
|
-
*
|
|
143
|
+
* a reader already decoded one byte per character, decodeEscapes with HTML references left as
|
|
144
|
+
* written and that reading with transfer escapes expanded, and the text with only HTML references
|
|
145
|
+
* decoded. Either percent reading can split a value that the other keeps whole, as `%E2%80%80`
|
|
146
|
+
* (U+2000, a space, in UTF-8) does.
|
|
121
147
|
*/
|
|
122
148
|
export function readingsOf(text) {
|
|
123
149
|
return readings(text).all;
|
|
124
150
|
}
|
|
125
151
|
function decodeWith(text, percent) {
|
|
152
|
+
return decodeReferences(decodeCodeEscapes(text, percent));
|
|
153
|
+
}
|
|
154
|
+
/** JSON and JS string escapes, then percent-encoding. */
|
|
155
|
+
function decodeCodeEscapes(text, percent) {
|
|
126
156
|
return text
|
|
127
157
|
.replace(/\\u([0-9a-f]{4})/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
|
|
128
158
|
.replace(/\\x([0-9a-f]{2})/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
|
|
129
159
|
.replace(/\\\//g, "/")
|
|
130
|
-
.replace(/(?:%[0-9a-f]{2})+/gi, percent)
|
|
160
|
+
.replace(/(?:%[0-9a-f]{2})+/gi, percent);
|
|
161
|
+
}
|
|
162
|
+
/** HTML character references: numeric ones and the ASCII_ENTITIES names. */
|
|
163
|
+
function decodeReferences(text) {
|
|
164
|
+
return text
|
|
131
165
|
.replace(/&#x([0-9a-f]{1,6});?/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
|
|
132
166
|
.replace(/&#(\d{1,7});?/g, (match, digits) => codePoint(Number.parseInt(digits, 10), match))
|
|
133
167
|
.replace(/&([a-z]+);/gi, namedReference);
|
|
@@ -144,6 +178,8 @@ const BASE64_RUN = /[A-Za-z0-9+/]{16}[A-Za-z0-9+/]*={0,2}/g;
|
|
|
144
178
|
const BASE64URL_RUN = /[A-Za-z0-9_-]{16}[A-Za-z0-9_-]*={0,2}/g;
|
|
145
179
|
// Hex-encoded text, at least sixteen bytes of it.
|
|
146
180
|
const HEX_RUN = /[0-9a-f]{32}[0-9a-f]*/gi;
|
|
181
|
+
// The base64, URL-safe base64 or hex run right after a `;`, such as the one that ends a reference.
|
|
182
|
+
const RUN_AFTER_SEMICOLON = /;([A-Za-z0-9+/_-]{16}[A-Za-z0-9+/_-]*={0,2})/g;
|
|
147
183
|
// A base64 run that decodes to bytes that are neither text nor an archive is unreadable only past
|
|
148
184
|
// this length. Across 173 real run folders surveyed on 2026-10-01, the longest such run outside
|
|
149
185
|
// observer/index.html was a 69-character URL path in a terminal log; base64 of a 94-byte payload,
|
|
@@ -293,6 +329,18 @@ function inspectDecoded(bytes, run, options, depth) {
|
|
|
293
329
|
options.allowOpaqueBase64 !== true,
|
|
294
330
|
};
|
|
295
331
|
}
|
|
332
|
+
/** The base64 and URL-safe base64 runs in a reading, each with its bytes. */
|
|
333
|
+
function base64Runs(reading) {
|
|
334
|
+
const runs = [];
|
|
335
|
+
for (const [match] of reading.matchAll(BASE64_RUN))
|
|
336
|
+
// Hex digests and ids decode to noise; scanEncodedText's hex pass reads hex as hex.
|
|
337
|
+
if (!/^[0-9a-f]+$/i.test(match.replace(/=+$/, "")))
|
|
338
|
+
runs.push({ run: match, bytes: Buffer.from(match, "base64") });
|
|
339
|
+
for (const [match] of reading.matchAll(BASE64URL_RUN))
|
|
340
|
+
if (/[-_]/.test(match))
|
|
341
|
+
runs.push({ run: match, bytes: Buffer.from(match, "base64url") });
|
|
342
|
+
return runs;
|
|
343
|
+
}
|
|
296
344
|
/**
|
|
297
345
|
* Scans text for secrets in each of its readingsOf, and inside each base64 (standard, URL-safe or
|
|
298
346
|
* line-wrapped) and hex run. A run that decodes to text is scanned in
|
|
@@ -301,31 +349,31 @@ function inspectDecoded(bytes, run, options, depth) {
|
|
|
301
349
|
*/
|
|
302
350
|
export function scanEncodedText(text, options = {}, depth = 0) {
|
|
303
351
|
const matches = matcherOf(options);
|
|
304
|
-
const { decoded, expanded,
|
|
352
|
+
const { all, decoded, expanded, unescapedExpanded } = readings(text);
|
|
305
353
|
if (all.some(matches))
|
|
306
354
|
return SENSITIVE;
|
|
307
|
-
const runs =
|
|
355
|
+
const runs = base64Runs(expanded);
|
|
308
356
|
// A piece or a run decoded from a slash only adds findings; its bytes are not judged opaque.
|
|
309
357
|
const slashStarts = [];
|
|
310
|
-
for (const
|
|
311
|
-
|
|
312
|
-
if (/^[0-9a-f]+$/i.test(match.replace(/=+$/, "")))
|
|
313
|
-
continue;
|
|
314
|
-
runs.push({ run: match, bytes: Buffer.from(match, "base64") });
|
|
315
|
-
if (match.length > MAX_SLASH_START_RUN)
|
|
358
|
+
for (const { run } of runs) {
|
|
359
|
+
if (run.length > MAX_SLASH_START_RUN)
|
|
316
360
|
continue;
|
|
317
|
-
for (const piece of
|
|
318
|
-
if (piece.length >= 16 && piece.length <
|
|
361
|
+
for (const piece of run.split("/"))
|
|
362
|
+
if (piece.length >= 16 && piece.length < run.length)
|
|
319
363
|
slashStarts.push(Buffer.from(piece, "base64"));
|
|
320
|
-
for (let slash =
|
|
321
|
-
slashStarts.push(Buffer.from(
|
|
322
|
-
}
|
|
323
|
-
for (const [match] of expanded.matchAll(BASE64URL_RUN)) {
|
|
324
|
-
if (/[-_]/.test(match))
|
|
325
|
-
runs.push({ run: match, bytes: Buffer.from(match, "base64url") });
|
|
364
|
+
for (let slash = run.indexOf("/"); slash !== -1 && slash < SLASH_START_SPAN && run.length - slash > 16; slash = run.indexOf("/", slash + 1))
|
|
365
|
+
slashStarts.push(Buffer.from(run.slice(slash + 1), "base64"));
|
|
326
366
|
}
|
|
327
367
|
for (const run of wrappedBase64Runs(expanded))
|
|
328
368
|
runs.push({ run, bytes: Buffer.from(run, "base64") });
|
|
369
|
+
// A decoded reference right before a run joins its character to the run, which shifts every
|
|
370
|
+
// base64 quartet and hex pair in it. The run after each `;` with references left as written
|
|
371
|
+
// adds findings, as a slash start does.
|
|
372
|
+
const runsAfterSemicolons = unescapedExpanded === expanded
|
|
373
|
+
? []
|
|
374
|
+
: [...unescapedExpanded.matchAll(RUN_AFTER_SEMICOLON)].map(([, run]) => run);
|
|
375
|
+
for (const { bytes } of runsAfterSemicolons.flatMap(base64Runs))
|
|
376
|
+
slashStarts.push(bytes);
|
|
329
377
|
let opaque = false;
|
|
330
378
|
for (const { run, bytes } of runs) {
|
|
331
379
|
const result = inspectDecoded(bytes, run, options, depth);
|
|
@@ -341,6 +389,7 @@ export function scanEncodedText(text, options = {}, depth = 0) {
|
|
|
341
389
|
const hexRuns = new Set([
|
|
342
390
|
...[...expanded.matchAll(HEX_RUN)].map(([match]) => match),
|
|
343
391
|
...(expanded === decoded ? [] : [...decoded.matchAll(HEX_RUN)].map(([match]) => match)),
|
|
392
|
+
...runsAfterSemicolons.flatMap((run) => [...run.matchAll(HEX_RUN)].map(([match]) => match)),
|
|
344
393
|
]);
|
|
345
394
|
for (const match of hexRuns) {
|
|
346
395
|
const plain = readPlainText(Buffer.from(match, "hex"));
|
|
@@ -349,10 +398,24 @@ export function scanEncodedText(text, options = {}, depth = 0) {
|
|
|
349
398
|
}
|
|
350
399
|
return { sensitive: false, opaque };
|
|
351
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* redactText, then each whitespace-separated piece that scanEncodedText reads as sensitive replaced
|
|
403
|
+
* by the secret marker, so a key written in base64 or percent-encoded goes as a literal one does.
|
|
404
|
+
* When the pieces read clean one at a time but the text still reads sensitive, as `Bearer` before a
|
|
405
|
+
* percent-encoded token does, the whole text becomes the marker. For study text the run records and
|
|
406
|
+
* the instructions composed from it: on 32 KiB of prose it took 3 ms, and 34 ms with a key in it.
|
|
407
|
+
*/
|
|
408
|
+
export function redactEncodedText(text) {
|
|
409
|
+
const literal = redactText(text);
|
|
410
|
+
if (!scanEncodedText(literal).sensitive)
|
|
411
|
+
return literal;
|
|
412
|
+
const pieces = literal.replace(/\S+/g, (piece) => scanEncodedText(piece).sensitive ? REDACTION_MARKERS.secret : piece);
|
|
413
|
+
return scanEncodedText(pieces).sensitive ? REDACTION_MARKERS.secret : pieces;
|
|
414
|
+
}
|
|
352
415
|
// Bump when scanEncodedText, decodeEscapes or the sensitive patterns change what they return. The
|
|
353
416
|
// cache lives in one process, so the version guards results across a hot reload or a test that
|
|
354
417
|
// swaps the scanner.
|
|
355
|
-
const ENCODED_SCAN_VERSION =
|
|
418
|
+
const ENCODED_SCAN_VERSION = 6;
|
|
356
419
|
// Distinct files one process verifies in a burst (a run's files, a serve library's runs).
|
|
357
420
|
const SCAN_CACHE_LIMIT = 256;
|
|
358
421
|
const scanCache = new Map();
|
package/dist/evidence/image.d.ts
CHANGED
|
@@ -20,5 +20,5 @@ export declare function pngDecodeRefusal(bytes: Buffer): string | null;
|
|
|
20
20
|
* were downscaled, and any other chunk is a place to carry bytes no one reviews.
|
|
21
21
|
*/
|
|
22
22
|
export declare function isRedactedFrameShape(bytes: Buffer): boolean;
|
|
23
|
-
export declare function screenshotEvidenceError(relativePath: string, bytes: Buffer): string | null
|
|
24
|
-
export declare function assertScreenshotEvidence(relativePath: string, bytes: Buffer): void
|
|
23
|
+
export declare function screenshotEvidenceError(relativePath: string, bytes: Buffer): Promise<string | null>;
|
|
24
|
+
export declare function assertScreenshotEvidence(relativePath: string, bytes: Buffer): Promise<void>;
|
package/dist/evidence/image.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { crc32,
|
|
1
|
+
import { crc32, createInflate } from "node:zlib";
|
|
2
2
|
import { PNG } from "pngjs";
|
|
3
3
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
4
4
|
const PNG_IHDR_LENGTH = 13;
|
|
@@ -128,7 +128,7 @@ export function isRedactedFrameShape(bytes) {
|
|
|
128
128
|
last.end === bytes.length &&
|
|
129
129
|
bytes.readUInt32BE(16) <= SCREENSHOT_MAX_WIDTH_CAP);
|
|
130
130
|
}
|
|
131
|
-
export function screenshotEvidenceError(relativePath, bytes) {
|
|
131
|
+
export async function screenshotEvidenceError(relativePath, bytes) {
|
|
132
132
|
const extension = relativePath.toLowerCase().split(".").pop() ?? "";
|
|
133
133
|
if (extension !== "png") {
|
|
134
134
|
return `unsupported screenshot extension .${extension || "unknown"}; only decoded PNG evidence is supported`;
|
|
@@ -146,7 +146,7 @@ export function screenshotEvidenceError(relativePath, bytes) {
|
|
|
146
146
|
if (chunks?.some((chunk) => chunk.type === "IEND" && chunk.end - chunk.start !== 12)) {
|
|
147
147
|
return "PNG IEND chunk must be empty";
|
|
148
148
|
}
|
|
149
|
-
return chunks === null || !pngStructureHolds(bytes, chunks)
|
|
149
|
+
return chunks === null || !(await pngStructureHolds(bytes, chunks))
|
|
150
150
|
? "could not decode PNG evidence"
|
|
151
151
|
: null;
|
|
152
152
|
}
|
|
@@ -164,14 +164,14 @@ const PNG_MIN_CHUNK_BYTES = { gAMA: 4 };
|
|
|
164
164
|
/**
|
|
165
165
|
* Whether a PNG that passed pngDecodeRefusal decodes, read from its structure: the file ends at an
|
|
166
166
|
* empty IEND, every chunk's CRC matches, IHDR comes once with fields PNG defines, the image data is
|
|
167
|
-
* one run of IDAT chunks, and it inflates
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
167
|
+
* one run of IDAT chunks, and it inflates (inflatesToRows) to exactly the bytes its rows need with
|
|
168
|
+
* nothing after the stream, each row starting with a filter type from 0 to 4. For an image without
|
|
169
|
+
* a palette that is all a decode establishes, at about an eighth of the time of pngjs, which
|
|
170
|
+
* unfilters every pixel in JavaScript; a run holds a screenshot for each turn. A palette image is
|
|
171
|
+
* also decoded, since each pixel must index a palette that comes before the image data; in any
|
|
172
|
+
* other image a palette chunk is only a suggestion, wherever it is.
|
|
173
173
|
*/
|
|
174
|
-
function pngStructureHolds(bytes, chunks) {
|
|
174
|
+
async function pngStructureHolds(bytes, chunks) {
|
|
175
175
|
const last = chunks.at(-1);
|
|
176
176
|
if (last?.type !== "IEND" || last.end !== bytes.length)
|
|
177
177
|
return false;
|
|
@@ -200,29 +200,44 @@ function pngStructureHolds(bytes, chunks) {
|
|
|
200
200
|
return false;
|
|
201
201
|
const rowBytes = 1 + Math.ceil(rowBits / 8);
|
|
202
202
|
const data = Buffer.concat(idats.map((chunk) => bytes.subarray(chunk.start + 8, chunk.end - 4)));
|
|
203
|
-
|
|
204
|
-
try {
|
|
205
|
-
// A stream that would inflate past the rows the image needs stops there, so a small file
|
|
206
|
-
// cannot claim gigabytes.
|
|
207
|
-
const options = {
|
|
208
|
-
maxOutputLength: rowBytes * height,
|
|
209
|
-
info: true,
|
|
210
|
-
};
|
|
211
|
-
const inflated = inflateSync(data, options);
|
|
212
|
-
if (inflated.engine.bytesWritten !== data.length)
|
|
213
|
-
return false;
|
|
214
|
-
rows = inflated.buffer;
|
|
215
|
-
}
|
|
216
|
-
catch {
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
if (rows.length !== rowBytes * height)
|
|
203
|
+
if (!(await inflatesToRows(data, rowBytes, height)))
|
|
220
204
|
return false;
|
|
221
|
-
for (let row = 0; row < rows.length; row += rowBytes)
|
|
222
|
-
if (rows[row] > 4)
|
|
223
|
-
return false;
|
|
224
205
|
return colorType !== 3 || pngDecodes(bytes);
|
|
225
206
|
}
|
|
207
|
+
// The most inflated bytes one check holds at once. Checks overlap on the thread pool, so rows held
|
|
208
|
+
// whole add up across them. A process running 8 checks of 5000x5000 RGBA frames peaked at 832 MB
|
|
209
|
+
// resident with each frame's rows held whole and at 202 MB checked 1 MiB at a time.
|
|
210
|
+
const INFLATE_CHUNK_BYTES = 1024 * 1024;
|
|
211
|
+
/**
|
|
212
|
+
* Whether `data` inflates to exactly `rows` rows of `rowBytes` bytes, each starting with a filter
|
|
213
|
+
* type from 0 to 4, with nothing after the stream. zlib inflates on libuv's thread pool, so checks
|
|
214
|
+
* of several frames overlap. A stream that would inflate past the rows stops there, so a small
|
|
215
|
+
* file cannot claim gigabytes.
|
|
216
|
+
*/
|
|
217
|
+
function inflatesToRows(data, rowBytes, rows) {
|
|
218
|
+
const total = rowBytes * rows;
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
const inflate = createInflate({
|
|
221
|
+
chunkSize: Math.max(64, Math.min(total, INFLATE_CHUNK_BYTES)),
|
|
222
|
+
});
|
|
223
|
+
let seen = 0;
|
|
224
|
+
inflate.on("data", (chunk) => {
|
|
225
|
+
let filtersHold = true;
|
|
226
|
+
// The first row that starts in this chunk, then each row after it.
|
|
227
|
+
for (let at = (rowBytes - (seen % rowBytes)) % rowBytes; at < chunk.length; at += rowBytes)
|
|
228
|
+
if (chunk[at] > 4)
|
|
229
|
+
filtersHold = false;
|
|
230
|
+
seen += chunk.length;
|
|
231
|
+
if (!filtersHold || seen > total) {
|
|
232
|
+
inflate.destroy();
|
|
233
|
+
resolve(false);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
inflate.on("error", () => resolve(false));
|
|
237
|
+
inflate.on("end", () => resolve(seen === total && inflate.bytesWritten === data.length));
|
|
238
|
+
inflate.end(data);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
226
241
|
function pngDecodes(bytes) {
|
|
227
242
|
try {
|
|
228
243
|
PNG.sync.read(bytes, { checkCRC: true });
|
|
@@ -232,8 +247,8 @@ function pngDecodes(bytes) {
|
|
|
232
247
|
return false;
|
|
233
248
|
}
|
|
234
249
|
}
|
|
235
|
-
export function assertScreenshotEvidence(relativePath, bytes) {
|
|
236
|
-
const error = screenshotEvidenceError(relativePath, bytes);
|
|
250
|
+
export async function assertScreenshotEvidence(relativePath, bytes) {
|
|
251
|
+
const error = await screenshotEvidenceError(relativePath, bytes);
|
|
237
252
|
if (error) {
|
|
238
253
|
throw new Error(`Invalid screenshot evidence ${relativePath}: ${error}`);
|
|
239
254
|
}
|
package/dist/observer/data.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { ActorTrace } from "../actors/contract.js";
|
|
1
2
|
import { type ActorEnding } from "../actors/stop-cause.js";
|
|
2
|
-
import type { RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
|
|
3
|
+
import type { DisplayedRunBundle, RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
|
|
3
4
|
import { type RunDisplay } from "../run/display.js";
|
|
4
|
-
import type { RunStream, RunStreamKind, StoredRunStream } from "../run/streams.js";
|
|
5
|
+
import type { RunStream, RunStreamKind, StoredRunStream, WithoutRequestLists } from "../run/streams.js";
|
|
5
6
|
/**
|
|
6
7
|
* The Observer's data. From v2 observer-data.json leaves out each participant's items, and from v3
|
|
7
8
|
* each participant's per-request lists (REQUEST_LISTS in src/run/stream-trace.ts), which no part
|
|
@@ -97,6 +98,9 @@ export interface ObserverData {
|
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
export interface ObserverStream extends StoredRunStream {
|
|
101
|
+
/** The loaded actor, with its items and without its per-request lists, as is the live actor. */
|
|
102
|
+
actor?: WithoutRequestLists<ActorTrace>;
|
|
103
|
+
liveActor?: WithoutRequestLists<NonNullable<RunStream["liveActor"]>>;
|
|
100
104
|
/** Only the attached server may grant provider-origin access for a live desktop iframe. */
|
|
101
105
|
embed?: NonNullable<RunStream["embed"]> & {
|
|
102
106
|
runtimeDesktop?: true;
|
|
@@ -127,7 +131,7 @@ interface ObserverLaneGroup {
|
|
|
127
131
|
* bundle with no outcome in run.json: a run in progress, interrupted, or recorded before run.json
|
|
128
132
|
* carried its outcome.
|
|
129
133
|
*/
|
|
130
|
-
export declare function buildObserverData(bundle:
|
|
134
|
+
export declare function buildObserverData(bundle: DisplayedRunBundle, generatedAt?: string, statusRecord?: unknown): ObserverData;
|
|
131
135
|
/**
|
|
132
136
|
* A saved Observer projection as this version reads it, as v3 and unchecked as before. An
|
|
133
137
|
* observer-data.json written before v2 holds every participant's items, and its per-request lists,
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import type { LiveItemsRead } from "../run/stream-trace.js";
|
|
1
2
|
import type { ObserverData } from "./data.js";
|
|
2
3
|
/** The page's data elements for `data`, in place of the built page's data slot. */
|
|
3
4
|
export declare function observerDataElements(data: ObserverData): string;
|
|
5
|
+
/**
|
|
6
|
+
* observer-feed.json's body: observer-data.json's content and the items packed as the page packs
|
|
7
|
+
* them, with each running participant's cursor from `live` on its entry.
|
|
8
|
+
*/
|
|
9
|
+
export declare function observerFeedBody(data: ObserverData, live?: readonly LiveItemsRead[]): Promise<string>;
|
|
4
10
|
/** JSON that stays inert inside a script element: no `</script>` breakout, no HTML entities. */
|
|
5
11
|
export declare function escapeJsonScript(value: unknown): string;
|
|
@@ -1,15 +1,42 @@
|
|
|
1
|
-
// How an Observer page
|
|
2
|
-
// participant's trace items gzip-compressed in base64, which a file://
|
|
3
|
-
// fetch. The items are most of a long run's bytes and compress about eight
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// How an Observer page and the served feed carry a run's data: observer-data.json's content as
|
|
2
|
+
// JSON, and each participant's trace items gzip-compressed in base64 on their own, which a file://
|
|
3
|
+
// page decodes without a fetch. The items are most of a long run's bytes and compress about eight
|
|
4
|
+
// times. Packing one participant at a time keeps every string under V8's longest string
|
|
5
|
+
// (536,870,888 characters) while each participant's trace stays under the 32 MiB read limit.
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { constants, gzip, gzipSync } from "node:zlib";
|
|
8
|
+
import { withFeedCursors } from "./feed-cursors.js";
|
|
9
|
+
import { splitObserverData, } from "./participant-items.js";
|
|
6
10
|
/** The page's data elements for `data`, in place of the built page's data slot. */
|
|
7
11
|
export function observerDataElements(data) {
|
|
8
12
|
const { shell, items } = splitObserverData(data);
|
|
9
|
-
const packed = gzipSync(JSON.stringify(items)).toString("base64");
|
|
10
13
|
return (`<script id="observer-data" type="application/json">${escapeJsonScript(shell)}</script>` +
|
|
11
|
-
`<script id="observer-items" type="application/
|
|
14
|
+
`<script id="observer-items" type="application/json">${escapeJsonScript(packObserverItems(items))}</script>`);
|
|
12
15
|
}
|
|
16
|
+
const gzipOnThreadPool = promisify(gzip);
|
|
17
|
+
/**
|
|
18
|
+
* observer-feed.json's body: observer-data.json's content and the items packed as the page packs
|
|
19
|
+
* them, with each running participant's cursor from `live` on its entry.
|
|
20
|
+
*/
|
|
21
|
+
export async function observerFeedBody(data, live = []) {
|
|
22
|
+
const { shell, items } = splitObserverData(data);
|
|
23
|
+
const cursored = withFeedCursors(items, live);
|
|
24
|
+
// Each open page polls every 5 seconds and gets every finished participant's items again, so the
|
|
25
|
+
// server compresses them on the thread pool and answers other requests meanwhile. Level 1 took
|
|
26
|
+
// half the time of the default on 30 MiB of items, for 13 percent more bytes. One participant
|
|
27
|
+
// at a time, so a poll holds one participant's JSON text at once.
|
|
28
|
+
const streams = [];
|
|
29
|
+
for (const [id, entry] of Object.entries(cursored.streams)) {
|
|
30
|
+
const packed = await gzipOnThreadPool(entryText(id, entry), { level: constants.Z_BEST_SPEED });
|
|
31
|
+
streams.push(packed.toString("base64"));
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify({ data: shell, items: { ...cursored, streams } });
|
|
34
|
+
}
|
|
35
|
+
function packObserverItems(items) {
|
|
36
|
+
const streams = Object.entries(items.streams).map(([id, entry]) => gzipSync(entryText(id, entry)).toString("base64"));
|
|
37
|
+
return { ...items, streams };
|
|
38
|
+
}
|
|
39
|
+
const entryText = (id, entry) => JSON.stringify({ id, ...entry });
|
|
13
40
|
/** JSON that stays inert inside a script element: no `</script>` breakout, no HTML entities. */
|
|
14
41
|
export function escapeJsonScript(value) {
|
|
15
42
|
return JSON.stringify(value)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ActorTraceItem } from "../actors/contract.js";
|
|
2
2
|
import type { ObserverData, ObserverStream } from "./data.js";
|
|
3
|
-
export declare const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.
|
|
3
|
+
export declare const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v2";
|
|
4
4
|
/** A stream's actor and live actor, each without its items. */
|
|
5
5
|
type ObserverShellStream = Omit<ObserverStream, "actor" | "liveActor"> & {
|
|
6
6
|
actor?: Omit<NonNullable<ObserverStream["actor"]>, "items">;
|
|
@@ -32,6 +32,20 @@ export interface ObserverItems {
|
|
|
32
32
|
liveAfter?: string;
|
|
33
33
|
}>;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* ObserverItems as the page and the feed carry them: each stream's entry with its stream id, as
|
|
37
|
+
* gzip-compressed JSON in base64, so no string holds two participants' items. The id goes inside
|
|
38
|
+
* the entry because the public-safety scan reads the page's plain text: there, `GITHUB_TOKEN`
|
|
39
|
+
* before a long base64 value reads as a credential, and `https://` with a later id shaped like an
|
|
40
|
+
* address at an E2B host reads as one E2B URL across the base64 between them.
|
|
41
|
+
*/
|
|
42
|
+
export interface PackedObserverItems extends Omit<ObserverItems, "streams"> {
|
|
43
|
+
streams: string[];
|
|
44
|
+
}
|
|
45
|
+
/** A packed entry once decoded: the stream's entry with its stream id. */
|
|
46
|
+
export type PackedObserverEntry = ObserverItems["streams"][string] & {
|
|
47
|
+
id: string;
|
|
48
|
+
};
|
|
35
49
|
/** The data as observer-data.json and the page's items carry it. */
|
|
36
50
|
export declare function splitObserverData(data: ObserverData): {
|
|
37
51
|
shell: ObserverShell;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// The Observer's data in two parts: observer-data.json, the run and every participant without
|
|
2
2
|
// their trace items, and the items themselves. A participant working for hours records thousands
|
|
3
3
|
// of items, so the items are most of the bytes; apart, the saved observer-data.json stays small
|
|
4
|
-
// enough to verify, and the page
|
|
5
|
-
// so it imports types only
|
|
6
|
-
|
|
4
|
+
// enough to verify, and the page and the feed carry each participant's items compressed on their
|
|
5
|
+
// own. The Observer bundles this module, so it imports types only
|
|
6
|
+
// (docs/decisions/0002-observer-is-one-self-contained-file.md).
|
|
7
|
+
export const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v2";
|
|
7
8
|
/** The data as observer-data.json and the page's items carry it. */
|
|
8
9
|
export function splitObserverData(data) {
|
|
9
10
|
// No prototype, so a stream id such as `__proto__` is an entry like any other.
|
package/dist/observer/render.js
CHANGED
|
@@ -11,7 +11,7 @@ import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
|
|
|
11
11
|
import { loadAnalysis } from "../analysis/load.js";
|
|
12
12
|
import { physicalCwdOf, runIdOf, bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
13
13
|
import { writeContainedOutputFile } from "../run/contained-output.js";
|
|
14
|
-
import {
|
|
14
|
+
import { loadDisplayedRunBundle, readRunJsonIfExists, runJsonValue } from "../run/locate.js";
|
|
15
15
|
import { readRunNotes } from "../run/note-files.js";
|
|
16
16
|
import { RUN_STATUS_FILE } from "../run/status.js";
|
|
17
17
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
@@ -91,9 +91,7 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
|
|
|
91
91
|
: "HUMANISH_INVALID_RUN_BUNDLE", verified.error?.message ?? "Run bundle failed verification.");
|
|
92
92
|
}
|
|
93
93
|
// Verify passes a trace past the read limit, so its participant shows without its items.
|
|
94
|
-
const loaded = await
|
|
95
|
-
display: true,
|
|
96
|
-
});
|
|
94
|
+
const loaded = await loadDisplayedRunBundle(selectedPhysicalCwd, preparedRunPaths);
|
|
97
95
|
await validatePreparedRunArtifactPaths(preparedRunPaths);
|
|
98
96
|
if (!loaded)
|
|
99
97
|
return runNotFound(cwd, runInput);
|
|
@@ -17,7 +17,8 @@ import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-f
|
|
|
17
17
|
import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
|
|
18
18
|
import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
|
|
19
19
|
import { renderObserverHtml } from "./artifact.js";
|
|
20
|
-
import { parseFeedCursors
|
|
20
|
+
import { parseFeedCursors } from "./feed-cursors.js";
|
|
21
|
+
import { observerFeedBody } from "./page-data.js";
|
|
21
22
|
import { splitObserverData } from "./participant-items.js";
|
|
22
23
|
import { buildObserverData, observerDataFromSaved, recordedStreamEmbed, withObserverEndings, } from "./data.js";
|
|
23
24
|
import { buildArtifactSecurityHeaders } from "./http.js";
|
|
@@ -130,10 +131,9 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
|
|
|
130
131
|
writeResponse(response, 404, `Observer data not found: ${observed.unavailable}`, "text/plain; charset=utf-8");
|
|
131
132
|
return;
|
|
132
133
|
}
|
|
133
|
-
const { shell, items } = splitObserverData(observed.data);
|
|
134
134
|
writeResponse(response, 200, cleanedRelativePath === "observer/observer-data.json"
|
|
135
|
-
? JSON.stringify(shell, null, 2)
|
|
136
|
-
:
|
|
135
|
+
? JSON.stringify(splitObserverData(observed.data).shell, null, 2)
|
|
136
|
+
: await observerFeedBody(observed.data, observed.live), "application/json; charset=utf-8");
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
139
139
|
if (cleanedRelativePath === "observer/study-analysis.json") {
|