humanish 0.133.0 → 0.133.1
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 +11 -0
- package/dist/evidence/json-split.d.ts +21 -0
- package/dist/evidence/json-split.js +57 -0
- package/dist/evidence/sensitive-json.js +5 -28
- package/dist/run/sandbox-ids.d.ts +6 -1
- package/dist/run/sandbox-ids.js +65 -5
- package/dist/study/bundle-estimate.d.ts +1 -4
- package/dist/study/bundle-estimate.js +6 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,17 @@ The Unreleased section holds the full notes for the next version until it is tag
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.133.1: Hosted runs keep writing run.json past 512 MiB of traces (2026-10-14)
|
|
12
|
+
|
|
13
|
+
humanish 0.133.1 keeps a hosted run writing run.json once its finished participants' traces
|
|
14
|
+
together pass V8's longest string, 536,870,888 characters. The sandbox id scrub serialized the whole
|
|
15
|
+
bundle into one string on every write, so past that size every write threw and run.json, the trace
|
|
16
|
+
files, review.json and events.ndjson stopped updating. It now scrubs the bundle and each participant
|
|
17
|
+
apart and writes the same bytes. On synthetic runs at a recorded hosted run's density, a snapshot
|
|
18
|
+
write of 10 participants of 10 MiB took 3.8 seconds where it took 4.7, at half the peak memory.
|
|
19
|
+
|
|
20
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.133.1)
|
|
21
|
+
|
|
11
22
|
## 0.133.0: An OpenAI participant names how long it waits (2026-10-14)
|
|
12
23
|
|
|
13
24
|
humanish 0.133.0 lets an OpenAI computer-use participant name how long it waits, with a
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The JSON text of a value cut around the elements of one of its arrays. */
|
|
2
|
+
export interface ArraySplit {
|
|
3
|
+
/** The array's elements, read as JSON.stringify reads them. */
|
|
4
|
+
readonly elements: readonly Record<string, unknown>[];
|
|
5
|
+
/** The value's JSON text up to and including the array's `[`. */
|
|
6
|
+
before(): string;
|
|
7
|
+
/** The value's JSON text from the array's `]` on. */
|
|
8
|
+
after(): string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The cut of JSON.stringify(value) around the elements of its array `value[key]`, when that text
|
|
12
|
+
* holds each element's text as JSON.stringify(element) writes it: `value` is a plain object that
|
|
13
|
+
* has `key` among its own enumerable keys, `value[key]` is a plain array with no holes, and each
|
|
14
|
+
* element is a plain object. A plain object has Object.prototype or null as its prototype, a plain
|
|
15
|
+
* array Array.prototype, and neither has a toJSON. Otherwise undefined: the caller needs the whole
|
|
16
|
+
* text. Getters, proxies and a toJSON that changes `value` while it is serialized are not detected:
|
|
17
|
+
* the elements are read before anything is serialized. before() and after() serialize the members
|
|
18
|
+
* on their side of the array when called, so a caller that calls before(), serializes each element
|
|
19
|
+
* and then calls after() runs every toJSON in the order JSON.stringify(value) runs them.
|
|
20
|
+
*/
|
|
21
|
+
export declare function splitAtArray(value: unknown, key: string): ArraySplit | undefined;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// When the JSON text of a value can be worked on one element of an array at a time. A run bundle
|
|
2
|
+
// holds every participant's trace in its `streams` array, and the bundle's whole text can pass
|
|
3
|
+
// V8's longest string, 536,870,888 characters on Node 24.
|
|
4
|
+
//
|
|
5
|
+
// JSON.stringify calls a toJSON with the value's key in its parent, and an element serialized
|
|
6
|
+
// alone gets "" in place of its index. It writes a boxed primitive as the primitive. A plain object
|
|
7
|
+
// or array with no toJSON is written as its own keys or items wherever it sits, and every value
|
|
8
|
+
// inside an element gets the same key alone as inside the whole value.
|
|
9
|
+
import { isRecord } from "../run/type-guards.js";
|
|
10
|
+
/**
|
|
11
|
+
* The cut of JSON.stringify(value) around the elements of its array `value[key]`, when that text
|
|
12
|
+
* holds each element's text as JSON.stringify(element) writes it: `value` is a plain object that
|
|
13
|
+
* has `key` among its own enumerable keys, `value[key]` is a plain array with no holes, and each
|
|
14
|
+
* element is a plain object. A plain object has Object.prototype or null as its prototype, a plain
|
|
15
|
+
* array Array.prototype, and neither has a toJSON. Otherwise undefined: the caller needs the whole
|
|
16
|
+
* text. Getters, proxies and a toJSON that changes `value` while it is serialized are not detected:
|
|
17
|
+
* the elements are read before anything is serialized. before() and after() serialize the members
|
|
18
|
+
* on their side of the array when called, so a caller that calls before(), serializes each element
|
|
19
|
+
* and then calls after() runs every toJSON in the order JSON.stringify(value) runs them.
|
|
20
|
+
*/
|
|
21
|
+
export function splitAtArray(value, key) {
|
|
22
|
+
// JSON.stringify writes only own enumerable keys.
|
|
23
|
+
if (!isPlainRecord(value))
|
|
24
|
+
return undefined;
|
|
25
|
+
const keys = Object.keys(value);
|
|
26
|
+
const at = keys.indexOf(key);
|
|
27
|
+
if (at === -1)
|
|
28
|
+
return undefined;
|
|
29
|
+
const array = value[key];
|
|
30
|
+
if (!isPlainArray(array))
|
|
31
|
+
return undefined;
|
|
32
|
+
// JSON.stringify reads the length once and then each index, so a hole reads as undefined and an
|
|
33
|
+
// own iterator is never called. Array.from would call that iterator, and every skips holes.
|
|
34
|
+
const elements = [];
|
|
35
|
+
for (let index = 0, length = array.length; index < length; index += 1)
|
|
36
|
+
elements.push(array[index]);
|
|
37
|
+
if (!elements.every(isPlainRecord))
|
|
38
|
+
return undefined;
|
|
39
|
+
// fromEntries keeps a key such as __proto__ an own property.
|
|
40
|
+
const members = (from, to) => Object.fromEntries(keys.slice(from, to).map((name) => [name, value[name]]));
|
|
41
|
+
return {
|
|
42
|
+
elements,
|
|
43
|
+
before: () => JSON.stringify({ ...members(0, at), [key]: [] }).slice(0, -"]}".length),
|
|
44
|
+
after: () => {
|
|
45
|
+
const text = JSON.stringify(members(at + 1));
|
|
46
|
+
return text === "{}" ? "]}" : `],${text.slice(1)}`;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const hasToJson = (value) => typeof value.toJSON === "function";
|
|
51
|
+
function isPlainRecord(value) {
|
|
52
|
+
if (!isRecord(value) || hasToJson(value))
|
|
53
|
+
return false;
|
|
54
|
+
const prototype = Object.getPrototypeOf(value);
|
|
55
|
+
return prototype === Object.prototype || prototype === null;
|
|
56
|
+
}
|
|
57
|
+
const isPlainArray = (value) => Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype && !hasToJson(value);
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// piece up to its first slash or whitespace. A public E2B page after the scheme would need a space
|
|
22
22
|
// or the text's end before the cut, so neither text exempts the URL. The scan holds one piece at a
|
|
23
23
|
// time.
|
|
24
|
-
import {
|
|
24
|
+
import { splitAtArray } from "./json-split.js";
|
|
25
25
|
import { containsSandboxUrl, containsSensitive } from "./redaction.js";
|
|
26
26
|
// The sandbox URL's scheme, `https://` or `https:\/\/` as JSON may escape it, at a text's end.
|
|
27
27
|
const SCHEME_AT_END = /https?:\\?\/\\?\/$/i;
|
|
@@ -35,14 +35,11 @@ const SLASH_OR_SPACE = /[/\s]/;
|
|
|
35
35
|
* them.
|
|
36
36
|
*/
|
|
37
37
|
export function containsSensitiveJson(value, key) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// Array.from reads a hole as undefined, where every would skip it.
|
|
41
|
-
if (!isPlainRecord(value) ||
|
|
42
|
-
!isPlainArray(elements) ||
|
|
43
|
-
!Array.from(elements).every(isPlainRecord))
|
|
38
|
+
const split = splitAtArray(value, key);
|
|
39
|
+
if (split === undefined)
|
|
44
40
|
return wholeTextSensitive(value);
|
|
45
|
-
const
|
|
41
|
+
const { elements } = split;
|
|
42
|
+
const [prefix, suffix] = [split.before(), split.after()];
|
|
46
43
|
if (elements.length === 0)
|
|
47
44
|
return containsSensitive(prefix + suffix);
|
|
48
45
|
// The scheme of a URL no slash or whitespace has followed, from an earlier piece.
|
|
@@ -59,26 +56,6 @@ export function containsSensitiveJson(value, key) {
|
|
|
59
56
|
return false;
|
|
60
57
|
}
|
|
61
58
|
const wholeTextSensitive = (value) => containsSensitive(JSON.stringify(value) ?? "");
|
|
62
|
-
// JSON.stringify calls a toJSON with the value's key in its parent, and an element serialized
|
|
63
|
-
// alone gets "" in place of its index. It writes a boxed primitive as the primitive. A plain object
|
|
64
|
-
// or array with no toJSON is written as its own keys or items wherever it sits.
|
|
65
|
-
const hasToJson = (value) => typeof value.toJSON === "function";
|
|
66
|
-
function isPlainRecord(value) {
|
|
67
|
-
if (!isRecord(value) || hasToJson(value))
|
|
68
|
-
return false;
|
|
69
|
-
const prototype = Object.getPrototypeOf(value);
|
|
70
|
-
return prototype === Object.prototype || prototype === null;
|
|
71
|
-
}
|
|
72
|
-
const isPlainArray = (value) => Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype && !hasToJson(value);
|
|
73
|
-
/** The document's text before the first element of `value[key]` and after its last. */
|
|
74
|
-
function around(value, key) {
|
|
75
|
-
const keys = Object.keys(value);
|
|
76
|
-
const at = keys.indexOf(key);
|
|
77
|
-
const entries = (from, to) => Object.fromEntries(keys.slice(from, to).map((name) => [name, value[name]]));
|
|
78
|
-
const before = JSON.stringify({ ...entries(0, at), [key]: [] });
|
|
79
|
-
const after = JSON.stringify(entries(at + 1));
|
|
80
|
-
return [before.slice(0, -"]}".length), after === "{}" ? "]}" : `],${after.slice(1)}`];
|
|
81
|
-
}
|
|
82
59
|
/** `piece` up to and including its first slash or whitespace, or all of it when it has none. */
|
|
83
60
|
function head(piece) {
|
|
84
61
|
const at = piece.search(SLASH_OR_SPACE);
|
|
@@ -4,7 +4,12 @@ import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
|
4
4
|
export declare function scrubSandboxIds(text: string, ids: Iterable<string>, label?: (id: string) => string): string;
|
|
5
5
|
/**
|
|
6
6
|
* `value` as it may be written or returned: ids at the sandbox-id keys become the marker and a
|
|
7
|
-
* digest, and any raw id left in free text or a URL becomes its label.
|
|
7
|
+
* digest, and any raw id left in free text, a key or a URL becomes its label. When no label goes
|
|
8
|
+
* in, the result is the key-redacted value. Otherwise it is what JSON.parse returns for the
|
|
9
|
+
* scrubbed JSON text of that value. A run bundle is scrubbed one stream at a time, so no string
|
|
10
|
+
* holds two streams unless a member outside `streams` holds them too. An id that holds one of
|
|
11
|
+
* {}[],:"\ makes the scrub work on the whole text, which throws RangeError for a bundle past V8's
|
|
12
|
+
* longest string. No E2B id holds one.
|
|
8
13
|
*/
|
|
9
14
|
export declare function publicSandboxView<T>(value: T, ids: Iterable<string>): T;
|
|
10
15
|
/**
|
package/dist/run/sandbox-ids.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// so output an agent pastes into an issue never carries an id.
|
|
5
5
|
import { opendir, realpath } from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { splitAtArray } from "../evidence/json-split.js";
|
|
7
8
|
import { readPlainText } from "../evidence/plain-text.js";
|
|
8
9
|
import { collectSandboxIds, redactSandboxIds, sandboxIdDigest, REDACTED_SANDBOX_ID, } from "../evidence/redaction.js";
|
|
9
10
|
import { ContainedReadRefusedError, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
|
|
@@ -26,20 +27,79 @@ export function scrubSandboxIds(text, ids, label = sandboxIdLabel) {
|
|
|
26
27
|
unique.sort((left, right) => right.length - left.length);
|
|
27
28
|
return unique.reduce((current, id) => current.split(id).join(label(id)), text);
|
|
28
29
|
}
|
|
30
|
+
// A run bundle holds every finished participant's trace, and its whole JSON text can pass V8's
|
|
31
|
+
// longest string, 536,870,888 characters on Node 24. publicSandboxView scrubs such a bundle in
|
|
32
|
+
// pieces: the shell, which is the bundle's JSON text with `streams` written as `[]`, and the JSON
|
|
33
|
+
// text of each stream on its own. The pieces give what the whole text gives:
|
|
34
|
+
// - An id that holds none of {}[],:"\ lies inside one token of the whole text. Each cut between
|
|
35
|
+
// pieces falls between two of the characters {}[], and the placeholder adds only [ and ], so no
|
|
36
|
+
// occurrence crosses a cut. A replacement leaves those characters in place, so the next id's
|
|
37
|
+
// occurrences cannot cross one either.
|
|
38
|
+
// - A label holds no quote or backslash, so it never moves the end of a string. A label inside an
|
|
39
|
+
// escape, a number, true, false or null makes its piece fail to parse, and the whole text fails
|
|
40
|
+
// the same way.
|
|
41
|
+
// - JSON.stringify writes a stream's members alone as it writes them inside the bundle, since each
|
|
42
|
+
// gets the same key for its toJSON. Only the stream itself would get "" in place of its index,
|
|
43
|
+
// and splitAtArray takes no stream with a toJSON. The members before `streams`, each stream and
|
|
44
|
+
// the members after are serialized in that order, which is the order the whole text runs each
|
|
45
|
+
// toJSON in.
|
|
46
|
+
// - The parsed shell keeps its keys in the order JSON.parse gives for the whole text, and the
|
|
47
|
+
// streams go in at the place of `streams`. A key a label went into holds the label's `[`, so no
|
|
48
|
+
// other key becomes `streams`. An id inside the key `streams` itself takes the whole text.
|
|
49
|
+
// - Once a label goes in, the view is parsed from the scrubbed pieces, as it was from the whole
|
|
50
|
+
// text. A piece kept as given could run a toJSON or getter again when the view is written.
|
|
51
|
+
// - All of this holds for a bundle that serializing leaves unchanged, as every bundle JSON.parse
|
|
52
|
+
// returns is. splitAtArray reads the streams before any toJSON runs.
|
|
53
|
+
/** The characters that let an id reach past one token of a JSON text or move where a string ends. */
|
|
54
|
+
const CROSSES_TOKENS = /[{}[\],:"\\]/;
|
|
55
|
+
/** Whether every occurrence of `id` in a bundle's JSON text stays inside one piece. */
|
|
56
|
+
const staysInOnePiece = (id) => id.length === 0 || (!CROSSES_TOKENS.test(id) && !"streams".includes(id));
|
|
29
57
|
/**
|
|
30
58
|
* `value` as it may be written or returned: ids at the sandbox-id keys become the marker and a
|
|
31
|
-
* digest, and any raw id left in free text or a URL becomes its label.
|
|
59
|
+
* digest, and any raw id left in free text, a key or a URL becomes its label. When no label goes
|
|
60
|
+
* in, the result is the key-redacted value. Otherwise it is what JSON.parse returns for the
|
|
61
|
+
* scrubbed JSON text of that value. A run bundle is scrubbed one stream at a time, so no string
|
|
62
|
+
* holds two streams unless a member outside `streams` holds them too. An id that holds one of
|
|
63
|
+
* {}[],:"\ makes the scrub work on the whole text, which throws RangeError for a bundle past V8's
|
|
64
|
+
* longest string. No E2B id holds one.
|
|
32
65
|
*/
|
|
33
66
|
export function publicSandboxView(value, ids) {
|
|
34
67
|
const redacted = redactSandboxIds(value);
|
|
35
68
|
const known = [...ids];
|
|
36
69
|
if (known.length === 0)
|
|
37
70
|
return redacted;
|
|
38
|
-
const
|
|
71
|
+
const split = known.every(staysInOnePiece) ? splitAtArray(redacted, "streams") : undefined;
|
|
72
|
+
return (split === undefined ? wholeTextView(redacted, known) : streamwiseView(redacted, split, known));
|
|
73
|
+
}
|
|
74
|
+
/** The scrub of `value`'s whole JSON text, parsed back when a label went in. */
|
|
75
|
+
function wholeTextView(value, ids) {
|
|
76
|
+
const text = JSON.stringify(value);
|
|
39
77
|
if (text === undefined)
|
|
40
|
-
return
|
|
41
|
-
const scrubbed = scrubSandboxIds(text,
|
|
42
|
-
return
|
|
78
|
+
return value;
|
|
79
|
+
const scrubbed = scrubSandboxIds(text, ids);
|
|
80
|
+
return scrubbed === text ? value : JSON.parse(scrubbed);
|
|
81
|
+
}
|
|
82
|
+
/** The bundle's whole-text scrub, built from its shell and each stream scrubbed on its own. */
|
|
83
|
+
function streamwiseView(bundle, split, ids) {
|
|
84
|
+
const before = split.before();
|
|
85
|
+
let changed = false;
|
|
86
|
+
// Each stream is parsed at its turn, even before any label goes in, since a later piece can still
|
|
87
|
+
// take one and by then the earlier streams' text is gone.
|
|
88
|
+
const streams = split.elements.map((stream) => {
|
|
89
|
+
const text = JSON.stringify(stream);
|
|
90
|
+
const scrubbed = scrubSandboxIds(text, ids);
|
|
91
|
+
if (scrubbed !== text)
|
|
92
|
+
changed = true;
|
|
93
|
+
return JSON.parse(scrubbed);
|
|
94
|
+
});
|
|
95
|
+
const shellText = before + split.after();
|
|
96
|
+
const shell = scrubSandboxIds(shellText, ids);
|
|
97
|
+
if (!changed && shell === shellText)
|
|
98
|
+
return bundle;
|
|
99
|
+
// Assigning to `streams` keeps its place among the keys.
|
|
100
|
+
const view = JSON.parse(shell);
|
|
101
|
+
view.streams = streams;
|
|
102
|
+
return view;
|
|
43
103
|
}
|
|
44
104
|
/** The raw sandbox ids a receipts journal names. */
|
|
45
105
|
function receiptSandboxIds(journal) {
|
|
@@ -16,10 +16,7 @@ export interface BundleEstimate {
|
|
|
16
16
|
readonly runFileBytes: number;
|
|
17
17
|
/** The largest participant trace file, which analysis also reads whole. */
|
|
18
18
|
readonly largestTraceBytes: number;
|
|
19
|
-
/**
|
|
20
|
-
* Every participant's trace file together, whose items the run writer and the Observer page join
|
|
21
|
-
* in one string.
|
|
22
|
-
*/
|
|
19
|
+
/** Every participant's trace file together, whose items the Observer page joins in one string. */
|
|
23
20
|
readonly tracesBytes: number;
|
|
24
21
|
/** observer/index.html, which verify reads whole. */
|
|
25
22
|
readonly observerPageBytes: number;
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
// runs measured, so it passes 32 MiB only after run.json passes 16 MiB, and the estimate does not
|
|
18
18
|
// check it.
|
|
19
19
|
// The Observer page and the Observer server join every participant's items in one string before
|
|
20
|
-
// gzip.
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// on Node 24. A trace file's bytes are at least the characters of its
|
|
24
|
-
// estimate compares the traces' bytes and may warn a little early.
|
|
20
|
+
// gzip. A bundle export renders the Observer page, so it fails there too once --max-bytes is
|
|
21
|
+
// raised past its 25 MiB default, which refuses such a run first. Writing run.json joins none: the
|
|
22
|
+
// sandbox id scrub (publicSandboxView) works one participant at a time. V8's longest string is
|
|
23
|
+
// 536,870,888 characters on Node 24. A trace file's bytes are at least the characters of its
|
|
24
|
+
// items as compact JSON, so the estimate compares the traces' bytes and may warn a little early.
|
|
25
25
|
import { constants } from "node:buffer";
|
|
26
26
|
import { EVIDENCE_LIMITS } from "../analysis/analysis-limits.js";
|
|
27
27
|
import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
|
|
@@ -206,7 +206,7 @@ export function bundleLimitReason(estimate) {
|
|
|
206
206
|
: []),
|
|
207
207
|
...(joined
|
|
208
208
|
? [
|
|
209
|
-
`All participants' traces together may grow to about ${mib(estimate.tracesBytes)}, past the ${mib(constants.MAX_STRING_LENGTH)} that fits in one string.
|
|
209
|
+
`All participants' traces together may grow to about ${mib(estimate.tracesBytes)}, past the ${mib(constants.MAX_STRING_LENGTH)} that fits in one string. The Observer page puts all their items in one string, so the page would not build. A bundle export allowed past its 25 MiB default renders that page, so it would fail too.`,
|
|
210
210
|
]
|
|
211
211
|
: []),
|
|
212
212
|
...(entries
|