humanish 0.112.0 → 0.114.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 (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -0
  3. package/README.md +12 -1
  4. package/TELEMETRY.md +21 -0
  5. package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
  6. package/dist/actors/codex/restricted-participant-policy.js +20 -2
  7. package/dist/actors/codex/restricted-participant.d.ts +1 -1
  8. package/dist/actors/codex/restricted-participant.js +2 -1
  9. package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
  10. package/dist/actors/computer-use/loop/debrief.js +57 -20
  11. package/dist/actors/computer-use/loop/ending.d.ts +5 -2
  12. package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
  13. package/dist/actors/computer-use/loop/impressions.js +40 -0
  14. package/dist/actors/computer-use/loop/session.d.ts +3 -1
  15. package/dist/actors/computer-use/loop/session.js +2 -0
  16. package/dist/actors/computer-use/loop/trace.js +2 -0
  17. package/dist/actors/computer-use/loop/types.d.ts +8 -1
  18. package/dist/actors/computer-use/loop.d.ts +2 -1
  19. package/dist/actors/computer-use/loop.js +15 -3
  20. package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
  21. package/dist/actors/computer-use/openai-conversation.js +60 -29
  22. package/dist/actors/computer-use/openai-provider.js +18 -12
  23. package/dist/actors/contract.d.ts +47 -5
  24. package/dist/actors/contract.js +13 -0
  25. package/dist/analysis/admission.d.ts +37 -0
  26. package/dist/analysis/admission.js +60 -0
  27. package/dist/analysis/automatic-completion.d.ts +0 -6
  28. package/dist/analysis/automatic-completion.js +3 -3
  29. package/dist/analysis/automatic-config.d.ts +10 -2
  30. package/dist/analysis/automatic-config.js +29 -14
  31. package/dist/analysis/automatic.js +7 -0
  32. package/dist/analysis/evidence-sources.d.ts +3 -1
  33. package/dist/analysis/evidence-sources.js +10 -1
  34. package/dist/analysis/evidence.js +2 -2
  35. package/dist/analysis/execute.d.ts +20 -6
  36. package/dist/analysis/execute.js +61 -28
  37. package/dist/analysis/image-tokens.d.ts +2 -0
  38. package/dist/analysis/image-tokens.js +1 -1
  39. package/dist/analysis/job.d.ts +9 -1
  40. package/dist/analysis/job.js +11 -2
  41. package/dist/analysis/service.js +6 -3
  42. package/dist/cli/commands/analyze.js +7 -2
  43. package/dist/cli/commands/notes.d.ts +3 -0
  44. package/dist/cli/commands/notes.js +144 -0
  45. package/dist/cli/commands/observe.js +1 -1
  46. package/dist/cli/commands/runs.js +33 -10
  47. package/dist/cli/commands/setup.js +95 -6
  48. package/dist/cli/commands/study-format.js +7 -4
  49. package/dist/cli/commands/study-run.js +2 -1
  50. package/dist/cli/commands/study.js +1 -1
  51. package/dist/cli/commands/tui.js +1 -1
  52. package/dist/cli/doctor.d.ts +2 -0
  53. package/dist/cli/doctor.js +18 -0
  54. package/dist/cli/findings.js +4 -1
  55. package/dist/cli/io.d.ts +3 -0
  56. package/dist/cli/io.js +11 -12
  57. package/dist/cli/program.js +37 -1
  58. package/dist/cli/secret-prompt.d.ts +5 -1
  59. package/dist/cli/secret-prompt.js +6 -2
  60. package/dist/cli/update-check-worker.d.ts +1 -0
  61. package/dist/cli/update-check-worker.js +5 -0
  62. package/dist/cli/update-check.d.ts +46 -0
  63. package/dist/cli/update-check.js +203 -0
  64. package/dist/evidence/encoded-text.d.ts +15 -2
  65. package/dist/evidence/encoded-text.js +68 -4
  66. package/dist/evidence/secret-scrub.d.ts +1 -1
  67. package/dist/evidence/secret-scrub.js +23 -2
  68. package/dist/feedback/draft.d.ts +19 -0
  69. package/dist/feedback/draft.js +69 -1
  70. package/dist/feedback/export-bundle.js +2 -2
  71. package/dist/feedback/export.js +47 -3
  72. package/dist/feedback/feedback.js +42 -2
  73. package/dist/keys/key-resolution.js +2 -0
  74. package/dist/keys/key-status.d.ts +37 -0
  75. package/dist/keys/key-status.js +68 -0
  76. package/dist/observer/artifact.d.ts +5 -0
  77. package/dist/observer/artifact.js +18 -2
  78. package/dist/observer/notes-route.d.ts +21 -0
  79. package/dist/observer/notes-route.js +140 -0
  80. package/dist/observer/pinned-files.d.ts +8 -1
  81. package/dist/observer/pinned-files.js +32 -3
  82. package/dist/observer/render.js +38 -14
  83. package/dist/observer/run-routes.d.ts +8 -2
  84. package/dist/observer/run-routes.js +47 -6
  85. package/dist/observer/serve.d.ts +3 -0
  86. package/dist/observer/serve.js +28 -3
  87. package/dist/observer-app.html +7 -6
  88. package/dist/routes/computer-use/local-vm.js +9 -2
  89. package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
  90. package/dist/routes/computer-use/participant-execution.js +9 -1
  91. package/dist/routes/computer-use/rerun-selection.js +23 -21
  92. package/dist/routes/computer-use/result.js +3 -2
  93. package/dist/routes/computer-use/self-report.js +7 -1
  94. package/dist/routes/computer-use/setup.js +14 -0
  95. package/dist/routes/computer-use/types.d.ts +4 -1
  96. package/dist/routes/shared-world/bundle-records.js +8 -7
  97. package/dist/routes/shared-world/bundle.js +1 -1
  98. package/dist/run/contained-output.d.ts +7 -0
  99. package/dist/run/contained-output.js +35 -1
  100. package/dist/run/detail.js +4 -4
  101. package/dist/run/note-files.d.ts +73 -0
  102. package/dist/run/note-files.js +184 -0
  103. package/dist/run/notes.d.ts +37 -0
  104. package/dist/run/notes.js +198 -0
  105. package/dist/run/participant-caption.d.ts +11 -1
  106. package/dist/run/participant-caption.js +30 -1
  107. package/dist/run/participant-records.d.ts +3 -1
  108. package/dist/run/participant-records.js +6 -0
  109. package/dist/run-study.js +17 -1
  110. package/dist/study/discover.js +6 -1
  111. package/dist/study/doctor.d.ts +2 -0
  112. package/dist/study/doctor.js +46 -8
  113. package/dist/study/init-templates.js +10 -5
  114. package/dist/study/plan.js +3 -2
  115. package/dist/study/preflight-result.d.ts +1 -0
  116. package/dist/study/preflight-result.js +3 -2
  117. package/dist/study/preflight.d.ts +2 -0
  118. package/dist/study/preflight.js +54 -14
  119. package/dist/study/summary.js +2 -2
  120. package/dist/study/warnings.d.ts +5 -0
  121. package/dist/study/warnings.js +79 -2
  122. package/dist/substrates/local/capacity.d.ts +75 -0
  123. package/dist/substrates/local/capacity.js +143 -0
  124. package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
  125. package/dist/substrates/local/firecracker-desktop.js +34 -3
  126. package/dist/substrates/local/runtime-host.d.ts +16 -3
  127. package/dist/substrates/local/runtime-host.js +49 -4
  128. package/dist/substrates/local/runtime.d.ts +10 -0
  129. package/dist/substrates/local/runtime.js +44 -5
  130. package/dist/tui-app.js +58 -58
  131. package/docs/architecture/actor-contract.md +20 -6
  132. package/docs/contracts/errors.md +2 -2
  133. package/docs/contracts/feedback.md +8 -1
  134. package/docs/contracts/run-bundle.md +57 -0
  135. package/docs/contracts/schemas.md +62 -36
  136. package/docs/contracts/study-analysis.md +41 -8
  137. package/docs/principles/invariants-and-defaults.md +2 -2
  138. package/docs/product/automatic-analysis.md +22 -16
  139. package/package.json +2 -2
  140. package/skills/humanish/SKILL.md +26 -10
@@ -6,8 +6,10 @@ import path from "node:path";
6
6
  import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
7
7
  import { bindExistingManagedHumanishOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
8
8
  import { loadRunBundlePrepared } from "../run/locate.js";
9
+ import { readNotesForSharing } from "../run/notes.js";
10
+ import { cli } from "../cli/invocation.js";
9
11
  import { verifyRunPrepared } from "../verify/verify.js";
10
- import { buildAnalysisDraft, buildDraft, isUsableFeedbackCandidate, renderMarkdown, } from "./draft.js";
12
+ import { buildAnalysisDraft, buildDraft, isUsableFeedbackCandidate, renderMarkdown, withReviewerNotes, } from "./draft.js";
11
13
  const FEEDBACK_RESULT_SCHEMA = "humanish.feedback-result.v1";
12
14
  function summarizeCandidates(bundle) {
13
15
  return bundle.feedbackCandidates
@@ -90,6 +92,14 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
90
92
  },
91
93
  };
92
94
  }
95
+ // The notes the draft includes are this set, checked here: a note can be added or changed after
96
+ // verify read the run.
97
+ const shared = await readNotesForSharing(context.preparedRunPaths);
98
+ if (shared.finding !== null)
99
+ return {
100
+ context,
101
+ result: notesRefusal(cwd, runInput, context.storedRunId, verified, shared.finding),
102
+ };
93
103
  const candidates = summarizeCandidates(context.loaded.bundle);
94
104
  if (options.candidate !== undefined &&
95
105
  !candidates.some((item) => item.id === options.candidate)) {
@@ -111,9 +121,11 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
111
121
  };
112
122
  }
113
123
  const independent = options.analysis !== undefined || options.finding !== undefined;
114
- const draft = independent
124
+ const built = independent
115
125
  ? await buildAnalysisDraft(context, options)
116
126
  : buildDraft(context.loaded.bundle, context.loaded.bundlePath, options.candidate);
127
+ const draft = built &&
128
+ withReviewerNotes(built, shared.notes, context.loaded.bundle, path.dirname(context.loaded.bundlePath));
117
129
  if (!draft)
118
130
  return {
119
131
  context,
@@ -143,6 +155,34 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
143
155
  },
144
156
  };
145
157
  }
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) {
160
+ return {
161
+ schema: FEEDBACK_RESULT_SCHEMA,
162
+ ok: false,
163
+ cwd,
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
+ },
180
+ error: {
181
+ code: "HUMANISH_FEEDBACK_SHARE_SAFETY_BLOCKED",
182
+ 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/.`,
183
+ },
184
+ };
185
+ }
146
186
  export async function verifyFeedback(cwdInput, runInput, options = {}) {
147
187
  return (await verifyFeedbackBound(cwdInput, runInput, options)).result;
148
188
  }
@@ -245,6 +245,8 @@ export function missingKeyHint(name) {
245
245
  return `run \`${cli("keys set openai")}\``;
246
246
  case "ANTHROPIC_API_KEY":
247
247
  return `run \`${cli("keys set anthropic")}\``;
248
+ case "AGENTMAIL_API_KEY":
249
+ return `run \`${cli("keys set agentmail")}\``;
248
250
  default:
249
251
  return `run \`${cli(`keys set ${name}`)}\``;
250
252
  }
@@ -0,0 +1,37 @@
1
+ import { type KeyResolutionDeps, type KeySourceProbe } from "./key-resolution.js";
2
+ interface KeyStatusRow extends KeySourceProbe {
3
+ /** What humanish uses the key for, in a few words. */
4
+ use: string;
5
+ }
6
+ export declare function keyStatus(args: {
7
+ cwd: string;
8
+ env: NodeJS.ProcessEnv;
9
+ deps?: KeyResolutionDeps;
10
+ }): Promise<KeyStatusRow[]>;
11
+ /** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
12
+ export declare function otherStoredKeys(env: NodeJS.ProcessEnv, deps?: KeyResolutionDeps): string[];
13
+ export declare function formatKeyStatus(rows: readonly KeyStatusRow[], otherStored?: readonly string[]): string;
14
+ interface MissingKeysOutcome {
15
+ stored: string[];
16
+ /** Keys left unset because the person pressed Enter without a value. */
17
+ skipped: string[];
18
+ /** Keys whose value the store refused, with the reason. */
19
+ rejected?: Array<{
20
+ name: string;
21
+ message: string;
22
+ }>;
23
+ /** The key whose prompt was cancelled. No later key was asked for. */
24
+ stoppedAt?: string;
25
+ }
26
+ /**
27
+ * `humanish keys set` with no key named: ask for each missing key in turn and store each answer in
28
+ * the user store. An empty answer skips that key; a cancelled prompt (null) stops the walk and
29
+ * keeps what was already stored.
30
+ */
31
+ export declare function askForMissingKeys(args: {
32
+ rows: readonly KeyStatusRow[];
33
+ env: NodeJS.ProcessEnv;
34
+ deps?: KeyResolutionDeps;
35
+ prompt: (label: string) => Promise<string | null>;
36
+ }): Promise<MissingKeysOutcome>;
37
+ export {};
@@ -0,0 +1,68 @@
1
+ // `humanish keys`: which provider keys are set and where each comes from, through the same chain a
2
+ // live command resolves. Never a value.
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
+ ];
16
+ export async function keyStatus(args) {
17
+ const probes = await probeKeySources(STATUS_KEYS.map((key) => key.name), {
18
+ cwd: args.cwd,
19
+ env: args.env,
20
+ ...(args.deps === undefined ? {} : { deps: args.deps }),
21
+ });
22
+ return probes.map((probe, index) => ({ ...probe, use: STATUS_KEYS[index].use }));
23
+ }
24
+ /** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
25
+ export function otherStoredKeys(env, deps = {}) {
26
+ const listed = new Set(STATUS_KEYS.map((key) => key.name));
27
+ return listUserKeys(env, deps).filter((name) => !listed.has(name));
28
+ }
29
+ 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}`}`);
31
+ if (otherStored.length > 0)
32
+ lines.push(`Also in the user store: ${otherStored.join(", ")}. humanish does not use ${otherStored.length === 1 ? "it" : "them"} today.`);
33
+ const missing = rows.some((row) => row.source === null);
34
+ lines.push("", missing
35
+ ? `Values are never printed. \`${cli("keys set")}\` asks for each missing key in turn.`
36
+ : "Values are never printed.");
37
+ return `${lines.join("\n")}\n`;
38
+ }
39
+ /**
40
+ * `humanish keys set` with no key named: ask for each missing key in turn and store each answer in
41
+ * the user store. An empty answer skips that key; a cancelled prompt (null) stops the walk and
42
+ * keeps what was already stored.
43
+ */
44
+ export async function askForMissingKeys(args) {
45
+ const outcome = { stored: [], skipped: [] };
46
+ for (const row of args.rows) {
47
+ if (row.source !== null)
48
+ continue;
49
+ const value = await args.prompt(`${row.name} for ${row.use}`);
50
+ if (value === null)
51
+ return { ...outcome, stoppedAt: row.name };
52
+ if (value === "") {
53
+ outcome.skipped.push(row.name);
54
+ continue;
55
+ }
56
+ try {
57
+ setUserKey(row.name, value, args.env, args.deps);
58
+ outcome.stored.push(row.name);
59
+ }
60
+ catch (error) {
61
+ (outcome.rejected ??= []).push({
62
+ name: row.name,
63
+ message: error instanceof Error ? error.message : String(error),
64
+ });
65
+ }
66
+ }
67
+ return outcome;
68
+ }
@@ -1,4 +1,5 @@
1
1
  import type { LoadedAnalysis } from "../analysis/types.js";
2
+ import type { RunNotes } from "../run/notes.js";
2
3
  import { type ObserverData } from "./data.js";
3
4
  /**
4
5
  * Resolve (and in a repo checkout, build) the Observer artifact before any run or
@@ -19,5 +20,9 @@ export type ObserverExportAssets = Record<string, {
19
20
  export declare function renderObserverHtml(data: ObserverData, options?: {
20
21
  snapshot?: boolean;
21
22
  analysis?: LoadedAnalysis;
23
+ /** The run's reviewer notes, shown read-only unless `notesToken` is set. */
24
+ notes?: RunNotes | null;
25
+ /** The serving process's token for adding a note. Only a loopback server's page carries it. */
26
+ notesToken?: string;
22
27
  assets?: ObserverExportAssets;
23
28
  }): string;
@@ -159,7 +159,7 @@ function renderExportAssets(assets) {
159
159
  })
160
160
  .join("");
161
161
  }
162
- function renderObserverAppHtml(data, snapshot, analysis, assets) {
162
+ function renderObserverAppHtml(data, snapshot, analysis, notes, assets) {
163
163
  const artifact = loadObserverArtifact();
164
164
  // A renderer-owned boot marker, outside the untrusted run-data contract. Only
165
165
  // portable HTML exports opt out of a live feed; ordinary served pages still poll.
@@ -168,6 +168,7 @@ function renderObserverAppHtml(data, snapshot, analysis, assets) {
168
168
  : artifact)
169
169
  .replace(OBSERVER_DATA_SLOT, () => `<script id="observer-data" type="application/json">${escapeJsonScript(data)}</script>`)
170
170
  .replace(/<script id="study-analysis" type="application\/json">[\s\S]*?<\/script>/, () => `<script id="study-analysis" type="application/json">${escapeJsonScript(analysis)}</script>`)
171
+ .replace(/<script id="run-notes" type="application\/json">[\s\S]*?<\/script>/, () => `<script id="run-notes" type="application/json">${escapeJsonScript(notes)}</script>`)
171
172
  .replace("</body>", () => `${renderExportAssets(assets)}</body>`)
172
173
  .replace(/<title>[^<]*<\/title>/, () => `<title>humanish Observer · ${escapeHtml(data.run.runId)}</title>`);
173
174
  }
@@ -207,7 +208,22 @@ export function renderObserverHtml(data, options = {}) {
207
208
  },
208
209
  };
209
210
  }
210
- return renderObserverAppHtml(withObserverEndings(data), options.snapshot === true, projectShareCheckedAnalysis(analysis), options.assets ?? {});
211
+ // A snapshot is a file anyone may open later, so it never carries a server's token.
212
+ const write = options.notesToken !== undefined && options.snapshot !== true
213
+ ? { token: options.notesToken }
214
+ : null;
215
+ return renderObserverAppHtml(withObserverEndings(data), options.snapshot === true, projectShareCheckedAnalysis(analysis), {
216
+ // A run without notes renders the slot as before notes existed.
217
+ notes: options.notes == null ||
218
+ (options.notes.notes.length === 0 && options.notes.skipped.length === 0)
219
+ ? null
220
+ : {
221
+ runId: options.notes.runId,
222
+ notes: options.notes.notes,
223
+ skipped: options.notes.skipped.length,
224
+ },
225
+ write,
226
+ }, options.assets ?? {});
211
227
  }
212
228
  function escapeHtml(value) {
213
229
  return value.replace(/[&<>"']/g, (char) => {
@@ -0,0 +1,21 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { PreparedRunArtifactPaths } from "../run/paths.js";
3
+ export declare const NOTES_PATH = "/api/notes";
4
+ export interface NotesWriterOptions {
5
+ /** An exposed server refuses every note and gives its pages no token. */
6
+ exposed: boolean;
7
+ /** The port the server listens on, once it does. */
8
+ port(): number;
9
+ /** The run's paths when this server serves it, otherwise null. */
10
+ resolveRun(runId: string): Promise<PreparedRunArtifactPaths | null>;
11
+ /** Renders the run's saved Observer page again after a note is added. */
12
+ refresh(prepared: PreparedRunArtifactPaths): Promise<unknown>;
13
+ }
14
+ export interface NotesWriter {
15
+ /** The token for a page this server renders for `request`, or undefined when it takes no notes. */
16
+ pageToken(request: Pick<IncomingMessage, "headers">): string | undefined;
17
+ handle(request: IncomingMessage, response: ServerResponse): Promise<void>;
18
+ /** Resolves once every saved page a note started rendering again is written. */
19
+ settled(): Promise<void>;
20
+ }
21
+ export declare function createNotesWriter(options: NotesWriterOptions): NotesWriter;
@@ -0,0 +1,140 @@
1
+ // POST /api/notes, the one write an Observer server accepts: a reviewer note on a run it serves.
2
+ // A server takes it only while it listens on loopback and is not exposed, with the token it put in
3
+ // the page it rendered, and from that page's own origin. Every other request is refused before its
4
+ // body is read past 16 KiB. A page from another site can send a POST to a loopback port, but it
5
+ // cannot read the token from the page or send this origin, and a name rebound to 127.0.0.1 arrives
6
+ // with its own Host.
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";
10
+ 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";
14
+ const MAX_BODY_BYTES = 16 * 1024;
15
+ /** The HTTP status for a refusal from the notes store. */
16
+ const STORE_STATUS = {
17
+ HUMANISH_NOTE_INVALID: 400,
18
+ HUMANISH_NOTE_UNKNOWN_PARTICIPANT: 400,
19
+ HUMANISH_NOTE_OUTSIDE_RUN: 400,
20
+ HUMANISH_NOTE_NO_CLOCK: 409,
21
+ HUMANISH_INVALID_RUN_BUNDLE: 409,
22
+ HUMANISH_NOTES_UNREADABLE: 409,
23
+ HUMANISH_NOTES_FULL: 409,
24
+ HUMANISH_NOTE_ID_TAKEN: 409,
25
+ };
26
+ /** The Host values a loopback page uses for this port. */
27
+ function loopbackHosts(port) {
28
+ return new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
29
+ }
30
+ function reply(response, status, body, close = false) {
31
+ response.writeHead(status, {
32
+ "content-type": "application/json; charset=utf-8",
33
+ "cache-control": "no-store",
34
+ ...(status === 405 ? { allow: "POST" } : {}),
35
+ // A refused request's body is never read, so the connection cannot carry another request.
36
+ ...(close ? { connection: "close" } : {}),
37
+ });
38
+ response.end(`${JSON.stringify(body)}\n`);
39
+ }
40
+ function refuse(response, status, code, message) {
41
+ reply(response, status, { ok: false, error: { code, message } }, true);
42
+ }
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
+ function sameToken(sent, token) {
64
+ if (typeof sent !== "string")
65
+ return false;
66
+ const left = Buffer.from(sent);
67
+ const right = Buffer.from(token);
68
+ return left.length === right.length && timingSafeEqual(left, right);
69
+ }
70
+ /** The note in a request body, or null when the body is not one. */
71
+ function noteInput(body) {
72
+ let value;
73
+ try {
74
+ value = JSON.parse(body.toString("utf8"));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
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
+ };
93
+ }
94
+ export function createNotesWriter(options) {
95
+ const token = randomBytes(32).toString("base64url");
96
+ // Renders run one after another, so an older render cannot overwrite a newer one.
97
+ let refreshes = Promise.resolve();
98
+ return {
99
+ pageToken(request) {
100
+ return !options.exposed && hostAllowed(request.headers.host, loopbackHosts(options.port()))
101
+ ? token
102
+ : undefined;
103
+ },
104
+ async handle(request, response) {
105
+ if (request.method !== "POST")
106
+ return refuse(response, 405, "HUMANISH_NOTES_METHOD", "Notes are added with POST.");
107
+ if (options.exposed)
108
+ return refuse(response, 403, "HUMANISH_NOTES_EXPOSED", `This Observer is shared beyond this machine, so it does not take notes. Add notes from an Observer served on 127.0.0.1, or with \`${cli("notes <run> --add")}\`.`);
109
+ const host = request.headers.host?.trim().toLowerCase();
110
+ if (host === undefined ||
111
+ !hostAllowed(host, loopbackHosts(options.port())) ||
112
+ request.headers.origin !== `http://${host}`)
113
+ 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))
115
+ 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
+ if (!/^application\/json(?:\s*;|$)/i.test(request.headers["content-type"] ?? ""))
117
+ return refuse(response, 415, "HUMANISH_NOTES_BODY", "A note is sent as JSON with runId, atMs, participant and text.");
118
+ 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
+ if (Number(request.headers["content-length"] ?? 0) > MAX_BODY_BYTES)
120
+ return tooLarge();
121
+ const body = await readBody(request);
122
+ if (body === null)
123
+ return tooLarge();
124
+ const input = noteInput(body);
125
+ if (input === null)
126
+ 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
+ const prepared = await options.resolveRun(input.runId).catch(() => null);
128
+ if (prepared === null)
129
+ return refuse(response, 404, "HUMANISH_RUN_NOT_FOUND", "This server does not serve that run, so it cannot take a note for it.");
130
+ const added = await addRunNote(prepared, input);
131
+ if (!added.ok)
132
+ return refuse(response, STORE_STATUS[added.error.code], added.error.code, added.error.message);
133
+ reply(response, 201, added);
134
+ refreshes = refreshes.then(() => options.refresh(prepared).then(() => undefined, () => undefined));
135
+ },
136
+ settled() {
137
+ return refreshes;
138
+ },
139
+ };
140
+ }
@@ -14,7 +14,14 @@ export interface PinnedDirectory {
14
14
  /** When set, a file's bytes are served only if this accepts their sha256. */
15
15
  readonly admitsContent?: (relativePath: string, sha256: string) => boolean;
16
16
  }
17
- export declare function readContainedFile(root: PinnedDirectory, filePathInput: string): Promise<Buffer | null>;
17
+ /**
18
+ * The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
19
+ * file larger than that is refused from its opened size and the read stops there, so a large file
20
+ * is never held in memory.
21
+ */
22
+ export declare function readContainedFile(root: PinnedDirectory, filePathInput: string, options?: {
23
+ maxBytes?: number;
24
+ }): Promise<Buffer | null>;
18
25
  export declare function openContainedFile(root: PinnedDirectory, filePathInput: string): Promise<{
19
26
  handle: FileHandle;
20
27
  size: number;
@@ -6,12 +6,21 @@ import { constants as fsConstants } from "node:fs";
6
6
  import { lstat, open, realpath } from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { isPathInside, isSafeRunIdSegment } from "../run/paths.js";
9
- export async function readContainedFile(root, filePathInput) {
9
+ /**
10
+ * The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
11
+ * file larger than that is refused from its opened size and the read stops there, so a large file
12
+ * is never held in memory.
13
+ */
14
+ export async function readContainedFile(root, filePathInput, options = {}) {
10
15
  const opened = await openContainedFile(root, filePathInput);
11
16
  if (!opened)
12
17
  return null;
13
18
  try {
14
- const body = await opened.handle.readFile();
19
+ const body = options.maxBytes === undefined
20
+ ? await opened.handle.readFile()
21
+ : await readAtMost(opened.handle, opened.size, options.maxBytes);
22
+ if (body === null)
23
+ return null;
15
24
  // A write that landed during the read changed the file's mtime and ctime.
16
25
  if (root.admitsFile !== undefined &&
17
26
  !root.admitsFile(relativeToRoot(root, filePathInput), await opened.handle.stat({ bigint: true }))) {
@@ -41,7 +50,9 @@ export async function openContainedFile(root, filePathInput) {
41
50
  const expectedStats = await inspectContainedRegularFile(root, filePath);
42
51
  if (!expectedStats)
43
52
  return null;
44
- handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
53
+ // O_NONBLOCK: a leaf swapped for a FIFO after the checks above opens at once, without waiting
54
+ // for a writer, and the descriptor checks below refuse it.
55
+ handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
45
56
  const openedStats = await handle.stat({ bigint: true });
46
57
  if (!openedStats.isFile() ||
47
58
  openedStats.nlink !== 1n ||
@@ -68,6 +79,24 @@ export async function openContainedFile(root, filePathInput) {
68
79
  return null;
69
80
  }
70
81
  }
82
+ /** At most `maxBytes` from the start of an opened file, or null when it holds more. */
83
+ async function readAtMost(handle, size, maxBytes) {
84
+ if (size > maxBytes)
85
+ return null;
86
+ const chunks = [];
87
+ let total = 0;
88
+ for (;;) {
89
+ const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total));
90
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
91
+ if (bytesRead === 0)
92
+ return Buffer.concat(chunks, total);
93
+ total += bytesRead;
94
+ // The file grew past the limit after it was opened.
95
+ if (total > maxBytes)
96
+ return null;
97
+ chunks.push(chunk.subarray(0, bytesRead));
98
+ }
99
+ }
71
100
  /** The sha256 of an opened file, read by position so the handle's own offset is untouched. */
72
101
  export async function sha256OfOpenedFile(handle) {
73
102
  const hash = createHash("sha256");
@@ -12,12 +12,14 @@ import { loadAnalysis } from "../analysis/load.js";
12
12
  import { physicalCwdOf, runIdOf, bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
13
13
  import { writeContainedOutputFile } from "../run/contained-output.js";
14
14
  import { loadRunBundlePrepared, readRunJsonIfExists } from "../run/locate.js";
15
+ import { readRunNotes } from "../run/notes.js";
15
16
  import { RUN_STATUS_FILE } from "../run/status.js";
16
17
  import { verifyRunPrepared } from "../verify/verify.js";
17
18
  import { renderObserverHtml } from "./artifact.js";
18
19
  import { buildObserverData } from "./data.js";
19
20
  import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin } from "./http.js";
20
21
  import { listenOnLoopback } from "./listen.js";
22
+ import { createNotesWriter, NOTES_PATH } from "./notes-route.js";
21
23
  import { pinDirectChildDirectory, pinDirectory, readContainedFile, } from "./pinned-files.js";
22
24
  import { buildHistoryIndex, matchRunRoute, serveRunPath, writeResponse, } from "./run-routes.js";
23
25
  import { cli } from "../cli/invocation.js";
@@ -114,7 +116,7 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
114
116
  };
115
117
  await writeContainedOutputFile(preparedRunPaths, path.join("observer", "observer-data.json"), `${JSON.stringify(observerData, null, 2)}\n`, "utf8");
116
118
  await writeContainedOutputFile(preparedRunPaths, path.join("observer", "study-analysis.json"), `${JSON.stringify(analysis, null, 2)}\n`, "utf8");
117
- await writeContainedOutputFile(preparedRunPaths, path.join("observer", "index.html"), renderObserverHtml(observerData, { analysis }), "utf8");
119
+ await writeContainedOutputFile(preparedRunPaths, path.join("observer", "index.html"), renderObserverHtml(observerData, { analysis, notes: await readRunNotes(preparedRunPaths) }), "utf8");
118
120
  await validatePreparedRunArtifactPaths(preparedRunPaths);
119
121
  const relativeObserverPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "index.html");
120
122
  const relativeObserverDataPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "observer-data.json");
@@ -180,6 +182,16 @@ async function resolveObserverRunSelection(cwd, runInput) {
180
182
  return null;
181
183
  return { runId: pointer.runId, runRoot, runsRoot };
182
184
  }
185
+ /**
186
+ * The served run history. Exposed watch and selected-run viewers pass `onlyRun` and cannot
187
+ * enumerate other runs; full-library loopback viewers get the complete project index.
188
+ */
189
+ async function writeHistory(response, proofRoot, onlyRun) {
190
+ const history = await buildHistoryIndex(proofRoot);
191
+ const runs = onlyRun === null ? history.runs : history.runs.filter((run) => run.runId === onlyRun);
192
+ const listed = onlyRun === null ? history : { latestRunId: runs.length > 0 ? onlyRun : null, runs };
193
+ writeResponse(response, 200, JSON.stringify(listed, null, 2), "application/json; charset=utf-8");
194
+ }
183
195
  export async function serveObserver(result, options = {}) {
184
196
  if (!result.ok || !result.observerPath) {
185
197
  throw new Error("Cannot serve an observer result that did not render successfully.");
@@ -206,6 +218,14 @@ export async function serveObserver(result, options = {}) {
206
218
  const runtimeStreamUrls = () => observerRuntimeStreamUrls.get(result) ?? [];
207
219
  const exposed = options.exposed === true;
208
220
  const scopedToRun = exposed || options.scope === "run";
221
+ let port = 0;
222
+ // Notes are taken for the selected run only, whose paths this server already holds.
223
+ const notes = createNotesWriter({
224
+ exposed,
225
+ port: () => port,
226
+ resolveRun: async (runId) => (runId === result.run ? preparedRunPaths : null),
227
+ refresh: (prepared) => renderObserver(cwd, result.run, { open: false, expectedRun: prepared }),
228
+ });
209
229
  // Host allowlist for exposed mode (DNS-rebinding defense, identical to the run-library surface).
210
230
  // Seeded with the loopback names after bind; addPublicOrigin extends it with the tunnel/public-url
211
231
  // host. Never consulted in loopback (non-exposed) mode.
@@ -224,21 +244,22 @@ export async function serveObserver(result, options = {}) {
224
244
  }
225
245
  }
226
246
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`);
247
+ if (url.pathname === NOTES_PATH) {
248
+ await notes.handle(request, response);
249
+ return;
250
+ }
251
+ // Adding a note is the only write; nothing else is read with another method.
252
+ if (request.method !== "GET" && request.method !== "HEAD") {
253
+ writeResponse(response, 405, "Method Not Allowed", "text/plain; charset=utf-8");
254
+ return;
255
+ }
227
256
  if (url.pathname === "/") {
228
257
  response.writeHead(302, { location: "/observer/index.html" });
229
258
  response.end();
230
259
  return;
231
260
  }
232
261
  if (url.pathname === "/_humanish/history.json") {
233
- const history = await buildHistoryIndex(proofRoot);
234
- // Exposed watch and selected-run viewers cannot enumerate other runs. Full-library
235
- // loopback viewers retain the complete project index.
236
- if (scopedToRun) {
237
- const attachedRuns = history.runs.filter((entry) => entry.runId === result.run);
238
- writeResponse(response, 200, JSON.stringify({ latestRunId: attachedRuns.length > 0 ? result.run : null, runs: attachedRuns }, null, 2), "application/json; charset=utf-8");
239
- return;
240
- }
241
- writeResponse(response, 200, JSON.stringify(history, null, 2), "application/json; charset=utf-8");
262
+ await writeHistory(response, proofRoot, scopedToRun ? result.run : null);
242
263
  return;
243
264
  }
244
265
  if (url.pathname.startsWith("/_humanish/runs/")) {
@@ -257,10 +278,10 @@ export async function serveObserver(result, options = {}) {
257
278
  writeResponse(response, 404, "Run not found", "text/plain; charset=utf-8");
258
279
  return;
259
280
  }
260
- await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, runRoute.runId === result.run ? runtimeStreamUrls() : [], request);
281
+ await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, runRoute.runId === result.run ? runtimeStreamUrls() : [], request, runRoute.runId === result.run ? notes.pageToken(request) : undefined);
261
282
  return;
262
283
  }
263
- await serveRunPath(runRoot, decodeURIComponent(url.pathname.slice(1)), response, runtimeStreamUrls(), request);
284
+ await serveRunPath(runRoot, decodeURIComponent(url.pathname.slice(1)), response, runtimeStreamUrls(), request, notes.pageToken(request));
264
285
  }
265
286
  catch {
266
287
  writeResponse(response, 500, "Observer request failed", "text/plain; charset=utf-8");
@@ -269,7 +290,7 @@ export async function serveObserver(result, options = {}) {
269
290
  const server = createServer((request, response) => {
270
291
  void handleRequest(request, response);
271
292
  });
272
- const port = await listenOnLoopback(server, options.port ?? 0);
293
+ port = await listenOnLoopback(server, options.port ?? 0);
273
294
  if (exposed) {
274
295
  hostAllowlist.add(`127.0.0.1:${port}`);
275
296
  hostAllowlist.add(`localhost:${port}`);
@@ -295,7 +316,10 @@ export async function serveObserver(result, options = {}) {
295
316
  hostAllowlist.add(parsed.host);
296
317
  }
297
318
  },
298
- close: () => closeServer(server),
319
+ close: async () => {
320
+ await notes.settled();
321
+ await closeServer(server);
322
+ },
299
323
  };
300
324
  }
301
325
  /**
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type PreparedRunArtifactPaths } from "../run/paths.js";
2
3
  import type { RunDisplay } from "../run/display.js";
3
4
  import { type ObserverData } from "./data.js";
4
5
  import { type PinnedDirectory } from "./pinned-files.js";
@@ -10,8 +11,13 @@ export interface ObserverRuntimeStreamUrl {
10
11
  * evidence (keyframe replay/screenshot) instead of rendering "sandbox not found". */
11
12
  ended?: boolean;
12
13
  }
13
- /** internal: consumed by src/observer/serve.ts */
14
- export declare function serveRunPath(runRoot: PinnedDirectory, relativePath: string, response: ServerResponse, runtimeStreamUrls?: ObserverRuntimeStreamUrl[], request?: Pick<IncomingMessage, "method" | "headers">): Promise<void>;
14
+ /** The run's artifact paths, only when they are still the pinned directory being served. */
15
+ export declare function servedRunPaths(runRoot: PinnedDirectory): Promise<PreparedRunArtifactPaths>;
16
+ /**
17
+ * internal: consumed by src/observer/serve.ts. `notesToken` goes into the run's Observer page so
18
+ * it can add notes; a server passes it only for a run it takes notes for.
19
+ */
20
+ export declare function serveRunPath(runRoot: PinnedDirectory, relativePath: string, response: ServerResponse, runtimeStreamUrls?: ObserverRuntimeStreamUrl[], request?: Pick<IncomingMessage, "method" | "headers">, notesToken?: string): Promise<void>;
15
21
  /** internal: exported for the stream lifecycle tests. */
16
22
  export declare function withRuntimeStreamUrls(data: ObserverData, runtimeStreamUrls: ObserverRuntimeStreamUrl[]): ObserverData;
17
23
  /** internal: consumed by src/observer/serve.ts */