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.
- package/CHANGELOG.md +15 -0
- package/dist/analysis/automatic-config.js +5 -3
- package/dist/analysis/execute.js +2 -1
- package/dist/analysis/service.js +2 -1
- package/dist/evidence/encoded-text.d.ts +13 -3
- package/dist/evidence/encoded-text.js +88 -25
- 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/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-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/run.js +4 -3
- package/dist/run-study.d.ts +3 -3
- package/dist/run-study.js +3 -3
- package/dist/study/bundle-estimate.d.ts +0 -2
- package/dist/study/bundle-estimate.js +10 -16
- 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/docs/architecture/observer.md +24 -7
- package/docs/contracts/run-bundle.md +3 -0
- package/docs/contracts/study-analysis.md +4 -3
- package/docs/decisions/0002-observer-is-one-self-contained-file.md +3 -3
- 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 {
|
|
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 &&
|
|
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 &&
|
|
104
|
+
if (question !== null && scanEncodedText(question).sensitive)
|
|
103
105
|
return SENSITIVE_QUESTION;
|
|
104
106
|
return {
|
|
105
107
|
ok: true,
|
package/dist/analysis/execute.js
CHANGED
|
@@ -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 &&
|
|
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)
|
package/dist/analysis/service.js
CHANGED
|
@@ -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 &&
|
|
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,
|
|
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
|
|
14
|
-
*
|
|
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
|
|
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();
|
|
@@ -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.
|
|
@@ -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") {
|