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
@@ -2,6 +2,21 @@
2
2
  export declare function sensitivePatterns(): readonly RegExp[];
3
3
  /** True if the text contains any secret-shaped token or known local path. */
4
4
  export declare function containsSensitive(text: string): boolean;
5
+ /**
6
+ * What a shared copy of a run says in place of a provider sandbox id. scripts/public-surface-scan.mjs
7
+ * passes only this value at a sandbox-id key.
8
+ */
9
+ export declare const REDACTED_SANDBOX_ID = "[redacted-sandbox-id]";
10
+ /** A sandbox id's public stand-in: its digest matches a receipt without naming the sandbox. */
11
+ export declare function sandboxIdDigest(id: string): string;
12
+ /** The raw ids at the keys redactSandboxIds replaces, anywhere in `value`. */
13
+ export declare function collectSandboxIds(value: unknown, into?: Set<string>): Set<string>;
14
+ /**
15
+ * `value` with every sandbox id replaced by REDACTED_SANDBOX_ID plus its digest: strings at
16
+ * `sandboxId` and `subjectSandboxId`, and the `id` of each `providerResources` or `resources` entry. Raw ids live
17
+ * only in a run's sandbox-receipts.ndjson; every record and output names a sandbox by digest.
18
+ */
19
+ export declare function redactSandboxIds(value: unknown): unknown;
5
20
  /** Redact secrets to [REDACTED_SECRET] and local paths to their path labels. */
6
21
  export declare function redactText(text: string): string;
7
22
  /** Redact every sensitive match (secrets and paths) to a single [REDACTED_SECRET] label. */
@@ -55,6 +55,93 @@ export function containsSensitive(text) {
55
55
  function redactLocalPaths(text, label) {
56
56
  return LOCAL_PATH_PATTERNS.reduce((current, [pattern, replacement]) => current.replace(pattern, (match) => (label ?? replacement) + (match.match(/\\+$/)?.[0] ?? "")), text);
57
57
  }
58
+ /**
59
+ * What a shared copy of a run says in place of a provider sandbox id. scripts/public-surface-scan.mjs
60
+ * passes only this value at a sandbox-id key.
61
+ */
62
+ export const REDACTED_SANDBOX_ID = "[redacted-sandbox-id]";
63
+ /** The keys run writers put a sandbox id under; resource entries hold one as `id`. */
64
+ const SANDBOX_ID_KEYS = new Set(["sandboxId", "subjectSandboxId"]);
65
+ /** Arrays of sandbox records: run.json's `providerResources` and cleanup.json's `resources`. */
66
+ const RESOURCE_ARRAY_KEYS = new Set(["providerResources", "resources"]);
67
+ /** A sandbox id's public stand-in: its digest matches a receipt without naming the sandbox. */
68
+ export function sandboxIdDigest(id) {
69
+ return digestText(id, 16);
70
+ }
71
+ /** The digest field written beside a redacted id: `idDigest`, `sandboxIdDigest`, … */
72
+ const digestKey = (key) => `${key}Digest`;
73
+ /**
74
+ * The entries of `object` with each raw id at one of `keys` replaced by REDACTED_SANDBOX_ID and its
75
+ * digest written beside it, unless the object already records one.
76
+ */
77
+ function redactIdEntries(object, keys) {
78
+ const entries = Object.entries(object);
79
+ const present = new Set(entries.map(([key]) => key));
80
+ return entries.flatMap(([key, child]) => {
81
+ if (!keys.has(key) || typeof child !== "string" || child === REDACTED_SANDBOX_ID)
82
+ return [[key, child]];
83
+ // A label that a text scrub left at the key already carries the id's digest.
84
+ const digest = SANDBOX_ID_LABEL.exec(child)?.[1] ?? sandboxIdDigest(child);
85
+ return [
86
+ [key, REDACTED_SANDBOX_ID],
87
+ ...(present.has(digestKey(key)) ? [] : [[digestKey(key), digest]]),
88
+ ];
89
+ });
90
+ }
91
+ /** How free text names a sandbox: the marker with the id's digest inside it. */
92
+ const SANDBOX_ID_LABEL = /^\[redacted-sandbox-id ([0-9a-f]{16})\]$/;
93
+ const RESOURCE_ID_KEYS = new Set(["id"]);
94
+ /** A raw id at a sandbox-id key: neither the marker nor a label that names its digest. */
95
+ const isRawSandboxId = (value) => typeof value === "string" && value !== REDACTED_SANDBOX_ID && !SANDBOX_ID_LABEL.test(value);
96
+ /** The raw ids at the keys redactSandboxIds replaces, anywhere in `value`. */
97
+ export function collectSandboxIds(value, into = new Set()) {
98
+ if (Array.isArray(value)) {
99
+ for (const item of value)
100
+ collectSandboxIds(item, into);
101
+ return into;
102
+ }
103
+ if (value === null || typeof value !== "object")
104
+ return into;
105
+ for (const [key, child] of Object.entries(value)) {
106
+ if (SANDBOX_ID_KEYS.has(key) && isRawSandboxId(child))
107
+ into.add(child);
108
+ if (RESOURCE_ARRAY_KEYS.has(key) && Array.isArray(child))
109
+ for (const resource of child) {
110
+ const id = resource !== null && typeof resource === "object" ? Reflect.get(resource, "id") : null;
111
+ if (isRawSandboxId(id))
112
+ into.add(id);
113
+ }
114
+ collectSandboxIds(child, into);
115
+ }
116
+ return into;
117
+ }
118
+ /**
119
+ * `value` with every sandbox id replaced by REDACTED_SANDBOX_ID plus its digest: strings at
120
+ * `sandboxId` and `subjectSandboxId`, and the `id` of each `providerResources` or `resources` entry. Raw ids live
121
+ * only in a run's sandbox-receipts.ndjson; every record and output names a sandbox by digest.
122
+ */
123
+ export function redactSandboxIds(value) {
124
+ if (Array.isArray(value)) {
125
+ const items = value.map(redactSandboxIds);
126
+ return items.every((item, index) => item === value[index]) ? value : items;
127
+ }
128
+ if (value === null || typeof value !== "object")
129
+ return value;
130
+ // fromEntries keeps a key such as __proto__ an own property, as JSON.parse made it.
131
+ const entries = redactIdEntries(value, SANDBOX_ID_KEYS).map(([key, child]) => {
132
+ if (RESOURCE_ARRAY_KEYS.has(key) && Array.isArray(child))
133
+ return [
134
+ key,
135
+ child.map((resource) => resource !== null && typeof resource === "object" && !Array.isArray(resource)
136
+ ? Object.fromEntries(redactIdEntries(redactSandboxIds(resource), RESOURCE_ID_KEYS))
137
+ : redactSandboxIds(resource)),
138
+ ];
139
+ return [key, redactSandboxIds(child)];
140
+ });
141
+ const changed = entries.length !== Object.keys(value).length ||
142
+ entries.some(([key, child]) => child !== value[key]);
143
+ return changed ? Object.fromEntries(entries) : value;
144
+ }
58
145
  /** Redact secrets to [REDACTED_SECRET] and local paths to their path labels. */
59
146
  export function redactText(text) {
60
147
  const withoutSecrets = SECRET_PATTERNS.reduce((current, pattern) => current.replace(pattern, "[REDACTED_SECRET]"), text);
@@ -8,7 +8,8 @@ import { parse as parseYaml } from "yaml";
8
8
  import { ACTOR_TRACE_SCHEMA } from "../actors/contract.js";
9
9
  import { renderObserver } from "../observer/render.js";
10
10
  import { buildObserverData } from "../observer/data.js";
11
- import { containsSensitive, redactScreenshot, redactText } from "../evidence/redaction.js";
11
+ import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
12
+ import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
12
13
  import { decodeEscapes } from "../evidence/encoded-text.js";
13
14
  import { readPlainText } from "../evidence/plain-text.js";
14
15
  import { streamScreenshotPaths } from "../verify/artifacts.js";
@@ -224,7 +225,8 @@ function transformText(file, imagePaths) {
224
225
  const parsed = JSON.parse(text);
225
226
  // Parsing also resolves escape sequences in string values before inspection.
226
227
  assertNoInlineRaster(JSON.stringify(parsed));
227
- const transformed = updateScreenshotDeclarations(parsed, imagePaths);
228
+ // The copy names no sandbox: an id is local operational evidence, and the copy is shared.
229
+ const transformed = redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths));
228
230
  if (JSON.stringify(transformed) === JSON.stringify(parsed))
229
231
  return file.bytes;
230
232
  assertRewritableNumbers(text);
@@ -237,7 +239,7 @@ function transformText(file, imagePaths) {
237
239
  return line;
238
240
  const parsed = JSON.parse(line);
239
241
  assertNoInlineRaster(JSON.stringify(parsed));
240
- const updated = JSON.stringify(updateScreenshotDeclarations(parsed, imagePaths));
242
+ const updated = JSON.stringify(redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths)));
241
243
  if (updated === JSON.stringify(parsed))
242
244
  return line;
243
245
  assertRewritableNumbers(line);
@@ -299,6 +301,7 @@ function keptPngPaths(source, bundle) {
299
301
  async function writeDerivative(source, stagePaths, bundle) {
300
302
  const entries = [];
301
303
  const imagePaths = keptPngPaths(source, bundle);
304
+ const sandboxIds = inventorySandboxIds(source.files);
302
305
  let images = 0;
303
306
  for (const file of source.files) {
304
307
  const isPng = path.extname(file.path).toLowerCase() === ".png";
@@ -329,7 +332,7 @@ async function writeDerivative(source, stagePaths, bundle) {
329
332
  bytes = transformText(file, imagePaths);
330
333
  if (file.path === "run.json") {
331
334
  assertRewritableNumbers(file.bytes.toString("utf8"));
332
- const transformed = updateScreenshotDeclarations(bundle, imagePaths);
335
+ const transformed = redactSandboxIds(updateScreenshotDeclarations(bundle, imagePaths));
333
336
  transformed.redaction = {
334
337
  status: "passed",
335
338
  notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
@@ -342,6 +345,7 @@ async function writeDerivative(source, stagePaths, bundle) {
342
345
  }
343
346
  bytes = jsonBytes(transformed);
344
347
  }
348
+ bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
345
349
  action = bytes.equals(file.bytes) ? "copied" : "updated";
346
350
  }
347
351
  await writeContainedOutputFile(stagePaths, file.path, bytes);
@@ -495,7 +499,9 @@ async function verifyFrozenSource(workspace, source, runId) {
495
499
  verified.shareSafety.reasons.every(
496
500
  // Both are repaired in the copy: frames are blurred, and an unread PNG is dropped or
497
501
  // refused by keptPngPaths. Any other unread file fails as an unsupported format.
498
- (reason) => reason.code === "RAW_SCREENSHOTS" || reason.code === "UNSCANNED_ARTIFACT")))) {
502
+ (reason) => reason.code === "RAW_SCREENSHOTS" ||
503
+ reason.code === "RAW_SANDBOX_ID" ||
504
+ reason.code === "UNSCANNED_ARTIFACT")))) {
499
505
  return { shareSafety: verified.shareSafety };
500
506
  }
501
507
  const loaded = await loadRunBundlePrepared(rawSnapshot.physicalPath, snapshotPaths);
@@ -384,6 +384,9 @@ function shareSafetyRemedy(codes) {
384
384
  if (codes.includes("RAW_SCREENSHOTS")) {
385
385
  steps.push("Re-run with policies.redactScreenshots: true to blur screenshots at capture.");
386
386
  }
387
+ if (codes.includes("RAW_SANDBOX_ID")) {
388
+ steps.push("Export with --format bundle --redact-screenshots, which writes a copy without raw sandbox ids, and share from that copy.");
389
+ }
387
390
  if (codes.includes("UNSCANNED_ARTIFACT")) {
388
391
  steps.push("verify cannot read the files that `humanish verify --json` lists under UNSCANNED_ARTIFACT as text: remove them from the run folder, or keep only images an actor trace references as stream screenshots.");
389
392
  }
@@ -22,6 +22,11 @@ export declare function discoverProviderKeys(args: {
22
22
  deps?: KeyResolutionDeps;
23
23
  announced?: ReadonlySet<string>;
24
24
  }): Promise<ResolvedKeyFill[]>;
25
+ /** What `--dotenv` loaded: the path as given, and the names it set because they were unset. */
26
+ export interface DotenvLoad {
27
+ path: string;
28
+ names: readonly string[];
29
+ }
25
30
  export interface KeySourceProbe {
26
31
  name: string;
27
32
  /** The source that supplies the key right now, or null when missing everywhere. */
@@ -43,6 +48,8 @@ export declare function probeKeySources(names: readonly string[], args: {
43
48
  cwd: string;
44
49
  env: NodeJS.ProcessEnv;
45
50
  deps?: KeyResolutionDeps;
51
+ /** The names `--dotenv` put into `env`, so their rows name the file, not the process env. */
52
+ dotenv?: DotenvLoad;
46
53
  }): Promise<KeySourceProbe[]>;
47
54
  /** Resolve a `humanish keys set` target: a vendor alias or a raw env name. Null = invalid. */
48
55
  export declare function resolveKeyName(vendorOrName: string): string | null;
@@ -1,8 +1,8 @@
1
1
  // Provider-key discovery: resolve the keys a live run needs through each vendor's
2
- // native chain instead of demanding a per-repo --env-file that operators hand-copy keys into.
2
+ // native chain instead of demanding a per-repo --dotenv that operators hand-copy keys into.
3
3
  //
4
4
  // The chain, per key, fill-only (a rung never overrides anything already present):
5
- // 1. process env, including whatever --env-file just loaded (explicit always wins);
5
+ // 1. process env, including whatever --dotenv just loaded (explicit always wins);
6
6
  // 2. `.humanish/local/provider.env`, the project-local overlay this CLI's own --help
7
7
  // examples document;
8
8
  // 3. the owning vendor's native store, where one exists:
@@ -24,7 +24,7 @@ import { loadEnvFile } from "./env-file.js";
24
24
  /** The only names implicit discovery may fill (and `humanish keys set` may store). Everything
25
25
  * else in an overlay/store file is ignored-and-named: a repo-planted NODE_OPTIONS/LD_PRELOAD
26
26
  * must never enter process env off a file the operator did not explicitly pass (an explicit
27
- * --env-file remains the operator's own full-file load). */
27
+ * --dotenv remains the operator's own full-file load). */
28
28
  const KNOWN_PROVIDER_KEYS = [
29
29
  "OPENAI_API_KEY",
30
30
  "ANTHROPIC_API_KEY",
@@ -213,7 +213,7 @@ export async function discoverProviderKeys(args) {
213
213
  if (announced === undefined || announced.has(fill_.name))
214
214
  announce(`humanish keys: ${fill_.name} from ${fill_.source}`);
215
215
  for (const name of ignored)
216
- announce(`humanish keys: ignored ${name}, which is not a provider key. Discovery loads provider keys only; pass the file with --env-file to load every name in it.`);
216
+ announce(`humanish keys: ignored ${name}, which is not a provider key. Discovery loads provider keys only; pass the file with --dotenv to load every name in it.`);
217
217
  return fills;
218
218
  }
219
219
  /** True when the file's parent directory is (or traverses) a symlink at its last component:
@@ -253,7 +253,7 @@ export function describeMissingKeys(names, env) {
253
253
  const hints = names.map((name) => `${name}: ${missingKeyHint(name)}`).join("; ");
254
254
  const strict = env.HUMANISH_STRICT_KEYS?.trim() === "1";
255
255
  const chain = strict
256
- ? "key discovery is disabled (HUMANISH_STRICT_KEYS=1); only process env and --env-file are read"
256
+ ? "key discovery is disabled (HUMANISH_STRICT_KEYS=1); only process env and --dotenv are read"
257
257
  : `also checked ${PROJECT_OVERLAY_RELATIVE}, ~/.e2b/config.json, gh auth token, and ${path.join("~", ".config", "humanish", "keys.env")}`;
258
258
  return `${chain}. Fill: ${hints}.`;
259
259
  }
@@ -278,7 +278,9 @@ export async function probeKeySources(names, args) {
278
278
  args.env.GITHUB_TOKEN !== undefined &&
279
279
  args.env.GITHUB_TOKEN.trim() !== "";
280
280
  const source = inEnv
281
- ? "process env"
281
+ ? args.dotenv?.names.includes(name)
282
+ ? `--dotenv ${args.dotenv.path}`
283
+ : "process env"
282
284
  : aliasInEnv
283
285
  ? "process env (GITHUB_TOKEN)"
284
286
  : (bySource.get(name) ?? null);
@@ -307,7 +309,7 @@ export function setUserKey(name, value, env, deps = {}) {
307
309
  throw new Error(`Not a valid env name: ${name}`);
308
310
  }
309
311
  if (!PROVIDER_KEY_SET.has(name)) {
310
- throw new Error(`The store holds provider keys only (${[...PROVIDER_KEY_SET].join(", ")}). For anything else, use an explicit --env-file.`);
312
+ throw new Error(`The store holds provider keys only (${[...PROVIDER_KEY_SET].join(", ")}). For anything else, use an explicit --dotenv.`);
311
313
  }
312
314
  const storePath = userKeyStorePath(env, deps);
313
315
  const dir = path.dirname(storePath);
@@ -325,7 +327,7 @@ export function setUserKey(name, value, env, deps = {}) {
325
327
  // would silently resolve to a different secret on the next run.
326
328
  const roundTrip = parseStoreLine(`${name}=${trimmed}`);
327
329
  if (roundTrip === null || roundTrip[0] !== name || roundTrip[1] !== trimmed) {
328
- throw new Error("The value does not round-trip the store format (avoid leading quotes and '#'); pass it via --env-file instead.");
330
+ throw new Error("The value does not round-trip the store format (avoid leading quotes and '#'); pass it via --dotenv instead.");
329
331
  }
330
332
  writeStore(storePath, entries);
331
333
  return { path: storePath };
@@ -20,7 +20,7 @@ export function createInProcessDesktop(deps) {
20
20
  throw new Error("The participant desktop must be prepared and may only be opened once, before finalization.");
21
21
  opened = true;
22
22
  if (deps.inProcessExecutor === undefined)
23
- throw new Error("The in-process route needs RunLabOptions.inProcess.");
23
+ throw new Error("The in-process route needs RunStudyOptions.inProcess.");
24
24
  return { executor: await deps.inProcessExecutor(deps.appUrl) };
25
25
  },
26
26
  finalize() {
@@ -27,6 +27,7 @@ export declare function runStudyParticipants(setup: CuaRunSetup, participants: C
27
27
  };
28
28
  sandbox?: {
29
29
  sandboxId: string;
30
+ sandboxIdDigest?: string;
30
31
  killed: boolean;
31
32
  streamUrlPresent: boolean;
32
33
  };
@@ -159,7 +159,7 @@ function driverReason(driving, { localAppSubject, inProcessRoute }) {
159
159
  if (driving.inProcess && !driving.createProvider)
160
160
  return {
161
161
  code: "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER",
162
- message: "RunLabOptions.inProcess needs RunLabOptions.createProvider: an in-process executor returns no screenshot, so it needs a provider that does not read images. The default OpenAI computer-use provider reads screenshots and would stop the session.",
162
+ message: "RunStudyOptions.inProcess needs RunStudyOptions.createProvider: an in-process executor returns no screenshot, so it needs a provider that does not read images. The default OpenAI computer-use provider reads screenshots and would stop the session.",
163
163
  };
164
164
  // There is no built-in in-process driver for a local app.
165
165
  if (localAppSubject && !inProcessRoute)
@@ -233,7 +233,7 @@ export function planComputerUseStudy(config, input) {
233
233
  const source = config.subject.source;
234
234
  if (driving.inProcess &&
235
235
  (source === "clone" || source === "local-tree" || source === "desktop-cli"))
236
- return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", `RunLabOptions.inProcess drives subject.appUrl in this process, and a ${source} subject needs the hosted desktop the in-process route never creates. Use an app-url or local-app subject with inProcess, or remove inProcess to run on a hosted desktop.`, actor);
236
+ return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", `RunStudyOptions.inProcess drives subject.appUrl in this process, and a ${source} subject needs the hosted desktop the in-process route never creates. Use an app-url or local-app subject with inProcess, or remove inProcess to run on a hosted desktop.`, actor);
237
237
  // The parser's rule; without a product the desktop study fails later.
238
238
  const productReason = desktopCliProductReason(config);
239
239
  if (productReason)
@@ -243,7 +243,7 @@ export function planComputerUseStudy(config, input) {
243
243
  return refuse("after-personas", "HUMANISH_COMPUTER_USE_FANOUT_INVALID", `Computer-use fan-out is capped at ${MAX_COMPUTER_USE_PARTICIPANTS} participants (resolved ${participants.length}); N concurrent paid desktops is real spend.`, actor);
244
244
  const [first, ...rest] = participants;
245
245
  if (driving.inProcess && rest.length > 0)
246
- return refuse("after-personas", "HUMANISH_COMPUTER_USE_FANOUT_INVALID", "Fan-out to more than one participant is not supported on the in-process route (RunLabOptions.inProcess): fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Run a single in-process participant, or fan out on the E2B route.", actor);
246
+ return refuse("after-personas", "HUMANISH_COMPUTER_USE_FANOUT_INVALID", "Fan-out to more than one participant is not supported on the in-process route (RunStudyOptions.inProcess): fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Run a single in-process participant, or fan out on the E2B route.", actor);
247
247
  if (first === undefined)
248
248
  throw new Error("computerUseParticipants returned no participant");
249
249
  const appUrl = config.subject.appUrl ?? "";
@@ -55,7 +55,7 @@ export async function liveCuaRejection(args) {
55
55
  if (unsetSubjectEnv.length > 0) {
56
56
  return {
57
57
  code: "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING",
58
- message: `subject.env declares ${unsetSubjectEnv.join(", ")} but the environment does not provide ${unsetSubjectEnv.length === 1 ? "it" : "them"} (pass via --env-file; values are never persisted).`,
58
+ message: `subject.env declares ${unsetSubjectEnv.join(", ")} but the environment does not provide ${unsetSubjectEnv.length === 1 ? "it" : "them"} (pass via --dotenv; values are never persisted).`,
59
59
  };
60
60
  }
61
61
  // Fail-closed cap: a maxUsd cap needs a measurable per-turn estimate.
@@ -89,7 +89,7 @@ export async function admitCuaRun(plan, input, config) {
89
89
  ok: false,
90
90
  result: fail(...args),
91
91
  });
92
- // runStudyWith's local VM study supplies a local-target app-url study's desktop; a direct route call or
92
+ // runStudyWith's `localVm` supplies the desktop of a local-target app-url study; a direct route call or
93
93
  // an in-process executor on the same study has none. It reads the declared source, as the planner
94
94
  // did: a library config the parser never saw can plan to an app-url subject from another source.
95
95
  if (config.subject.source === "app-url" &&
@@ -155,7 +155,10 @@ export interface CuaParticipantResult {
155
155
  screenshots: number;
156
156
  };
157
157
  sandbox?: {
158
+ /** "[redacted-sandbox-id]"; the raw id is only in the run's sandbox-receipts.ndjson. */
158
159
  sandboxId: string;
160
+ /** The id's digest, which matches its receipt. */
161
+ sandboxIdDigest?: string;
159
162
  killed: boolean;
160
163
  streamUrlPresent: boolean;
161
164
  };
@@ -240,7 +243,10 @@ export interface CuaActorStudyResult extends AutomaticAnalysisResult, StudyResul
240
243
  screenshots: number;
241
244
  };
242
245
  sandbox?: {
246
+ /** "[redacted-sandbox-id]"; the raw id is only in the run's sandbox-receipts.ndjson. */
243
247
  sandboxId: string;
248
+ /** The id's digest, which matches its receipt. */
249
+ sandboxIdDigest?: string;
244
250
  killed: boolean;
245
251
  /** The stream URL itself (carries an auth key) is runtime-only and is deliberately not
246
252
  * surfaced on the result; the sandbox is already dead by the time the result exists. */
@@ -48,6 +48,7 @@ export interface ScriptedBrowserStudyResult extends AutomaticAnalysisResult, Stu
48
48
  subject?: RunSubjectProvenance;
49
49
  subjectSandbox?: {
50
50
  sandboxId: string;
51
+ sandboxIdDigest?: string;
51
52
  killed: boolean;
52
53
  };
53
54
  hostDigest?: string;
@@ -141,7 +141,7 @@ async function admitSharedWorldRun(plan, input) {
141
141
  }
142
142
  const unsetSubjectEnv = missingSubjectEnv(plan.requirements, env);
143
143
  if (unsetSubjectEnv.length > 0) {
144
- return fail("HUMANISH_SHARED_WORLD_SUBJECT_ENV_MISSING", `subject.env declares ${unsetSubjectEnv.join(", ")} but the environment does not provide ${unsetSubjectEnv.length === 1 ? "it" : "them"} (pass via --env-file; values are never persisted).`);
144
+ return fail("HUMANISH_SHARED_WORLD_SUBJECT_ENV_MISSING", `subject.env declares ${unsetSubjectEnv.join(", ")} but the environment does not provide ${unsetSubjectEnv.length === 1 ? "it" : "them"} (pass via --dotenv; values are never persisted).`);
145
145
  }
146
146
  return undefined;
147
147
  }
@@ -95,6 +95,7 @@ export interface ConcurrentSharedWorldParticipantResult {
95
95
  /** The actor sandbox lifecycle proof (the getHost/key value is never surfaced here). */
96
96
  sandbox?: {
97
97
  sandboxId: string;
98
+ sandboxIdDigest?: string;
98
99
  killed: boolean;
99
100
  };
100
101
  error?: {
@@ -119,6 +120,7 @@ export interface ConcurrentSharedWorldStudyResult extends AutomaticAnalysisResul
119
120
  /** The one subject sandbox lifecycle proof. */
120
121
  subjectSandbox?: {
121
122
  sandboxId: string;
123
+ sandboxIdDigest?: string;
122
124
  killed: boolean;
123
125
  };
124
126
  /** Whether ≥2 actor windows overlapped in time (proven concurrency; live only). */
@@ -38,7 +38,7 @@ export function buildRuntimeAuth(args) {
38
38
  return {
39
39
  ok: false,
40
40
  code: "HUMANISH_TERMINAL_RUNTIME_AUTH_MISSING",
41
- message: `Live terminal-product studies declare runtimeAuth "${String(args.runtimeAuth)}" and need ${ALLOWED_RUNTIME_KEY_NAMES.join(" or ")} in the environment (pass via --env-file; the selected auth mode places the value in command-scoped env or an external E2B header transform; the value is never persisted).`,
41
+ message: `Live terminal-product studies declare runtimeAuth "${String(args.runtimeAuth)}" and need ${ALLOWED_RUNTIME_KEY_NAMES.join(" or ")} in the environment (pass via --dotenv; the selected auth mode places the value in command-scoped env or an external E2B header transform; the value is never persisted).`,
42
42
  };
43
43
  }
44
44
  const keyValue = args.env[keyName];
@@ -1,5 +1,6 @@
1
1
  // Finishing a live terminal session: the actor trace from the captured stream, the spend ledger
2
2
  // and its caps check, the evidence files, the bundle, and the study result.
3
+ import { publicSandboxView, readRunSandboxIds, scrubSandboxIds } from "../../run/sandbox-ids.js";
3
4
  import { buildRunCostSummary } from "../../run/cost-summary.js";
4
5
  import { writeContainedOutputFile } from "../../run/contained-output.js";
5
6
  import { validatePreparedRunArtifactPaths, } from "../../run/paths.js";
@@ -209,8 +210,11 @@ export async function finishLiveTerminalSession(inputs) {
209
210
  /** Persist the terminal evidence: redacted events, normalized transcript, ledgers, actor trace. */
210
211
  async function writeTerminalEvidence(runPaths, evidence) {
211
212
  const { terminalEvents, normalizedTranscript, ledgers, trace } = evidence;
212
- await writeContainedOutputFile(runPaths, TERMINAL_EVENTS_ARTIFACT, `${terminalEvents.map((e) => JSON.stringify(e)).join("\n")}${terminalEvents.length > 0 ? "\n" : ""}`, "utf8");
213
- await writeContainedOutputFile(runPaths, TERMINAL_TRANSCRIPT_ARTIFACT, `${normalizedTranscript}\n`, "utf8");
214
- await writeContainedOutputFile(runPaths, TERMINAL_LEDGERS_ARTIFACT, `${JSON.stringify(ledgers, null, 2)}\n`, "utf8");
215
- await writeContainedOutputFile(runPaths, "actor.json", `${JSON.stringify(trace, null, 2)}\n`, "utf8");
213
+ // The sandbox's raw id stays in sandbox-receipts.ndjson; these files name it by digest.
214
+ const ids = await readRunSandboxIds(runPaths);
215
+ const scrub = (text) => scrubSandboxIds(text, ids);
216
+ await writeContainedOutputFile(runPaths, TERMINAL_EVENTS_ARTIFACT, scrub(`${terminalEvents.map((e) => JSON.stringify(e)).join("\n")}${terminalEvents.length > 0 ? "\n" : ""}`), "utf8");
217
+ await writeContainedOutputFile(runPaths, TERMINAL_TRANSCRIPT_ARTIFACT, scrub(`${normalizedTranscript}\n`), "utf8");
218
+ await writeContainedOutputFile(runPaths, TERMINAL_LEDGERS_ARTIFACT, `${JSON.stringify(publicSandboxView(ledgers, ids), null, 2)}\n`, "utf8");
219
+ await writeContainedOutputFile(runPaths, "actor.json", scrub(`${JSON.stringify(trace, null, 2)}\n`), "utf8");
216
220
  }
@@ -147,7 +147,10 @@ export interface TerminalProductStudyResult extends AutomaticAnalysisResult, Stu
147
147
  };
148
148
  /** Live-only: the sandbox lifecycle proof (the key/auth value is never surfaced here). */
149
149
  sandbox?: {
150
+ /** "[redacted-sandbox-id]"; the raw id is only in the run's sandbox-receipts.ndjson. */
150
151
  sandboxId: string;
152
+ /** The id's digest, which matches its receipt. */
153
+ sandboxIdDigest?: string;
151
154
  killed: boolean;
152
155
  /** By-id proof (never a re-list): 0 = confirmed reclaimed, 1 = still present (unconfirmed),
153
156
  * -1 = kill(id) itself failed or was unavailable. See TerminalLedgers["cleanup"]. */
@@ -73,6 +73,7 @@ function isRunProviderResource(value) {
73
73
  value.kind === "sandbox" &&
74
74
  typeof value.id === "string" &&
75
75
  value.id.trim().length > 0 &&
76
+ (value.idDigest === undefined || typeof value.idDigest === "string") &&
76
77
  value.owner === "humanish" &&
77
78
  (value.status === "running" || value.status === "killed" || value.status === "unknown") &&
78
79
  (value.simId === undefined || typeof value.simId === "string") &&
@@ -413,7 +413,13 @@ export interface RunProviderResource {
413
413
  schema: "humanish.provider-resource.v1";
414
414
  provider: "e2b-desktop";
415
415
  kind: "sandbox";
416
+ /**
417
+ * "[redacted-sandbox-id]" in runs from 0.110 on; earlier runs recorded the raw id here. The raw id
418
+ * is in the run's sandbox-receipts.ndjson.
419
+ */
416
420
  id: string;
421
+ /** The id's digest, which matches its receipt. Absent on runs recorded before 0.110. */
422
+ idDigest?: string;
417
423
  owner: "humanish";
418
424
  status: "running" | "killed" | "unknown";
419
425
  simId?: string;
@@ -3,7 +3,10 @@ import { type PreparedRunArtifactPaths } from "./paths.js";
3
3
  declare const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
4
4
  export declare const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
5
5
  interface ReclaimOutcome {
6
+ /** "[redacted-sandbox-id]"; the raw id is only in the run's sandbox-receipts.ndjson. */
6
7
  sandboxId: string;
8
+ /** The id's digest, which matches its receipt. */
9
+ sandboxIdDigest: string;
7
10
  laneId: string;
8
11
  /** killed = kill(id) confirmed; already-gone = the server no longer knows the id (TTL or a
9
12
  * prior cleanup got it); kill-failed = the attempt errored (the TTL backstop still applies);
@@ -9,7 +9,8 @@ import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecisio
9
9
  import { resolveRunPath } from "./locate.js";
10
10
  import { parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
11
11
  import { destroyE2BSandbox } from "../substrates/e2b/sandbox.js";
12
- import { redactText, toErrorMessage } from "../evidence/redaction.js";
12
+ import { REDACTED_SANDBOX_ID, redactText, sandboxIdDigest, toErrorMessage, } from "../evidence/redaction.js";
13
+ import { scrubSandboxIds } from "./sandbox-ids.js";
13
14
  import { runIdOf } from "./paths.js";
14
15
  const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
15
16
  export const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
@@ -182,10 +183,16 @@ async function reclaimJournal(root, hooks) {
182
183
  if (seen.has(key))
183
184
  continue;
184
185
  seen.add(key);
186
+ // The result and reclaim-receipt.json name the sandbox by digest, and a provider error that
187
+ // quotes the id is scrubbed; the raw id stays in sandbox-receipts.ndjson.
188
+ const attempt = await destroy(receipt);
185
189
  outcomes.push({
186
- sandboxId: receipt.sandboxId,
190
+ sandboxId: REDACTED_SANDBOX_ID,
191
+ sandboxIdDigest: sandboxIdDigest(receipt.sandboxId),
187
192
  laneId: receipt.laneId,
188
- ...(await destroy(receipt)),
193
+ ...("detail" in attempt
194
+ ? { ...attempt, detail: scrubSandboxIds(attempt.detail, [receipt.sandboxId]) }
195
+ : attempt),
189
196
  });
190
197
  }
191
198
  return { kind: "done", receiptCount: receipts.length, outcomes };
@@ -37,7 +37,10 @@ export interface RunResult {
37
37
  export interface CleanupResourceResult {
38
38
  provider: RunProviderResource["provider"];
39
39
  kind: RunProviderResource["kind"];
40
+ /** "[redacted-sandbox-id]"; the raw id is only in the run's sandbox-receipts.ndjson. */
40
41
  id: string;
42
+ /** The id's digest, which matches its receipt; absent when the record had neither. */
43
+ idDigest?: string;
41
44
  /** Cleanup reads recorded evidence and never kills a sandbox, so it never writes `killed`. */
42
45
  status: "already_clean" | "failed" | "skipped";
43
46
  message: string;
package/dist/run/run.js CHANGED
@@ -9,6 +9,7 @@ import {} from "./results.js";
9
9
  import { createRunArtifactPaths, validatePreparedRunArtifactPaths, } from "./paths.js";
10
10
  import { registerActiveRun } from "./active-runs.js";
11
11
  import { writeContainedOutputFile, writePreparedRunLatestPointer } from "./contained-output.js";
12
+ import { scrubRunSandboxIds, withPublicSandboxIds } from "./sandbox-ids.js";
12
13
  import { beginRunStatus, runStatusOutcome } from "./status.js";
13
14
  /** An entry point called in a state that does not admit it. */
14
15
  class RunLifecycleError extends Error {
@@ -55,6 +56,16 @@ export class FinishedRun {
55
56
  }
56
57
  }
57
58
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
59
+ /** Where FinishedRun renders the Observer, when the run was started with one. */
60
+ function observerTarget(options) {
61
+ return options.observer === undefined
62
+ ? undefined
63
+ : {
64
+ cwd: options.cwd,
65
+ open: options.observer.open,
66
+ render: options.observer.render ?? renderObserver,
67
+ };
68
+ }
58
69
  /**
59
70
  * Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
60
71
  * so the scope and its run admit no further calls, even through references `fn` leaked into
@@ -100,13 +111,7 @@ export async function runScope(fn) {
100
111
  const openRun = (options, runId, paths, runStatus) => {
101
112
  const now = options.now ?? Date.now;
102
113
  const createdAt = new Date(now()).toISOString();
103
- const observer = options.observer === undefined
104
- ? undefined
105
- : {
106
- cwd: options.cwd,
107
- open: options.observer.open,
108
- render: options.observer.render ?? renderObserver,
109
- };
114
+ const observer = observerTarget(options);
110
115
  let finishCalled = false;
111
116
  let pointerWritten = false;
112
117
  // One chain serializes every write. A rejection reaches only the caller of that write; the
@@ -133,7 +138,7 @@ export async function runScope(fn) {
133
138
  /** run.json, then `afterBundle`, then the review and projections. */
134
139
  const writeBundleFiles = async (bundle, afterBundle) => {
135
140
  await validatePreparedRunArtifactPaths(paths);
136
- const publicBundle = { ...bundle, cwd: PUBLIC_TARGET_CWD };
141
+ const publicBundle = await withPublicSandboxIds(paths, { ...bundle, cwd: PUBLIC_TARGET_CWD });
137
142
  await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(publicBundle), "utf8");
138
143
  await afterBundle(publicBundle);
139
144
  await writeContainedOutputFile(paths, "review.json", json(publicBundle.review), "utf8");
@@ -149,6 +154,7 @@ export async function runScope(fn) {
149
154
  };
150
155
  const publish = async (bundle) => {
151
156
  await writeBundleFiles(bundle, (publicBundle) => runStatus.finish(runStatusOutcome(publicBundle)));
157
+ await scrubRunSandboxIds(paths);
152
158
  await writePointer();
153
159
  return new FinishedRun(issueKey, runId, paths, observer, runStatus);
154
160
  };