humanish 0.109.1 → 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 (57) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/dist/cli/commands/comms.js +25 -7
  4. package/dist/cli/commands/observe.js +4 -2
  5. package/dist/cli/commands/run-command.js +4 -3
  6. package/dist/cli/commands/runs.js +11 -5
  7. package/dist/cli/commands/setup.js +19 -5
  8. package/dist/cli/commands/study-format.js +29 -2
  9. package/dist/cli/commands/study.js +4 -3
  10. package/dist/cli/commands/tui.js +4 -3
  11. package/dist/cli/commands/watch.js +2 -2
  12. package/dist/cli/doctor.d.ts +3 -1
  13. package/dist/cli/doctor.js +5 -4
  14. package/dist/cli/io.d.ts +17 -3
  15. package/dist/cli/io.js +20 -3
  16. package/dist/comms/receiving-runtime.js +1 -1
  17. package/dist/comms/setup.js +1 -1
  18. package/dist/evidence/redaction.d.ts +7 -3
  19. package/dist/evidence/redaction.js +68 -24
  20. package/dist/feedback/export-bundle.js +6 -1
  21. package/dist/feedback/export.js +3 -0
  22. package/dist/keys/key-resolution.d.ts +7 -0
  23. package/dist/keys/key-resolution.js +10 -8
  24. package/dist/routes/computer-use/live-phase.d.ts +1 -0
  25. package/dist/routes/computer-use/preflight.js +1 -1
  26. package/dist/routes/computer-use/types.d.ts +6 -0
  27. package/dist/routes/scripted/types.d.ts +1 -0
  28. package/dist/routes/shared-world/route.js +1 -1
  29. package/dist/routes/shared-world/types.d.ts +2 -0
  30. package/dist/routes/terminal/credentials.js +1 -1
  31. package/dist/routes/terminal/live-finish.js +8 -4
  32. package/dist/routes/terminal/types.d.ts +3 -0
  33. package/dist/run/bundle-shape.js +1 -0
  34. package/dist/run/bundle.d.ts +6 -0
  35. package/dist/run/reclaim.d.ts +3 -0
  36. package/dist/run/reclaim.js +10 -3
  37. package/dist/run/results.d.ts +3 -0
  38. package/dist/run/run.js +14 -8
  39. package/dist/run/sandbox-ids.d.ts +53 -0
  40. package/dist/run/sandbox-ids.js +198 -0
  41. package/dist/run/sandbox-receipts.d.ts +2 -0
  42. package/dist/run/sandbox-receipts.js +26 -0
  43. package/dist/run/status.js +5 -2
  44. package/dist/run/stored-runs.js +10 -1
  45. package/dist/run-study.js +6 -1
  46. package/dist/study/doctor.js +2 -2
  47. package/dist/study/init-templates.js +2 -2
  48. package/dist/study/types.d.ts +1 -1
  49. package/dist/verify/artifacts.d.ts +4 -0
  50. package/dist/verify/artifacts.js +8 -0
  51. package/dist/verify/liveness.js +8 -4
  52. package/dist/verify/verify.d.ts +1 -1
  53. package/dist/verify/verify.js +21 -1
  54. package/docs/architecture/restricted-codex-analysis.md +1 -1
  55. package/docs/contracts/run-bundle.md +18 -4
  56. package/package.json +3 -2
  57. package/skills/humanish/SKILL.md +2 -2
@@ -60,12 +60,65 @@ function redactLocalPaths(text, label) {
60
60
  * passes only this value at a sandbox-id key.
61
61
  */
62
62
  export const REDACTED_SANDBOX_ID = "[redacted-sandbox-id]";
63
- /** The keys run writers put a sandbox id under; `providerResources` entries hold one as `id`. */
63
+ /** The keys run writers put a sandbox id under; resource entries hold one as `id`. */
64
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
+ }
65
118
  /**
66
- * `value` with every sandbox id replaced by REDACTED_SANDBOX_ID: strings at `sandboxId` and
67
- * `subjectSandboxId`, and the `id` of each `providerResources` entry. A run keeps raw ids as local
68
- * evidence for cleanup; a copy made to be shared names no sandbox.
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.
69
122
  */
70
123
  export function redactSandboxIds(value) {
71
124
  if (Array.isArray(value)) {
@@ -74,28 +127,19 @@ export function redactSandboxIds(value) {
74
127
  }
75
128
  if (value === null || typeof value !== "object")
76
129
  return value;
77
- let changed = false;
78
130
  // fromEntries keeps a key such as __proto__ an own property, as JSON.parse made it.
79
- const entries = Object.entries(value).map(([key, child]) => {
80
- let next;
81
- if (SANDBOX_ID_KEYS.has(key) && typeof child === "string")
82
- next = REDACTED_SANDBOX_ID;
83
- else if (key === "providerResources" && Array.isArray(child))
84
- next = child.map((resource) => resource !== null &&
85
- typeof resource === "object" &&
86
- !Array.isArray(resource) &&
87
- "id" in resource
88
- ? Object.fromEntries(Object.entries(redactSandboxIds(resource)).map(([field, entry]) => [
89
- field,
90
- field === "id" ? REDACTED_SANDBOX_ID : entry,
91
- ]))
92
- : redactSandboxIds(resource));
93
- else
94
- next = redactSandboxIds(child);
95
- if (next !== child && JSON.stringify(next) !== JSON.stringify(child))
96
- changed = true;
97
- return [key, next];
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)];
98
140
  });
141
+ const changed = entries.length !== Object.keys(value).length ||
142
+ entries.some(([key, child]) => child !== value[key]);
99
143
  return changed ? Object.fromEntries(entries) : value;
100
144
  }
101
145
  /** Redact secrets to [REDACTED_SECRET] and local paths to their path labels. */
@@ -8,6 +8,7 @@ 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 { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
11
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";
@@ -300,6 +301,7 @@ function keptPngPaths(source, bundle) {
300
301
  async function writeDerivative(source, stagePaths, bundle) {
301
302
  const entries = [];
302
303
  const imagePaths = keptPngPaths(source, bundle);
304
+ const sandboxIds = inventorySandboxIds(source.files);
303
305
  let images = 0;
304
306
  for (const file of source.files) {
305
307
  const isPng = path.extname(file.path).toLowerCase() === ".png";
@@ -343,6 +345,7 @@ async function writeDerivative(source, stagePaths, bundle) {
343
345
  }
344
346
  bytes = jsonBytes(transformed);
345
347
  }
348
+ bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
346
349
  action = bytes.equals(file.bytes) ? "copied" : "updated";
347
350
  }
348
351
  await writeContainedOutputFile(stagePaths, file.path, bytes);
@@ -496,7 +499,9 @@ async function verifyFrozenSource(workspace, source, runId) {
496
499
  verified.shareSafety.reasons.every(
497
500
  // Both are repaired in the copy: frames are blurred, and an unread PNG is dropped or
498
501
  // refused by keptPngPaths. Any other unread file fails as an unsupported format.
499
- (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")))) {
500
505
  return { shareSafety: verified.shareSafety };
501
506
  }
502
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 };
@@ -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
  };
@@ -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.
@@ -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
  };
@@ -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>;