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
@@ -13,6 +13,14 @@ import { resolveStudyManifest } from "../study/discover.js";
13
13
  import { assertPreparedSelectedOutputDirectory, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
14
14
  import { otherStudyFiles } from "../study/files.js";
15
15
  import { isNodeError } from "../run/type-guards.js";
16
+ /** The most of a study file the receiving copy reads, for its source and its destination. */
17
+ const MAX_STUDY_COPY_BYTES = 1024 * 1024;
18
+ /** The read found exactly `expected`, or found nothing where `expected` is null. */
19
+ function readsAs(read, expected) {
20
+ return expected === null
21
+ ? read.status === "missing"
22
+ : read.status === "read" && read.bytes.equals(expected);
23
+ }
16
24
  export async function checkCommsConnection(args) {
17
25
  const base = {
18
26
  schema: "humanish.comms-check.v1",
@@ -128,9 +136,10 @@ export async function configureCommsStudy(args) {
128
136
  const rel = path
129
137
  .relative(root.requestedPath, path.resolve(args.cwd, source.path))
130
138
  .replace(/\\/g, "/");
131
- const bytes = await readContainedRegularFile(root, rel);
132
- if (!bytes || bytes.length > 1024 * 1024)
139
+ const sourceRead = await readContainedRegularFile(root, rel, MAX_STUDY_COPY_BYTES);
140
+ if (sourceRead.status !== "read")
133
141
  return { ...base, message: "The study file is missing or too large." };
142
+ const bytes = sourceRead.bytes;
134
143
  const text = bytes.toString("utf8");
135
144
  const raw = parse(text);
136
145
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -182,13 +191,12 @@ export async function configureCommsStudy(args) {
182
191
  ...base,
183
192
  message: `${others.join(", ")} already uses the name ${stem}. Rename or remove it before configuring email, so \`run ${stem}\` reads one file.`,
184
193
  };
185
- let prior;
186
- try {
187
- prior = await readContainedRegularFile(root, destination);
188
- }
189
- catch {
194
+ // An existing destination is replaced only when its bytes are in the plan token, so one that
195
+ // cannot be read whole is refused instead of being taken as absent.
196
+ const destinationRead = await readContainedRegularFile(root, destination, MAX_STUDY_COPY_BYTES);
197
+ if (destinationRead.status === "refused")
190
198
  return { ...base, message: "The local destination could not be read safely." };
191
- }
199
+ const prior = destinationRead.status === "read" ? destinationRead.bytes : null;
192
200
  const content = String(study);
193
201
  const token = createHash("sha256")
194
202
  .update(bytes)
@@ -219,10 +227,8 @@ export async function configureCommsStudy(args) {
219
227
  const lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
220
228
  const identity = await lock.stat();
221
229
  try {
222
- const currentSource = await readContainedRegularFile(root, rel);
223
- const currentDestination = await readContainedRegularFile(root, destination);
224
- if (!currentSource?.equals(bytes) ||
225
- (prior === null ? currentDestination !== null : !currentDestination?.equals(prior)))
230
+ const reread = (file) => readContainedRegularFile(root, file, MAX_STUDY_COPY_BYTES);
231
+ if (!readsAs(await reread(rel), bytes) || !readsAs(await reread(destination), prior))
226
232
  return { ...base, message: "Files changed while saving. Preview again." };
227
233
  await writeContainedOutputFile(directory, filename, content, "utf8");
228
234
  }
@@ -182,7 +182,15 @@ export function containsSensitive(text) {
182
182
  // suffix so redaction cannot turn a string's contents into JSON delimiters.
183
183
  // This also works when the closing quote arrives in a later stream callback.
184
184
  function redactLocalPaths(text, label) {
185
- return LOCAL_PATH_PATTERNS.reduce((current, [pattern, replacement]) => current.replace(pattern, (match) => (label ?? replacement) + (match.match(/\\+$/)?.[0] ?? "")), text);
185
+ return LOCAL_PATH_PATTERNS.reduce((current, [pattern, replacement]) => current.replace(pattern, (match) => (label ?? replacement) + trailingBackslashes(match)), text);
186
+ }
187
+ // Read from the end: `/\\+$/` retries a run of backslashes from each of its characters when
188
+ // something follows the run, which took 1.4 s on a path holding 40,000 of them.
189
+ function trailingBackslashes(text) {
190
+ let start = text.length;
191
+ while (start > 0 && text.charCodeAt(start - 1) === 0x5c)
192
+ start -= 1;
193
+ return text.slice(start);
186
194
  }
187
195
  /**
188
196
  * What a shared copy of a run says in place of a provider sandbox id. scripts/public-surface-scan.mjs
@@ -1,12 +1,22 @@
1
1
  /**
2
2
  * A value as written, and percent-encoded, JSON-escaped once (also with non-ASCII characters as
3
- * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 at each
4
- * byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as
5
- * a string. An
6
- * escaped form is searched for at any length, as the value is: it holds a backslash or a `%`, so it
7
- * is not ordinary text.
3
+ * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 padded,
4
+ * unpadded and at each byte offset, base64url and hex. Twice, because a JSON event can carry a
5
+ * command's JSON output as a string. An escaped form is searched for at any length, as the value
6
+ * is: it holds a backslash or a `%`, so it is not ordinary text. A base64 or hex form is searched
7
+ * for from MIN_ENCODED_FORM characters, so a four-byte value is found in base64 written whole and
8
+ * not inside a longer run, where four or five of its characters do not depend on its neighbours.
8
9
  */
9
10
  export declare function encodedForms(value: string): string[];
11
+ /**
12
+ * A scrub that replaces each non-empty value as written with `[REDACTED_SECRET]`, markers
13
+ * included. From the start of the text, the longest value that starts at a position is replaced
14
+ * and the search goes on after it, so the marker it writes is never searched. One global regex of
15
+ * the values, longest first, does that fastest. V8 compiles it at its first use and refuses it
16
+ * once a value has 32,768 characters; the scrub then finds each value itself, with the same
17
+ * output, in time linear in the text for each value.
18
+ */
19
+ export declare function scrubValuesAsWritten(values: readonly string[]): (text: string) => string;
10
20
  /**
11
21
  * A check that the text holds one of the values' forms outside the markers in any of its
12
22
  * readingsOf. A scrub's output must pass it.
@@ -10,9 +10,12 @@ const REDACTED = REDACTION_MARKERS.secret;
10
10
  // of the marker's name. Other bracketed text, such as `[REDACTED_373433393231]`, is searched like
11
11
  // any text.
12
12
  const MARKER = new RegExp(Object.values(REDACTION_MARKERS).map(escapeRegExp).join("|"), "g");
13
- // The shortest encoded form searched for. Shorter base64 or hex runs are ordinary text often enough
14
- // that matching them would redact words. A value itself is searched for at any length.
15
- const MIN_ENCODED_FORM = 8;
13
+ // The shortest encoded form searched for: the unpadded base64 of a four-byte value, such as a
14
+ // four-digit code. A given form of n characters turns up by chance in random base64 about once in
15
+ // 64^n characters, once in 69 billion at 6 and once in 17 million at 4. In the text of 9 real runs
16
+ // (2.5 million characters) no 6- or 7-character form of any 4-, 5- or 6-digit code occurred. A
17
+ // value itself is searched for at any length.
18
+ const MIN_ENCODED_FORM = 6;
16
19
  /**
17
20
  * The characters of a value's base64 encoding that do not depend on its neighbours, with 0, 1 or
18
21
  * 2 bytes before it. A prefix byte shares the next character with the value, and so does the byte
@@ -31,11 +34,12 @@ function base64Middles(bytes, encoding) {
31
34
  }
32
35
  /**
33
36
  * A value as written, and percent-encoded, JSON-escaped once (also with non-ASCII characters as
34
- * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 at each
35
- * byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as
36
- * a string. An
37
- * escaped form is searched for at any length, as the value is: it holds a backslash or a `%`, so it
38
- * is not ordinary text.
37
+ * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 padded,
38
+ * unpadded and at each byte offset, base64url and hex. Twice, because a JSON event can carry a
39
+ * command's JSON output as a string. An escaped form is searched for at any length, as the value
40
+ * is: it holds a backslash or a `%`, so it is not ordinary text. A base64 or hex form is searched
41
+ * for from MIN_ENCODED_FORM characters, so a four-byte value is found in base64 written whole and
42
+ * not inside a longer run, where four or five of its characters do not depend on its neighbours.
39
43
  */
40
44
  export function encodedForms(value) {
41
45
  const bytes = Buffer.from(value, "utf8");
@@ -51,8 +55,10 @@ export function encodedForms(value) {
51
55
  catch {
52
56
  // no percent-encoded form
53
57
  }
58
+ const base64 = bytes.toString("base64");
54
59
  const binary = [
55
- bytes.toString("base64"),
60
+ base64,
61
+ base64.replace(/=+$/, ""),
56
62
  bytes.toString("base64url"),
57
63
  bytes.toString("hex"),
58
64
  ...base64Middles(bytes, "base64"),
@@ -127,6 +133,58 @@ function eachOccurrence(text, form, found) {
127
133
  at = matched > 0 ? -1 : text.indexOf(form, next);
128
134
  }
129
135
  }
136
+ /**
137
+ * Each value's occurrences, the longest value that starts at a position replaced from the start of
138
+ * the text on, in time linear in the text for each value.
139
+ */
140
+ function replaceLongestFirst(text, values) {
141
+ // The length of the longest value that starts at each position, allocated at the first find.
142
+ let longest;
143
+ for (const value of values)
144
+ eachOccurrence(text, value, (at) => {
145
+ longest ??= new Uint32Array(text.length);
146
+ longest[at] = Math.max(longest[at], value.length);
147
+ });
148
+ if (longest === undefined)
149
+ return text;
150
+ let result = "";
151
+ let cursor = 0;
152
+ for (let at = 0; at < text.length; at += 1) {
153
+ if (longest[at] === 0)
154
+ continue;
155
+ result += text.slice(cursor, at) + REDACTED;
156
+ cursor = at + longest[at];
157
+ at = cursor - 1;
158
+ }
159
+ return result + text.slice(cursor);
160
+ }
161
+ /**
162
+ * A scrub that replaces each non-empty value as written with `[REDACTED_SECRET]`, markers
163
+ * included. From the start of the text, the longest value that starts at a position is replaced
164
+ * and the search goes on after it, so the marker it writes is never searched. One global regex of
165
+ * the values, longest first, does that fastest. V8 compiles it at its first use and refuses it
166
+ * once a value has 32,768 characters; the scrub then finds each value itself, with the same
167
+ * output, in time linear in the text for each value.
168
+ */
169
+ export function scrubValuesAsWritten(values) {
170
+ const written = [...new Set(values)].filter((value) => value.length > 0);
171
+ if (written.length === 0)
172
+ return (text) => text;
173
+ let pattern = new RegExp([...written]
174
+ .sort((left, right) => right.length - left.length)
175
+ .map(escapeRegExp)
176
+ .join("|"), "g");
177
+ return (text) => {
178
+ if (pattern !== undefined)
179
+ try {
180
+ return text.replace(pattern, REDACTED);
181
+ }
182
+ catch {
183
+ pattern = undefined;
184
+ }
185
+ return replaceLongestFirst(text, written);
186
+ };
187
+ }
130
188
  /** Every occurrence of every form not wholly inside a marker, in no order. */
131
189
  function occurrences(text, forms) {
132
190
  const markers = [];
@@ -11,7 +11,7 @@ import { loadRunBundlePrepared } from "../run/locate.js";
11
11
  import { isFeedbackIdempotencyKey } from "../run/feedback-shape.js";
12
12
  import { runNoteFile } from "../run/note-files.js";
13
13
  import { byRunTime } from "../run/note-shape.js";
14
- import { formatRunTime } from "../run/notes.js";
14
+ import { formatRunTime } from "../run/run-clock.js";
15
15
  import { runParticipantCaptions } from "../run/participant-records.js";
16
16
  import { isRecord } from "../run/type-guards.js";
17
17
  import { feedbackProofCommands, projectFeedbackAcceptanceProof } from "./proof.js";
@@ -12,16 +12,16 @@ import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js"
12
12
  import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
13
13
  import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
14
14
  import { readPlainText } from "../evidence/plain-text.js";
15
- import { streamScreenshotPaths } from "../verify/artifacts.js";
15
+ import { MAX_RUN_ENTRIES, streamScreenshotPaths } from "../verify/artifacts.js";
16
16
  import { verifyRunPrepared } from "../verify/verify.js";
17
17
  import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
18
18
  import {} from "../run/bundle.js";
19
19
  import { runIdOf, isPathInside, prepareRunArtifactPaths, validatePreparedRunArtifactPaths, } from "../run/paths.js";
20
20
  import { isAnalysisRecordPath } from "../analysis/sharing.js";
21
21
  import { LOCAL_ONLY_RUN_FILES } from "../run/local-only-files.js";
22
- import { assertPreparedSelectedOutputDirectory, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
22
+ import { readUnchangedFile } from "../run/evidence-files.js";
23
+ import { assertPreparedSelectedOutputDirectory, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, writeContainedOutputFile, } from "../run/contained-output.js";
23
24
  const DERIVATION_SCHEMA = "humanish.redacted-derivation.v1";
24
- const MAX_FILES = 10_000;
25
25
  const TEXT_EXTENSIONS = new Set([
26
26
  ".json",
27
27
  ".ndjson",
@@ -69,8 +69,8 @@ async function inventory(root, maxBytes) {
69
69
  if (!before.isDirectory() || before.isSymbolicLink())
70
70
  throw new Error("Source contains an unsafe directory.");
71
71
  for (const name of (await readdir(directory)).sort()) {
72
- if (++entriesSeen > MAX_FILES)
73
- throw new Error(`Source exceeds ${MAX_FILES} inventory entries.`);
72
+ if (++entriesSeen > MAX_RUN_ENTRIES)
73
+ throw new Error(`Source exceeds ${MAX_RUN_ENTRIES} inventory entries.`);
74
74
  if (name.includes("\\") || name.includes("\0"))
75
75
  throw new Error("Source contains an unsafe path segment.");
76
76
  const rel = relative ? `${relative}/${name}` : name;
@@ -86,15 +86,17 @@ async function inventory(root, maxBytes) {
86
86
  else {
87
87
  if (path.extname(rel).toLowerCase() === ".mp4")
88
88
  throw new Error("Continuous video/audio cannot be redacted by bundle export. Use --local-only HTML export for a snapshot-only copy.");
89
- if (info.size > BigInt(maxBytes - total))
90
- throw new Error("Source inventory exceeds --max-bytes.");
91
- const bytes = await readContainedRegularFile(root, rel);
92
- if (bytes === null)
89
+ // At most what is left of --max-bytes, so a file that grows after this lstat is refused
90
+ // unread. With nothing left, an empty file still reads and any other is refused.
91
+ const read = await readUnchangedFile(root, rel, Math.max(1, maxBytes - total));
92
+ if (read.state === "unavailable")
93
93
  throw new Error("Source artifact could not be read through its bound identity.");
94
- total += bytes.length;
94
+ if (read.state === "limit")
95
+ throw new Error("Source inventory exceeds --max-bytes.");
96
+ total += read.bytes.length;
95
97
  if (total > maxBytes)
96
98
  throw new Error("Source inventory exceeds --max-bytes.");
97
- files.push({ path: rel, bytes, sha256: hash(bytes) });
99
+ files.push({ path: rel, bytes: read.bytes, sha256: hash(read.bytes) });
98
100
  }
99
101
  }
100
102
  const after = await lstat(directory, { bigint: true });
@@ -541,10 +543,12 @@ async function buildDerivative(stage, source, runId, bundle, maxBytes) {
541
543
  throw new Error("Derivative Observer could not be rebuilt.");
542
544
  const generated = [];
543
545
  for (const relative of ["observer/index.html", "observer/observer-data.json"]) {
544
- const bytes = await readContainedRegularFile(stagePaths, relative);
545
- if (bytes === null)
546
+ const read = await readUnchangedFile(stagePaths, relative, maxBytes);
547
+ if (read.state === "limit")
548
+ throw new Error("The regenerated Observer exceeds --max-bytes.");
549
+ if (read.state === "unavailable")
546
550
  throw new Error("Regenerated Observer artifact could not be read safely.");
547
- generated.push({ path: relative, sha256: hash(bytes) });
551
+ generated.push({ path: relative, sha256: hash(read.bytes) });
548
552
  }
549
553
  await writeContainedOutputFile(stagePaths, "derivation.json", jsonBytes({ ...receipt, generated }));
550
554
  const derivativeVerify = await verifyRunPrepared(stage.physicalPath, runId, stagePaths);
@@ -4,7 +4,7 @@ import { realpath } from "node:fs/promises";
4
4
  import { runNotFoundMessage } from "../run/run-not-found.js";
5
5
  import path from "node:path";
6
6
  import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
7
- import { bindExistingManagedHumanishOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
7
+ import { bindExistingManagedHumanishOutputDirectory, ContainedReadRefusedError, LATEST_POINTER_MAX_BYTES, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "../run/contained-output.js";
8
8
  import { loadRunBundlePrepared } from "../run/locate.js";
9
9
  import { readNotesForSharing } from "../run/note-files.js";
10
10
  import { cli } from "../cli/invocation.js";
@@ -208,7 +208,9 @@ async function isSafeFeedbackEvidenceFile(context, evidencePath) {
208
208
  path.isAbsolute(relative)) {
209
209
  return false;
210
210
  }
211
- return (await readContainedRegularFile(context.preparedRunPaths, relative)) !== null;
211
+ // Evidence a draft cites must be readable: a refused file is listed with the missing ones.
212
+ const read = await readContainedRegularFile(context.preparedRunPaths, relative, RUN_ARTIFACT_MAX_BYTES);
213
+ return read.status === "read";
212
214
  }
213
215
  export async function renderIssueMarkdown(cwdInput, runInput, repo, options = {}) {
214
216
  const verified = await verifyFeedbackBound(cwdInput, runInput, options);
@@ -253,9 +255,22 @@ export async function listFeedback(cwdInput, runInput) {
253
255
  },
254
256
  };
255
257
  }
256
- const draftBytes = await readContainedRegularFile(context.preparedRunPaths, path.join("feedback", "draft.json"));
257
- const draft = draftBytes === null ? undefined : JSON.parse(draftBytes.toString("utf8"));
258
+ const draftRead = await readContainedRegularFile(context.preparedRunPaths, path.join("feedback", "draft.json"), RUN_ARTIFACT_MAX_BYTES);
258
259
  const draftPath = path.join(context.preparedRunPaths.relativeRunRoot, "feedback", "draft.json");
260
+ if (draftRead.status === "refused")
261
+ return {
262
+ schema: FEEDBACK_RESULT_SCHEMA,
263
+ ok: false,
264
+ cwd,
265
+ run: runInput,
266
+ error: {
267
+ code: "HUMANISH_INVALID_FEEDBACK_DRAFT",
268
+ message: `The feedback draft could not be read: ${refusalText(draftPath, draftRead)}.`,
269
+ },
270
+ };
271
+ const draft = draftRead.status === "missing"
272
+ ? undefined
273
+ : JSON.parse(draftRead.bytes.toString("utf8"));
259
274
  return {
260
275
  schema: FEEDBACK_RESULT_SCHEMA,
261
276
  ok: true,
@@ -284,10 +299,13 @@ async function resolveFeedbackRunContext(cwd, runInput) {
284
299
  const runsRoot = await bindExistingManagedHumanishOutputDirectory(physicalCwd, "runs");
285
300
  if (!runsRoot)
286
301
  return null;
287
- const pointerBytes = await readContainedRegularFile(runsRoot, "latest.json");
288
- if (!pointerBytes)
302
+ const pointerRead = await readContainedRegularFile(runsRoot, "latest.json", LATEST_POINTER_MAX_BYTES);
303
+ if (pointerRead.status === "missing")
289
304
  return null;
290
- const pointer = JSON.parse(pointerBytes.toString("utf8"));
305
+ // A pointer that is there and refused names no run, and saying so beats "run not found".
306
+ if (pointerRead.status === "refused")
307
+ throw new ContainedReadRefusedError(path.join(".humanish", "runs", "latest.json"), pointerRead);
308
+ const pointer = JSON.parse(pointerRead.bytes.toString("utf8"));
291
309
  if (typeof pointer.runId !== "string" ||
292
310
  typeof pointer.path !== "string" ||
293
311
  !resolveLatestRunDirectory(physicalCwd, { path: pointer.path, runId: pointer.runId })) {
@@ -3,18 +3,35 @@ import { ComputerUseExecutorError } from "../actors/computer-use/executor-error.
3
3
  // hand these strings to xdotool, which has a command language, so only names from these tables
4
4
  // ever reach it. The guest runtime ships this module, so it lives in src/guest/, which CI's
5
5
  // guest-desktop job and scripts/guest-desktop-proof.mjs key on.
6
+ //
7
+ // The tables take the names in OpenAI's computer-use key map, the names @e2b/desktop's press()
8
+ // mapped (the hosted desktop sent keys through it before), and every printable ASCII
9
+ // punctuation character. xdotool finds the key and the Shift level for a keysym itself, so `?`
10
+ // arrives as `question` with Shift held on an American English keymap.
6
11
  /** The modifiers a pointer action may hold, keyed by the upper-cased provider name. */
7
12
  const modifierNames = Object.freeze({
8
13
  CTRL: "ctrl",
9
14
  CONTROL: "ctrl",
10
15
  ALT: "alt",
16
+ OPTION: "alt",
11
17
  SHIFT: "shift",
12
18
  META: "super",
13
19
  SUPER: "super",
14
20
  CMD: "super",
21
+ COMMAND: "super",
22
+ WIN: "super",
23
+ WINDOWS: "super",
15
24
  });
16
25
  const keyNames = Object.freeze({
17
26
  ...modifierNames,
27
+ CONTROL_LEFT: "Control_L",
28
+ CONTROL_RIGHT: "Control_R",
29
+ ALT_LEFT: "Alt_L",
30
+ ALT_RIGHT: "Alt_R",
31
+ SHIFT_LEFT: "Shift_L",
32
+ SHIFT_RIGHT: "Shift_R",
33
+ SUPER_LEFT: "Super_L",
34
+ SUPER_RIGHT: "Super_R",
18
35
  ENTER: "Return",
19
36
  RETURN: "Return",
20
37
  TAB: "Tab",
@@ -24,11 +41,14 @@ const keyNames = Object.freeze({
24
41
  " ": "space",
25
42
  BACKSPACE: "BackSpace",
26
43
  DELETE: "Delete",
44
+ DEL: "Delete",
27
45
  INSERT: "Insert",
28
46
  HOME: "Home",
29
47
  END: "End",
30
48
  PAGEUP: "Prior",
31
49
  PAGEDOWN: "Next",
50
+ PAGE_UP: "Prior",
51
+ PAGE_DOWN: "Next",
32
52
  ARROWUP: "Up",
33
53
  ARROWDOWN: "Down",
34
54
  ARROWLEFT: "Left",
@@ -37,18 +57,45 @@ const keyNames = Object.freeze({
37
57
  DOWN: "Down",
38
58
  LEFT: "Left",
39
59
  RIGHT: "Right",
60
+ CAPS_LOCK: "Caps_Lock",
61
+ NUM_LOCK: "Num_Lock",
62
+ SCROLL_LOCK: "Scroll_Lock",
63
+ PAUSE: "Pause",
64
+ BREAK: "Pause",
65
+ PRINT: "Print",
66
+ MENU: "Menu",
67
+ "!": "exclam",
68
+ '"': "quotedbl",
69
+ "#": "numbersign",
70
+ $: "dollar",
71
+ "%": "percent",
72
+ "&": "ampersand",
73
+ "'": "apostrophe",
74
+ "(": "parenleft",
75
+ ")": "parenright",
76
+ "*": "asterisk",
40
77
  "+": "plus",
41
- "-": "minus",
42
- "=": "equal",
43
78
  ",": "comma",
79
+ "-": "minus",
44
80
  ".": "period",
45
81
  "/": "slash",
46
- "\\": "backslash",
82
+ ":": "colon",
47
83
  ";": "semicolon",
48
- "'": "apostrophe",
84
+ "<": "less",
85
+ "=": "equal",
86
+ ">": "greater",
87
+ "?": "question",
88
+ "@": "at",
49
89
  "[": "bracketleft",
90
+ "\\": "backslash",
50
91
  "]": "bracketright",
92
+ "^": "asciicircum",
93
+ _: "underscore",
51
94
  "`": "grave",
95
+ "{": "braceleft",
96
+ "|": "bar",
97
+ "}": "braceright",
98
+ "~": "asciitilde",
52
99
  });
53
100
  function lookup(table, key) {
54
101
  const upper = key.toUpperCase();
@@ -59,9 +106,16 @@ function chord(names) {
59
106
  throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
60
107
  return names.join("+");
61
108
  }
109
+ /**
110
+ * A name that is itself a chord (`Control+a`, `ctrl++`) split at each plus sign that joins two
111
+ * names. A lone `+` and a trailing `+` after a joining one are the plus key.
112
+ */
113
+ function chordParts(key) {
114
+ return key.length > 1 ? key.split(/\+(?!$)/) : [key];
115
+ }
62
116
  /** A keypress chord in xdotool syntax. Unknown or repeated keys are refused before dispatch. */
63
117
  export function xdotoolChord(keys) {
64
- return chord(keys.map((key) => {
118
+ return chord(keys.flatMap(chordParts).map((key) => {
65
119
  if (/^[a-z0-9]$/i.test(key))
66
120
  return key.toLowerCase();
67
121
  if (/^F(?:[1-9]|1[0-2])$/i.test(key))
@@ -1,3 +1,12 @@
1
+ /** The vendor aliases `humanish keys set` accepts. A raw ENV_NAME is also accepted. */
2
+ export declare const KEY_VENDORS: string[];
3
+ /** The keys `humanish keys` lists, in its order, each with what humanish uses it for. */
4
+ export declare const LISTED_KEYS: {
5
+ name: string;
6
+ use: string;
7
+ }[];
8
+ /** The keys `humanish doctor` reports on every run, in its row order. */
9
+ export declare const DOCTOR_KEYS: string[];
1
10
  export interface ResolvedKeyFill {
2
11
  name: string;
3
12
  /** Human-readable source label (a path or command name), never a value. */
@@ -24,33 +24,56 @@ import { loadEnvFile } from "./env-file.js";
24
24
  import { cli } from "../cli/invocation.js";
25
25
  import { humanishConfigFile } from "../cli/user-config.js";
26
26
  import { OPENAI_EGRESS_PLACEHOLDER } from "../routes/terminal/runtime-auth.js";
27
- /** The only names implicit discovery may fill (and `humanish keys set` may store). Everything
28
- * else in an overlay/store file is ignored-and-named: a repo-planted NODE_OPTIONS/LD_PRELOAD
29
- * must never enter process env off a file the operator did not explicitly pass (an explicit
30
- * --dotenv remains the operator's own full-file load). */
31
- const KNOWN_PROVIDER_KEYS = [
32
- "OPENAI_API_KEY",
33
- "ANTHROPIC_API_KEY",
34
- "E2B_API_KEY",
35
- "GH_TOKEN",
36
- "GITHUB_TOKEN",
37
- "CODEX_API_KEY",
38
- "AGENTMAIL_API_KEY",
27
+ /**
28
+ * The provider keys, in the order `humanish keys set` names their vendors. Implicit discovery fills
29
+ * only these names and the user store holds only these. Everything else in an overlay or store file
30
+ * is ignored and named: a repo-planted NODE_OPTIONS/LD_PRELOAD must never enter process env off a
31
+ * file the operator did not explicitly pass (an explicit --dotenv remains the operator's own
32
+ * full-file load). ANTHROPIC_API_KEY is not listed, and doctor does not report it: a Claude Code
33
+ * participant runs on its own login and never receives it. CODEX_API_KEY is an alternative spelling
34
+ * of the OpenAI key for terminal studies. Doctor reports AGENTMAIL_API_KEY only under `--study`, for
35
+ * a study whose email connection names it, since a connection may name another variable.
36
+ */
37
+ const PROVIDER_KEYS = [
38
+ {
39
+ name: "OPENAI_API_KEY",
40
+ vendor: "openai",
41
+ listed: { order: 2, use: "participant model and analysis" },
42
+ doctor: true,
43
+ },
44
+ {
45
+ name: "E2B_API_KEY",
46
+ vendor: "e2b",
47
+ login: "e2b auth login",
48
+ listed: { order: 1, use: "hosted desktops" },
49
+ doctor: true,
50
+ },
51
+ { name: "ANTHROPIC_API_KEY", vendor: "anthropic" },
52
+ {
53
+ name: "GH_TOKEN",
54
+ vendor: "github",
55
+ login: "gh auth login",
56
+ listed: { order: 3, use: "private repository subjects" },
57
+ doctor: true,
58
+ },
59
+ { name: "GITHUB_TOKEN", spellingOf: "GH_TOKEN" },
60
+ { name: "CODEX_API_KEY", doctor: true },
61
+ { name: "AGENTMAIL_API_KEY", vendor: "agentmail", listed: { order: 4, use: "email in studies" } },
39
62
  ];
40
- const PROVIDER_KEY_SET = new Set(KNOWN_PROVIDER_KEYS);
63
+ const PROVIDER_KEY_SET = new Set(PROVIDER_KEYS.map((key) => key.name));
64
+ const providerKey = (name) => PROVIDER_KEYS.find((key) => key.name === name);
65
+ /** The vendor aliases `humanish keys set` accepts. A raw ENV_NAME is also accepted. */
66
+ export const KEY_VENDORS = PROVIDER_KEYS.flatMap((key) => key.vendor === undefined ? [] : [key.vendor]);
67
+ /** The keys `humanish keys` lists, in its order, each with what humanish uses it for. */
68
+ export const LISTED_KEYS = PROVIDER_KEYS.flatMap(({ name, listed }) => listed === undefined ? [] : [{ name, ...listed }])
69
+ .sort((a, b) => a.order - b.order)
70
+ .map(({ name, use }) => ({ name, use }));
71
+ /** The keys `humanish doctor` reports on every run, in its row order. */
72
+ export const DOCTOR_KEYS = PROVIDER_KEYS.flatMap(({ name, doctor }) => (doctor ? [name] : []));
41
73
  /** An env for a vendor CLI that discovery runs: the caller's, without any provider key name. */
42
74
  function withoutProviderKeys(env) {
43
75
  return Object.fromEntries(Object.entries(env).filter(([name]) => !PROVIDER_KEY_SET.has(name)));
44
76
  }
45
- /** `humanish keys set <vendor>` aliases; a raw ENV_NAME is also accepted. A Map, so a name such as
46
- * `constructor` is no alias. */
47
- const KEY_VENDOR_ALIASES = new Map([
48
- ["openai", "OPENAI_API_KEY"],
49
- ["e2b", "E2B_API_KEY"],
50
- ["anthropic", "ANTHROPIC_API_KEY"],
51
- ["github", "GH_TOKEN"],
52
- ["agentmail", "AGENTMAIL_API_KEY"],
53
- ]);
54
77
  const PROJECT_OVERLAY_RELATIVE = path.join(".humanish", "local", "provider.env");
55
78
  export function userKeyStorePath(env, deps = {}) {
56
79
  return humanishConfigFile(env, "keys.env", deps.homeDir);
@@ -230,21 +253,10 @@ function storeLabel(env, deps) {
230
253
  /** The nearest fill instruction for a missing key. Doctor rows use it, and it is appended to
231
254
  * *_KEYS_MISSING errors so the failure names the fix. */
232
255
  export function missingKeyHint(name) {
233
- switch (name) {
234
- case "E2B_API_KEY":
235
- return `run \`e2b auth login\`, or \`${cli("keys set e2b")}\``;
236
- case "GH_TOKEN":
237
- case "GITHUB_TOKEN":
238
- return `run \`gh auth login\`, or \`${cli("keys set github")}\``;
239
- case "OPENAI_API_KEY":
240
- return `run \`${cli("keys set openai")}\``;
241
- case "ANTHROPIC_API_KEY":
242
- return `run \`${cli("keys set anthropic")}\``;
243
- case "AGENTMAIL_API_KEY":
244
- return `run \`${cli("keys set agentmail")}\``;
245
- default:
246
- return `run \`${cli(`keys set ${name}`)}\``;
247
- }
256
+ const named = providerKey(name);
257
+ const key = named?.spellingOf === undefined ? named : providerKey(named.spellingOf);
258
+ const set = `\`${cli(`keys set ${key?.vendor ?? name}`)}\``;
259
+ return key?.login === undefined ? `run ${set}` : `run \`${key.login}\`, or ${set}`;
248
260
  }
249
261
  /** One shared suffix for *_KEYS_MISSING messages: where discovery looked, and what fills each
250
262
  * missing key. Pure text; never values. */
@@ -276,19 +288,18 @@ export async function probeKeySources(names, args) {
276
288
  ...(args.deps === undefined ? {} : { deps: args.deps }),
277
289
  });
278
290
  const bySource = new Map(fills.map((fill) => [fill.name, fill.source]));
291
+ const setInEnv = (name) => args.env[name] !== undefined && args.env[name]?.trim() !== "";
279
292
  return names.map((name) => {
280
- const inEnv = args.env[name] !== undefined && args.env[name]?.trim() !== "" && !placeholder(name);
281
- // GH_TOKEN and GITHUB_TOKEN are one credential with two spellings; a doctor row that says
282
- // "missing" while GITHUB_TOKEN sits in the env would be wrong.
283
- const aliasInEnv = name === "GH_TOKEN" &&
284
- args.env.GITHUB_TOKEN !== undefined &&
285
- args.env.GITHUB_TOKEN.trim() !== "";
293
+ const inEnv = setInEnv(name) && !placeholder(name);
294
+ // A second spelling of the key in the env, such as GITHUB_TOKEN for GH_TOKEN, is the same
295
+ // credential; a doctor row that says "missing" while it sits in the env would be wrong.
296
+ const spelling = PROVIDER_KEYS.find((key) => key.spellingOf === name && setInEnv(key.name))?.name;
286
297
  const source = inEnv
287
298
  ? args.dotenv?.names.includes(name)
288
299
  ? `--dotenv ${args.dotenv.path}`
289
300
  : "process env"
290
- : aliasInEnv
291
- ? "process env (GITHUB_TOKEN)"
301
+ : spelling !== undefined
302
+ ? `process env (${spelling})`
292
303
  : (bySource.get(name) ?? null);
293
304
  return {
294
305
  name,
@@ -301,9 +312,10 @@ export async function probeKeySources(names, args) {
301
312
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
302
313
  /** Resolve a `humanish keys set` target: a vendor alias or a raw env name. Null = invalid. */
303
314
  export function resolveKeyName(vendorOrName) {
304
- const alias = KEY_VENDOR_ALIASES.get(vendorOrName.toLowerCase());
305
- if (alias !== undefined)
306
- return alias;
315
+ const vendor = vendorOrName.toLowerCase();
316
+ const key = PROVIDER_KEYS.find((candidate) => candidate.vendor === vendor);
317
+ if (key !== undefined)
318
+ return key.name;
307
319
  return ENV_NAME.test(vendorOrName) ? vendorOrName : null;
308
320
  }
309
321
  /** Write one key into the user store (0700 dir, 0600 file). Only allowlisted provider names