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.
Files changed (66) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/dist/analysis/automatic-config.d.ts +1 -1
  4. package/dist/cli/commands/comms.js +25 -7
  5. package/dist/cli/commands/migrate.js +15 -4
  6. package/dist/cli/commands/observe.js +4 -2
  7. package/dist/cli/commands/run-command.js +4 -3
  8. package/dist/cli/commands/runs.js +13 -3
  9. package/dist/cli/commands/setup.js +19 -5
  10. package/dist/cli/commands/study-format.js +29 -2
  11. package/dist/cli/commands/study.js +4 -3
  12. package/dist/cli/commands/tui.js +4 -3
  13. package/dist/cli/commands/watch.js +2 -2
  14. package/dist/cli/doctor.d.ts +3 -1
  15. package/dist/cli/doctor.js +5 -4
  16. package/dist/cli/io.d.ts +17 -3
  17. package/dist/cli/io.js +20 -3
  18. package/dist/comms/receiving-runtime.js +1 -1
  19. package/dist/comms/setup.js +1 -1
  20. package/dist/evidence/redaction.d.ts +15 -0
  21. package/dist/evidence/redaction.js +87 -0
  22. package/dist/feedback/export-bundle.js +11 -5
  23. package/dist/feedback/export.js +3 -0
  24. package/dist/keys/key-resolution.d.ts +7 -0
  25. package/dist/keys/key-resolution.js +10 -8
  26. package/dist/routes/computer-use/in-process-desktop.js +1 -1
  27. package/dist/routes/computer-use/live-phase.d.ts +1 -0
  28. package/dist/routes/computer-use/plan.js +3 -3
  29. package/dist/routes/computer-use/preflight.js +1 -1
  30. package/dist/routes/computer-use/setup.js +1 -1
  31. package/dist/routes/computer-use/types.d.ts +6 -0
  32. package/dist/routes/scripted/types.d.ts +1 -0
  33. package/dist/routes/shared-world/route.js +1 -1
  34. package/dist/routes/shared-world/types.d.ts +2 -0
  35. package/dist/routes/terminal/credentials.js +1 -1
  36. package/dist/routes/terminal/live-finish.js +8 -4
  37. package/dist/routes/terminal/types.d.ts +3 -0
  38. package/dist/run/bundle-shape.js +1 -0
  39. package/dist/run/bundle.d.ts +6 -0
  40. package/dist/run/reclaim.d.ts +3 -0
  41. package/dist/run/reclaim.js +10 -3
  42. package/dist/run/results.d.ts +3 -0
  43. package/dist/run/run.js +14 -8
  44. package/dist/run/sandbox-ids.d.ts +53 -0
  45. package/dist/run/sandbox-ids.js +198 -0
  46. package/dist/run/sandbox-receipts.d.ts +2 -0
  47. package/dist/run/sandbox-receipts.js +26 -0
  48. package/dist/run/status.js +5 -2
  49. package/dist/run/stored-runs.js +10 -1
  50. package/dist/run-study.js +6 -1
  51. package/dist/study/doctor.js +2 -2
  52. package/dist/study/init-templates.js +2 -2
  53. package/dist/study/migrate.d.ts +7 -2
  54. package/dist/study/migrate.js +60 -4
  55. package/dist/study/run-study-events.js +1 -1
  56. package/dist/study/run-study-options.js +3 -3
  57. package/dist/study/types.d.ts +1 -1
  58. package/dist/verify/artifacts.d.ts +4 -0
  59. package/dist/verify/artifacts.js +8 -0
  60. package/dist/verify/liveness.js +8 -4
  61. package/dist/verify/verify.d.ts +1 -1
  62. package/dist/verify/verify.js +21 -1
  63. package/docs/architecture/restricted-codex-analysis.md +1 -1
  64. package/docs/contracts/run-bundle.md +20 -1
  65. package/package.json +4 -2
  66. 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");
@@ -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,
@@ -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
- const base = { provider: resource.provider, kind: resource.kind, id: resource.id };
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
- return outcome;
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();
@@ -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 --env-file. Authentication has not been checked.`
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 --env-file",
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 --env-file. The clone subject below serves your
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
- --env-file without the values being saved. Screenshots keep full fidelity by default; set
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
@@ -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
- /** Called with the plan before anything is written, so a caller can print every destination. */
38
- readonly onPlan?: (files: readonly MigrateFile[]) => void;
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. */
@@ -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
- options.onPlan?.(files);
360
- if (options.dryRun === true || writes.length === 0)
361
- return { ...base, ok: true, files };
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
- return { ...base, ok: true, files };
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(`RunLabOptions.onEvent failed on ${type}: ${detail}`);
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: `RunLabOptions.${option} is not supported on the ${route} route: ${reason}`,
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: 'RunLabOptions.rerun.laneIds was removed. Use rerun.participantIds. See docs/contracts/schemas.md, "Library options".',
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: `RunLabOptions.${field} was removed. ${REMOVED_OPTIONS[field]} See docs/contracts/schemas.md, "Library options".`,
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. */
@@ -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 (--env-file). Names are recorded in evidence; values never are. Consumed
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
@@ -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, {
@@ -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
- const journaled = new Set((journal === null ? [] : parseSandboxReceipts(journal)).map((receipt) => receipt.sandboxId));
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.sandboxId));
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
- outcomes.push({ sandboxId, state });
75
+ const digest = typeof recorded === "string" ? recorded : sandboxIdDigest(sandboxId);
76
+ outcomes.push({ digest, state });
73
77
  }
74
78
  return outcomes;
75
79
  }
@@ -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
  };