humanish 0.134.0 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/analysis/automatic-config.js +5 -3
  3. package/dist/analysis/execute.js +2 -1
  4. package/dist/analysis/service.js +2 -1
  5. package/dist/evidence/encoded-text.d.ts +13 -3
  6. package/dist/evidence/encoded-text.js +88 -25
  7. package/dist/observer/page-data.d.ts +6 -0
  8. package/dist/observer/page-data.js +34 -7
  9. package/dist/observer/participant-items.d.ts +15 -1
  10. package/dist/observer/participant-items.js +4 -3
  11. package/dist/observer/run-routes.js +4 -4
  12. package/dist/observer-app.html +4 -4
  13. package/dist/routes/computer-use/fanout-bundle.js +3 -3
  14. package/dist/routes/computer-use/participant-runs.js +2 -2
  15. package/dist/routes/computer-use/single-bundle.js +3 -3
  16. package/dist/routes/scripted/setup.js +62 -17
  17. package/dist/routes/shared-world/bundle.js +3 -3
  18. package/dist/routes/shared-world/participant-specs.js +2 -2
  19. package/dist/routes/terminal/bundle.js +2 -2
  20. package/dist/run/run.js +4 -3
  21. package/dist/run-study.d.ts +3 -3
  22. package/dist/run-study.js +3 -3
  23. package/dist/study/bundle-estimate.d.ts +0 -2
  24. package/dist/study/bundle-estimate.js +10 -16
  25. package/dist/study/config.d.ts +4 -3
  26. package/dist/study/config.js +8 -5
  27. package/dist/study/parse/actors.d.ts +6 -0
  28. package/dist/study/parse/actors.js +14 -0
  29. package/dist/study/parse/subject-state.js +4 -4
  30. package/dist/study/participant-assignment.js +2 -2
  31. package/dist/study/persona.d.ts +7 -1
  32. package/dist/study/persona.js +13 -5
  33. package/dist/study/plan-base.js +4 -3
  34. package/docs/architecture/observer.md +24 -7
  35. package/docs/contracts/run-bundle.md +3 -0
  36. package/docs/contracts/study-analysis.md +4 -3
  37. package/docs/decisions/0002-observer-is-one-self-contained-file.md +3 -3
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.135.0: Encoded keys in a study's own text stay out of the run (2026-10-14)
12
+
13
+ humanish 0.135.0 keeps a base64- or percent-encoded key in a study's own text out of the copies of
14
+ that text the run records. The run redacted keys as written, and verify also decodes those
15
+ encodings, so a study whose title, mission, instructions, persona or scripted scenario held an
16
+ encoded key produced a run graded `blocked`. A participant that repeats such a key in its own words
17
+ still does (#1892). `actor.model`, `subject.envValues` and the analysis question now refuse such a
18
+ value before anything runs, which a study that ran before may now meet. verify's per-file scan also
19
+ finds a key behind an HTML reference and a JSON escape. The Observer page and its live feed carry
20
+ each participant's items on their own, so `humanish observe` writes the page of a run whose traces
21
+ together pass V8's longest string: on a synthetic run of 25 participants with 30 MiB traces it
22
+ exits 0 where it exited 2.
23
+
24
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.135.0)
25
+
11
26
  ## 0.134.0: Participants held to an end keep reloading until it (2026-10-14)
12
27
 
13
28
  humanish 0.134.0 keeps a participant held to a scheduled end running when it reloads a page that
@@ -3,7 +3,7 @@ import { DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS, MAX_ANALYSIS_OUTPUT_TOKENS, analysi
3
3
  import { admissionRule } from "./admission.js";
4
4
  import { analysisRequestCount, EVIDENCE_LIMITS } from "./analysis-limits.js";
5
5
  import { plural } from "../run/text.js";
6
- import { containsSensitive } from "../evidence/redaction.js";
6
+ import { scanEncodedText } from "../evidence/encoded-text.js";
7
7
  export const DEFAULT_ANALYSIS_TIMEOUT_MS = 600_000;
8
8
  const MAX_ANALYSIS_TIMEOUT_MS = 600_000;
9
9
  export const DEFAULT_ANALYSIS_MODEL = "gpt-6-astra";
@@ -21,6 +21,8 @@ const validTimeout = (value) => typeof value === "number" &&
21
21
  value <= MAX_ANALYSIS_TIMEOUT_MS;
22
22
  /** An absent question is valid; a present one must be a string of at most 4000 characters. */
23
23
  const validQuestion = (value) => value === undefined || (typeof value === "string" && value.length <= 4000);
24
+ // The provider receives the question and analysis.json keeps it. verify reads that file as written
25
+ // and decoded, so the question is refused when scanEncodedText reads a secret in it.
24
26
  const SENSITIVE_QUESTION = {
25
27
  ok: false,
26
28
  message: "review.analysis.question contains sensitive text and cannot be sent for analysis.",
@@ -60,7 +62,7 @@ export function resolveAutomaticAnalysis(raw) {
60
62
  message: `Codex analysis requires the qualified ${CODEX_ANALYSIS_MODEL} model and timeoutMs 1–${MAX_ANALYSIS_TIMEOUT_MS}. Dollar and output-token caps are unavailable; omit them. The optional question is limited to 4000 characters.`,
61
63
  };
62
64
  }
63
- if (question !== null && containsSensitive(question))
65
+ if (question !== null && scanEncodedText(question).sensitive)
64
66
  return SENSITIVE_QUESTION;
65
67
  return {
66
68
  ok: true,
@@ -99,7 +101,7 @@ export function resolveAutomaticAnalysis(raw) {
99
101
  message: `review.analysis requires a positive maxCostUsd up to 1000, a supported analysis model, timeoutMs 1–${MAX_ANALYSIS_TIMEOUT_MS}, maxOutputTokens 256–${MAX_ANALYSIS_OUTPUT_TOKENS}, and an optional question of at most 4000 characters.`,
100
102
  };
101
103
  }
102
- if (question !== null && containsSensitive(question))
104
+ if (question !== null && scanEncodedText(question).sensitive)
103
105
  return SENSITIVE_QUESTION;
104
106
  return {
105
107
  ok: true,
@@ -4,6 +4,7 @@ import { estimateAnalysisCost } from "./admission.js";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { MODEL_RATES } from "../run/pricing.js";
6
6
  import { containsSensitive } from "../evidence/redaction.js";
7
+ import { scanEncodedText } from "../evidence/encoded-text.js";
7
8
  import { ANALYSIS_ID_PATTERN, ANALYSIS_SCHEMA, } from "./types.js";
8
9
  import { INPUT_IMAGE_DATA_URL, createAnalysisProvider, } from "./provider.js";
9
10
  import { hashAnalysisValue, analysisResultJsonSchema, validateAnalysisInputMetadata, } from "./validation.js";
@@ -189,7 +190,7 @@ export function estimateAnalysisAdmission(input, config) {
189
190
  (typeof config.question !== "string" || config.question.length > 4000))) {
190
191
  return denied("analysis_config_invalid");
191
192
  }
192
- if (config.question !== null && containsSensitive(config.question))
193
+ if (config.question !== null && scanEncodedText(config.question).sensitive)
193
194
  return denied("analysis_question_sensitive");
194
195
  const badInput = inputError(input);
195
196
  if (badInput)
@@ -5,6 +5,7 @@ import { lstat, mkdir, realpath, rmdir } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { renderObserver } from "../observer/render.js";
7
7
  import { containsSensitive } from "../evidence/redaction.js";
8
+ import { scanEncodedText } from "../evidence/encoded-text.js";
8
9
  import { transientCommsKnownValueScrub } from "../run/transient-comms-secrets.js";
9
10
  import { verifyRunPrepared } from "../verify/verify.js";
10
11
  import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
@@ -189,7 +190,7 @@ function analyzeConfigRefusal(config) {
189
190
  ? !validCodexAnalysisConfig(config)
190
191
  : !Number.isFinite(config.maxCostUsd) || config.maxCostUsd <= 0 || config.maxCostUsd > 1000)
191
192
  return "ANALYSIS_CONFIG_INVALID";
192
- if (config.question !== null && containsSensitive(config.question))
193
+ if (config.question !== null && scanEncodedText(config.question).sensitive)
193
194
  return "ANALYSIS_QUESTION_UNSAFE";
194
195
  return null;
195
196
  }
@@ -8,10 +8,12 @@ export declare const latin1RunsAsUtf8: (text: string) => string;
8
8
  export declare function decodeEscapes(text: string): string;
9
9
  /**
10
10
  * Every reading of the text a scan matches, each once: as written, decodeEscapes, with percent
11
- * escapes read as UTF-8, decodeEscapes with transfer escapes expanded, and the text as written and
11
+ * escapes read as UTF-8, decodeEscapes with transfer escapes expanded, the text as written and
12
12
  * decoded with each run of U+0080 to U+00FF characters read as UTF-8 bytes, which undoes text that
13
- * a reader already decoded one byte per character. Either percent reading can split a value that
14
- * the other keeps whole, as `%E2%80%80` (U+2000, a space, in UTF-8) does.
13
+ * a reader already decoded one byte per character, decodeEscapes with HTML references left as
14
+ * written and that reading with transfer escapes expanded, and the text with only HTML references
15
+ * decoded. Either percent reading can split a value that the other keeps whole, as `%E2%80%80`
16
+ * (U+2000, a space, in UTF-8) does.
15
17
  */
16
18
  export declare function readingsOf(text: string): string[];
17
19
  /** How scanEncodedText reads text. */
@@ -34,6 +36,14 @@ export interface EncodedTextScan {
34
36
  * is opaque unless the caller allows opaque runs.
35
37
  */
36
38
  export declare function scanEncodedText(text: string, options?: EncodedTextScanOptions, depth?: number): EncodedTextScan;
39
+ /**
40
+ * redactText, then each whitespace-separated piece that scanEncodedText reads as sensitive replaced
41
+ * by the secret marker, so a key written in base64 or percent-encoded goes as a literal one does.
42
+ * When the pieces read clean one at a time but the text still reads sensitive, as `Bearer` before a
43
+ * percent-encoded token does, the whole text becomes the marker. For study text the run records and
44
+ * the instructions composed from it: on 32 KiB of prose it took 3 ms, and 34 ms with a key in it.
45
+ */
46
+ export declare function redactEncodedText(text: string): string;
37
47
  /**
38
48
  * scanEncodedText, cached in this process. The key is the sha256 of the scanned string's UTF-16
39
49
  * code units, the options and ENCODED_SCAN_VERSION. Encoding as UTF-16LE keeps every code unit,
@@ -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 two that scanEncodedText also searches for runs. */
100
+ /** The readings a scan matches, with the three that scanEncodedText also searches for runs. */
101
101
  function readings(text) {
102
- const decoded = decodeEscapes(text);
102
+ // Decoding references with the other escapes joins a letter reference to the escaped key after
103
+ // it, `&#65;\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, `&#108;\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
- all: [...new Set([text, decoded, utf8, expanded, ...recovered.map(latin1RunsAsUtf8)])],
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, and the text as written and
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. Either percent reading can split a value that
120
- * the other keeps whole, as `%E2%80%80` (U+2000, a space, in UTF-8) does.
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, all } = readings(text);
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 [match] of expanded.matchAll(BASE64_RUN)) {
311
- // Hex digests and ids decode to noise; the hex pass below reads hex as hex.
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 match.split("/"))
318
- if (piece.length >= 16 && piece.length < match.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 = match.indexOf("/"); slash !== -1 && slash < SLASH_START_SPAN && match.length - slash > 16; slash = match.indexOf("/", slash + 1))
321
- slashStarts.push(Buffer.from(match.slice(slash + 1), "base64"));
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 = 5;
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();
@@ -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 carries a run's data: observer-data.json's content as JSON, and every
2
- // participant's trace items gzip-compressed in base64, which a file:// page decodes without a
3
- // fetch. The items are most of a long run's bytes and compress about eight times.
4
- import { gzipSync } from "node:zlib";
5
- import { splitObserverData } from "./participant-items.js";
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/octet-stream" data-encoding="gzip">${packed}</script>`);
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.v1";
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 carries the items compressed. The Observer bundles this module,
5
- // so it imports types only (docs/decisions/0002-observer-is-one-self-contained-file.md).
6
- export const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v1";
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.
@@ -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, withFeedCursors } from "./feed-cursors.js";
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
- : JSON.stringify({ data: shell, items: withFeedCursors(items, observed.live) }), "application/json; charset=utf-8");
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") {