humanish 0.109.0 → 0.110.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 +20 -0
- package/README.md +1 -1
- package/dist/analysis/automatic-config.d.ts +1 -1
- package/dist/cli/commands/comms.js +25 -7
- package/dist/cli/commands/migrate.js +15 -4
- package/dist/cli/commands/observe.js +4 -2
- package/dist/cli/commands/run-command.js +4 -3
- package/dist/cli/commands/runs.js +13 -3
- package/dist/cli/commands/setup.js +19 -5
- package/dist/cli/commands/study-format.js +29 -2
- package/dist/cli/commands/study.js +4 -3
- package/dist/cli/commands/tui.js +4 -3
- package/dist/cli/commands/watch.js +2 -2
- package/dist/cli/doctor.d.ts +3 -1
- package/dist/cli/doctor.js +5 -4
- package/dist/cli/io.d.ts +17 -3
- package/dist/cli/io.js +20 -3
- package/dist/comms/receiving-runtime.js +1 -1
- package/dist/comms/setup.js +1 -1
- package/dist/evidence/redaction.d.ts +15 -0
- package/dist/evidence/redaction.js +87 -0
- package/dist/feedback/export-bundle.js +11 -5
- package/dist/feedback/export.js +3 -0
- package/dist/keys/key-resolution.d.ts +7 -0
- package/dist/keys/key-resolution.js +10 -8
- package/dist/routes/computer-use/in-process-desktop.js +1 -1
- package/dist/routes/computer-use/live-phase.d.ts +1 -0
- package/dist/routes/computer-use/plan.js +3 -3
- package/dist/routes/computer-use/preflight.js +1 -1
- package/dist/routes/computer-use/setup.js +1 -1
- package/dist/routes/computer-use/types.d.ts +6 -0
- package/dist/routes/scripted/types.d.ts +1 -0
- package/dist/routes/shared-world/route.js +1 -1
- package/dist/routes/shared-world/types.d.ts +2 -0
- package/dist/routes/terminal/credentials.js +1 -1
- package/dist/routes/terminal/live-finish.js +8 -4
- package/dist/routes/terminal/types.d.ts +3 -0
- package/dist/run/bundle-shape.js +1 -0
- package/dist/run/bundle.d.ts +6 -0
- package/dist/run/reclaim.d.ts +3 -0
- package/dist/run/reclaim.js +10 -3
- package/dist/run/results.d.ts +3 -0
- package/dist/run/run.js +14 -8
- package/dist/run/sandbox-ids.d.ts +53 -0
- package/dist/run/sandbox-ids.js +198 -0
- package/dist/run/sandbox-receipts.d.ts +2 -0
- package/dist/run/sandbox-receipts.js +26 -0
- package/dist/run/status.js +5 -2
- package/dist/run/stored-runs.js +10 -1
- package/dist/run-study.js +6 -1
- package/dist/study/doctor.js +2 -2
- package/dist/study/init-templates.js +2 -2
- package/dist/study/migrate.d.ts +7 -2
- package/dist/study/migrate.js +60 -4
- package/dist/study/run-study-events.js +1 -1
- package/dist/study/run-study-options.js +3 -3
- package/dist/study/types.d.ts +1 -1
- package/dist/verify/artifacts.d.ts +4 -0
- package/dist/verify/artifacts.js +8 -0
- package/dist/verify/liveness.js +8 -4
- package/dist/verify/verify.d.ts +1 -1
- package/dist/verify/verify.js +21 -1
- package/docs/architecture/restricted-codex-analysis.md +1 -1
- package/docs/contracts/run-bundle.md +20 -1
- package/package.json +4 -2
- package/skills/humanish/SKILL.md +2 -2
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type PreparedOutputRoot } from "./contained-output.js";
|
|
2
|
+
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
3
|
+
/** `text` with every occurrence of each raw id replaced by its label, longest id first. */
|
|
4
|
+
export declare function scrubSandboxIds(text: string, ids: Iterable<string>, label?: (id: string) => string): string;
|
|
5
|
+
/**
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
export declare function publicSandboxView<T>(value: T, ids: Iterable<string>): T;
|
|
10
|
+
/**
|
|
11
|
+
* The raw ids at sandbox-id keys in `value`, such as a run.json recorded before 0.110. A value
|
|
12
|
+
* shorter than an E2B id is left to the key rule, so the text scrub never rewrites a common word.
|
|
13
|
+
*/
|
|
14
|
+
export declare function keyedSandboxIds(value: unknown): string[];
|
|
15
|
+
/**
|
|
16
|
+
* Every raw sandbox id a run's files name: the receipts', and any at a sandbox-id key in its
|
|
17
|
+
* JSON, which is where a run from before 0.110 that has no receipts still records them.
|
|
18
|
+
*/
|
|
19
|
+
export declare function inventorySandboxIds(files: readonly {
|
|
20
|
+
path: string;
|
|
21
|
+
bytes: Buffer;
|
|
22
|
+
}[]): string[];
|
|
23
|
+
/** Whether a run file's JSON holds a raw id at a sandbox-id key, which no receipt may name. */
|
|
24
|
+
export declare function holdsKeyedSandboxId(file: string, text: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The raw sandbox ids a run's receipts journal, with any this process receipted whose append
|
|
27
|
+
* failed; empty when there are none or the journal cannot be read.
|
|
28
|
+
*/
|
|
29
|
+
export declare function readRunSandboxIds(root: PreparedOutputRoot): Promise<string[]>;
|
|
30
|
+
/**
|
|
31
|
+
* `value` as a run writes it: raw sandbox ids stay in the run's sandbox-receipts.ndjson, and the
|
|
32
|
+
* file names each by marker and digest.
|
|
33
|
+
*/
|
|
34
|
+
export declare function withPublicSandboxIds<T>(root: PreparedOutputRoot, value: T): Promise<T>;
|
|
35
|
+
/**
|
|
36
|
+
* The bytes of `file` with each raw id replaced by its label: the same buffer when none occurs, or
|
|
37
|
+
* when the bytes are not the plain text verify and export read as text.
|
|
38
|
+
*/
|
|
39
|
+
export declare function scrubSandboxIdBytes(file: string, bytes: Buffer, ids: readonly string[]): Buffer;
|
|
40
|
+
/**
|
|
41
|
+
* Rewrite each file of a finished run that names one of its raw sandbox ids, apart from the
|
|
42
|
+
* receipts, with the label in place of the id. Writers such as a participant's actor.json can
|
|
43
|
+
* quote an SDK error that names the sandbox. Run.finish calls it before analysis reads the run.
|
|
44
|
+
* Best effort: verify grades a file this misses.
|
|
45
|
+
*/
|
|
46
|
+
export declare function scrubRunSandboxIds(paths: PreparedRunArtifactPaths): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* A run's result as it may be returned or printed: the same object when it names no sandbox,
|
|
49
|
+
* otherwise its public view, scrubbed of the ids the run's receipts journal.
|
|
50
|
+
*/
|
|
51
|
+
export declare function publicRunResult<T extends {
|
|
52
|
+
runId?: string;
|
|
53
|
+
}>(result: T, cwd: string): Promise<T>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// A run's raw sandbox ids live in one place: sandbox-receipts.ndjson, which reclaim reads to kill
|
|
2
|
+
// them and export omits. Every other record a run writes (run.json, its review and projections, the
|
|
3
|
+
// cleanup and reclaim receipts) and every result or CLI line names a sandbox by marker and digest,
|
|
4
|
+
// so output an agent pastes into an issue never carries an id.
|
|
5
|
+
import { opendir, realpath } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { readPlainText } from "../evidence/plain-text.js";
|
|
8
|
+
import { collectSandboxIds, redactSandboxIds, sandboxIdDigest, REDACTED_SANDBOX_ID, } from "../evidence/redaction.js";
|
|
9
|
+
import { readContainedRegularFile, writeContainedOutputFile, } from "./contained-output.js";
|
|
10
|
+
import { bindExistingRunArtifactPaths, isSafeRunIdSegment, validatePreparedRunRootIdentity, } from "./paths.js";
|
|
11
|
+
import { appendedSandboxIds, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
|
|
12
|
+
/** How text names a sandbox: the marker with the id's digest. */
|
|
13
|
+
function sandboxIdLabel(id) {
|
|
14
|
+
return `${REDACTED_SANDBOX_ID.slice(0, -1)} ${sandboxIdDigest(id)}]`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* How YAML names a sandbox: the label without its brackets, since a plain scalar that starts with
|
|
18
|
+
* `[` reads as a list. Every other byte of the file stays as written.
|
|
19
|
+
*/
|
|
20
|
+
function yamlSandboxIdLabel(id) {
|
|
21
|
+
return `redacted-sandbox-id-${sandboxIdDigest(id)}`;
|
|
22
|
+
}
|
|
23
|
+
/** `text` with every occurrence of each raw id replaced by its label, longest id first. */
|
|
24
|
+
export function scrubSandboxIds(text, ids, label = sandboxIdLabel) {
|
|
25
|
+
const unique = [...new Set(ids)].filter((id) => id.length > 0);
|
|
26
|
+
unique.sort((left, right) => right.length - left.length);
|
|
27
|
+
return unique.reduce((current, id) => current.split(id).join(label(id)), text);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `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.
|
|
32
|
+
*/
|
|
33
|
+
export function publicSandboxView(value, ids) {
|
|
34
|
+
const redacted = redactSandboxIds(value);
|
|
35
|
+
const known = [...ids];
|
|
36
|
+
if (known.length === 0)
|
|
37
|
+
return redacted;
|
|
38
|
+
const text = JSON.stringify(redacted);
|
|
39
|
+
if (text === undefined)
|
|
40
|
+
return redacted;
|
|
41
|
+
const scrubbed = scrubSandboxIds(text, known);
|
|
42
|
+
return (scrubbed === text ? redacted : JSON.parse(scrubbed));
|
|
43
|
+
}
|
|
44
|
+
/** The raw sandbox ids a receipts journal names. */
|
|
45
|
+
function receiptSandboxIds(journal) {
|
|
46
|
+
return parseSandboxReceipts(journal.toString()).map((receipt) => receipt.sandboxId);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The raw ids at sandbox-id keys in `value`, such as a run.json recorded before 0.110. A value
|
|
50
|
+
* shorter than an E2B id is left to the key rule, so the text scrub never rewrites a common word.
|
|
51
|
+
*/
|
|
52
|
+
export function keyedSandboxIds(value) {
|
|
53
|
+
return [...collectSandboxIds(value)].filter((id) => id.length >= 8);
|
|
54
|
+
}
|
|
55
|
+
/** The JSON records in one file's text: one document, or one per NDJSON line. */
|
|
56
|
+
function jsonRecords(file, text) {
|
|
57
|
+
// Most run files name no sandbox key, and parsing each one would slow verify on large runs. A
|
|
58
|
+
// key spelled with an escape only shows once parsed.
|
|
59
|
+
if (!/"(?:sandboxId|subjectSandboxId|providerResources|resources)"|\\u/.test(text))
|
|
60
|
+
return [];
|
|
61
|
+
const parse = (part) => {
|
|
62
|
+
try {
|
|
63
|
+
return [JSON.parse(part)];
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
if (/\.json$/i.test(file))
|
|
70
|
+
return parse(text);
|
|
71
|
+
if (/\.(?:ndjson|jsonl)$/i.test(file))
|
|
72
|
+
return text.split("\n").flatMap(parse);
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Every raw sandbox id a run's files name: the receipts', and any at a sandbox-id key in its
|
|
77
|
+
* JSON, which is where a run from before 0.110 that has no receipts still records them.
|
|
78
|
+
*/
|
|
79
|
+
export function inventorySandboxIds(files) {
|
|
80
|
+
const ids = new Set();
|
|
81
|
+
for (const file of files) {
|
|
82
|
+
if (file.path === SANDBOX_RECEIPTS_ARTIFACT) {
|
|
83
|
+
for (const id of receiptSandboxIds(file.bytes))
|
|
84
|
+
ids.add(id);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const record of jsonRecords(file.path, file.bytes.toString("utf8")))
|
|
88
|
+
for (const id of keyedSandboxIds(record))
|
|
89
|
+
ids.add(id);
|
|
90
|
+
}
|
|
91
|
+
return [...ids];
|
|
92
|
+
}
|
|
93
|
+
/** Whether a run file's JSON holds a raw id at a sandbox-id key, which no receipt may name. */
|
|
94
|
+
export function holdsKeyedSandboxId(file, text) {
|
|
95
|
+
return jsonRecords(file, text).some((record) => collectSandboxIds(record).size > 0);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The raw sandbox ids a run's receipts journal, with any this process receipted whose append
|
|
99
|
+
* failed; empty when there are none or the journal cannot be read.
|
|
100
|
+
*/
|
|
101
|
+
export async function readRunSandboxIds(root) {
|
|
102
|
+
let journaled = [];
|
|
103
|
+
try {
|
|
104
|
+
const bytes = await readContainedRegularFile(root, SANDBOX_RECEIPTS_ARTIFACT);
|
|
105
|
+
if (bytes)
|
|
106
|
+
journaled = receiptSandboxIds(bytes);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// The ids this process receipted still apply.
|
|
110
|
+
}
|
|
111
|
+
return [...new Set([...journaled, ...appendedSandboxIds(root)])];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* `value` as a run writes it: raw sandbox ids stay in the run's sandbox-receipts.ndjson, and the
|
|
115
|
+
* file names each by marker and digest.
|
|
116
|
+
*/
|
|
117
|
+
export async function withPublicSandboxIds(root, value) {
|
|
118
|
+
return publicSandboxView(value, await readRunSandboxIds(root));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The bytes of `file` with each raw id replaced by its label: the same buffer when none occurs, or
|
|
122
|
+
* when the bytes are not the plain text verify and export read as text.
|
|
123
|
+
*/
|
|
124
|
+
export function scrubSandboxIdBytes(file, bytes, ids) {
|
|
125
|
+
if (!ids.some((id) => id.length > 0 && bytes.includes(id)))
|
|
126
|
+
return bytes;
|
|
127
|
+
const decoded = readPlainText(bytes);
|
|
128
|
+
if (!decoded.ok)
|
|
129
|
+
return bytes;
|
|
130
|
+
const label = /\.ya?ml$/i.test(file) ? yamlSandboxIdLabel : sandboxIdLabel;
|
|
131
|
+
return Buffer.from(scrubSandboxIds(decoded.text, ids, label));
|
|
132
|
+
}
|
|
133
|
+
/** The run files the sweep rewrites: the text formats export copies, and the Observer page. */
|
|
134
|
+
const TEXT_FILE = /\.(?:json|ndjson|jsonl|md|txt|log|yaml|yml|csv|html)$/i;
|
|
135
|
+
/** Enough for any run directory; a sweep stops there rather than hold up the run's finish. */
|
|
136
|
+
const MAX_SWEPT_ENTRIES = 10_000;
|
|
137
|
+
/**
|
|
138
|
+
* Rewrite each file of a finished run that names one of its raw sandbox ids, apart from the
|
|
139
|
+
* receipts, with the label in place of the id. Writers such as a participant's actor.json can
|
|
140
|
+
* quote an SDK error that names the sandbox. Run.finish calls it before analysis reads the run.
|
|
141
|
+
* Best effort: verify grades a file this misses.
|
|
142
|
+
*/
|
|
143
|
+
export async function scrubRunSandboxIds(paths) {
|
|
144
|
+
const ids = await readRunSandboxIds(paths);
|
|
145
|
+
if (ids.length === 0)
|
|
146
|
+
return;
|
|
147
|
+
const valid = await validatePreparedRunRootIdentity(paths).then(() => true, () => false);
|
|
148
|
+
if (!valid)
|
|
149
|
+
return;
|
|
150
|
+
const scrub = async (file) => {
|
|
151
|
+
const bytes = await readContainedRegularFile(paths, file);
|
|
152
|
+
if (bytes === null)
|
|
153
|
+
return;
|
|
154
|
+
const scrubbed = scrubSandboxIdBytes(file, bytes, ids);
|
|
155
|
+
if (scrubbed !== bytes)
|
|
156
|
+
await writeContainedOutputFile(paths, file, scrubbed);
|
|
157
|
+
};
|
|
158
|
+
let seen = 0;
|
|
159
|
+
const walk = async (relative) => {
|
|
160
|
+
const directory = path.join(paths.physicalRunRoot, relative);
|
|
161
|
+
// A directory replaced by a link after it was listed resolves elsewhere and is not walked.
|
|
162
|
+
if ((await realpath(directory).catch(() => null)) !== directory)
|
|
163
|
+
return;
|
|
164
|
+
const listing = await opendir(directory).catch(() => null);
|
|
165
|
+
if (listing === null)
|
|
166
|
+
return;
|
|
167
|
+
for await (const entry of listing) {
|
|
168
|
+
// Returning from the loop closes the listing.
|
|
169
|
+
if (++seen > MAX_SWEPT_ENTRIES)
|
|
170
|
+
return;
|
|
171
|
+
const file = relative ? `${relative}/${entry.name}` : entry.name;
|
|
172
|
+
if (entry.isDirectory())
|
|
173
|
+
await walk(file);
|
|
174
|
+
else if (entry.isFile() && file !== SANDBOX_RECEIPTS_ARTIFACT && TEXT_FILE.test(file))
|
|
175
|
+
// A file that cannot be scrubbed stays as written; verify names it.
|
|
176
|
+
await scrub(file).catch(() => { });
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
await walk("").catch(() => {
|
|
180
|
+
// A directory that changed while it was listed ends the sweep; verify names what it missed.
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* A run's result as it may be returned or printed: the same object when it names no sandbox,
|
|
185
|
+
* otherwise its public view, scrubbed of the ids the run's receipts journal.
|
|
186
|
+
*/
|
|
187
|
+
export async function publicRunResult(result, cwd) {
|
|
188
|
+
let ids = [];
|
|
189
|
+
if (typeof result.runId === "string" && isSafeRunIdSegment(result.runId)) {
|
|
190
|
+
try {
|
|
191
|
+
ids = await readRunSandboxIds(await bindExistingRunArtifactPaths(cwd, result.runId));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// A run that never created its directory has no receipts; the keys are still redacted.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return publicSandboxView(result, ids);
|
|
198
|
+
}
|
|
@@ -20,6 +20,8 @@ export interface SandboxReceipt {
|
|
|
20
20
|
export interface ParsedSandboxReceipt extends Omit<SandboxReceipt, "provider"> {
|
|
21
21
|
provider: string;
|
|
22
22
|
}
|
|
23
|
+
/** The ids this process has receipted for `root`, whether or not the journal write landed. */
|
|
24
|
+
export declare function appendedSandboxIds(root: PreparedOutputRoot): string[];
|
|
23
25
|
/**
|
|
24
26
|
* Append one receipt. Best-effort by design: the receipt exists to protect the run, so a failed
|
|
25
27
|
* receipt write must never fail the participant. The only cost of a miss is that `reclaim` cannot see
|
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
import { appendFile } from "node:fs/promises";
|
|
5
5
|
import { prepareContainedOutputFile } from "./contained-output.js";
|
|
6
6
|
export const SANDBOX_RECEIPTS_ARTIFACT = "sandbox-receipts.ndjson";
|
|
7
|
+
/**
|
|
8
|
+
* The ids this process has receipted for each run directory, kept even when the append fails, so
|
|
9
|
+
* the run's other records can still be scrubbed of them. Keyed by the directory's identity, so a
|
|
10
|
+
* run created later at a deleted run's path starts empty, and only the latest runs are kept.
|
|
11
|
+
*/
|
|
12
|
+
const appended = new Map();
|
|
13
|
+
const MAX_REMEMBERED_RUNS = 64;
|
|
14
|
+
function rootKey(root) {
|
|
15
|
+
const [where, { dev, ino, birthtimeNs }] = "physicalRunRoot" in root
|
|
16
|
+
? [root.physicalRunRoot, root.runRootIdentity]
|
|
17
|
+
: [root.physicalPath, root.identity];
|
|
18
|
+
return `${where}\0${dev}:${ino}:${birthtimeNs}`;
|
|
19
|
+
}
|
|
20
|
+
/** The ids this process has receipted for `root`, whether or not the journal write landed. */
|
|
21
|
+
export function appendedSandboxIds(root) {
|
|
22
|
+
return [...(appended.get(rootKey(root)) ?? [])];
|
|
23
|
+
}
|
|
7
24
|
/**
|
|
8
25
|
* Append one receipt. Best-effort by design: the receipt exists to protect the run, so a failed
|
|
9
26
|
* receipt write must never fail the participant. The only cost of a miss is that `reclaim` cannot see
|
|
@@ -11,6 +28,15 @@ export const SANDBOX_RECEIPTS_ARTIFACT = "sandbox-receipts.ndjson";
|
|
|
11
28
|
* artifact write uses; append (not atomic-replace) keeps racing participants' receipts intact.
|
|
12
29
|
*/
|
|
13
30
|
export async function appendSandboxReceipt(root, receipt) {
|
|
31
|
+
const key = rootKey(root);
|
|
32
|
+
const ids = appended.get(key) ?? new Set();
|
|
33
|
+
appended.delete(key);
|
|
34
|
+
appended.set(key, ids.add(receipt.sandboxId));
|
|
35
|
+
for (const oldest of appended.keys()) {
|
|
36
|
+
if (appended.size <= MAX_REMEMBERED_RUNS)
|
|
37
|
+
break;
|
|
38
|
+
appended.delete(oldest);
|
|
39
|
+
}
|
|
14
40
|
try {
|
|
15
41
|
const filePath = await prepareContainedOutputFile(root, SANDBOX_RECEIPTS_ARTIFACT);
|
|
16
42
|
await appendFile(filePath, `${JSON.stringify(receipt)}\n`, "utf8");
|
package/dist/run/status.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { isProvenanceField, studyFields } from "./study-provenance.js";
|
|
13
13
|
import { writeContainedOutputFile } from "./contained-output.js";
|
|
14
14
|
import { redactText } from "../evidence/redaction.js";
|
|
15
|
+
import { readRunSandboxIds, scrubSandboxIds } from "./sandbox-ids.js";
|
|
15
16
|
export const RUN_STATUS_SCHEMA = "humanish.run-status.v1";
|
|
16
17
|
/** The file, relative to the run directory. */
|
|
17
18
|
export const RUN_STATUS_FILE = "status.json";
|
|
@@ -106,10 +107,12 @@ export function beginRunStatus(runPaths, options) {
|
|
|
106
107
|
if (finishedRecord === undefined)
|
|
107
108
|
return;
|
|
108
109
|
// The record is public-safe by construction, so each message passes the shape redaction
|
|
109
|
-
// again even though the routes scrubbed it.
|
|
110
|
+
// again even though the routes scrubbed it. A route settles with its own result, so an SDK
|
|
111
|
+
// error here can still name a sandbox; the receipts give the ids to replace.
|
|
112
|
+
const ids = await readRunSandboxIds(runPaths);
|
|
110
113
|
const redacted = (failure) => ({
|
|
111
114
|
kind: failure.kind,
|
|
112
|
-
message: redactText(failure.message),
|
|
115
|
+
message: scrubSandboxIds(redactText(failure.message), ids),
|
|
113
116
|
});
|
|
114
117
|
const execution = {
|
|
115
118
|
succeeded: result.execution.succeeded,
|
package/dist/run/stored-runs.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { REDACTED_SANDBOX_ID, sandboxIdDigest } from "../evidence/redaction.js";
|
|
1
2
|
import { lstat, readdir } from "node:fs/promises";
|
|
2
3
|
import { runNotFoundMessage } from "./run-not-found.js";
|
|
3
4
|
import path from "node:path";
|
|
@@ -30,7 +31,15 @@ function cleanupRefusal(cwd, runInput, checkedAt, error, bundlePath) {
|
|
|
30
31
|
}
|
|
31
32
|
/** What the recorded evidence says about one provider resource; nothing is killed from here. */
|
|
32
33
|
function recordedResourceResult(resource) {
|
|
33
|
-
|
|
34
|
+
// Named by digest: a run recorded before 0.110 holds the raw id here, which is digested.
|
|
35
|
+
const raw = resource.id === REDACTED_SANDBOX_ID ? undefined : resource.id;
|
|
36
|
+
const idDigest = resource.idDigest ?? (raw === undefined ? undefined : sandboxIdDigest(raw));
|
|
37
|
+
const base = {
|
|
38
|
+
provider: resource.provider,
|
|
39
|
+
kind: resource.kind,
|
|
40
|
+
id: REDACTED_SANDBOX_ID,
|
|
41
|
+
...(idDigest === undefined ? {} : { idDigest }),
|
|
42
|
+
};
|
|
34
43
|
if (resource.provider !== "e2b-desktop" || resource.kind !== "sandbox") {
|
|
35
44
|
return {
|
|
36
45
|
...base,
|
package/dist/run-study.js
CHANGED
|
@@ -19,6 +19,7 @@ import {} from "./routes/shared-world/types.js";
|
|
|
19
19
|
import { admitTerminalPlan, terminalStudyRefusal } from "./routes/terminal/route.js";
|
|
20
20
|
import {} from "./routes/terminal/types.js";
|
|
21
21
|
import {} from "./run/bundle.js";
|
|
22
|
+
import { publicRunResult } from "./run/sandbox-ids.js";
|
|
22
23
|
import { isLocalBrowserStudy, localBrowserDefaults } from "./substrates/local/runtime-config.js";
|
|
23
24
|
/**
|
|
24
25
|
* Runs a study on its route. The options are checked against the route and mapped into the route's
|
|
@@ -100,7 +101,11 @@ export async function prepareStudy(config, options, deps = {}) {
|
|
|
100
101
|
// study, so a rebuilt one would leave the participants they start for no finally to close.
|
|
101
102
|
const outcome = await admitted.run(scorer);
|
|
102
103
|
outcome.result.warnings.push(...normalized.warnings);
|
|
103
|
-
|
|
104
|
+
// The result names each sandbox by digest; the raw ids stay in the run's receipts.
|
|
105
|
+
return {
|
|
106
|
+
...outcome,
|
|
107
|
+
result: await publicRunResult(outcome.result, options.cwd),
|
|
108
|
+
};
|
|
104
109
|
}
|
|
105
110
|
finally {
|
|
106
111
|
await vm?.close();
|
package/dist/study/doctor.js
CHANGED
|
@@ -227,7 +227,7 @@ async function realEmailCheck(connection, keys, args) {
|
|
|
227
227
|
message: name === null
|
|
228
228
|
? "The selected email connection is missing or invalid. Open Connections in the TUI."
|
|
229
229
|
: !args.keyPresent(name)
|
|
230
|
-
? `Missing ${name} for the selected email connection. Provide it through process env or --
|
|
230
|
+
? `Missing ${name} for the selected email connection. Provide it through process env or --dotenv. Authentication has not been checked.`
|
|
231
231
|
: "Fresh hosted inbox per participant. Local presence only; run humanish comms check --online to authenticate. Provider permissions/capacity and delivery remain untested.",
|
|
232
232
|
};
|
|
233
233
|
}
|
|
@@ -300,7 +300,7 @@ function subjectEnvChecks(names, args) {
|
|
|
300
300
|
ok: !!args.env[name]?.trim() || args.keyPresent(name),
|
|
301
301
|
message: args.env[name]?.trim() || args.keyPresent(name)
|
|
302
302
|
? "present; value not shown"
|
|
303
|
-
: "missing declared subject environment variable; provide it with --
|
|
303
|
+
: "missing declared subject environment variable; provide it with --dotenv",
|
|
304
304
|
}));
|
|
305
305
|
}
|
|
306
306
|
/** The post-run analysis: the Codex account, or the OpenAI key and the analysis cost limit. */
|
|
@@ -245,9 +245,9 @@ title: Computer-use browser study
|
|
|
245
245
|
description: >-
|
|
246
246
|
A computer-use participant drives your app in a hosted desktop browser and writes its evidence
|
|
247
247
|
to gitignored .humanish/. Starts as a dry run. For a live session, set mode to live
|
|
248
|
-
and pass OPENAI_API_KEY and E2B_API_KEY with --
|
|
248
|
+
and pass OPENAI_API_KEY and E2B_API_KEY with --dotenv. The clone subject below serves your
|
|
249
249
|
repo inside the sandbox, and the env names declared under subject get their values from
|
|
250
|
-
--
|
|
250
|
+
--dotenv without the values being saved. Screenshots keep full fidelity by default; set
|
|
251
251
|
policies.redactScreenshots: true to blur them at capture for a bundle you can share as is.
|
|
252
252
|
Typed text is recorded as its length only.
|
|
253
253
|
route: computer-use
|
package/dist/study/migrate.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface MigrateResult {
|
|
|
21
21
|
readonly files: readonly MigrateFile[];
|
|
22
22
|
/** Files left behind after a clean-up step found them changed: sources, backups or temp files. */
|
|
23
23
|
readonly unresolved?: readonly string[];
|
|
24
|
+
/** The labs/ directories the moves emptied and migrate removed, or with `--dry-run` would remove. */
|
|
25
|
+
readonly removedDirectories?: readonly string[];
|
|
24
26
|
readonly error?: {
|
|
25
27
|
readonly code: "HUMANISH_MIGRATE_REFUSED" | "HUMANISH_MIGRATE_FAILED";
|
|
26
28
|
readonly phase: "plan" | "stage" | "commit" | "clean-up";
|
|
@@ -34,8 +36,11 @@ export interface MigrateOptions {
|
|
|
34
36
|
/** Files to convert. Without any, the six study directories are scanned. */
|
|
35
37
|
readonly paths?: readonly string[];
|
|
36
38
|
readonly dryRun?: boolean;
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Called with the plan before anything is written, so a caller can print every destination and
|
|
41
|
+
* each labs/ directory the moves empty, which migrate then removes.
|
|
42
|
+
*/
|
|
43
|
+
readonly onPlan?: (files: readonly MigrateFile[], emptied: readonly string[]) => void;
|
|
39
44
|
/** Replaces `fs.link`. Tests use it to stand in for a filesystem without hard links. */
|
|
40
45
|
readonly link?: (from: string, to: string) => Promise<void>;
|
|
41
46
|
/** Called between stage and commit, and between commit and clean-up. Tests change files there. */
|
package/dist/study/migrate.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// name, so no file this run did not write is ever overwritten. A failure undoes the commits.
|
|
9
9
|
// 4. Clean up: delete each moved source and each rewrite's original, once checked (owned-files.ts).
|
|
10
10
|
// A file that changed since the plan is kept and listed; a rewrite's original as <name>.v2.bak.
|
|
11
|
+
// Then a labs/ directory whose every entry moved is removed with a plain rmdir, which fails, and
|
|
12
|
+
// keeps the directory, when anything else is in it by then.
|
|
11
13
|
import { randomUUID } from "node:crypto";
|
|
12
14
|
import { link, lstat, readdir, readFile, realpath, rename, rmdir } from "node:fs/promises";
|
|
13
15
|
import path from "node:path";
|
|
@@ -317,6 +319,46 @@ async function undoCommits(root, writes, linkFile) {
|
|
|
317
319
|
}
|
|
318
320
|
return unrestored;
|
|
319
321
|
}
|
|
322
|
+
// Each labs/ directory whose every entry, a dotfile or a subdirectory included, is a file this run
|
|
323
|
+
// moves out. Only these are removed after the move.
|
|
324
|
+
async function emptiedRetiredDirectories(root, writes) {
|
|
325
|
+
const moving = new Map();
|
|
326
|
+
for (const write of writes) {
|
|
327
|
+
if (write.file.action !== "move")
|
|
328
|
+
continue;
|
|
329
|
+
const directory = path.dirname(write.sourcePath);
|
|
330
|
+
const relative = path.relative(root, directory);
|
|
331
|
+
const retired = STUDY_DIRECTORIES.some((entry) => entry.family === "labs" && entry.relativeDir === relative);
|
|
332
|
+
if (!retired)
|
|
333
|
+
continue;
|
|
334
|
+
const entry = moving.get(directory) ?? { write, names: new Set() };
|
|
335
|
+
entry.names.add(path.basename(write.sourcePath));
|
|
336
|
+
moving.set(directory, entry);
|
|
337
|
+
}
|
|
338
|
+
const emptied = [];
|
|
339
|
+
for (const [directory, { write, names }] of moving) {
|
|
340
|
+
const entries = await readdir(directory).catch(() => undefined);
|
|
341
|
+
if (entries !== undefined && entries.every((name) => names.has(name)))
|
|
342
|
+
emptied.push(write);
|
|
343
|
+
}
|
|
344
|
+
return emptied;
|
|
345
|
+
}
|
|
346
|
+
// Remove each emptied labs/ directory, if it is still the one the plan read. rmdir is not
|
|
347
|
+
// recursive, so a file that appeared in it since the plan keeps it. Returns the removed paths.
|
|
348
|
+
async function removeEmptiedDirectories(root, emptied) {
|
|
349
|
+
const removed = [];
|
|
350
|
+
for (const write of emptied) {
|
|
351
|
+
try {
|
|
352
|
+
await assertDirectory(write.sourceDirectory, root);
|
|
353
|
+
await rmdir(path.dirname(write.sourcePath));
|
|
354
|
+
removed.push(path.relative(root, path.dirname(write.sourcePath)));
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
// Not empty, or not the directory the plan read: it stays.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return removed;
|
|
361
|
+
}
|
|
320
362
|
// Delete each moved source and each rewrite's original, but only once the v3 file is in place as
|
|
321
363
|
// written, and only if the old file is still the one the plan read (removeChecked). Anything kept
|
|
322
364
|
// is listed; a rewrite's original is kept as <name>.v2.bak.
|
|
@@ -356,9 +398,17 @@ export async function migrateStudies(options) {
|
|
|
356
398
|
const linkFile = options.link ?? link;
|
|
357
399
|
try {
|
|
358
400
|
({ files, writes } = await planWrites(root, options));
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
401
|
+
const emptied = await emptiedRetiredDirectories(root, writes);
|
|
402
|
+
const emptiedPaths = emptied.map((write) => path.relative(root, path.dirname(write.sourcePath)));
|
|
403
|
+
options.onPlan?.(files, emptiedPaths);
|
|
404
|
+
if (options.dryRun === true || writes.length === 0) {
|
|
405
|
+
return {
|
|
406
|
+
...base,
|
|
407
|
+
ok: true,
|
|
408
|
+
files,
|
|
409
|
+
...(emptiedPaths.length === 0 ? {} : { removedDirectories: emptiedPaths }),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
362
412
|
try {
|
|
363
413
|
await stage(root, writes, created);
|
|
364
414
|
options.onPhase?.("stage");
|
|
@@ -386,7 +436,13 @@ export async function migrateStudies(options) {
|
|
|
386
436
|
},
|
|
387
437
|
};
|
|
388
438
|
}
|
|
389
|
-
|
|
439
|
+
const removed = await removeEmptiedDirectories(root, emptied);
|
|
440
|
+
return {
|
|
441
|
+
...base,
|
|
442
|
+
ok: true,
|
|
443
|
+
files,
|
|
444
|
+
...(removed.length === 0 ? {} : { removedDirectories: removed }),
|
|
445
|
+
};
|
|
390
446
|
}
|
|
391
447
|
catch (error) {
|
|
392
448
|
const failure = error instanceof MigrateError
|
|
@@ -43,7 +43,7 @@ export function studyEventEmitter(onEvent, warnings, secretValues) {
|
|
|
43
43
|
catch {
|
|
44
44
|
detail = "the thrown value has no message";
|
|
45
45
|
}
|
|
46
|
-
warnings.push(`
|
|
46
|
+
warnings.push(`RunStudyOptions.onEvent failed on ${type}: ${detail}`);
|
|
47
47
|
};
|
|
48
48
|
try {
|
|
49
49
|
const returned = onEvent(event);
|
|
@@ -11,7 +11,7 @@ import { studyResultIdentity } from "../run/study-result.js";
|
|
|
11
11
|
const unsupported = (option, route, reason) => ({
|
|
12
12
|
ok: false,
|
|
13
13
|
code: "HUMANISH_STUDY_OPTION_UNSUPPORTED",
|
|
14
|
-
message: `
|
|
14
|
+
message: `RunStudyOptions.${option} is not supported on the ${route} route: ${reason}`,
|
|
15
15
|
});
|
|
16
16
|
// Fields RunStudyOptions no longer has, and where each one's job went.
|
|
17
17
|
const REMOVED_OPTIONS = {
|
|
@@ -32,7 +32,7 @@ export function removedOptionRefusal(options) {
|
|
|
32
32
|
return {
|
|
33
33
|
ok: false,
|
|
34
34
|
code: "HUMANISH_STUDY_OPTION_UNSUPPORTED",
|
|
35
|
-
message: '
|
|
35
|
+
message: '`rerun.laneIds` was removed in 0.107.0. Use `rerun.participantIds`. See docs/contracts/schemas.md, "Library options".',
|
|
36
36
|
};
|
|
37
37
|
const field = Object.keys(REMOVED_OPTIONS).find((key) => Reflect.get(options, key) !== undefined);
|
|
38
38
|
if (field === undefined)
|
|
@@ -40,7 +40,7 @@ export function removedOptionRefusal(options) {
|
|
|
40
40
|
return {
|
|
41
41
|
ok: false,
|
|
42
42
|
code: "HUMANISH_STUDY_OPTION_UNSUPPORTED",
|
|
43
|
-
message:
|
|
43
|
+
message: `\`${field}\` was removed. ${REMOVED_OPTIONS[field]} See docs/contracts/schemas.md, "Library options".`,
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
/** Why the route cannot honor an option it was given, or undefined when it can. */
|
package/dist/study/types.d.ts
CHANGED
|
@@ -239,7 +239,7 @@ export interface StudySubject {
|
|
|
239
239
|
serve?: StudySubjectServe;
|
|
240
240
|
/**
|
|
241
241
|
* Env var names the subject app needs, provisioned into the sandbox from the caller's
|
|
242
|
-
* environment (--
|
|
242
|
+
* environment (--dotenv). Names are recorded in evidence; values never are. Consumed
|
|
243
243
|
* on the computer-use clone route.
|
|
244
244
|
*/
|
|
245
245
|
env?: string[];
|
|
@@ -37,6 +37,10 @@ export declare function rawScreenshotStreamIds(bundle: RunBundle, redactedShapeF
|
|
|
37
37
|
interface RegisteredStreamMedia {
|
|
38
38
|
recordingPaths: Set<string>;
|
|
39
39
|
screenshotPaths: Set<string>;
|
|
40
|
+
/** The run's raw sandbox ids, from its receipts; any other file holding one is listed below. */
|
|
41
|
+
sandboxIds?: readonly string[];
|
|
42
|
+
/** Filled by the scan: files other than the receipts that name a raw sandbox id. */
|
|
43
|
+
sandboxIdFiles?: string[];
|
|
40
44
|
}
|
|
41
45
|
/**
|
|
42
46
|
* Scans every run file for secret and path patterns and returns the findings. A file that is not
|
package/dist/verify/artifacts.js
CHANGED
|
@@ -7,6 +7,8 @@ import { readPlainText } from "../evidence/plain-text.js";
|
|
|
7
7
|
import { containsSensitive } from "../evidence/redaction.js";
|
|
8
8
|
import { isLocalEvidenceArtifactPath, isRiskyPublicArtifactPath, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
9
9
|
import { openContainedRegularFile } from "../run/contained-output.js";
|
|
10
|
+
import { holdsKeyedSandboxId } from "../run/sandbox-ids.js";
|
|
11
|
+
import { SANDBOX_RECEIPTS_ARTIFACT } from "../run/sandbox-receipts.js";
|
|
10
12
|
import { readSafeRunArtifactBytes, readSafeRunArtifactJson } from "../run/locate.js";
|
|
11
13
|
import { isRecord } from "../run/type-guards.js";
|
|
12
14
|
import { TERMINAL_EVENTS_ARTIFACT } from "../run/terminal-contract.js";
|
|
@@ -348,6 +350,12 @@ async function scanRunPublicSafetyDirectory(runPaths, relativeDirectory, finding
|
|
|
348
350
|
unscanned.push(relativePath);
|
|
349
351
|
continue;
|
|
350
352
|
}
|
|
353
|
+
// A receipt's id in any other file, or a raw id at a sandbox-id key, which an older run or
|
|
354
|
+
// export copy can hold with no receipt to name it.
|
|
355
|
+
if (relativePath !== SANDBOX_RECEIPTS_ARTIFACT &&
|
|
356
|
+
(media.sandboxIds?.some((id) => decoded.text.includes(id)) ||
|
|
357
|
+
holdsKeyedSandboxId(relativePath, decoded.text)))
|
|
358
|
+
media.sandboxIdFiles?.push(relativePath);
|
|
351
359
|
// serve renders observer/index.html from run.json and export regenerates it, so the on-disk
|
|
352
360
|
// copy reaches neither. It embeds the Observer's own base64 fonts and scripts.
|
|
353
361
|
const scan = scanEncodedTextCached(decoded.text, {
|
package/dist/verify/liveness.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `humanish verify`'s RUN_NOT_FINISHED warning. Verify checks the integrity of what a run wrote,
|
|
2
2
|
// and a run killed mid-way leaves an in-progress bundle that passes those checks. The warning says
|
|
3
3
|
// what verify saw on disk, so `ok: true` is not read as "the run finished". It never flips `ok`.
|
|
4
|
+
import { sandboxIdDigest } from "../evidence/redaction.js";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { readRunJsonIfExists, readRunTextIfExists } from "../run/locate.js";
|
|
6
7
|
import { isRecord } from "../run/type-guards.js";
|
|
@@ -45,12 +46,14 @@ export async function runNotFinishedWarnings(runPaths, bundle, nowMs = Date.now(
|
|
|
45
46
|
/** What the reclaim receipt says about the sandboxes this run journaled, or what to run. */
|
|
46
47
|
async function reclaimState(runPaths, runId) {
|
|
47
48
|
const journal = await readRunTextIfExists(runPaths, SANDBOX_RECEIPTS_ARTIFACT);
|
|
48
|
-
|
|
49
|
+
// Matched by digest: a reclaim receipt names each sandbox by digest, and one written before it
|
|
50
|
+
// did holds the raw id, which is digested here.
|
|
51
|
+
const journaled = new Set((journal === null ? [] : parseSandboxReceipts(journal)).map((receipt) => sandboxIdDigest(receipt.sandboxId)));
|
|
49
52
|
const receipt = await readRunJsonIfExists(runPaths, RECLAIM_RECEIPT_ARTIFACT);
|
|
50
53
|
const outcomes = reclaimOutcomes(receipt, runId);
|
|
51
54
|
if (outcomes !== undefined) {
|
|
52
55
|
const gone = outcomes.filter((outcome) => GONE_STATES.has(outcome.state)).length;
|
|
53
|
-
const reclaimed = new Set(outcomes.map((outcome) => outcome.
|
|
56
|
+
const reclaimed = new Set(outcomes.map((outcome) => outcome.digest));
|
|
54
57
|
const missing = [...journaled].filter((id) => !reclaimed.has(id)).length;
|
|
55
58
|
return `${RECLAIM_RECEIPT_ARTIFACT} records ${gone} of ${outcomes.length} sandboxes gone${missing === 0 ? "" : `, and ${missing} journaled sandboxes are not in it`}.`;
|
|
56
59
|
}
|
|
@@ -66,10 +69,11 @@ function reclaimOutcomes(receipt, runId) {
|
|
|
66
69
|
for (const outcome of receipt.outcomes) {
|
|
67
70
|
if (!isRecord(outcome))
|
|
68
71
|
return undefined;
|
|
69
|
-
const { sandboxId, state } = outcome;
|
|
72
|
+
const { sandboxId, sandboxIdDigest: recorded, state } = outcome;
|
|
70
73
|
if (typeof sandboxId !== "string" || typeof state !== "string")
|
|
71
74
|
return undefined;
|
|
72
|
-
|
|
75
|
+
const digest = typeof recorded === "string" ? recorded : sandboxIdDigest(sandboxId);
|
|
76
|
+
outcomes.push({ digest, state });
|
|
73
77
|
}
|
|
74
78
|
return outcomes;
|
|
75
79
|
}
|
package/dist/verify/verify.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface VerifyResult {
|
|
|
16
16
|
shareSafety: {
|
|
17
17
|
status: "share_ready" | "local_only" | "blocked";
|
|
18
18
|
reasons: Array<{
|
|
19
|
-
code: "VERIFY_FAILED" | "PUBLIC_SAFETY_FINDINGS" | "ANALYSIS_UNVERIFIED" | "RAW_SCREENSHOTS" | "CONTINUOUS_MEDIA" | "REAL_COMMUNICATIONS" | "UNSCANNED_ARTIFACT";
|
|
19
|
+
code: "VERIFY_FAILED" | "PUBLIC_SAFETY_FINDINGS" | "ANALYSIS_UNVERIFIED" | "RAW_SCREENSHOTS" | "RAW_SANDBOX_ID" | "CONTINUOUS_MEDIA" | "REAL_COMMUNICATIONS" | "UNSCANNED_ARTIFACT";
|
|
20
20
|
message: string;
|
|
21
21
|
}>;
|
|
22
22
|
};
|