humanish 0.113.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 (68) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/CONTEXT.md +1 -0
  3. package/README.md +1 -1
  4. package/dist/actors/computer-use/openai-conversation.js +9 -7
  5. package/dist/actors/contract.d.ts +4 -2
  6. package/dist/analysis/admission.d.ts +37 -0
  7. package/dist/analysis/admission.js +60 -0
  8. package/dist/analysis/automatic-completion.d.ts +0 -6
  9. package/dist/analysis/automatic-completion.js +3 -3
  10. package/dist/analysis/automatic-config.d.ts +10 -2
  11. package/dist/analysis/automatic-config.js +29 -14
  12. package/dist/analysis/automatic.js +7 -0
  13. package/dist/analysis/execute.d.ts +19 -5
  14. package/dist/analysis/execute.js +58 -27
  15. package/dist/analysis/image-tokens.d.ts +2 -0
  16. package/dist/analysis/image-tokens.js +1 -1
  17. package/dist/analysis/job.d.ts +9 -1
  18. package/dist/analysis/job.js +11 -2
  19. package/dist/analysis/service.js +6 -3
  20. package/dist/cli/commands/analyze.js +7 -2
  21. package/dist/cli/commands/notes.d.ts +3 -0
  22. package/dist/cli/commands/notes.js +144 -0
  23. package/dist/cli/commands/observe.js +1 -1
  24. package/dist/cli/commands/study-run.js +2 -1
  25. package/dist/cli/findings.js +4 -1
  26. package/dist/cli/io.d.ts +1 -0
  27. package/dist/cli/io.js +11 -12
  28. package/dist/cli/program.js +2 -0
  29. package/dist/evidence/encoded-text.d.ts +15 -2
  30. package/dist/evidence/encoded-text.js +68 -4
  31. package/dist/evidence/secret-scrub.d.ts +1 -1
  32. package/dist/evidence/secret-scrub.js +23 -2
  33. package/dist/feedback/draft.d.ts +19 -0
  34. package/dist/feedback/draft.js +51 -1
  35. package/dist/feedback/export-bundle.js +2 -2
  36. package/dist/feedback/export.js +47 -3
  37. package/dist/feedback/feedback.js +42 -2
  38. package/dist/observer/artifact.d.ts +5 -0
  39. package/dist/observer/artifact.js +18 -2
  40. package/dist/observer/notes-route.d.ts +21 -0
  41. package/dist/observer/notes-route.js +140 -0
  42. package/dist/observer/pinned-files.d.ts +8 -1
  43. package/dist/observer/pinned-files.js +32 -3
  44. package/dist/observer/render.js +38 -14
  45. package/dist/observer/run-routes.d.ts +8 -2
  46. package/dist/observer/run-routes.js +47 -6
  47. package/dist/observer/serve.d.ts +3 -0
  48. package/dist/observer/serve.js +28 -3
  49. package/dist/observer-app.html +7 -6
  50. package/dist/run/contained-output.d.ts +7 -0
  51. package/dist/run/contained-output.js +35 -1
  52. package/dist/run/note-files.d.ts +73 -0
  53. package/dist/run/note-files.js +184 -0
  54. package/dist/run/notes.d.ts +37 -0
  55. package/dist/run/notes.js +198 -0
  56. package/dist/run/participant-caption.d.ts +9 -0
  57. package/dist/run/participant-caption.js +19 -0
  58. package/dist/study/doctor.js +1 -1
  59. package/dist/study/preflight-result.js +2 -1
  60. package/dist/study/summary.js +2 -2
  61. package/docs/contracts/errors.md +2 -2
  62. package/docs/contracts/run-bundle.md +57 -0
  63. package/docs/contracts/schemas.md +5 -2
  64. package/docs/contracts/study-analysis.md +26 -8
  65. package/docs/principles/invariants-and-defaults.md +2 -2
  66. package/docs/product/automatic-analysis.md +22 -16
  67. package/package.json +2 -2
  68. package/skills/humanish/SKILL.md +5 -0
@@ -8,6 +8,8 @@ import { hashAnalysisValue } from "../analysis/validation.js";
8
8
  import { formatParticipantOutcomes, formatRunTaskFunnel, participantOutcomeDetails, withCuaReviewProvenance, } from "../run/outcomes.js";
9
9
  import { loadRunBundlePrepared } from "../run/locate.js";
10
10
  import { isFeedbackIdempotencyKey } from "../run/feedback-shape.js";
11
+ import { runNoteFile } from "../run/note-files.js";
12
+ import { formatRunTime, runParticipantCaptions } from "../run/notes.js";
11
13
  import { isRecord } from "../run/type-guards.js";
12
14
  import { feedbackProofCommands, projectFeedbackAcceptanceProof } from "./proof.js";
13
15
  export const FEEDBACK_SCHEMA = "humanish.feedback.v1";
@@ -224,6 +226,54 @@ function citedImpressions(bundle, evidence) {
224
226
  : [{ streamId: item.streamId, kind: impression.kind, text: impression.text }];
225
227
  });
226
228
  }
229
+ /**
230
+ * The draft with the run's reviewer notes and each note's file cited. The caller passes the set it
231
+ * read and checked for sharing (readNotesForSharing).
232
+ */
233
+ export function withReviewerNotes(draft, notes, bundle, runRoot) {
234
+ if (notes === null || notes.notes.length === 0)
235
+ return draft;
236
+ const captions = runParticipantCaptions(bundle);
237
+ const ordered = [...notes.notes].sort((left, right) => left.atMs - right.atMs || left.createdAt.localeCompare(right.createdAt));
238
+ return {
239
+ ...draft,
240
+ evidence: [
241
+ ...draft.evidence,
242
+ ...ordered.map((note) => ({
243
+ path: path.join(runRoot, runNoteFile(note.id)),
244
+ kind: "review",
245
+ note: `Reviewer note at ${formatRunTime(note.atMs)}, added while reviewing the recording.`,
246
+ })),
247
+ ],
248
+ reviewer_notes: ordered.map((note) => ({
249
+ id: note.id,
250
+ at: formatRunTime(note.atMs),
251
+ at_ms: note.atMs,
252
+ participant: note.participant,
253
+ participant_caption: note.participant === null ? null : (captions.get(note.participant) ?? note.participant),
254
+ author: note.author,
255
+ text: note.text,
256
+ })),
257
+ };
258
+ }
259
+ function reviewerNotesSection(draft) {
260
+ if (!draft.reviewer_notes?.length)
261
+ return "";
262
+ const items = draft.reviewer_notes.map((note) => {
263
+ const [first = "", ...rest] = note.text.split("\n");
264
+ return [
265
+ `- ${note.at}, ${note.participant_caption ?? "whole run"}, ${note.author}: ${first}`,
266
+ ...rest.map((line) => ` ${line}`),
267
+ ].join("\n");
268
+ });
269
+ return `## Reviewer notes
270
+
271
+ Notes a person added while reviewing the recording. They are not participant feedback.
272
+
273
+ ${items.join("\n")}
274
+
275
+ `;
276
+ }
227
277
  export function isUsableFeedbackCandidate(candidate) {
228
278
  if (!isRecord(candidate) ||
229
279
  candidate.schema !== "humanish.feedback-candidate.v1" ||
@@ -262,7 +312,7 @@ ${draft.expected}
262
312
 
263
313
  ${draft.actual}
264
314
 
265
- ## Evidence
315
+ ${reviewerNotesSection(draft)}## Evidence
266
316
 
267
317
  ${draft.evidence.map((item) => `- ${item.kind} ${runRelative(draft, item.path)}: ${item.note}`).join("\n")}
268
318
 
@@ -10,7 +10,7 @@ import { renderObserver } from "../observer/render.js";
10
10
  import { buildObserverData } from "../observer/data.js";
11
11
  import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
12
12
  import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
13
- import { decodeEscapes } from "../evidence/encoded-text.js";
13
+ import { decodeEscapes, utf8ReadingOf } from "../evidence/encoded-text.js";
14
14
  import { readPlainText } from "../evidence/plain-text.js";
15
15
  import { streamScreenshotPaths } from "../verify/artifacts.js";
16
16
  import { verifyRunPrepared } from "../verify/verify.js";
@@ -149,7 +149,7 @@ function assertNoInlineRaster(text) {
149
149
  // Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
150
150
  // refused: copying a text extension must not smuggle untransformed pixels.
151
151
  const decoded = decodeEscapes(text);
152
- if (containsSensitive(text) || containsSensitive(decoded))
152
+ if ([text, decoded, utf8ReadingOf(text, decoded) ?? ""].some(containsSensitive))
153
153
  throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
154
154
  if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
155
155
  /iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
@@ -19,6 +19,8 @@ import { verifyRun } from "../verify/verify.js";
19
19
  import {} from "../run/bundle.js";
20
20
  import { exportRedactedBundle } from "./export-bundle.js";
21
21
  import { loadAnalysis } from "../analysis/load.js";
22
+ import { readNotesForSharing } from "../run/notes.js";
23
+ import { cli } from "../cli/invocation.js";
22
24
  import { analysisSharingProblems } from "../analysis/sharing.js";
23
25
  import { EVIDENCE_LIMITS, validateAnalysisEvidence } from "../analysis/evidence.js";
24
26
  import { readBoundedFile } from "../run/evidence-files.js";
@@ -96,8 +98,13 @@ export async function exportRun(cwdInput, runInput, options = {}, deps = {}) {
96
98
  if (analysisCheck === "downgraded")
97
99
  shareReady = false;
98
100
  warnings.push(...analysis.warnings);
101
+ const notes = await checkedNotes(runPaths, verified, inlined, options, warnings);
102
+ if (notes.check === "blocked")
103
+ return exportFailure(cwd, runInput, "HUMANISH_EXPORT_SHARE_SAFETY_BLOCKED", `The run's reviewer notes hold text that looks like a secret, a token or a local path, which verify may not have seen if a note was added or changed after it ran. Run \`${cli(`verify --run ${runId}`)}\`, fix the note files in notes/, and export again.`, verified.shareSafety);
104
+ if (notes.check === "downgraded")
105
+ shareReady = false;
99
106
  const watermarked = !shareReady || omittedRecording;
100
- const output = renderExportHtml(inlined, analysis, assets, verified, watermarked, omittedRecording);
107
+ const output = renderExportHtml(inlined, { analysis, notes: notes.notes }, assets, verified, watermarked, omittedRecording);
101
108
  const bytes = Buffer.byteLength(output, "utf8");
102
109
  const maxBytes = options.maxBytes ?? DEFAULT_EXPORT_MAX_BYTES;
103
110
  if (bytes > maxBytes) {
@@ -334,11 +341,48 @@ async function recheckAnalysis(runPaths, analysis, verified, inlined, options) {
334
341
  };
335
342
  return "downgraded";
336
343
  }
344
+ /**
345
+ * The run's notes for the export, read once within the notes size limit. That snapshot is scanned
346
+ * as verify scans run text and is the one rendered, so a note added or changed after verify ran cannot
347
+ * reach the file unchecked. A finding blocks the export, or downgrades it under --local-only.
348
+ */
349
+ async function checkedNotes(runPaths, verified, inlined, options, warnings) {
350
+ const { notes, finding } = await readNotesForSharing(runPaths);
351
+ for (const skipped of notes.skipped)
352
+ warnings.push(`Reviewer notes: ${skipped}`);
353
+ if (finding === null)
354
+ return { notes, check: "ok" };
355
+ verified.shareSafety = {
356
+ status: finding === "sensitive" || verified.shareSafety.status === "blocked"
357
+ ? "blocked"
358
+ : "local_only",
359
+ reasons: [
360
+ ...verified.shareSafety.reasons,
361
+ finding === "sensitive"
362
+ ? {
363
+ code: "PUBLIC_SAFETY_FINDINGS",
364
+ message: "The reviewer notes being exported match secret, token or local-path patterns.",
365
+ }
366
+ : {
367
+ code: "UNSCANNED_ARTIFACT",
368
+ message: "The reviewer notes being exported hold encoded text the scan cannot read.",
369
+ },
370
+ ],
371
+ };
372
+ if (options.localOnly !== true)
373
+ return { notes, check: "blocked" };
374
+ inlined.publicSafety.share = {
375
+ status: verified.shareSafety.status,
376
+ verifiedAt: new Date().toISOString(),
377
+ reasons: verified.shareSafety.reasons.map((reason) => reason.code),
378
+ };
379
+ return { notes, check: "downgraded" };
380
+ }
337
381
  /** The portable Observer HTML, with the local-only banner when the export is watermarked. */
338
- function renderExportHtml(inlined, analysis, assets, verified, watermarked, omittedRecording) {
382
+ function renderExportHtml(inlined, review, assets, verified, watermarked, omittedRecording) {
339
383
  const output = renderObserverHtml(inlined, {
340
384
  snapshot: true,
341
- analysis,
385
+ ...review,
342
386
  assets,
343
387
  });
344
388
  if (!watermarked)
@@ -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
  }
@@ -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");