humanish 0.114.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 (204) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +7 -6
  4. package/TELEMETRY.md +6 -0
  5. package/dist/actors/closing-report-limits.d.ts +11 -0
  6. package/dist/actors/closing-report-limits.js +13 -0
  7. package/dist/actors/closing-report.d.ts +68 -0
  8. package/dist/actors/closing-report.js +55 -0
  9. package/dist/actors/codex/app-server-ui.js +11 -4
  10. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  11. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  12. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  13. package/dist/actors/computer-use/loop/debrief.js +68 -70
  14. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  15. package/dist/actors/computer-use/loop/impressions.js +6 -1
  16. package/dist/actors/computer-use/loop.d.ts +0 -1
  17. package/dist/actors/computer-use/loop.js +3 -3
  18. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  19. package/dist/actors/computer-use/openai-conversation.js +3 -29
  20. package/dist/actors/computer-use/openai-provider.js +11 -5
  21. package/dist/actors/contract.d.ts +2 -20
  22. package/dist/actors/contract.js +0 -13
  23. package/dist/actors/scripted-browser/steps.js +6 -5
  24. package/dist/analysis/admission.d.ts +14 -4
  25. package/dist/analysis/admission.js +14 -9
  26. package/dist/analysis/analysis-limits.d.ts +35 -0
  27. package/dist/analysis/analysis-limits.js +41 -0
  28. package/dist/analysis/execute.d.ts +5 -0
  29. package/dist/analysis/execute.js +3 -0
  30. package/dist/analysis/finding-lead.d.ts +27 -0
  31. package/dist/analysis/finding-lead.js +23 -0
  32. package/dist/analysis/service.js +5 -3
  33. package/dist/analysis/validation.js +14 -21
  34. package/dist/cli/commands/analyze.js +2 -1
  35. package/dist/cli/commands/notes.js +5 -3
  36. package/dist/cli/commands/run-signals.js +5 -1
  37. package/dist/cli/commands/runs.js +2 -1
  38. package/dist/cli/commands/setup.js +3 -3
  39. package/dist/cli/commands/study-format.d.ts +5 -2
  40. package/dist/cli/commands/study-format.js +29 -9
  41. package/dist/cli/commands/study-route-computer-use.js +2 -2
  42. package/dist/cli/commands/study-route-scripted.js +1 -1
  43. package/dist/cli/commands/tui.js +59 -17
  44. package/dist/cli/doctor.js +9 -2
  45. package/dist/cli/findings-text.d.ts +5 -0
  46. package/dist/cli/findings-text.js +169 -0
  47. package/dist/cli/findings.d.ts +0 -4
  48. package/dist/cli/findings.js +11 -195
  49. package/dist/cli/io.js +10 -8
  50. package/dist/cli/telemetry.js +10 -22
  51. package/dist/cli/update-check.js +10 -19
  52. package/dist/cli/user-config.d.ts +11 -0
  53. package/dist/cli/user-config.js +25 -0
  54. package/dist/comms/connections.js +4 -3
  55. package/dist/comms/email-catch.js +7 -31
  56. package/dist/comms/setup.js +18 -12
  57. package/dist/evidence/encoded-text.d.ts +12 -11
  58. package/dist/evidence/encoded-text.js +81 -70
  59. package/dist/evidence/redaction.d.ts +11 -0
  60. package/dist/evidence/redaction.js +30 -11
  61. package/dist/evidence/secret-scrub.d.ts +26 -3
  62. package/dist/evidence/secret-scrub.js +172 -38
  63. package/dist/feedback/draft.d.ts +1 -1
  64. package/dist/feedback/draft.js +13 -16
  65. package/dist/feedback/export-bundle.js +21 -17
  66. package/dist/feedback/export.js +32 -46
  67. package/dist/feedback/feedback.js +31 -26
  68. package/dist/guest/desktop-keys.js +59 -5
  69. package/dist/keys/key-resolution.d.ts +9 -0
  70. package/dist/keys/key-resolution.js +62 -55
  71. package/dist/keys/key-status.d.ts +2 -0
  72. package/dist/keys/key-status.js +9 -16
  73. package/dist/observer/artifact.d.ts +1 -1
  74. package/dist/observer/http.d.ts +7 -0
  75. package/dist/observer/http.js +19 -1
  76. package/dist/observer/notes-route.d.ts +0 -1
  77. package/dist/observer/notes-route.js +24 -44
  78. package/dist/observer/pinned-files.js +4 -19
  79. package/dist/observer/render.js +6 -4
  80. package/dist/observer/run-routes.js +13 -28
  81. package/dist/observer/serve.js +2 -1
  82. package/dist/observer-app.html +7 -7
  83. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  84. package/dist/routes/computer-use/bundle-parts.js +2 -2
  85. package/dist/routes/computer-use/bundle.d.ts +4 -5
  86. package/dist/routes/computer-use/bundle.js +1 -3
  87. package/dist/routes/computer-use/fanout-records.js +10 -11
  88. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  89. package/dist/routes/computer-use/live-phase.js +0 -1
  90. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  91. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  92. package/dist/routes/computer-use/participant-runs.js +9 -5
  93. package/dist/routes/computer-use/plan.d.ts +3 -0
  94. package/dist/routes/computer-use/plan.js +14 -11
  95. package/dist/routes/computer-use/rerun-selection.js +2 -2
  96. package/dist/routes/computer-use/result.js +3 -6
  97. package/dist/routes/computer-use/setup.d.ts +4 -6
  98. package/dist/routes/computer-use/setup.js +2 -6
  99. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  100. package/dist/routes/computer-use/single-bundle.js +22 -25
  101. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  102. package/dist/routes/computer-use/subject-projection.js +9 -31
  103. package/dist/routes/computer-use/types.d.ts +4 -41
  104. package/dist/routes/scripted/scenario.js +28 -12
  105. package/dist/routes/scripted/session-result.js +6 -3
  106. package/dist/routes/shared-world/bundle-records.js +2 -8
  107. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  108. package/dist/routes/shared-world/participant-specs.js +1 -20
  109. package/dist/routes/shared-world/plan.d.ts +3 -0
  110. package/dist/routes/shared-world/plan.js +23 -3
  111. package/dist/routes/shared-world/setup.js +2 -2
  112. package/dist/routes/terminal/ledger.d.ts +9 -9
  113. package/dist/routes/terminal/ledger.js +63 -40
  114. package/dist/routes/terminal/live-finish.js +7 -10
  115. package/dist/routes/terminal/plan.d.ts +3 -0
  116. package/dist/routes/terminal/plan.js +8 -5
  117. package/dist/routes/terminal/token-usage.d.ts +1 -2
  118. package/dist/routes/terminal/token-usage.js +4 -6
  119. package/dist/routes/terminal/types.d.ts +9 -1
  120. package/dist/run/bundle-shape.js +6 -1
  121. package/dist/run/bundle.d.ts +5 -3
  122. package/dist/run/contained-output.d.ts +59 -10
  123. package/dist/run/contained-output.js +160 -68
  124. package/dist/run/detail.d.ts +2 -1
  125. package/dist/run/detail.js +12 -7
  126. package/dist/run/escape-sequences.d.ts +11 -0
  127. package/dist/run/escape-sequences.js +93 -0
  128. package/dist/run/evidence-files.d.ts +8 -1
  129. package/dist/run/evidence-files.js +24 -18
  130. package/dist/run/locate.d.ts +32 -3
  131. package/dist/run/locate.js +45 -40
  132. package/dist/run/note-files.d.ts +26 -45
  133. package/dist/run/note-files.js +86 -58
  134. package/dist/run/note-shape.d.ts +35 -0
  135. package/dist/run/note-shape.js +20 -0
  136. package/dist/run/notes.d.ts +3 -14
  137. package/dist/run/notes.js +23 -75
  138. package/dist/run/outcomes.d.ts +24 -0
  139. package/dist/run/outcomes.js +19 -0
  140. package/dist/run/participant-records.d.ts +2 -0
  141. package/dist/run/participant-records.js +17 -2
  142. package/dist/run/paths.d.ts +2 -0
  143. package/dist/run/paths.js +5 -1
  144. package/dist/run/preflight-receipts.js +13 -8
  145. package/dist/run/project.js +7 -4
  146. package/dist/run/reclaim-outcomes.d.ts +8 -6
  147. package/dist/run/reclaim-outcomes.js +10 -6
  148. package/dist/run/reclaim.d.ts +5 -0
  149. package/dist/run/reclaim.js +43 -16
  150. package/dist/run/run-clock.d.ts +50 -0
  151. package/dist/run/run-clock.js +66 -0
  152. package/dist/run/run.js +5 -2
  153. package/dist/run/sandbox-ids.d.ts +7 -2
  154. package/dist/run/sandbox-ids.js +27 -11
  155. package/dist/run/secrets.d.ts +2 -1
  156. package/dist/run/secrets.js +18 -22
  157. package/dist/run/stored-runs.js +14 -7
  158. package/dist/run/transient-comms-secrets.d.ts +6 -5
  159. package/dist/run/transient-comms-secrets.js +23 -39
  160. package/dist/run-study.js +22 -13
  161. package/dist/study/adapter-scorer-loader.js +6 -4
  162. package/dist/study/discover.js +9 -6
  163. package/dist/study/init-paths.js +7 -5
  164. package/dist/study/persona-resolve.d.ts +2 -2
  165. package/dist/study/persona-resolve.js +9 -5
  166. package/dist/study/plan-types.d.ts +2 -2
  167. package/dist/study/plan.d.ts +5 -3
  168. package/dist/study/plan.js +18 -9
  169. package/dist/study/preflight-probes.js +11 -3
  170. package/dist/study/preflight.js +9 -5
  171. package/dist/study/summary.d.ts +12 -7
  172. package/dist/study/summary.js +24 -24
  173. package/dist/study/warnings.d.ts +19 -12
  174. package/dist/study/warnings.js +39 -17
  175. package/dist/substrates/e2b/connection.d.ts +12 -0
  176. package/dist/substrates/e2b/connection.js +35 -0
  177. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  178. package/dist/substrates/e2b/desktop-executor.js +42 -9
  179. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  180. package/dist/substrates/e2b/lifetime.js +41 -4
  181. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  182. package/dist/substrates/e2b/sandbox.js +13 -18
  183. package/dist/substrates/e2b/sdk.d.ts +20 -9
  184. package/dist/substrates/local/capacity.d.ts +18 -2
  185. package/dist/substrates/local/capacity.js +21 -2
  186. package/dist/substrates/local/runtime-host.js +21 -14
  187. package/dist/substrates/local/runtime.js +3 -11
  188. package/dist/tui/contract.d.ts +30 -5
  189. package/dist/tui/contract.js +4 -1
  190. package/dist/tui-app.js +149 -147
  191. package/dist/verify/artifacts.d.ts +9 -2
  192. package/dist/verify/artifacts.js +37 -13
  193. package/dist/verify/liveness.js +19 -5
  194. package/dist/verify/verify.js +54 -27
  195. package/docs/architecture/actor-contract.md +3 -3
  196. package/docs/architecture/guest-desktop.md +7 -2
  197. package/docs/architecture/restricted-codex-analysis.md +1 -1
  198. package/docs/architecture/state-driven-executor.md +5 -3
  199. package/docs/contracts/run-bundle.md +25 -13
  200. package/docs/contracts/schemas.md +38 -29
  201. package/docs/contracts/study-analysis.md +22 -5
  202. package/docs/product/automatic-analysis.md +7 -1
  203. package/package.json +8 -7
  204. package/skills/humanish/SKILL.md +1 -2
@@ -4,9 +4,9 @@ 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
- import { readNotesForSharing } from "../run/notes.js";
9
+ import { readNotesForSharing } from "../run/note-files.js";
10
10
  import { cli } from "../cli/invocation.js";
11
11
  import { verifyRunPrepared } from "../verify/verify.js";
12
12
  import { buildAnalysisDraft, buildDraft, isUsableFeedbackCandidate, renderMarkdown, withReviewerNotes, } from "./draft.js";
@@ -95,10 +95,10 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
95
95
  // The notes the draft includes are this set, checked here: a note can be added or changed after
96
96
  // verify read the run.
97
97
  const shared = await readNotesForSharing(context.preparedRunPaths);
98
- if (shared.finding !== null)
98
+ if (shared.problem !== null)
99
99
  return {
100
100
  context,
101
- result: notesRefusal(cwd, runInput, context.storedRunId, verified, shared.finding),
101
+ result: notesRefusal(cwd, runInput, context.storedRunId, verified, shared.problem),
102
102
  };
103
103
  const candidates = summarizeCandidates(context.loaded.bundle);
104
104
  if (options.candidate !== undefined &&
@@ -156,27 +156,14 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
156
156
  };
157
157
  }
158
158
  /** The refusal of a draft whose reviewer notes, as read for it, hold what verify's scan flags. */
159
- function notesRefusal(cwd, runInput, runId, verified, finding) {
159
+ function notesRefusal(cwd, runInput, runId, verified, problem) {
160
160
  return {
161
161
  schema: FEEDBACK_RESULT_SCHEMA,
162
162
  ok: false,
163
163
  cwd,
164
164
  run: runInput,
165
- shareSafety: {
166
- status: "blocked",
167
- reasons: [
168
- ...verified.shareSafety.reasons,
169
- finding === "sensitive"
170
- ? {
171
- code: "PUBLIC_SAFETY_FINDINGS",
172
- message: "The reviewer notes the draft would include match secret, token or local-path patterns.",
173
- }
174
- : {
175
- code: "UNSCANNED_ARTIFACT",
176
- message: "The reviewer notes the draft would include hold encoded text the scan cannot read.",
177
- },
178
- ],
179
- },
165
+ // A draft is written only from share_ready evidence, so any problem in its notes refuses it.
166
+ shareSafety: { status: "blocked", reasons: [...verified.shareSafety.reasons, problem.reason] },
180
167
  error: {
181
168
  code: "HUMANISH_FEEDBACK_SHARE_SAFETY_BLOCKED",
182
169
  message: `The run's reviewer notes hold text that looks like a secret, a token or a local path, so no draft was written. Run \`${cli(`verify --run ${runId}`)}\` and fix the note files in notes/.`,
@@ -221,7 +208,9 @@ async function isSafeFeedbackEvidenceFile(context, evidencePath) {
221
208
  path.isAbsolute(relative)) {
222
209
  return false;
223
210
  }
224
- 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";
225
214
  }
226
215
  export async function renderIssueMarkdown(cwdInput, runInput, repo, options = {}) {
227
216
  const verified = await verifyFeedbackBound(cwdInput, runInput, options);
@@ -266,9 +255,22 @@ export async function listFeedback(cwdInput, runInput) {
266
255
  },
267
256
  };
268
257
  }
269
- const draftBytes = await readContainedRegularFile(context.preparedRunPaths, path.join("feedback", "draft.json"));
270
- 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);
271
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"));
272
274
  return {
273
275
  schema: FEEDBACK_RESULT_SCHEMA,
274
276
  ok: true,
@@ -297,10 +299,13 @@ async function resolveFeedbackRunContext(cwd, runInput) {
297
299
  const runsRoot = await bindExistingManagedHumanishOutputDirectory(physicalCwd, "runs");
298
300
  if (!runsRoot)
299
301
  return null;
300
- const pointerBytes = await readContainedRegularFile(runsRoot, "latest.json");
301
- if (!pointerBytes)
302
+ const pointerRead = await readContainedRegularFile(runsRoot, "latest.json", LATEST_POINTER_MAX_BYTES);
303
+ if (pointerRead.status === "missing")
302
304
  return null;
303
- 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"));
304
309
  if (typeof pointer.runId !== "string" ||
305
310
  typeof pointer.path !== "string" ||
306
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. */
@@ -22,43 +22,61 @@ import { homedir } from "node:os";
22
22
  import path from "node:path";
23
23
  import { loadEnvFile } from "./env-file.js";
24
24
  import { cli } from "../cli/invocation.js";
25
+ import { humanishConfigFile } from "../cli/user-config.js";
25
26
  import { OPENAI_EGRESS_PLACEHOLDER } from "../routes/terminal/runtime-auth.js";
26
- /** The only names implicit discovery may fill (and `humanish keys set` may store). Everything
27
- * else in an overlay/store file is ignored-and-named: a repo-planted NODE_OPTIONS/LD_PRELOAD
28
- * must never enter process env off a file the operator did not explicitly pass (an explicit
29
- * --dotenv remains the operator's own full-file load). */
30
- const KNOWN_PROVIDER_KEYS = [
31
- "OPENAI_API_KEY",
32
- "ANTHROPIC_API_KEY",
33
- "E2B_API_KEY",
34
- "GH_TOKEN",
35
- "GITHUB_TOKEN",
36
- "CODEX_API_KEY",
37
- "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" } },
38
62
  ];
39
- 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] : []));
40
73
  /** An env for a vendor CLI that discovery runs: the caller's, without any provider key name. */
41
74
  function withoutProviderKeys(env) {
42
75
  return Object.fromEntries(Object.entries(env).filter(([name]) => !PROVIDER_KEY_SET.has(name)));
43
76
  }
44
- /** `humanish keys set <vendor>` aliases; a raw ENV_NAME is also accepted. */
45
- const KEY_VENDOR_ALIASES = {
46
- openai: "OPENAI_API_KEY",
47
- e2b: "E2B_API_KEY",
48
- anthropic: "ANTHROPIC_API_KEY",
49
- github: "GH_TOKEN",
50
- agentmail: "AGENTMAIL_API_KEY",
51
- };
52
77
  const PROJECT_OVERLAY_RELATIVE = path.join(".humanish", "local", "provider.env");
53
78
  export function userKeyStorePath(env, deps = {}) {
54
- const home = deps.homeDir ?? homedir();
55
- const declared = env.XDG_CONFIG_HOME?.trim();
56
- // The XDG spec: a relative XDG_CONFIG_HOME must be ignored. Honoring one would make the
57
- // key store cwd-relative: `humanish keys set` would write a secret into the current repo.
58
- const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
59
- ? declared
60
- : path.join(home, ".config");
61
- return path.join(configHome, "humanish", "keys.env");
79
+ return humanishConfigFile(env, "keys.env", deps.homeDir);
62
80
  }
63
81
  function e2bConfigPath(deps) {
64
82
  return path.join(deps.homeDir ?? homedir(), ".e2b", "config.json");
@@ -235,21 +253,10 @@ function storeLabel(env, deps) {
235
253
  /** The nearest fill instruction for a missing key. Doctor rows use it, and it is appended to
236
254
  * *_KEYS_MISSING errors so the failure names the fix. */
237
255
  export function missingKeyHint(name) {
238
- switch (name) {
239
- case "E2B_API_KEY":
240
- return `run \`e2b auth login\`, or \`${cli("keys set e2b")}\``;
241
- case "GH_TOKEN":
242
- case "GITHUB_TOKEN":
243
- return `run \`gh auth login\`, or \`${cli("keys set github")}\``;
244
- case "OPENAI_API_KEY":
245
- return `run \`${cli("keys set openai")}\``;
246
- case "ANTHROPIC_API_KEY":
247
- return `run \`${cli("keys set anthropic")}\``;
248
- case "AGENTMAIL_API_KEY":
249
- return `run \`${cli("keys set agentmail")}\``;
250
- default:
251
- return `run \`${cli(`keys set ${name}`)}\``;
252
- }
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}`;
253
260
  }
254
261
  /** One shared suffix for *_KEYS_MISSING messages: where discovery looked, and what fills each
255
262
  * missing key. Pure text; never values. */
@@ -281,19 +288,18 @@ export async function probeKeySources(names, args) {
281
288
  ...(args.deps === undefined ? {} : { deps: args.deps }),
282
289
  });
283
290
  const bySource = new Map(fills.map((fill) => [fill.name, fill.source]));
291
+ const setInEnv = (name) => args.env[name] !== undefined && args.env[name]?.trim() !== "";
284
292
  return names.map((name) => {
285
- const inEnv = args.env[name] !== undefined && args.env[name]?.trim() !== "" && !placeholder(name);
286
- // GH_TOKEN and GITHUB_TOKEN are one credential with two spellings; a doctor row that says
287
- // "missing" while GITHUB_TOKEN sits in the env would be wrong.
288
- const aliasInEnv = name === "GH_TOKEN" &&
289
- args.env.GITHUB_TOKEN !== undefined &&
290
- 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;
291
297
  const source = inEnv
292
298
  ? args.dotenv?.names.includes(name)
293
299
  ? `--dotenv ${args.dotenv.path}`
294
300
  : "process env"
295
- : aliasInEnv
296
- ? "process env (GITHUB_TOKEN)"
301
+ : spelling !== undefined
302
+ ? `process env (${spelling})`
297
303
  : (bySource.get(name) ?? null);
298
304
  return {
299
305
  name,
@@ -306,9 +312,10 @@ export async function probeKeySources(names, args) {
306
312
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
307
313
  /** Resolve a `humanish keys set` target: a vendor alias or a raw env name. Null = invalid. */
308
314
  export function resolveKeyName(vendorOrName) {
309
- const alias = KEY_VENDOR_ALIASES[vendorOrName.toLowerCase()];
310
- if (alias !== undefined)
311
- 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;
312
319
  return ENV_NAME.test(vendorOrName) ? vendorOrName : null;
313
320
  }
314
321
  /** Write one key into the user store (0700 dir, 0600 file). Only allowlisted provider names
@@ -10,6 +10,8 @@ export declare function keyStatus(args: {
10
10
  }): Promise<KeyStatusRow[]>;
11
11
  /** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
12
12
  export declare function otherStoredKeys(env: NodeJS.ProcessEnv, deps?: KeyResolutionDeps): string[];
13
+ /** The line `humanish keys` prints for one key: what it is for, and its source or how to add it. */
14
+ export declare function keyStatusLine(row: KeyStatusRow): string;
13
15
  export declare function formatKeyStatus(rows: readonly KeyStatusRow[], otherStored?: readonly string[]): string;
14
16
  interface MissingKeysOutcome {
15
17
  stored: string[];
@@ -1,33 +1,26 @@
1
1
  // `humanish keys`: which provider keys are set and where each comes from, through the same chain a
2
2
  // live command resolves. Never a value.
3
3
  import { cli } from "../cli/invocation.js";
4
- import { listUserKeys, probeKeySources, setUserKey, } from "./key-resolution.js";
5
- /**
6
- * The keys a person sets for humanish, in the order the status lists them and `keys set` asks for
7
- * them. ANTHROPIC_API_KEY is left out: a Claude Code participant runs on its own login and never
8
- * receives it. CODEX_API_KEY is an alternative spelling of the OpenAI key for terminal studies.
9
- */
10
- const STATUS_KEYS = [
11
- { name: "E2B_API_KEY", use: "hosted desktops" },
12
- { name: "OPENAI_API_KEY", use: "participant model and analysis" },
13
- { name: "GH_TOKEN", use: "private repository subjects" },
14
- { name: "AGENTMAIL_API_KEY", use: "email in studies" },
15
- ];
4
+ import { LISTED_KEYS, listUserKeys, probeKeySources, setUserKey, } from "./key-resolution.js";
16
5
  export async function keyStatus(args) {
17
- const probes = await probeKeySources(STATUS_KEYS.map((key) => key.name), {
6
+ const probes = await probeKeySources(LISTED_KEYS.map((key) => key.name), {
18
7
  cwd: args.cwd,
19
8
  env: args.env,
20
9
  ...(args.deps === undefined ? {} : { deps: args.deps }),
21
10
  });
22
- return probes.map((probe, index) => ({ ...probe, use: STATUS_KEYS[index].use }));
11
+ return probes.map((probe, index) => ({ ...probe, use: LISTED_KEYS[index].use }));
23
12
  }
24
13
  /** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
25
14
  export function otherStoredKeys(env, deps = {}) {
26
- const listed = new Set(STATUS_KEYS.map((key) => key.name));
15
+ const listed = new Set(LISTED_KEYS.map((key) => key.name));
27
16
  return listUserKeys(env, deps).filter((name) => !listed.has(name));
28
17
  }
18
+ /** The line `humanish keys` prints for one key: what it is for, and its source or how to add it. */
19
+ export function keyStatusLine(row) {
20
+ return `${row.name} (${row.use}): ${row.source === null ? `missing; ${row.hint}` : `set, from ${row.source}`}`;
21
+ }
29
22
  export function formatKeyStatus(rows, otherStored = []) {
30
- const lines = rows.map((row) => `${row.name} (${row.use}): ${row.source === null ? `missing; ${row.hint}` : `set, from ${row.source}`}`);
23
+ const lines = rows.map(keyStatusLine);
31
24
  if (otherStored.length > 0)
32
25
  lines.push(`Also in the user store: ${otherStored.join(", ")}. humanish does not use ${otherStored.length === 1 ? "it" : "them"} today.`);
33
26
  const missing = rows.some((row) => row.source === null);
@@ -1,5 +1,5 @@
1
1
  import type { LoadedAnalysis } from "../analysis/types.js";
2
- import type { RunNotes } from "../run/notes.js";
2
+ import type { RunNotes } from "../run/note-files.js";
3
3
  import { type ObserverData } from "./data.js";
4
4
  /**
5
5
  * Resolve (and in a repo checkout, build) the Observer artifact before any run or
@@ -10,3 +10,10 @@ export declare function parsePublicOrigin(value: string): {
10
10
  host: string;
11
11
  scheme: "http" | "https";
12
12
  } | null;
13
+ /**
14
+ * A request body, or null once it passes `limit` bytes. Reading stops at the limit: leaving the
15
+ * loop destroys the request with its socket detached, so the caller can still answer, and should
16
+ * send `connection: close`, since the rest of the body is never read. Rejects when the client
17
+ * aborts.
18
+ */
19
+ export declare function readBodyAtMost(request: AsyncIterable<Buffer>, limit: number): Promise<Buffer | null>;
@@ -1,7 +1,8 @@
1
1
  // Shared HTTP hardening primitives for the serve surfaces. Extracted here so both the live
2
2
  // Observer server (src/observer/render.ts) and the run-library server
3
3
  // (src/observer/serve.ts) can enforce the identical Host allowlist + security-header posture
4
- // without a module cycle. This file imports nothing from the serve modules.
4
+ // without a module cycle. The email catch (src/comms/email-catch.ts) reads request bodies through
5
+ // readBodyAtMost too. This file imports nothing from the serve modules.
5
6
  export function buildServeSecurityHeaders() {
6
7
  return {
7
8
  "cache-control": "no-store",
@@ -48,3 +49,20 @@ export function parsePublicOrigin(value) {
48
49
  scheme: parsed.protocol === "https:" ? "https" : "http",
49
50
  };
50
51
  }
52
+ /**
53
+ * A request body, or null once it passes `limit` bytes. Reading stops at the limit: leaving the
54
+ * loop destroys the request with its socket detached, so the caller can still answer, and should
55
+ * send `connection: close`, since the rest of the body is never read. Rejects when the client
56
+ * aborts.
57
+ */
58
+ export async function readBodyAtMost(request, limit) {
59
+ const chunks = [];
60
+ let size = 0;
61
+ for await (const chunk of request) {
62
+ size += chunk.length;
63
+ if (size > limit)
64
+ return null;
65
+ chunks.push(chunk);
66
+ }
67
+ return Buffer.concat(chunks, size);
68
+ }
@@ -1,6 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { PreparedRunArtifactPaths } from "../run/paths.js";
3
- export declare const NOTES_PATH = "/api/notes";
4
3
  export interface NotesWriterOptions {
5
4
  /** An exposed server refuses every note and gives its pages no token. */
6
5
  exposed: boolean;
@@ -5,13 +5,25 @@
5
5
  // cannot read the token from the page or send this origin, and a name rebound to 127.0.0.1 arrives
6
6
  // with its own Host.
7
7
  import { randomBytes, timingSafeEqual } from "node:crypto";
8
- import { addRunNote, MAX_NOTE_TEXT, } from "../run/notes.js";
9
- import { isRecord } from "../run/type-guards.js";
8
+ import { z } from "zod";
9
+ import { MAX_NOTE_TEXT, NOTES_TOKEN_HEADER } from "../run/note-shape.js";
10
+ import { addRunNote } from "../run/notes.js";
10
11
  import { cli } from "../cli/invocation.js";
11
- import { hostAllowed } from "./http.js";
12
- export const NOTES_PATH = "/api/notes";
13
- const TOKEN_HEADER = "x-humanish-notes-token";
12
+ import { hostAllowed, readBodyAtMost } from "./http.js";
14
13
  const MAX_BODY_BYTES = 16 * 1024;
14
+ /**
15
+ * A request to add a note. Its text, time and participant are checked by addRunNote, which says
16
+ * what is wrong with each, so this checks only their types.
17
+ */
18
+ const noteRequestSchema = z.object({
19
+ runId: z.string(),
20
+ atMs: z.number(),
21
+ participant: z
22
+ .string()
23
+ .nullish()
24
+ .transform((participant) => participant ?? null),
25
+ text: z.string(),
26
+ });
15
27
  /** The HTTP status for a refusal from the notes store. */
16
28
  const STORE_STATUS = {
17
29
  HUMANISH_NOTE_INVALID: 400,
@@ -40,26 +52,6 @@ function reply(response, status, body, close = false) {
40
52
  function refuse(response, status, code, message) {
41
53
  reply(response, status, { ok: false, error: { code, message } }, true);
42
54
  }
43
- /** The body, or null once it passes MAX_BODY_BYTES; reading stops there. */
44
- function readBody(request) {
45
- return new Promise((resolve, reject) => {
46
- const chunks = [];
47
- let size = 0;
48
- const onData = (chunk) => {
49
- size += chunk.length;
50
- if (size > MAX_BODY_BYTES) {
51
- request.off("data", onData);
52
- request.pause();
53
- resolve(null);
54
- return;
55
- }
56
- chunks.push(chunk);
57
- };
58
- request.on("data", onData);
59
- request.once("end", () => resolve(Buffer.concat(chunks)));
60
- request.once("error", reject);
61
- });
62
- }
63
55
  function sameToken(sent, token) {
64
56
  if (typeof sent !== "string")
65
57
  return false;
@@ -67,8 +59,8 @@ function sameToken(sent, token) {
67
59
  const right = Buffer.from(token);
68
60
  return left.length === right.length && timingSafeEqual(left, right);
69
61
  }
70
- /** The note in a request body, or null when the body is not one. */
71
- function noteInput(body) {
62
+ /** The note request in a body, or null when the body is not one. */
63
+ function noteRequest(body) {
72
64
  let value;
73
65
  try {
74
66
  value = JSON.parse(body.toString("utf8"));
@@ -76,20 +68,8 @@ function noteInput(body) {
76
68
  catch {
77
69
  return null;
78
70
  }
79
- if (!isRecord(value) ||
80
- typeof value.runId !== "string" ||
81
- typeof value.atMs !== "number" ||
82
- typeof value.text !== "string" ||
83
- !(value.participant === undefined ||
84
- value.participant === null ||
85
- typeof value.participant === "string"))
86
- return null;
87
- return {
88
- runId: value.runId,
89
- atMs: value.atMs,
90
- participant: typeof value.participant === "string" ? value.participant : null,
91
- text: value.text,
92
- };
71
+ const parsed = noteRequestSchema.safeParse(value);
72
+ return parsed.success ? parsed.data : null;
93
73
  }
94
74
  export function createNotesWriter(options) {
95
75
  const token = randomBytes(32).toString("base64url");
@@ -111,17 +91,17 @@ export function createNotesWriter(options) {
111
91
  !hostAllowed(host, loopbackHosts(options.port())) ||
112
92
  request.headers.origin !== `http://${host}`)
113
93
  return refuse(response, 403, "HUMANISH_NOTES_ORIGIN", "A note can only come from the Observer page this server rendered, at the address it was opened from. Open the Observer from the address humanish printed and add the note there.");
114
- if (!sameToken(request.headers[TOKEN_HEADER], token))
94
+ if (!sameToken(request.headers[NOTES_TOKEN_HEADER], token))
115
95
  return refuse(response, 403, "HUMANISH_NOTES_TOKEN", "The request does not carry this server's note token, which changes each time the server starts. Reload the Observer page and add the note again.");
116
96
  if (!/^application\/json(?:\s*;|$)/i.test(request.headers["content-type"] ?? ""))
117
97
  return refuse(response, 415, "HUMANISH_NOTES_BODY", "A note is sent as JSON with runId, atMs, participant and text.");
118
98
  const tooLarge = () => refuse(response, 413, "HUMANISH_NOTES_TOO_LARGE", `The request is larger than ${MAX_BODY_BYTES / 1024} KiB. A note holds at most ${MAX_NOTE_TEXT} characters.`);
119
99
  if (Number(request.headers["content-length"] ?? 0) > MAX_BODY_BYTES)
120
100
  return tooLarge();
121
- const body = await readBody(request);
101
+ const body = await readBodyAtMost(request, MAX_BODY_BYTES);
122
102
  if (body === null)
123
103
  return tooLarge();
124
- const input = noteInput(body);
104
+ const input = noteRequest(body);
125
105
  if (input === null)
126
106
  return refuse(response, 400, "HUMANISH_NOTES_BODY", "A note is a JSON object with runId, atMs (milliseconds on the run clock), participant (a stream id or null) and text.");
127
107
  const prepared = await options.resolveRun(input.runId).catch(() => null);