humanish 0.115.0 → 0.116.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 (154) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -6
  3. package/TELEMETRY.md +6 -0
  4. package/dist/actors/codex/app-server-ui.js +11 -4
  5. package/dist/actors/computer-use/loop/debrief.js +61 -27
  6. package/dist/actors/scripted-browser/steps.js +6 -5
  7. package/dist/analysis/analysis-limits.d.ts +35 -0
  8. package/dist/analysis/analysis-limits.js +41 -0
  9. package/dist/analysis/validation.js +14 -21
  10. package/dist/cli/commands/analyze.js +2 -1
  11. package/dist/cli/commands/notes.js +2 -1
  12. package/dist/cli/commands/run-signals.js +5 -1
  13. package/dist/cli/commands/runs.js +2 -1
  14. package/dist/cli/commands/setup.js +3 -3
  15. package/dist/cli/commands/study-format.d.ts +5 -2
  16. package/dist/cli/commands/study-format.js +29 -9
  17. package/dist/cli/commands/study-route-computer-use.js +2 -2
  18. package/dist/cli/commands/study-route-scripted.js +1 -1
  19. package/dist/cli/commands/tui.js +59 -17
  20. package/dist/cli/doctor.js +9 -2
  21. package/dist/cli/findings-text.d.ts +5 -0
  22. package/dist/cli/findings-text.js +169 -0
  23. package/dist/cli/findings.d.ts +0 -4
  24. package/dist/cli/findings.js +5 -167
  25. package/dist/cli/io.js +6 -1
  26. package/dist/cli/update-check.js +4 -1
  27. package/dist/comms/connections.js +4 -3
  28. package/dist/comms/setup.js +18 -12
  29. package/dist/evidence/redaction.js +9 -1
  30. package/dist/evidence/secret-scrub.d.ts +15 -5
  31. package/dist/evidence/secret-scrub.js +67 -9
  32. package/dist/feedback/draft.js +1 -1
  33. package/dist/feedback/export-bundle.js +18 -14
  34. package/dist/feedback/feedback.js +25 -7
  35. package/dist/guest/desktop-keys.js +59 -5
  36. package/dist/keys/key-resolution.d.ts +9 -0
  37. package/dist/keys/key-resolution.js +60 -48
  38. package/dist/keys/key-status.d.ts +2 -0
  39. package/dist/keys/key-status.js +9 -16
  40. package/dist/observer/pinned-files.js +1 -1
  41. package/dist/observer/render.js +3 -2
  42. package/dist/observer-app.html +7 -7
  43. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  44. package/dist/routes/computer-use/bundle-parts.js +2 -2
  45. package/dist/routes/computer-use/bundle.d.ts +4 -5
  46. package/dist/routes/computer-use/bundle.js +1 -3
  47. package/dist/routes/computer-use/fanout-records.js +10 -11
  48. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  49. package/dist/routes/computer-use/live-phase.js +0 -1
  50. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  51. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  52. package/dist/routes/computer-use/participant-runs.js +9 -5
  53. package/dist/routes/computer-use/plan.d.ts +3 -0
  54. package/dist/routes/computer-use/plan.js +14 -11
  55. package/dist/routes/computer-use/rerun-selection.js +2 -2
  56. package/dist/routes/computer-use/result.js +3 -6
  57. package/dist/routes/computer-use/setup.d.ts +4 -6
  58. package/dist/routes/computer-use/setup.js +2 -6
  59. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  60. package/dist/routes/computer-use/single-bundle.js +22 -25
  61. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  62. package/dist/routes/computer-use/subject-projection.js +9 -31
  63. package/dist/routes/computer-use/types.d.ts +4 -41
  64. package/dist/routes/scripted/scenario.js +28 -12
  65. package/dist/routes/scripted/session-result.js +6 -3
  66. package/dist/routes/shared-world/bundle-records.js +2 -8
  67. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  68. package/dist/routes/shared-world/participant-specs.js +1 -20
  69. package/dist/routes/shared-world/plan.d.ts +3 -0
  70. package/dist/routes/shared-world/plan.js +23 -3
  71. package/dist/routes/shared-world/setup.js +2 -2
  72. package/dist/routes/terminal/ledger.d.ts +9 -9
  73. package/dist/routes/terminal/ledger.js +63 -40
  74. package/dist/routes/terminal/live-finish.js +7 -10
  75. package/dist/routes/terminal/plan.d.ts +3 -0
  76. package/dist/routes/terminal/plan.js +8 -5
  77. package/dist/routes/terminal/token-usage.d.ts +1 -2
  78. package/dist/routes/terminal/token-usage.js +4 -6
  79. package/dist/routes/terminal/types.d.ts +9 -1
  80. package/dist/run/bundle-shape.js +6 -1
  81. package/dist/run/bundle.d.ts +5 -3
  82. package/dist/run/contained-output.d.ts +53 -5
  83. package/dist/run/contained-output.js +141 -42
  84. package/dist/run/detail.d.ts +2 -1
  85. package/dist/run/detail.js +12 -7
  86. package/dist/run/escape-sequences.d.ts +11 -0
  87. package/dist/run/escape-sequences.js +93 -0
  88. package/dist/run/evidence-files.d.ts +8 -8
  89. package/dist/run/evidence-files.js +19 -23
  90. package/dist/run/locate.d.ts +32 -3
  91. package/dist/run/locate.js +45 -40
  92. package/dist/run/notes.d.ts +0 -8
  93. package/dist/run/notes.js +5 -51
  94. package/dist/run/outcomes.d.ts +24 -0
  95. package/dist/run/outcomes.js +19 -0
  96. package/dist/run/paths.d.ts +2 -0
  97. package/dist/run/paths.js +5 -1
  98. package/dist/run/preflight-receipts.js +13 -8
  99. package/dist/run/project.js +7 -4
  100. package/dist/run/reclaim-outcomes.d.ts +8 -6
  101. package/dist/run/reclaim-outcomes.js +10 -6
  102. package/dist/run/reclaim.d.ts +5 -0
  103. package/dist/run/reclaim.js +43 -16
  104. package/dist/run/run-clock.d.ts +50 -0
  105. package/dist/run/run-clock.js +66 -0
  106. package/dist/run/run.js +5 -2
  107. package/dist/run/sandbox-ids.d.ts +7 -2
  108. package/dist/run/sandbox-ids.js +27 -11
  109. package/dist/run/secrets.d.ts +2 -1
  110. package/dist/run/secrets.js +18 -22
  111. package/dist/run/stored-runs.js +14 -7
  112. package/dist/run/transient-comms-secrets.js +6 -17
  113. package/dist/run-study.js +22 -13
  114. package/dist/study/adapter-scorer-loader.js +6 -4
  115. package/dist/study/discover.js +9 -6
  116. package/dist/study/init-paths.js +7 -5
  117. package/dist/study/persona-resolve.d.ts +2 -2
  118. package/dist/study/persona-resolve.js +9 -5
  119. package/dist/study/plan-types.d.ts +2 -2
  120. package/dist/study/plan.d.ts +5 -3
  121. package/dist/study/plan.js +18 -9
  122. package/dist/study/preflight-probes.js +11 -3
  123. package/dist/study/preflight.js +9 -5
  124. package/dist/study/summary.d.ts +8 -2
  125. package/dist/study/summary.js +20 -22
  126. package/dist/study/warnings.d.ts +19 -12
  127. package/dist/study/warnings.js +38 -17
  128. package/dist/substrates/e2b/connection.d.ts +12 -0
  129. package/dist/substrates/e2b/connection.js +35 -0
  130. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  131. package/dist/substrates/e2b/desktop-executor.js +42 -9
  132. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  133. package/dist/substrates/e2b/lifetime.js +41 -4
  134. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  135. package/dist/substrates/e2b/sandbox.js +13 -18
  136. package/dist/substrates/e2b/sdk.d.ts +20 -9
  137. package/dist/substrates/local/capacity.d.ts +18 -2
  138. package/dist/substrates/local/capacity.js +21 -2
  139. package/dist/substrates/local/runtime-host.js +21 -14
  140. package/dist/substrates/local/runtime.js +3 -11
  141. package/dist/tui/contract.d.ts +30 -5
  142. package/dist/tui/contract.js +4 -1
  143. package/dist/tui-app.js +149 -147
  144. package/dist/verify/artifacts.d.ts +9 -2
  145. package/dist/verify/artifacts.js +37 -13
  146. package/dist/verify/liveness.js +19 -5
  147. package/dist/verify/verify.js +54 -27
  148. package/docs/architecture/guest-desktop.md +7 -2
  149. package/docs/architecture/state-driven-executor.md +4 -2
  150. package/docs/contracts/run-bundle.md +18 -10
  151. package/docs/contracts/schemas.md +27 -18
  152. package/docs/contracts/study-analysis.md +10 -1
  153. package/docs/product/automatic-analysis.md +7 -1
  154. package/package.json +2 -2
@@ -8,10 +8,11 @@
8
8
  // reclaim-receipt.json is written before the first kill and after each one, so an exit at any
9
9
  // point leaves a record of every sandbox not yet confirmed gone. `--check` asks E2B the same
10
10
  // questions and kills nothing.
11
+ import { e2bAccount, e2bConnection } from "../substrates/e2b/connection.js";
11
12
  import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
12
- import { containedPathAbsent, readContainedRegularFile, } from "./contained-output.js";
13
+ import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
13
14
  import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
14
- import { readRunJsonIfExists, resolveRunPath } from "./locate.js";
15
+ import { readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
15
16
  import { RUN_BUNDLE_FILE } from "./bundle.js";
16
17
  import { RUN_STATUS_FILE } from "./status.js";
17
18
  import { isRecord } from "./type-guards.js";
@@ -80,7 +81,7 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
80
81
  return {
81
82
  ...base,
82
83
  runId,
83
- error: { code: "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE", message: UNREADABLE_MESSAGE },
84
+ error: { code: "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE", message: reclaimed.message },
84
85
  };
85
86
  const { state, receiptCount, outcomes, tagSearch, createsInFlight } = reclaimed;
86
87
  return {
@@ -105,8 +106,14 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
105
106
  * whatever its records say.
106
107
  */
107
108
  async function recordedNoSandbox(runPaths) {
108
- const bundle = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
109
- const status = await readRunJsonIfExists(runPaths, RUN_STATUS_FILE);
109
+ const bundleRead = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
110
+ const statusRead = await readRunJsonIfExists(runPaths, RUN_STATUS_FILE);
111
+ // A record that is there and refused could say anything, so neither shortcut applies and
112
+ // reclaim runs in full. A missing one says nothing, as before.
113
+ if (bundleRead.status === "refused" || statusRead.status === "refused")
114
+ return undefined;
115
+ const bundle = runJsonValue(bundleRead);
116
+ const status = runJsonValue(statusRead);
110
117
  const records = [bundle, status].filter(isRecord);
111
118
  const reason = records.length > 0 && records.every((record) => record.mode === "dry-run")
112
119
  ? "dry-run"
@@ -118,15 +125,18 @@ async function recordedNoSandbox(runPaths) {
118
125
  : undefined;
119
126
  if (reason === undefined)
120
127
  return undefined;
121
- if (!(await containedPathAbsent(runPaths, RECLAIM_RECEIPT_ARTIFACT))) {
122
- const earlier = await readRunJsonIfExists(runPaths, RECLAIM_RECEIPT_ARTIFACT);
128
+ const earlierRead = await readRunJsonIfExists(runPaths, RECLAIM_RECEIPT_ARTIFACT);
129
+ if (earlierRead.status !== "missing") {
130
+ const earlier = runJsonValue(earlierRead);
123
131
  if (!isRecord(earlier) || !Array.isArray(earlier.outcomes) || earlier.outcomes.length > 0)
124
132
  return undefined;
125
133
  }
126
- if (await containedPathAbsent(runPaths, SANDBOX_RECEIPTS_ARTIFACT))
134
+ const journal = await readContainedRegularFile(runPaths, SANDBOX_RECEIPTS_ARTIFACT, RUN_ARTIFACT_MAX_BYTES);
135
+ if (journal.status === "missing")
127
136
  return reason;
128
- const journal = await readContainedRegularFile(runPaths, SANDBOX_RECEIPTS_ARTIFACT);
129
- return journal !== null && journal.toString("utf8").trim() === "" ? reason : undefined;
137
+ return journal.status === "read" && journal.bytes.toString("utf8").trim() === ""
138
+ ? reason
139
+ : undefined;
130
140
  }
131
141
  /** The execution failure kinds that leave a sandbox or a provider's resources unconfirmed. */
132
142
  const CLEANUP_KINDS = new Set(["sandbox-cleanup", "provider-cleanup"]);
@@ -190,7 +200,7 @@ export async function reclaimPreflightSandboxes(cwd, hooks = {}) {
190
200
  const reclaimed = await reclaimRoot(journal.root, journal.id, hooks, warnings);
191
201
  if (reclaimed.kind === "unreadable") {
192
202
  // The receipt may name a live sandbox; the journal stays for a later reclaim.
193
- warnings.push(`Preflight ${journal.id} left alone: ${UNREADABLE_MESSAGE}`);
203
+ warnings.push(`Preflight ${journal.id} left alone: ${reclaimed.message}`);
194
204
  states.push("unknown");
195
205
  continue;
196
206
  }
@@ -241,15 +251,28 @@ const E2B_DEBUG_REFUSAL = {
241
251
  message: `${E2B_DEBUG_ENV}=true puts the E2B SDK in debug mode, where Sandbox.kill reports success without contacting E2B. Nothing was killed and the receipts were kept; unset ${E2B_DEBUG_ENV} and run reclaim again.`,
242
252
  };
243
253
  const UNREADABLE_MESSAGE = "sandbox-receipts.ndjson is present but could not be read safely (not a single regular file, or a read error). Nothing was killed and the receipts were kept; check the file, then run reclaim again.";
254
+ /**
255
+ * Why reclaim stopped before it killed or wrote anything: the receipts journal, or the receipt an
256
+ * earlier reclaim wrote, is there and cannot be read.
257
+ */
258
+ function unreadableMessage(file, refusal) {
259
+ if (file === SANDBOX_RECEIPTS_ARTIFACT && refusal.reason !== "too-large")
260
+ return UNREADABLE_MESSAGE;
261
+ const kept = file === SANDBOX_RECEIPTS_ARTIFACT ? "the receipts were kept" : "the receipt was kept";
262
+ return `${refusalText(file, refusal)}. Nothing was killed and ${kept}; check the file, then run reclaim again.`;
263
+ }
244
264
  /**
245
265
  * Find and stop (or, for `--check`, look up) every sandbox one directory owns: its receipts, the
246
266
  * creates this process reported, and E2B's tagged list, each once by exact id.
247
267
  */
248
268
  async function reclaimRoot(root, label, hooks, warnings) {
249
- const bytes = await readContainedRegularFile(root, SANDBOX_RECEIPTS_ARTIFACT);
250
- if (bytes === null && !(await containedPathAbsent(root, SANDBOX_RECEIPTS_ARTIFACT)))
251
- return { kind: "unreadable" };
252
- const journal = bytes === null ? "" : bytes.toString("utf8");
269
+ const journalRead = await readContainedRegularFile(root, SANDBOX_RECEIPTS_ARTIFACT, RUN_ARTIFACT_MAX_BYTES);
270
+ if (journalRead.status === "refused")
271
+ return {
272
+ kind: "unreadable",
273
+ message: unreadableMessage(SANDBOX_RECEIPTS_ARTIFACT, journalRead),
274
+ };
275
+ const journal = journalRead.status === "read" ? journalRead.bytes.toString("utf8") : "";
253
276
  const receipts = parseSandboxReceipts(journal);
254
277
  const owners = parseSandboxOwners(journal, label);
255
278
  const check = hooks.check === true;
@@ -259,7 +282,8 @@ async function reclaimRoot(root, label, hooks, warnings) {
259
282
  let e2b;
260
283
  let moduleError;
261
284
  try {
262
- e2b = await (hooks.loadModule ?? loadE2BDesktopModule)();
285
+ const module = await (hooks.loadModule ?? loadE2BDesktopModule)();
286
+ e2b = e2bAccount(module, e2bConnection(hooks.env ?? process.env));
263
287
  }
264
288
  catch (error) {
265
289
  moduleError = `Cannot load @e2b/desktop to reach E2B: ${redactText(toErrorMessage(error))}`;
@@ -269,6 +293,9 @@ async function reclaimRoot(root, label, hooks, warnings) {
269
293
  // An earlier reclaim may have killed a sandbox no receipt names (the signal handler's creates
270
294
  // and tag finds). Its outcome is carried forward, so a later reclaim never erases that record.
271
295
  const earlier = check ? [] : await earlierOutcomes(root, label);
296
+ // The new receipt replaces the old one, so an old one that cannot be read stops reclaim here.
297
+ if (!Array.isArray(earlier))
298
+ return { kind: "unreadable", message: unreadableMessage(RECLAIM_RECEIPT_ARTIFACT, earlier) };
272
299
  const set = targetSet({
273
300
  e2b,
274
301
  check,
@@ -0,0 +1,50 @@
1
+ import type { ActorTraceItem } from "../actors/contract.js";
2
+ import type { RunDesktopRecording } from "../evidence/desktop-recording-types.js";
3
+ /** What the clock reads from a stream. A run.json stream and an Observer stream both have it. */
4
+ export interface ClockStream {
5
+ actor?: {
6
+ items: readonly ActorTraceItem[];
7
+ };
8
+ liveActor?: {
9
+ items: readonly ActorTraceItem[];
10
+ };
11
+ recording?: RunDesktopRecording;
12
+ }
13
+ /** Where the run clock starts and ends, in epoch ms. */
14
+ export interface RunClock {
15
+ startMs: number;
16
+ endMs: number;
17
+ /** Every moment on the clock, ascending without repeats, for keyboard seeking. */
18
+ boundariesMs: number[];
19
+ }
20
+ /** A desktop video's place on the run clock, in epoch ms. */
21
+ export interface RecordingInterval {
22
+ startMs: number;
23
+ endMs: number;
24
+ recording: RunDesktopRecording;
25
+ }
26
+ /**
27
+ * A stream's recorded trace items: the finished actor's, else the flush of a participant still
28
+ * running, so every reader of a live run sees the items grow.
29
+ */
30
+ export declare function traceItems(stream: ClockStream): readonly ActorTraceItem[];
31
+ /**
32
+ * Whether a trace item is a capture, a frame of the participant's replay: a screenshot, or a
33
+ * scripted action that carries its own screenshot. A notice can cite an earlier screenshot for
34
+ * context, and does not capture it again.
35
+ */
36
+ export declare function isCapture<Item extends Pick<ActorTraceItem, "kind" | "screenshotRef">>(item: Item): item is Item & {
37
+ screenshotRef: NonNullable<ActorTraceItem["screenshotRef"]>;
38
+ };
39
+ /**
40
+ * A participant's capture times in epoch ms, in recorded order. Null when a capture has no stamp
41
+ * or a stamp goes back: such a participant adds nothing to the run clock, and its replay runs at
42
+ * an average pace. Times are never made up from the actor's duration.
43
+ */
44
+ export declare function captureTimes(stamps: readonly (number | undefined)[]): number[] | null;
45
+ /** Where a desktop video sits on the run clock; null without a readable start and a length. */
46
+ export declare function recordingInterval(recording: RunDesktopRecording | undefined): RecordingInterval | null;
47
+ /** The run clock of every stream of a run, or null when none has a timed capture or a video. */
48
+ export declare function runClock(streams: readonly ClockStream[]): RunClock | null;
49
+ /** A run clock time or span in whole minutes and seconds: `02:31`. Minutes go past 59. */
50
+ export declare function formatRunTime(ms: number): string;
@@ -0,0 +1,66 @@
1
+ // The run clock: the Observer's study timeline, and the clock a reviewer note's time counts from.
2
+ // It runs from the first to the last moment any participant recorded: a timed capture, or the start
3
+ // or end of a desktop video. The Observer bundles this module, so it imports types only
4
+ // (docs/decisions/0002-observer-is-one-self-contained-file.md).
5
+ /**
6
+ * A stream's recorded trace items: the finished actor's, else the flush of a participant still
7
+ * running, so every reader of a live run sees the items grow.
8
+ */
9
+ export function traceItems(stream) {
10
+ return stream.actor?.items ?? stream.liveActor?.items ?? [];
11
+ }
12
+ /**
13
+ * Whether a trace item is a capture, a frame of the participant's replay: a screenshot, or a
14
+ * scripted action that carries its own screenshot. A notice can cite an earlier screenshot for
15
+ * context, and does not capture it again.
16
+ */
17
+ export function isCapture(item) {
18
+ return (item.kind === "screenshot" || item.kind === "ui_action") && Boolean(item.screenshotRef);
19
+ }
20
+ /**
21
+ * A participant's capture times in epoch ms, in recorded order. Null when a capture has no stamp
22
+ * or a stamp goes back: such a participant adds nothing to the run clock, and its replay runs at
23
+ * an average pace. Times are never made up from the actor's duration.
24
+ */
25
+ export function captureTimes(stamps) {
26
+ const times = [];
27
+ for (const time of stamps) {
28
+ if (time === undefined || !Number.isFinite(time) || time < (times.at(-1) ?? -Infinity))
29
+ return null;
30
+ times.push(time);
31
+ }
32
+ return times;
33
+ }
34
+ /** Where a desktop video sits on the run clock; null without a readable start and a length. */
35
+ export function recordingInterval(recording) {
36
+ if (!recording)
37
+ return null;
38
+ const startMs = Date.parse(recording.startedAt);
39
+ const endMs = startMs + recording.durationMs;
40
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || recording.durationMs <= 0)
41
+ return null;
42
+ return { startMs, endMs, recording };
43
+ }
44
+ /** The run clock of every stream of a run, or null when none has a timed capture or a video. */
45
+ export function runClock(streams) {
46
+ const moments = new Set();
47
+ for (const stream of streams) {
48
+ const stamps = traceItems(stream)
49
+ .filter(isCapture)
50
+ .map((item) => (item.at === undefined ? Number.NaN : Date.parse(item.at)));
51
+ for (const time of captureTimes(stamps) ?? [])
52
+ moments.add(time);
53
+ const video = recordingInterval(stream.recording);
54
+ if (video)
55
+ moments.add(video.startMs).add(video.endMs);
56
+ }
57
+ const boundariesMs = [...moments].sort((a, b) => a - b);
58
+ if (boundariesMs.length === 0)
59
+ return null;
60
+ return { startMs: boundariesMs[0], endMs: boundariesMs.at(-1), boundariesMs };
61
+ }
62
+ /** A run clock time or span in whole minutes and seconds: `02:31`. Minutes go past 59. */
63
+ export function formatRunTime(ms) {
64
+ const seconds = Math.max(0, Math.floor(ms / 1000));
65
+ return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
66
+ }
package/dist/run/run.js CHANGED
@@ -15,7 +15,7 @@ import { writeContainedOutputFile, writePreparedRunLatestPointer } from "./conta
15
15
  import { scrubRunSandboxIds, withPublicSandboxIds } from "./sandbox-ids.js";
16
16
  import { RunSecrets } from "./secrets.js";
17
17
  import { RUN_STATUS_FILE, beginRunStatus, runStatusOutcome, } from "./status.js";
18
- import { readRunJsonIfExists } from "./locate.js";
18
+ import { readRunJsonIfExists, runJsonValue } from "./locate.js";
19
19
  /** An entry point called in a state that does not admit it. */
20
20
  class RunLifecycleError extends Error {
21
21
  name = "RunLifecycleError";
@@ -166,7 +166,10 @@ function runPublisher(args) {
166
166
  });
167
167
  await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(publicBundle), "utf8");
168
168
  await afterBundle(publicBundle);
169
- const status = recorded === undefined ? await readRunJsonIfExists(paths, RUN_STATUS_FILE) : undefined;
169
+ // The run writes its own status.json; one it refuses renders as a missing one does.
170
+ const status = recorded === undefined
171
+ ? runJsonValue(await readRunJsonIfExists(paths, RUN_STATUS_FILE))
172
+ : undefined;
170
173
  await writeContainedOutputFile(paths, "review.json", json(publicBundle.review), "utf8");
171
174
  await writeContainedOutputFile(paths, "review.md", options.renderReview(publicBundle, status), "utf8");
172
175
  const events = publicBundle.events.map((event) => JSON.stringify(event)).join("\n");
@@ -24,7 +24,10 @@ export declare function inventorySandboxIds(files: readonly {
24
24
  export declare function holdsKeyedSandboxId(file: string, text: string): boolean;
25
25
  /**
26
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.
27
+ * failed; none from the journal when it is missing or malformed, or when a folder is where it goes
28
+ * (a failed append can leave one, and the process still holds those ids). A journal refused for
29
+ * any other reason, a link or one too large to read say, throws ContainedReadRefusedError: every
30
+ * caller would otherwise write or grade a run's files without the ids it may hold.
28
31
  */
29
32
  export declare function readRunSandboxIds(root: PreparedOutputRoot): Promise<string[]>;
30
33
  /**
@@ -41,7 +44,9 @@ export declare function scrubSandboxIdBytes(file: string, bytes: Buffer, ids: re
41
44
  * Rewrite each file of a finished run that names one of its raw sandbox ids, apart from the
42
45
  * receipts, with the label in place of the id. Writers such as a participant's actor.json can
43
46
  * 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.
47
+ * Best effort for writes and listings: verify grades a file this misses. A text file it refuses
48
+ * to read, or a journal it refuses, throws once the sweep ends, naming each, since such a file
49
+ * may still hold an id.
45
50
  */
46
51
  export declare function scrubRunSandboxIds(paths: PreparedRunArtifactPaths): Promise<void>;
47
52
  /**
@@ -6,7 +6,7 @@ import { opendir, realpath } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { readPlainText } from "../evidence/plain-text.js";
8
8
  import { collectSandboxIds, redactSandboxIds, sandboxIdDigest, REDACTED_SANDBOX_ID, } from "../evidence/redaction.js";
9
- import { readContainedRegularFile, writeContainedOutputFile, } from "./contained-output.js";
9
+ import { ContainedReadRefusedError, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
10
10
  import { bindExistingRunArtifactPaths, isSafeRunIdSegment, validatePreparedRunRootIdentity, } from "./paths.js";
11
11
  import { appendedSandboxIds, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
12
12
  /** How text names a sandbox: the marker with the id's digest. */
@@ -96,14 +96,19 @@ export function holdsKeyedSandboxId(file, text) {
96
96
  }
97
97
  /**
98
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.
99
+ * failed; none from the journal when it is missing or malformed, or when a folder is where it goes
100
+ * (a failed append can leave one, and the process still holds those ids). A journal refused for
101
+ * any other reason, a link or one too large to read say, throws ContainedReadRefusedError: every
102
+ * caller would otherwise write or grade a run's files without the ids it may hold.
100
103
  */
101
104
  export async function readRunSandboxIds(root) {
105
+ const read = await readContainedRegularFile(root, SANDBOX_RECEIPTS_ARTIFACT, RUN_ARTIFACT_MAX_BYTES);
106
+ if (read.status === "refused" && read.reason !== "directory")
107
+ throw new ContainedReadRefusedError(SANDBOX_RECEIPTS_ARTIFACT, read);
102
108
  let journaled = [];
103
109
  try {
104
- const bytes = await readContainedRegularFile(root, SANDBOX_RECEIPTS_ARTIFACT);
105
- if (bytes)
106
- journaled = receiptSandboxIds(bytes);
110
+ if (read.status === "read")
111
+ journaled = receiptSandboxIds(read.bytes);
107
112
  }
108
113
  catch {
109
114
  // The ids this process receipted still apply.
@@ -138,7 +143,9 @@ const MAX_SWEPT_ENTRIES = 10_000;
138
143
  * Rewrite each file of a finished run that names one of its raw sandbox ids, apart from the
139
144
  * receipts, with the label in place of the id. Writers such as a participant's actor.json can
140
145
  * 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.
146
+ * Best effort for writes and listings: verify grades a file this misses. A text file it refuses
147
+ * to read, or a journal it refuses, throws once the sweep ends, naming each, since such a file
148
+ * may still hold an id.
142
149
  */
143
150
  export async function scrubRunSandboxIds(paths) {
144
151
  const ids = await readRunSandboxIds(paths);
@@ -147,12 +154,15 @@ export async function scrubRunSandboxIds(paths) {
147
154
  const valid = await validatePreparedRunRootIdentity(paths).then(() => true, () => false);
148
155
  if (!valid)
149
156
  return;
157
+ const refused = [];
150
158
  const scrub = async (file) => {
151
- const bytes = await readContainedRegularFile(paths, file);
152
- if (bytes === null)
159
+ const read = await readContainedRegularFile(paths, file, RUN_ARTIFACT_MAX_BYTES);
160
+ if (read.status === "refused")
161
+ refused.push(refusalText(file, read));
162
+ if (read.status !== "read")
153
163
  return;
154
- const scrubbed = scrubSandboxIdBytes(file, bytes, ids);
155
- if (scrubbed !== bytes)
164
+ const scrubbed = scrubSandboxIdBytes(file, read.bytes, ids);
165
+ if (scrubbed !== read.bytes)
156
166
  await writeContainedOutputFile(paths, file, scrubbed);
157
167
  };
158
168
  let seen = 0;
@@ -179,6 +189,8 @@ export async function scrubRunSandboxIds(paths) {
179
189
  await walk("").catch(() => {
180
190
  // A directory that changed while it was listed ends the sweep; verify names what it missed.
181
191
  });
192
+ if (refused.length > 0)
193
+ throw new Error(`The sandbox id sweep could not read every run file, so these may still name a sandbox: ${refused.join("; ")}.`);
182
194
  }
183
195
  /**
184
196
  * A run's result as it may be returned or printed: the same object when it names no sandbox,
@@ -187,12 +199,16 @@ export async function scrubRunSandboxIds(paths) {
187
199
  export async function publicRunResult(result, cwd) {
188
200
  let ids = [];
189
201
  if (typeof result.runId === "string" && isSafeRunIdSegment(result.runId)) {
202
+ let paths;
190
203
  try {
191
- ids = await readRunSandboxIds(await bindExistingRunArtifactPaths(cwd, result.runId));
204
+ paths = await bindExistingRunArtifactPaths(cwd, result.runId);
192
205
  }
193
206
  catch {
194
207
  // A run that never created its directory has no receipts; the keys are still redacted.
195
208
  }
209
+ // A journal that is there and refused throws: the result cannot be printed without its ids.
210
+ if (paths !== undefined)
211
+ ids = await readRunSandboxIds(paths);
196
212
  }
197
213
  return publicSandboxView(result, ids);
198
214
  }
@@ -11,7 +11,8 @@ export declare class RunSecrets {
11
11
  * Replaces every held value with the marker, as written, in each encoded form, and where a URL
12
12
  * carries it partly percent-encoded or a terminal escape sequence splits it. It reads the values
13
13
  * on each call, so a value added after the scrub was handed to a participant or a subject is
14
- * scrubbed from then on.
14
+ * scrubbed from then on. If the result still holds a value in any readingsOf, as when a marker
15
+ * written before `x` spells the value `SECRET]x`, the whole text becomes the marker.
15
16
  */
16
17
  readonly scrub: (text: string) => string;
17
18
  /** `seed` keeps its order; values shorter than the floor are dropped. */
@@ -4,7 +4,8 @@
4
4
  // replaced as written and in the encoded forms a URL, a JSON body or a log line gives it;
5
5
  // redactText runs after the scrub and finds secrets by shape.
6
6
  import { scrubLiterals } from "../evidence/redaction.js";
7
- import { encodedForms } from "../evidence/secret-scrub.js";
7
+ import { encodedForms, holdsSecretValue } from "../evidence/secret-scrub.js";
8
+ import { escapeSequences } from "./escape-sequences.js";
8
9
  export class RunSecrets {
9
10
  // Private fields stay out of JSON.stringify and object spread, so a Run that reaches a writer
10
11
  // whole cannot carry the values with it.
@@ -12,11 +13,14 @@ export class RunSecrets {
12
13
  /** Every held value as written and encoded, longest first. */
13
14
  #forms = [];
14
15
  #minLength;
16
+ /** holdsSecretValue of the held values, built at its first use after `add` holds a new one. */
17
+ #holds;
15
18
  /**
16
19
  * Replaces every held value with the marker, as written, in each encoded form, and where a URL
17
20
  * carries it partly percent-encoded or a terminal escape sequence splits it. It reads the values
18
21
  * on each call, so a value added after the scrub was handed to a participant or a subject is
19
- * scrubbed from then on.
22
+ * scrubbed from then on. If the result still holds a value in any readingsOf, as when a marker
23
+ * written before `x` spells the value `SECRET]x`, the whole text becomes the marker.
20
24
  */
21
25
  scrub;
22
26
  /** `seed` keeps its order; values shorter than the floor are dropped. */
@@ -30,7 +34,9 @@ export class RunSecrets {
30
34
  const literal = scrubLiterals(this.#forms, marker);
31
35
  this.scrub = (text) => {
32
36
  const scrubbed = literal(text);
33
- return replaceSpans(scrubbed, viewSpans(scrubbed, this.#forms), marker);
37
+ const result = replaceSpans(scrubbed, viewSpans(scrubbed, this.#forms), marker);
38
+ this.#holds ??= holdsSecretValue(this.#held);
39
+ return this.#holds(result) ? marker : result;
34
40
  };
35
41
  }
36
42
  /** Holds each value the run learns while it runs, unless it is under the floor or already held. */
@@ -39,6 +45,7 @@ export class RunSecrets {
39
45
  if (value.length < this.#minLength || this.#held.includes(value))
40
46
  continue;
41
47
  this.#held.push(value);
48
+ this.#holds = undefined;
42
49
  for (const form of encodedForms(value))
43
50
  if (!this.#forms.includes(form))
44
51
  this.#forms.push(form);
@@ -69,20 +76,9 @@ export class RunSecrets {
69
76
  return mergeSpans(found);
70
77
  }
71
78
  }
72
- // What a terminal draws with (operating-system commands, control sequences and two-byte escapes,
73
- // as written or JSON-escaped, as a JSON event carries a command's colored output) and what a URL
74
- // encodes with (runs of percent escapes). The view a value is looked for in drops the first and
75
- // decodes the second.
76
- const VIEW_SEQUENCE = new RegExp([
77
- "\\x1b\\][^\\x07]*(?:\\x07|\\x1b\\\\)",
78
- "\\x1b\\[[0-?]*[ -/]*[@-~]",
79
- "\\x1b[78=>]",
80
- "\\\\u001b\\][^\\\\]*(?:\\\\u0007|\\\\u001b\\\\\\\\)",
81
- "\\\\u001b\\[[0-?]*[ -/]*[@-~]",
82
- "(?:%[0-9A-Fa-f]{2})+",
83
- ].join("|"), "g");
84
79
  /**
85
- * Where a value is found in the text's view. A browser encodes a space or a quote in a URL path
80
+ * Where a value is found in the text's view, which drops terminal escape sequences and decodes
81
+ * percent escapes (escapeSequences). A browser encodes a space or a quote in a URL path
86
82
  * and leaves a `/` or a `:` as written, so a value with both matches no single encoded form, and a
87
83
  * color code inside a value splits it. Each found value maps back to the span of `text` it came
88
84
  * from, so the text keeps every other character as written.
@@ -104,21 +100,21 @@ function viewSpans(text, values) {
104
100
  };
105
101
  let cursor = 0;
106
102
  let changed = false;
107
- for (const sequence of text.matchAll(VIEW_SEQUENCE)) {
103
+ for (const [start, end] of escapeSequences(text)) {
108
104
  let plain = "";
109
- if (sequence[0].startsWith("%")) {
105
+ if (text[start] === "%") {
110
106
  try {
111
- plain = decodeURIComponent(sequence[0]);
107
+ plain = decodeURIComponent(text.slice(start, end));
112
108
  }
113
109
  catch {
114
110
  // Not UTF-8: the run stays as written and is copied with the text after it.
115
111
  continue;
116
112
  }
117
113
  }
118
- copy(cursor, sequence.index);
119
- cursor = sequence.index + sequence[0].length;
114
+ copy(cursor, start);
115
+ cursor = end;
120
116
  for (let unit = 0; unit < plain.length; unit += 1) {
121
- starts.push(sequence.index);
117
+ starts.push(start);
122
118
  ends.push(cursor);
123
119
  }
124
120
  view += plain;
@@ -2,12 +2,12 @@ import { lstat, readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { redactText } from "../evidence/redaction.js";
4
4
  import { bindExistingRunArtifactPaths, RUNS_RELATIVE_ROOT, isSafeRunIdSegment, resolveRunsRoot, } from "./paths.js";
5
- import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, } from "./contained-output.js";
5
+ import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, refusalText, } from "./contained-output.js";
6
6
  import { RUN_BUNDLE_FILE } from "./bundle.js";
7
7
  import { isReviewSummary, isRunOutcome } from "./bundle-shape.js";
8
8
  import { bundleDisplayFacts, displayedReview, runDisplay, } from "./display.js";
9
9
  import { RUN_STATUS_FILE } from "./status.js";
10
- import { readLatest, readRunJsonIfExists, resolveRunPath } from "./locate.js";
10
+ import { readLatest, readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
11
11
  import { withCuaReviewProvenance } from "./outcomes.js";
12
12
  import { isNodeError, isRecord } from "./type-guards.js";
13
13
  import { invalidRunStorageVerifyResult, verifyResolvedRun, } from "../verify/verify.js";
@@ -70,7 +70,8 @@ export async function listRuns(cwdInput) {
70
70
  if (runsRoot && entryRunPaths.physicalRunsRoot !== runsRoot.physicalPath) {
71
71
  return runsUnavailableResult(cwd, new Error("humanish runs root changed physical destination."));
72
72
  }
73
- const bundle = await readRunJsonIfExists(entryRunPaths, RUN_BUNDLE_FILE);
73
+ // A listing shows what it can read: a run.json it refuses lists like a missing one.
74
+ const bundle = runJsonValue(await readRunJsonIfExists(entryRunPaths, RUN_BUNDLE_FILE));
74
75
  runs.push({
75
76
  runId: entryName,
76
77
  createdAt: isRecord(bundle) && typeof bundle.createdAt === "string" ? bundle.createdAt : null,
@@ -105,7 +106,7 @@ async function listedRunDisplay(runPaths, bundle) {
105
106
  if (displayed === undefined)
106
107
  return {};
107
108
  const status = displayed.outcome === undefined
108
- ? await readRunJsonIfExists(runPaths, RUN_STATUS_FILE)
109
+ ? runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE))
109
110
  : undefined;
110
111
  return { display: runDisplay(bundleDisplayFacts(displayed, status)) };
111
112
  }
@@ -155,18 +156,24 @@ export async function readReview(cwdInput, runInput) {
155
156
  if (!verified.ok || !verified.bundlePath) {
156
157
  return verified;
157
158
  }
158
- const review = runPaths ? await readRunJsonIfExists(runPaths, "review.json") : null;
159
+ const reviewRead = runPaths ? await readRunJsonIfExists(runPaths, "review.json") : undefined;
160
+ const review = reviewRead === undefined ? null : runJsonValue(reviewRead);
159
161
  if (!isReviewSummary(review)) {
160
162
  return {
161
163
  ...verified,
162
164
  ok: false,
163
165
  error: {
164
166
  code: "HUMANISH_INVALID_RUN_BUNDLE",
165
- message: "review.json is missing or invalid.",
167
+ message: reviewRead?.status === "refused"
168
+ ? `${refusalText("review.json", reviewRead)}.`
169
+ : "review.json is missing or invalid.",
166
170
  },
167
171
  };
168
172
  }
169
- const bundle = runPaths ? await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE) : null;
173
+ // The display reads what it can: a run.json refused here shows as a missing one does.
174
+ const bundle = runPaths
175
+ ? runJsonValue(await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE))
176
+ : null;
170
177
  const projected = isRecord(bundle) && Array.isArray(bundle.streams)
171
178
  ? withCuaReviewProvenance(review, bundle.streams.filter(isRecord))
172
179
  : review;
@@ -1,7 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { REDACTION_MARKERS } from "../evidence/redaction.js";
3
- import { holdsSecretValue, scrubSecretValues } from "../evidence/secret-scrub.js";
4
- import { escapeRegExp } from "./text.js";
3
+ import { holdsSecretValue, scrubSecretValues, scrubValuesAsWritten, } from "../evidence/secret-scrub.js";
5
4
  const scopes = new AsyncLocalStorage();
6
5
  const MAX_VALUES = 8192;
7
6
  const MAX_BYTES = 1024 * 1024;
@@ -15,7 +14,7 @@ function usable(scope) {
15
14
  function fail(scope) {
16
15
  scope.failed = true;
17
16
  scope.values.clear();
18
- delete scope.pattern;
17
+ delete scope.literal;
19
18
  throw new Error("TRANSIENT_NARRATION_SECRET_LIMIT");
20
19
  }
21
20
  /** Enclose both participant execution and its automatic analysis in one scope. Nested runs isolate too. */
@@ -30,7 +29,7 @@ export async function withTransientCommsSecrets(work) {
30
29
  scope.closed = true;
31
30
  scope.values.clear();
32
31
  scope.bytes = 0;
33
- delete scope.pattern;
32
+ delete scope.literal;
34
33
  }
35
34
  }
36
35
  export function registerTransientCommsSecrets(values) {
@@ -50,7 +49,7 @@ export function registerTransientCommsSecrets(values) {
50
49
  fail(scope);
51
50
  scope.values.add(value);
52
51
  scope.bytes += bytes;
53
- delete scope.pattern;
52
+ delete scope.literal;
54
53
  }
55
54
  }
56
55
  /** Literal, longest-first replacement in one pass; replacement text is never matched recursively. */
@@ -59,18 +58,8 @@ export function scrubTransientCommsText(text) {
59
58
  if (!scope)
60
59
  return text;
61
60
  usable(scope);
62
- if (!scope.values.size)
63
- return text;
64
- try {
65
- scope.pattern ??= new RegExp([...scope.values]
66
- .sort((a, b) => b.length - a.length)
67
- .map(escapeRegExp)
68
- .join("|"), "g");
69
- return text.replace(scope.pattern, REDACTION_MARKERS.secret);
70
- }
71
- catch {
72
- return fail(scope);
73
- }
61
+ scope.literal ??= scrubValuesAsWritten([...scope.values]);
62
+ return scope.literal(text);
74
63
  }
75
64
  const REDACTED = REDACTION_MARKERS.secret;
76
65
  /**