humanish 0.113.0 → 0.115.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 (109) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -0
  3. package/README.md +1 -1
  4. package/dist/actors/closing-report-limits.d.ts +11 -0
  5. package/dist/actors/closing-report-limits.js +13 -0
  6. package/dist/actors/closing-report.d.ts +68 -0
  7. package/dist/actors/closing-report.js +55 -0
  8. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  9. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  10. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  11. package/dist/actors/computer-use/loop/debrief.js +10 -46
  12. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  13. package/dist/actors/computer-use/loop/impressions.js +6 -1
  14. package/dist/actors/computer-use/loop.d.ts +0 -1
  15. package/dist/actors/computer-use/loop.js +3 -3
  16. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  17. package/dist/actors/computer-use/openai-conversation.js +12 -36
  18. package/dist/actors/computer-use/openai-provider.js +11 -5
  19. package/dist/actors/contract.d.ts +6 -22
  20. package/dist/actors/contract.js +0 -13
  21. package/dist/analysis/admission.d.ts +47 -0
  22. package/dist/analysis/admission.js +65 -0
  23. package/dist/analysis/automatic-completion.d.ts +0 -6
  24. package/dist/analysis/automatic-completion.js +3 -3
  25. package/dist/analysis/automatic-config.d.ts +10 -2
  26. package/dist/analysis/automatic-config.js +29 -14
  27. package/dist/analysis/automatic.js +7 -0
  28. package/dist/analysis/execute.d.ts +24 -5
  29. package/dist/analysis/execute.js +61 -27
  30. package/dist/analysis/finding-lead.d.ts +27 -0
  31. package/dist/analysis/finding-lead.js +23 -0
  32. package/dist/analysis/image-tokens.d.ts +2 -0
  33. package/dist/analysis/image-tokens.js +1 -1
  34. package/dist/analysis/job.d.ts +9 -1
  35. package/dist/analysis/job.js +11 -2
  36. package/dist/analysis/service.js +8 -3
  37. package/dist/cli/commands/analyze.js +7 -2
  38. package/dist/cli/commands/notes.d.ts +3 -0
  39. package/dist/cli/commands/notes.js +145 -0
  40. package/dist/cli/commands/observe.js +1 -1
  41. package/dist/cli/commands/study-run.js +2 -1
  42. package/dist/cli/findings.js +11 -30
  43. package/dist/cli/io.d.ts +1 -0
  44. package/dist/cli/io.js +8 -12
  45. package/dist/cli/program.js +2 -0
  46. package/dist/cli/telemetry.js +10 -22
  47. package/dist/cli/update-check.js +6 -18
  48. package/dist/cli/user-config.d.ts +11 -0
  49. package/dist/cli/user-config.js +25 -0
  50. package/dist/comms/email-catch.js +7 -31
  51. package/dist/evidence/encoded-text.d.ts +17 -3
  52. package/dist/evidence/encoded-text.js +121 -46
  53. package/dist/evidence/redaction.d.ts +11 -0
  54. package/dist/evidence/redaction.js +21 -10
  55. package/dist/evidence/secret-scrub.d.ts +14 -1
  56. package/dist/evidence/secret-scrub.js +125 -28
  57. package/dist/feedback/draft.d.ts +19 -0
  58. package/dist/feedback/draft.js +62 -15
  59. package/dist/feedback/export-bundle.js +3 -3
  60. package/dist/feedback/export.js +50 -20
  61. package/dist/feedback/feedback.js +29 -2
  62. package/dist/keys/key-resolution.js +12 -17
  63. package/dist/observer/artifact.d.ts +5 -0
  64. package/dist/observer/artifact.js +18 -2
  65. package/dist/observer/http.d.ts +7 -0
  66. package/dist/observer/http.js +19 -1
  67. package/dist/observer/notes-route.d.ts +20 -0
  68. package/dist/observer/notes-route.js +120 -0
  69. package/dist/observer/pinned-files.d.ts +8 -1
  70. package/dist/observer/pinned-files.js +17 -3
  71. package/dist/observer/render.js +39 -14
  72. package/dist/observer/run-routes.d.ts +8 -2
  73. package/dist/observer/run-routes.js +32 -6
  74. package/dist/observer/serve.d.ts +3 -0
  75. package/dist/observer/serve.js +29 -3
  76. package/dist/observer-app.html +7 -6
  77. package/dist/run/contained-output.d.ts +8 -0
  78. package/dist/run/contained-output.js +27 -0
  79. package/dist/run/evidence-files.d.ts +7 -0
  80. package/dist/run/evidence-files.js +24 -14
  81. package/dist/run/note-files.d.ts +54 -0
  82. package/dist/run/note-files.js +212 -0
  83. package/dist/run/note-shape.d.ts +35 -0
  84. package/dist/run/note-shape.js +20 -0
  85. package/dist/run/notes.d.ts +34 -0
  86. package/dist/run/notes.js +192 -0
  87. package/dist/run/participant-caption.d.ts +9 -0
  88. package/dist/run/participant-caption.js +19 -0
  89. package/dist/run/participant-records.d.ts +2 -0
  90. package/dist/run/participant-records.js +17 -2
  91. package/dist/run/transient-comms-secrets.d.ts +6 -5
  92. package/dist/run/transient-comms-secrets.js +19 -24
  93. package/dist/study/doctor.js +1 -1
  94. package/dist/study/preflight-result.js +2 -1
  95. package/dist/study/summary.d.ts +4 -5
  96. package/dist/study/summary.js +6 -4
  97. package/dist/study/warnings.js +2 -1
  98. package/dist/tui-app.js +1 -1
  99. package/docs/architecture/actor-contract.md +3 -3
  100. package/docs/architecture/restricted-codex-analysis.md +1 -1
  101. package/docs/architecture/state-driven-executor.md +1 -1
  102. package/docs/contracts/errors.md +2 -2
  103. package/docs/contracts/run-bundle.md +61 -0
  104. package/docs/contracts/schemas.md +17 -14
  105. package/docs/contracts/study-analysis.md +35 -9
  106. package/docs/principles/invariants-and-defaults.md +2 -2
  107. package/docs/product/automatic-analysis.md +22 -16
  108. package/package.json +8 -7
  109. package/skills/humanish/SKILL.md +6 -2
@@ -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) => {
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { PreparedRunArtifactPaths } from "../run/paths.js";
3
+ export interface NotesWriterOptions {
4
+ /** An exposed server refuses every note and gives its pages no token. */
5
+ exposed: boolean;
6
+ /** The port the server listens on, once it does. */
7
+ port(): number;
8
+ /** The run's paths when this server serves it, otherwise null. */
9
+ resolveRun(runId: string): Promise<PreparedRunArtifactPaths | null>;
10
+ /** Renders the run's saved Observer page again after a note is added. */
11
+ refresh(prepared: PreparedRunArtifactPaths): Promise<unknown>;
12
+ }
13
+ export interface NotesWriter {
14
+ /** The token for a page this server renders for `request`, or undefined when it takes no notes. */
15
+ pageToken(request: Pick<IncomingMessage, "headers">): string | undefined;
16
+ handle(request: IncomingMessage, response: ServerResponse): Promise<void>;
17
+ /** Resolves once every saved page a note started rendering again is written. */
18
+ settled(): Promise<void>;
19
+ }
20
+ export declare function createNotesWriter(options: NotesWriterOptions): NotesWriter;
@@ -0,0 +1,120 @@
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 { z } from "zod";
9
+ import { MAX_NOTE_TEXT, NOTES_TOKEN_HEADER } from "../run/note-shape.js";
10
+ import { addRunNote } from "../run/notes.js";
11
+ import { cli } from "../cli/invocation.js";
12
+ import { hostAllowed, readBodyAtMost } from "./http.js";
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
+ });
27
+ /** The HTTP status for a refusal from the notes store. */
28
+ const STORE_STATUS = {
29
+ HUMANISH_NOTE_INVALID: 400,
30
+ HUMANISH_NOTE_UNKNOWN_PARTICIPANT: 400,
31
+ HUMANISH_NOTE_OUTSIDE_RUN: 400,
32
+ HUMANISH_NOTE_NO_CLOCK: 409,
33
+ HUMANISH_INVALID_RUN_BUNDLE: 409,
34
+ HUMANISH_NOTES_UNREADABLE: 409,
35
+ HUMANISH_NOTES_FULL: 409,
36
+ HUMANISH_NOTE_ID_TAKEN: 409,
37
+ };
38
+ /** The Host values a loopback page uses for this port. */
39
+ function loopbackHosts(port) {
40
+ return new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
41
+ }
42
+ function reply(response, status, body, close = false) {
43
+ response.writeHead(status, {
44
+ "content-type": "application/json; charset=utf-8",
45
+ "cache-control": "no-store",
46
+ ...(status === 405 ? { allow: "POST" } : {}),
47
+ // A refused request's body is never read, so the connection cannot carry another request.
48
+ ...(close ? { connection: "close" } : {}),
49
+ });
50
+ response.end(`${JSON.stringify(body)}\n`);
51
+ }
52
+ function refuse(response, status, code, message) {
53
+ reply(response, status, { ok: false, error: { code, message } }, true);
54
+ }
55
+ function sameToken(sent, token) {
56
+ if (typeof sent !== "string")
57
+ return false;
58
+ const left = Buffer.from(sent);
59
+ const right = Buffer.from(token);
60
+ return left.length === right.length && timingSafeEqual(left, right);
61
+ }
62
+ /** The note request in a body, or null when the body is not one. */
63
+ function noteRequest(body) {
64
+ let value;
65
+ try {
66
+ value = JSON.parse(body.toString("utf8"));
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ const parsed = noteRequestSchema.safeParse(value);
72
+ return parsed.success ? parsed.data : null;
73
+ }
74
+ export function createNotesWriter(options) {
75
+ const token = randomBytes(32).toString("base64url");
76
+ // Renders run one after another, so an older render cannot overwrite a newer one.
77
+ let refreshes = Promise.resolve();
78
+ return {
79
+ pageToken(request) {
80
+ return !options.exposed && hostAllowed(request.headers.host, loopbackHosts(options.port()))
81
+ ? token
82
+ : undefined;
83
+ },
84
+ async handle(request, response) {
85
+ if (request.method !== "POST")
86
+ return refuse(response, 405, "HUMANISH_NOTES_METHOD", "Notes are added with POST.");
87
+ if (options.exposed)
88
+ 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")}\`.`);
89
+ const host = request.headers.host?.trim().toLowerCase();
90
+ if (host === undefined ||
91
+ !hostAllowed(host, loopbackHosts(options.port())) ||
92
+ request.headers.origin !== `http://${host}`)
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.");
94
+ if (!sameToken(request.headers[NOTES_TOKEN_HEADER], token))
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.");
96
+ if (!/^application\/json(?:\s*;|$)/i.test(request.headers["content-type"] ?? ""))
97
+ return refuse(response, 415, "HUMANISH_NOTES_BODY", "A note is sent as JSON with runId, atMs, participant and text.");
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.`);
99
+ if (Number(request.headers["content-length"] ?? 0) > MAX_BODY_BYTES)
100
+ return tooLarge();
101
+ const body = await readBodyAtMost(request, MAX_BODY_BYTES);
102
+ if (body === null)
103
+ return tooLarge();
104
+ const input = noteRequest(body);
105
+ if (input === null)
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.");
107
+ const prepared = await options.resolveRun(input.runId).catch(() => null);
108
+ if (prepared === null)
109
+ return refuse(response, 404, "HUMANISH_RUN_NOT_FOUND", "This server does not serve that run, so it cannot take a note for it.");
110
+ const added = await addRunNote(prepared, input);
111
+ if (!added.ok)
112
+ return refuse(response, STORE_STATUS[added.error.code], added.error.code, added.error.message);
113
+ reply(response, 201, added);
114
+ refreshes = refreshes.then(() => options.refresh(prepared).then(() => undefined, () => undefined));
115
+ },
116
+ settled() {
117
+ return refreshes;
118
+ },
119
+ };
120
+ }
@@ -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;
@@ -5,13 +5,25 @@ import { createHash } from "node:crypto";
5
5
  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
+ import { readOpenedAtMost } from "../run/evidence-files.js";
8
9
  import { isPathInside, isSafeRunIdSegment } from "../run/paths.js";
9
- export async function readContainedFile(root, filePathInput) {
10
+ /**
11
+ * The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
12
+ * file larger than that is refused from its opened size and the read stops there, so a large file
13
+ * is never held in memory.
14
+ */
15
+ export async function readContainedFile(root, filePathInput, options = {}) {
10
16
  const opened = await openContainedFile(root, filePathInput);
11
17
  if (!opened)
12
18
  return null;
13
19
  try {
14
- const body = await opened.handle.readFile();
20
+ const body = options.maxBytes === undefined
21
+ ? await opened.handle.readFile()
22
+ : opened.size > options.maxBytes
23
+ ? null
24
+ : await readOpenedAtMost(opened.handle, options.maxBytes);
25
+ if (body === null)
26
+ return null;
15
27
  // A write that landed during the read changed the file's mtime and ctime.
16
28
  if (root.admitsFile !== undefined &&
17
29
  !root.admitsFile(relativeToRoot(root, filePathInput), await opened.handle.stat({ bigint: true }))) {
@@ -41,7 +53,9 @@ export async function openContainedFile(root, filePathInput) {
41
53
  const expectedStats = await inspectContainedRegularFile(root, filePath);
42
54
  if (!expectedStats)
43
55
  return null;
44
- handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
56
+ // O_NONBLOCK: a leaf swapped for a FIFO after the checks above opens at once, without waiting
57
+ // for a writer, and the descriptor checks below refuse it.
58
+ handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
45
59
  const openedStats = await handle.stat({ bigint: true });
46
60
  if (!openedStats.isFile() ||
47
61
  openedStats.nlink !== 1n ||
@@ -12,12 +12,15 @@ 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/note-files.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 { NOTES_PATH } from "../run/note-shape.js";
23
+ import { createNotesWriter } from "./notes-route.js";
21
24
  import { pinDirectChildDirectory, pinDirectory, readContainedFile, } from "./pinned-files.js";
22
25
  import { buildHistoryIndex, matchRunRoute, serveRunPath, writeResponse, } from "./run-routes.js";
23
26
  import { cli } from "../cli/invocation.js";
@@ -114,7 +117,7 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
114
117
  };
115
118
  await writeContainedOutputFile(preparedRunPaths, path.join("observer", "observer-data.json"), `${JSON.stringify(observerData, null, 2)}\n`, "utf8");
116
119
  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");
120
+ await writeContainedOutputFile(preparedRunPaths, path.join("observer", "index.html"), renderObserverHtml(observerData, { analysis, notes: await readRunNotes(preparedRunPaths) }), "utf8");
118
121
  await validatePreparedRunArtifactPaths(preparedRunPaths);
119
122
  const relativeObserverPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "index.html");
120
123
  const relativeObserverDataPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "observer-data.json");
@@ -180,6 +183,16 @@ async function resolveObserverRunSelection(cwd, runInput) {
180
183
  return null;
181
184
  return { runId: pointer.runId, runRoot, runsRoot };
182
185
  }
186
+ /**
187
+ * The served run history. Exposed watch and selected-run viewers pass `onlyRun` and cannot
188
+ * enumerate other runs; full-library loopback viewers get the complete project index.
189
+ */
190
+ async function writeHistory(response, proofRoot, onlyRun) {
191
+ const history = await buildHistoryIndex(proofRoot);
192
+ const runs = onlyRun === null ? history.runs : history.runs.filter((run) => run.runId === onlyRun);
193
+ const listed = onlyRun === null ? history : { latestRunId: runs.length > 0 ? onlyRun : null, runs };
194
+ writeResponse(response, 200, JSON.stringify(listed, null, 2), "application/json; charset=utf-8");
195
+ }
183
196
  export async function serveObserver(result, options = {}) {
184
197
  if (!result.ok || !result.observerPath) {
185
198
  throw new Error("Cannot serve an observer result that did not render successfully.");
@@ -206,6 +219,14 @@ export async function serveObserver(result, options = {}) {
206
219
  const runtimeStreamUrls = () => observerRuntimeStreamUrls.get(result) ?? [];
207
220
  const exposed = options.exposed === true;
208
221
  const scopedToRun = exposed || options.scope === "run";
222
+ let port = 0;
223
+ // Notes are taken for the selected run only, whose paths this server already holds.
224
+ const notes = createNotesWriter({
225
+ exposed,
226
+ port: () => port,
227
+ resolveRun: async (runId) => (runId === result.run ? preparedRunPaths : null),
228
+ refresh: (prepared) => renderObserver(cwd, result.run, { open: false, expectedRun: prepared }),
229
+ });
209
230
  // Host allowlist for exposed mode (DNS-rebinding defense, identical to the run-library surface).
210
231
  // Seeded with the loopback names after bind; addPublicOrigin extends it with the tunnel/public-url
211
232
  // host. Never consulted in loopback (non-exposed) mode.
@@ -224,21 +245,22 @@ export async function serveObserver(result, options = {}) {
224
245
  }
225
246
  }
226
247
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`);
248
+ if (url.pathname === NOTES_PATH) {
249
+ await notes.handle(request, response);
250
+ return;
251
+ }
252
+ // Adding a note is the only write; nothing else is read with another method.
253
+ if (request.method !== "GET" && request.method !== "HEAD") {
254
+ writeResponse(response, 405, "Method Not Allowed", "text/plain; charset=utf-8");
255
+ return;
256
+ }
227
257
  if (url.pathname === "/") {
228
258
  response.writeHead(302, { location: "/observer/index.html" });
229
259
  response.end();
230
260
  return;
231
261
  }
232
262
  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");
263
+ await writeHistory(response, proofRoot, scopedToRun ? result.run : null);
242
264
  return;
243
265
  }
244
266
  if (url.pathname.startsWith("/_humanish/runs/")) {
@@ -257,10 +279,10 @@ export async function serveObserver(result, options = {}) {
257
279
  writeResponse(response, 404, "Run not found", "text/plain; charset=utf-8");
258
280
  return;
259
281
  }
260
- await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, runRoute.runId === result.run ? runtimeStreamUrls() : [], request);
282
+ await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, runRoute.runId === result.run ? runtimeStreamUrls() : [], request, runRoute.runId === result.run ? notes.pageToken(request) : undefined);
261
283
  return;
262
284
  }
263
- await serveRunPath(runRoot, decodeURIComponent(url.pathname.slice(1)), response, runtimeStreamUrls(), request);
285
+ await serveRunPath(runRoot, decodeURIComponent(url.pathname.slice(1)), response, runtimeStreamUrls(), request, notes.pageToken(request));
264
286
  }
265
287
  catch {
266
288
  writeResponse(response, 500, "Observer request failed", "text/plain; charset=utf-8");
@@ -269,7 +291,7 @@ export async function serveObserver(result, options = {}) {
269
291
  const server = createServer((request, response) => {
270
292
  void handleRequest(request, response);
271
293
  });
272
- const port = await listenOnLoopback(server, options.port ?? 0);
294
+ port = await listenOnLoopback(server, options.port ?? 0);
273
295
  if (exposed) {
274
296
  hostAllowlist.add(`127.0.0.1:${port}`);
275
297
  hostAllowlist.add(`localhost:${port}`);
@@ -295,7 +317,10 @@ export async function serveObserver(result, options = {}) {
295
317
  hostAllowlist.add(parsed.host);
296
318
  }
297
319
  },
298
- close: () => closeServer(server),
320
+ close: async () => {
321
+ await notes.settled();
322
+ await closeServer(server);
323
+ },
299
324
  };
300
325
  }
301
326
  /**
@@ -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 */
@@ -9,6 +9,7 @@ import { listRuns } from "../run/stored-runs.js";
9
9
  import { readAnalysisAccounting } from "../run/costs.js";
10
10
  import { bindExistingRunArtifactPaths, isPathInside, isSafeRunIdSegment, } from "../run/paths.js";
11
11
  import { isRunOutcome } from "../run/bundle-shape.js";
12
+ import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-files.js";
12
13
  import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
13
14
  import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
14
15
  import { renderObserverHtml } from "./artifact.js";
@@ -16,7 +17,7 @@ import { buildObserverData, recordedStreamEmbed, withObserverEndings, } from "./
16
17
  import { buildArtifactSecurityHeaders } from "./http.js";
17
18
  import { assertPinnedDirectory, openContainedFile, pinDirectChildDirectory, readContainedFile, relativeToRoot, sha256OfOpenedFile, } from "./pinned-files.js";
18
19
  /** The run's artifact paths, only when they are still the pinned directory being served. */
19
- async function servedRunPaths(runRoot) {
20
+ export async function servedRunPaths(runRoot) {
20
21
  const runId = path.basename(runRoot.physicalPath);
21
22
  const cwd = path.dirname(path.dirname(path.dirname(runRoot.physicalPath)));
22
23
  const prepared = await bindExistingRunArtifactPaths(cwd, runId);
@@ -57,8 +58,25 @@ async function readServedAnalysisSpend(runRoot) {
57
58
  return undefined;
58
59
  }
59
60
  }
60
- /** internal: consumed by src/observer/serve.ts */
61
- export async function serveRunPath(runRoot, relativePath, response, runtimeStreamUrls = [], request) {
61
+ /**
62
+ * The run's note files as served: each read through the root's checks, so a --safe root's
63
+ * admission and hash checks apply to every note.
64
+ */
65
+ function servedNoteFiles(runRoot) {
66
+ return {
67
+ runId: path.basename(runRoot.physicalPath),
68
+ async directory() {
69
+ await assertPinnedDirectory(runRoot);
70
+ return runRoot.physicalPath;
71
+ },
72
+ read: (relativePath, maxBytes) => readContainedFile(runRoot, path.join(runRoot.physicalPath, relativePath), { maxBytes }),
73
+ };
74
+ }
75
+ /**
76
+ * internal: consumed by src/observer/serve.ts. `notesToken` goes into the run's Observer page so
77
+ * it can add notes; a server passes it only for a run it takes notes for.
78
+ */
79
+ export async function serveRunPath(runRoot, relativePath, response, runtimeStreamUrls = [], request, notesToken) {
62
80
  const root = runRoot.physicalPath;
63
81
  const filePath = path.resolve(root, relativePath === "" ? "observer/index.html" : relativePath);
64
82
  if (!isPathInside(root, filePath)) {
@@ -87,7 +105,12 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
87
105
  return;
88
106
  }
89
107
  const analysis = await readObserverAnalysis(runRoot);
90
- writeResponse(response, 200, renderObserverHtml(observerData, { analysis }), "text/html; charset=utf-8");
108
+ const notes = await readRunNotes(servedNoteFiles(runRoot));
109
+ writeResponse(response, 200, renderObserverHtml(observerData, {
110
+ analysis,
111
+ notes,
112
+ ...(notesToken === undefined ? {} : { notesToken }),
113
+ }), "text/html; charset=utf-8");
91
114
  return;
92
115
  }
93
116
  if (cleanedRelativePath === "observer/observer-data.json") {
@@ -103,7 +126,10 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
103
126
  writeResponse(response, 200, JSON.stringify(await readObserverAnalysis(runRoot)), "application/json; charset=utf-8");
104
127
  return;
105
128
  }
106
- if (path.extname(filePath).toLowerCase() === ".mp4") {
129
+ // Every file under notes/ is read within the note limit, whatever its name, so media handling
130
+ // never streams one.
131
+ const notePath = derivedRoot === RUN_NOTES_DIR;
132
+ if (!notePath && path.extname(filePath).toLowerCase() === ".mp4") {
107
133
  await serveContainedMedia(runRoot, filePath, response, request);
108
134
  return;
109
135
  }
@@ -112,7 +138,7 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
112
138
  return;
113
139
  }
114
140
  try {
115
- const body = await readContainedFile(runRoot, filePath);
141
+ const body = await readContainedFile(runRoot, filePath, notePath ? { maxBytes: MAX_NOTE_FILE_BYTES } : {});
116
142
  if (!body) {
117
143
  writeResponse(response, 404, "Not found", "text/plain; charset=utf-8");
118
144
  return;
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type NotesWriter } from "./notes-route.js";
2
3
  import type { PinnedDirectory } from "./pinned-files.js";
3
4
  import { type AdmittedRun } from "./run-inventory.js";
4
5
  import type { LibraryHistory } from "./library.js";
@@ -70,6 +71,8 @@ export declare function createShareSafetyAdmission(cwd: string, options?: {
70
71
  }): ShareSafetyAdmission;
71
72
  export interface ServeRequestHandlerOptions {
72
73
  proofRoot: PinnedDirectory;
74
+ /** Takes reviewer notes on loopback; without it the library refuses them. */
75
+ notes?: NotesWriter;
73
76
  safe: boolean;
74
77
  admit: (runId: string) => Promise<AdmittedRun | null>;
75
78
  hostAllowlist: ReadonlySet<string>;
@@ -3,7 +3,10 @@ import { runNotFoundMessage } from "../run/run-not-found.js";
3
3
  import { createServer } from "node:http";
4
4
  import path from "node:path";
5
5
  import { pinDirectChildDirectory, pinDirectory } from "./pinned-files.js";
6
- import { buildHistoryIndex, matchRunRoute, serveRunPath } from "./run-routes.js";
6
+ import { NOTES_PATH } from "../run/note-shape.js";
7
+ import { createNotesWriter } from "./notes-route.js";
8
+ import { renderObserver } from "./render.js";
9
+ import { buildHistoryIndex, matchRunRoute, servedRunPaths, serveRunPath } from "./run-routes.js";
7
10
  import { hashRunInventory, inventoryRoot, readRunInventory, } from "./run-inventory.js";
8
11
  import { renderLibraryHtml } from "./library.js";
9
12
  import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin, } from "./http.js";
@@ -104,6 +107,13 @@ export function createServeRequestHandler(options) {
104
107
  for (const [name, value] of Object.entries(buildServeSecurityHeaders())) {
105
108
  response.setHeader(name, value);
106
109
  }
110
+ if (new URL(request.url ?? "/", "http://127.0.0.1").pathname === NOTES_PATH) {
111
+ if (options.notes)
112
+ await options.notes.handle(request, response);
113
+ else
114
+ writeText(response, 405, "Method Not Allowed");
115
+ return;
116
+ }
107
117
  const method = request.method ?? "GET";
108
118
  if (method !== "GET" && method !== "HEAD") {
109
119
  writeText(response, 405, "Method Not Allowed");
@@ -161,7 +171,7 @@ export function createServeRequestHandler(options) {
161
171
  }
162
172
  // The explicit empty runtimeStreamUrls keeps a future refactor from
163
173
  // reintroducing auth-keyed stream injection on the serve surface.
164
- await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, [], request);
174
+ await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, [], request, options.notes?.pageToken(request));
165
175
  return;
166
176
  }
167
177
  writeText(response, 404, "Not found");
@@ -260,8 +270,24 @@ export async function serveObserverLibrary(cwdInput, options) {
260
270
  }
261
271
  const declaredOrigin = options.publicOrigin ? parsePublicOrigin(options.publicOrigin) : null;
262
272
  const hostAllowlist = new Set();
273
+ let port = 0;
274
+ const notes = createNotesWriter({
275
+ exposed: mode !== "loopback",
276
+ port: () => port,
277
+ resolveRun: async (runId) => {
278
+ const pinned = await pinDirectChildDirectory(proofRoot, runId);
279
+ if (!pinned || (options.safe && (await admission.admit(runId)) === null))
280
+ return null;
281
+ return servedRunPaths(pinned);
282
+ },
283
+ refresh: (prepared) => renderObserver(cwd, path.basename(prepared.physicalRunRoot), {
284
+ open: false,
285
+ expectedRun: prepared,
286
+ }),
287
+ });
263
288
  const handler = createServeRequestHandler({
264
289
  proofRoot,
290
+ notes,
265
291
  safe: options.safe,
266
292
  admit: (runId) => admission.admit(runId),
267
293
  hostAllowlist,
@@ -276,7 +302,6 @@ export async function serveObserverLibrary(cwdInput, options) {
276
302
  const server = createServer((request, response) => {
277
303
  void handler(request, response);
278
304
  });
279
- let port;
280
305
  try {
281
306
  port = await listenOnLoopback(server, options.port);
282
307
  }
@@ -314,6 +339,7 @@ export async function serveObserverLibrary(cwdInput, options) {
314
339
  hostAllowlist.add(parsed.host);
315
340
  },
316
341
  close: async () => {
342
+ await notes.settled();
317
343
  const closed = closeServer(server);
318
344
  // Keep-alive sockets would otherwise keep close() pending past the point
319
345
  // the operator believes Ctrl-C tore the server down.