humanish 0.114.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 (82) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CONTEXT.md +1 -1
  3. package/dist/actors/closing-report-limits.d.ts +11 -0
  4. package/dist/actors/closing-report-limits.js +13 -0
  5. package/dist/actors/closing-report.d.ts +68 -0
  6. package/dist/actors/closing-report.js +55 -0
  7. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  8. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  9. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  10. package/dist/actors/computer-use/loop/debrief.js +10 -46
  11. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  12. package/dist/actors/computer-use/loop/impressions.js +6 -1
  13. package/dist/actors/computer-use/loop.d.ts +0 -1
  14. package/dist/actors/computer-use/loop.js +3 -3
  15. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  16. package/dist/actors/computer-use/openai-conversation.js +3 -29
  17. package/dist/actors/computer-use/openai-provider.js +11 -5
  18. package/dist/actors/contract.d.ts +2 -20
  19. package/dist/actors/contract.js +0 -13
  20. package/dist/analysis/admission.d.ts +14 -4
  21. package/dist/analysis/admission.js +14 -9
  22. package/dist/analysis/execute.d.ts +5 -0
  23. package/dist/analysis/execute.js +3 -0
  24. package/dist/analysis/finding-lead.d.ts +27 -0
  25. package/dist/analysis/finding-lead.js +23 -0
  26. package/dist/analysis/service.js +5 -3
  27. package/dist/cli/commands/notes.js +4 -3
  28. package/dist/cli/findings.js +10 -32
  29. package/dist/cli/io.js +4 -7
  30. package/dist/cli/telemetry.js +10 -22
  31. package/dist/cli/update-check.js +6 -18
  32. package/dist/cli/user-config.d.ts +11 -0
  33. package/dist/cli/user-config.js +25 -0
  34. package/dist/comms/email-catch.js +7 -31
  35. package/dist/evidence/encoded-text.d.ts +12 -11
  36. package/dist/evidence/encoded-text.js +81 -70
  37. package/dist/evidence/redaction.d.ts +11 -0
  38. package/dist/evidence/redaction.js +21 -10
  39. package/dist/evidence/secret-scrub.d.ts +14 -1
  40. package/dist/evidence/secret-scrub.js +108 -32
  41. package/dist/feedback/draft.d.ts +1 -1
  42. package/dist/feedback/draft.js +13 -16
  43. package/dist/feedback/export-bundle.js +3 -3
  44. package/dist/feedback/export.js +32 -46
  45. package/dist/feedback/feedback.js +6 -19
  46. package/dist/keys/key-resolution.js +12 -17
  47. package/dist/observer/artifact.d.ts +1 -1
  48. package/dist/observer/http.d.ts +7 -0
  49. package/dist/observer/http.js +19 -1
  50. package/dist/observer/notes-route.d.ts +0 -1
  51. package/dist/observer/notes-route.js +24 -44
  52. package/dist/observer/pinned-files.js +4 -19
  53. package/dist/observer/render.js +3 -2
  54. package/dist/observer/run-routes.js +13 -28
  55. package/dist/observer/serve.js +2 -1
  56. package/dist/observer-app.html +3 -3
  57. package/dist/run/contained-output.d.ts +6 -5
  58. package/dist/run/contained-output.js +19 -26
  59. package/dist/run/evidence-files.d.ts +7 -0
  60. package/dist/run/evidence-files.js +24 -14
  61. package/dist/run/note-files.d.ts +26 -45
  62. package/dist/run/note-files.js +86 -58
  63. package/dist/run/note-shape.d.ts +35 -0
  64. package/dist/run/note-shape.js +20 -0
  65. package/dist/run/notes.d.ts +3 -6
  66. package/dist/run/notes.js +18 -24
  67. package/dist/run/participant-records.d.ts +2 -0
  68. package/dist/run/participant-records.js +17 -2
  69. package/dist/run/transient-comms-secrets.d.ts +6 -5
  70. package/dist/run/transient-comms-secrets.js +19 -24
  71. package/dist/study/summary.d.ts +4 -5
  72. package/dist/study/summary.js +4 -2
  73. package/dist/study/warnings.js +2 -1
  74. package/dist/tui-app.js +1 -1
  75. package/docs/architecture/actor-contract.md +3 -3
  76. package/docs/architecture/restricted-codex-analysis.md +1 -1
  77. package/docs/architecture/state-driven-executor.md +1 -1
  78. package/docs/contracts/run-bundle.md +7 -3
  79. package/docs/contracts/schemas.md +12 -12
  80. package/docs/contracts/study-analysis.md +12 -4
  81. package/package.json +7 -6
  82. package/skills/humanish/SKILL.md +1 -2
@@ -2,6 +2,7 @@
2
2
  // participant's feedback candidate or from a reviewed analysis finding, and its markdown rendering.
3
3
  // The commands that resolve the run and read or write drafts are in feedback.ts.
4
4
  import path from "node:path";
5
+ import { findingLead } from "../analysis/finding-lead.js";
5
6
  import { analysisSharingProblems } from "../analysis/sharing.js";
6
7
  import { loadAnalysis } from "../analysis/load.js";
7
8
  import { hashAnalysisValue } from "../analysis/validation.js";
@@ -9,7 +10,9 @@ import { formatParticipantOutcomes, formatRunTaskFunnel, participantOutcomeDetai
9
10
  import { loadRunBundlePrepared } from "../run/locate.js";
10
11
  import { isFeedbackIdempotencyKey } from "../run/feedback-shape.js";
11
12
  import { runNoteFile } from "../run/note-files.js";
12
- import { formatRunTime, runParticipantCaptions } from "../run/notes.js";
13
+ import { byRunTime } from "../run/note-shape.js";
14
+ import { formatRunTime } from "../run/notes.js";
15
+ import { runParticipantCaptions } from "../run/participant-records.js";
13
16
  import { isRecord } from "../run/type-guards.js";
14
17
  import { feedbackProofCommands, projectFeedbackAcceptanceProof } from "./proof.js";
15
18
  export const FEEDBACK_SCHEMA = "humanish.feedback.v1";
@@ -122,8 +125,10 @@ export async function buildAnalysisDraft(context, options) {
122
125
  const root = path.dirname(context.loaded.bundlePath);
123
126
  const evidenceIds = new Set(finding.observations.flatMap((item) => item.evidenceIds));
124
127
  const evidence = analysis.evidence.filter((item) => evidenceIds.has(item.id));
125
- const amended = correction?.status === "amended";
126
- const claim = amended ? correction.replacementClaim : (finding.headline ?? finding.title);
128
+ const lead = findingLead(finding, correction);
129
+ // An analysis written before headlines has no lead, so the amended claim or the title names it.
130
+ const amendedClaim = correction?.status === "amended" ? correction.replacementClaim : null;
131
+ const claim = lead?.headline ?? amendedClaim ?? finding.title;
127
132
  const firstLine = claim.trim().split(/\r?\n/)[0] || `Reviewed finding ${finding.id}`;
128
133
  const summary = Array.from(firstLine).length > 160
129
134
  ? Array.from(firstLine).slice(0, 159).join("") + "…"
@@ -147,19 +152,11 @@ export async function buildAnalysisDraft(context, options) {
147
152
  summary,
148
153
  expected: "Review the cited behavior against the participant assignment and confirm the expected product behavior.",
149
154
  actual: [
150
- // The plain account leads; an amendment makes it stale, so the draft says so in its place.
151
- ...(finding.headline === undefined
152
- ? []
153
- : amended
154
- ? [
155
- "This finding was corrected in human review. The reviewer's claim above replaces its original headline and account.",
156
- "",
157
- ]
158
- : finding.experience === undefined
159
- ? []
160
- : [finding.experience, ""]),
155
+ ...(lead?.account ? [lead.account, ""] : []),
161
156
  "Independent study analysis; does not replace participant feedback or recorded completion outcomes.",
162
- amended ? `Amended claim: ${claim}. Original analysis: ${finding.summary}` : finding.summary,
157
+ amendedClaim === null
158
+ ? finding.summary
159
+ : `Amended claim: ${amendedClaim}. Original analysis: ${finding.summary}`,
163
160
  `Impact: ${finding.impact}. ${finding.affectedStreamIds.length} affected / ${finding.exposedStreamIds.length} observed exposed participants. ${finding.exposureReason}`,
164
161
  `Recovery: ${finding.recovery}. Confidence: ${finding.confidence}.`,
165
162
  ...finding.observations.map((item) => `${item.basis}: ${item.claim}${item.limitation ? ` Limitation: ${item.limitation}` : ""}`),
@@ -234,7 +231,7 @@ export function withReviewerNotes(draft, notes, bundle, runRoot) {
234
231
  if (notes === null || notes.notes.length === 0)
235
232
  return draft;
236
233
  const captions = runParticipantCaptions(bundle);
237
- const ordered = [...notes.notes].sort((left, right) => left.atMs - right.atMs || left.createdAt.localeCompare(right.createdAt));
234
+ const ordered = byRunTime(notes.notes);
238
235
  return {
239
236
  ...draft,
240
237
  evidence: [
@@ -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, utf8ReadingOf } from "../evidence/encoded-text.js";
13
+ import { decodeEscapes, readingsOf } 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";
@@ -148,9 +148,9 @@ async function prospectivePhysicalPath(requested) {
148
148
  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
- const decoded = decodeEscapes(text);
152
- if ([text, decoded, utf8ReadingOf(text, decoded) ?? ""].some(containsSensitive))
151
+ if (readingsOf(text).some(containsSensitive))
153
152
  throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
153
+ const decoded = decodeEscapes(text);
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)) {
156
156
  throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
@@ -19,7 +19,7 @@ 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";
22
+ import { readNotesForSharing } from "../run/note-files.js";
23
23
  import { cli } from "../cli/invocation.js";
24
24
  import { analysisSharingProblems } from "../analysis/sharing.js";
25
25
  import { EVIDENCE_LIMITS, validateAnalysisEvidence } from "../analysis/evidence.js";
@@ -288,7 +288,10 @@ function makePortable(inlined, verified) {
288
288
  }
289
289
  }
290
290
  }
291
- // What verify said, in the file, so the chrome can agree with the result envelope.
291
+ recordShareSafety(inlined, verified);
292
+ }
293
+ /** What verify said, in the file, so the chrome can agree with the result envelope. */
294
+ function recordShareSafety(inlined, verified) {
292
295
  const publicSafety = (inlined.publicSafety ?? {});
293
296
  inlined.publicSafety = {
294
297
  ...publicSafety,
@@ -299,6 +302,23 @@ function makePortable(inlined, verified) {
299
302
  },
300
303
  };
301
304
  }
305
+ /**
306
+ * Adds a failed sharing check to verify's result. The reason joins verify's reasons and the status
307
+ * is the worse of the two, as verify grades an analysis. The export is refused unless --local-only,
308
+ * which writes the new status into the file.
309
+ */
310
+ function applySharingProblem(verified, inlined, options, problem) {
311
+ verified.shareSafety = {
312
+ status: problem.status === "blocked" || verified.shareSafety.status === "blocked"
313
+ ? "blocked"
314
+ : "local_only",
315
+ reasons: [...verified.shareSafety.reasons, problem.reason],
316
+ };
317
+ if (options.localOnly !== true)
318
+ return "blocked";
319
+ recordShareSafety(inlined, verified);
320
+ return "downgraded";
321
+ }
302
322
  /**
303
323
  * Revalidates the analysis against source evidence and its sharing checks. A failed check
304
324
  * downgrades share safety, which only --local-only may export.
@@ -322,24 +342,13 @@ async function recheckAnalysis(runPaths, analysis, verified, inlined, options) {
322
342
  const analysisSharing = analysisSharingProblems(analysis);
323
343
  if (!analysisSharing.sensitive && !analysisSharing.unverified)
324
344
  return "ok";
325
- verified.shareSafety = {
345
+ return applySharingProblem(verified, inlined, options, {
326
346
  status: analysisSharing.sensitive ? "blocked" : "local_only",
327
- reasons: [
328
- ...verified.shareSafety.reasons,
329
- {
330
- code: "ANALYSIS_UNVERIFIED",
331
- message: "The exact analysis snapshot being exported did not pass sharing checks.",
332
- },
333
- ],
334
- };
335
- if (options.localOnly !== true)
336
- return "blocked";
337
- inlined.publicSafety.share = {
338
- status: verified.shareSafety.status,
339
- verifiedAt: new Date().toISOString(),
340
- reasons: verified.shareSafety.reasons.map((reason) => reason.code),
341
- };
342
- return "downgraded";
347
+ reason: {
348
+ code: "ANALYSIS_UNVERIFIED",
349
+ message: "The exact analysis snapshot being exported did not pass sharing checks.",
350
+ },
351
+ });
343
352
  }
344
353
  /**
345
354
  * The run's notes for the export, read once within the notes size limit. That snapshot is scanned
@@ -347,36 +356,13 @@ async function recheckAnalysis(runPaths, analysis, verified, inlined, options) {
347
356
  * reach the file unchecked. A finding blocks the export, or downgrades it under --local-only.
348
357
  */
349
358
  async function checkedNotes(runPaths, verified, inlined, options, warnings) {
350
- const { notes, finding } = await readNotesForSharing(runPaths);
359
+ const { notes, problem } = await readNotesForSharing(runPaths);
351
360
  for (const skipped of notes.skipped)
352
361
  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),
362
+ return {
363
+ notes,
364
+ check: problem === null ? "ok" : applySharingProblem(verified, inlined, options, problem),
378
365
  };
379
- return { notes, check: "downgraded" };
380
366
  }
381
367
  /** The portable Observer HTML, with the local-only banner when the export is watermarked. */
382
368
  function renderExportHtml(inlined, review, assets, verified, watermarked, omittedRecording) {
@@ -6,7 +6,7 @@ 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";
9
+ import { readNotesForSharing } from "../run/note-files.js";
10
10
  import { cli } from "../cli/invocation.js";
11
11
  import { verifyRunPrepared } from "../verify/verify.js";
12
12
  import { buildAnalysisDraft, buildDraft, isUsableFeedbackCandidate, renderMarkdown, withReviewerNotes, } from "./draft.js";
@@ -95,10 +95,10 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
95
95
  // The notes the draft includes are this set, checked here: a note can be added or changed after
96
96
  // verify read the run.
97
97
  const shared = await readNotesForSharing(context.preparedRunPaths);
98
- if (shared.finding !== null)
98
+ if (shared.problem !== null)
99
99
  return {
100
100
  context,
101
- result: notesRefusal(cwd, runInput, context.storedRunId, verified, shared.finding),
101
+ result: notesRefusal(cwd, runInput, context.storedRunId, verified, shared.problem),
102
102
  };
103
103
  const candidates = summarizeCandidates(context.loaded.bundle);
104
104
  if (options.candidate !== undefined &&
@@ -156,27 +156,14 @@ async function draftFeedbackBound(cwdInput, runInput, options = {}) {
156
156
  };
157
157
  }
158
158
  /** The refusal of a draft whose reviewer notes, as read for it, hold what verify's scan flags. */
159
- function notesRefusal(cwd, runInput, runId, verified, finding) {
159
+ function notesRefusal(cwd, runInput, runId, verified, problem) {
160
160
  return {
161
161
  schema: FEEDBACK_RESULT_SCHEMA,
162
162
  ok: false,
163
163
  cwd,
164
164
  run: runInput,
165
- shareSafety: {
166
- status: "blocked",
167
- reasons: [
168
- ...verified.shareSafety.reasons,
169
- finding === "sensitive"
170
- ? {
171
- code: "PUBLIC_SAFETY_FINDINGS",
172
- message: "The reviewer notes the draft would include match secret, token or local-path patterns.",
173
- }
174
- : {
175
- code: "UNSCANNED_ARTIFACT",
176
- message: "The reviewer notes the draft would include hold encoded text the scan cannot read.",
177
- },
178
- ],
179
- },
165
+ // A draft is written only from share_ready evidence, so any problem in its notes refuses it.
166
+ shareSafety: { status: "blocked", reasons: [...verified.shareSafety.reasons, problem.reason] },
180
167
  error: {
181
168
  code: "HUMANISH_FEEDBACK_SHARE_SAFETY_BLOCKED",
182
169
  message: `The run's reviewer notes hold text that looks like a secret, a token or a local path, so no draft was written. Run \`${cli(`verify --run ${runId}`)}\` and fix the note files in notes/.`,
@@ -22,6 +22,7 @@ import { homedir } from "node:os";
22
22
  import path from "node:path";
23
23
  import { loadEnvFile } from "./env-file.js";
24
24
  import { cli } from "../cli/invocation.js";
25
+ import { humanishConfigFile } from "../cli/user-config.js";
25
26
  import { OPENAI_EGRESS_PLACEHOLDER } from "../routes/terminal/runtime-auth.js";
26
27
  /** The only names implicit discovery may fill (and `humanish keys set` may store). Everything
27
28
  * else in an overlay/store file is ignored-and-named: a repo-planted NODE_OPTIONS/LD_PRELOAD
@@ -41,24 +42,18 @@ const PROVIDER_KEY_SET = new Set(KNOWN_PROVIDER_KEYS);
41
42
  function withoutProviderKeys(env) {
42
43
  return Object.fromEntries(Object.entries(env).filter(([name]) => !PROVIDER_KEY_SET.has(name)));
43
44
  }
44
- /** `humanish keys set <vendor>` aliases; a raw ENV_NAME is also accepted. */
45
- const KEY_VENDOR_ALIASES = {
46
- openai: "OPENAI_API_KEY",
47
- e2b: "E2B_API_KEY",
48
- anthropic: "ANTHROPIC_API_KEY",
49
- github: "GH_TOKEN",
50
- agentmail: "AGENTMAIL_API_KEY",
51
- };
45
+ /** `humanish keys set <vendor>` aliases; a raw ENV_NAME is also accepted. A Map, so a name such as
46
+ * `constructor` is no alias. */
47
+ const KEY_VENDOR_ALIASES = new Map([
48
+ ["openai", "OPENAI_API_KEY"],
49
+ ["e2b", "E2B_API_KEY"],
50
+ ["anthropic", "ANTHROPIC_API_KEY"],
51
+ ["github", "GH_TOKEN"],
52
+ ["agentmail", "AGENTMAIL_API_KEY"],
53
+ ]);
52
54
  const PROJECT_OVERLAY_RELATIVE = path.join(".humanish", "local", "provider.env");
53
55
  export function userKeyStorePath(env, deps = {}) {
54
- const home = deps.homeDir ?? homedir();
55
- const declared = env.XDG_CONFIG_HOME?.trim();
56
- // The XDG spec: a relative XDG_CONFIG_HOME must be ignored. Honoring one would make the
57
- // key store cwd-relative: `humanish keys set` would write a secret into the current repo.
58
- const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
59
- ? declared
60
- : path.join(home, ".config");
61
- return path.join(configHome, "humanish", "keys.env");
56
+ return humanishConfigFile(env, "keys.env", deps.homeDir);
62
57
  }
63
58
  function e2bConfigPath(deps) {
64
59
  return path.join(deps.homeDir ?? homedir(), ".e2b", "config.json");
@@ -306,7 +301,7 @@ export async function probeKeySources(names, args) {
306
301
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
307
302
  /** Resolve a `humanish keys set` target: a vendor alias or a raw env name. Null = invalid. */
308
303
  export function resolveKeyName(vendorOrName) {
309
- const alias = KEY_VENDOR_ALIASES[vendorOrName.toLowerCase()];
304
+ const alias = KEY_VENDOR_ALIASES.get(vendorOrName.toLowerCase());
310
305
  if (alias !== undefined)
311
306
  return alias;
312
307
  return ENV_NAME.test(vendorOrName) ? vendorOrName : null;
@@ -1,5 +1,5 @@
1
1
  import type { LoadedAnalysis } from "../analysis/types.js";
2
- import type { RunNotes } from "../run/notes.js";
2
+ import type { RunNotes } from "../run/note-files.js";
3
3
  import { type ObserverData } from "./data.js";
4
4
  /**
5
5
  * Resolve (and in a repo checkout, build) the Observer artifact before any run or
@@ -10,3 +10,10 @@ export declare function parsePublicOrigin(value: string): {
10
10
  host: string;
11
11
  scheme: "http" | "https";
12
12
  } | null;
13
+ /**
14
+ * A request body, or null once it passes `limit` bytes. Reading stops at the limit: leaving the
15
+ * loop destroys the request with its socket detached, so the caller can still answer, and should
16
+ * send `connection: close`, since the rest of the body is never read. Rejects when the client
17
+ * aborts.
18
+ */
19
+ export declare function readBodyAtMost(request: AsyncIterable<Buffer>, limit: number): Promise<Buffer | null>;
@@ -1,7 +1,8 @@
1
1
  // Shared HTTP hardening primitives for the serve surfaces. Extracted here so both the live
2
2
  // Observer server (src/observer/render.ts) and the run-library server
3
3
  // (src/observer/serve.ts) can enforce the identical Host allowlist + security-header posture
4
- // without a module cycle. This file imports nothing from the serve modules.
4
+ // without a module cycle. The email catch (src/comms/email-catch.ts) reads request bodies through
5
+ // readBodyAtMost too. This file imports nothing from the serve modules.
5
6
  export function buildServeSecurityHeaders() {
6
7
  return {
7
8
  "cache-control": "no-store",
@@ -48,3 +49,20 @@ export function parsePublicOrigin(value) {
48
49
  scheme: parsed.protocol === "https:" ? "https" : "http",
49
50
  };
50
51
  }
52
+ /**
53
+ * A request body, or null once it passes `limit` bytes. Reading stops at the limit: leaving the
54
+ * loop destroys the request with its socket detached, so the caller can still answer, and should
55
+ * send `connection: close`, since the rest of the body is never read. Rejects when the client
56
+ * aborts.
57
+ */
58
+ export async function readBodyAtMost(request, limit) {
59
+ const chunks = [];
60
+ let size = 0;
61
+ for await (const chunk of request) {
62
+ size += chunk.length;
63
+ if (size > limit)
64
+ return null;
65
+ chunks.push(chunk);
66
+ }
67
+ return Buffer.concat(chunks, size);
68
+ }
@@ -1,6 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { PreparedRunArtifactPaths } from "../run/paths.js";
3
- export declare const NOTES_PATH = "/api/notes";
4
3
  export interface NotesWriterOptions {
5
4
  /** An exposed server refuses every note and gives its pages no token. */
6
5
  exposed: boolean;
@@ -5,13 +5,25 @@
5
5
  // cannot read the token from the page or send this origin, and a name rebound to 127.0.0.1 arrives
6
6
  // with its own Host.
7
7
  import { randomBytes, timingSafeEqual } from "node:crypto";
8
- import { addRunNote, MAX_NOTE_TEXT, } from "../run/notes.js";
9
- import { isRecord } from "../run/type-guards.js";
8
+ import { z } from "zod";
9
+ import { MAX_NOTE_TEXT, NOTES_TOKEN_HEADER } from "../run/note-shape.js";
10
+ import { addRunNote } from "../run/notes.js";
10
11
  import { cli } from "../cli/invocation.js";
11
- import { hostAllowed } from "./http.js";
12
- export const NOTES_PATH = "/api/notes";
13
- const TOKEN_HEADER = "x-humanish-notes-token";
12
+ import { hostAllowed, readBodyAtMost } from "./http.js";
14
13
  const MAX_BODY_BYTES = 16 * 1024;
14
+ /**
15
+ * A request to add a note. Its text, time and participant are checked by addRunNote, which says
16
+ * what is wrong with each, so this checks only their types.
17
+ */
18
+ const noteRequestSchema = z.object({
19
+ runId: z.string(),
20
+ atMs: z.number(),
21
+ participant: z
22
+ .string()
23
+ .nullish()
24
+ .transform((participant) => participant ?? null),
25
+ text: z.string(),
26
+ });
15
27
  /** The HTTP status for a refusal from the notes store. */
16
28
  const STORE_STATUS = {
17
29
  HUMANISH_NOTE_INVALID: 400,
@@ -40,26 +52,6 @@ function reply(response, status, body, close = false) {
40
52
  function refuse(response, status, code, message) {
41
53
  reply(response, status, { ok: false, error: { code, message } }, true);
42
54
  }
43
- /** The body, or null once it passes MAX_BODY_BYTES; reading stops there. */
44
- function readBody(request) {
45
- return new Promise((resolve, reject) => {
46
- const chunks = [];
47
- let size = 0;
48
- const onData = (chunk) => {
49
- size += chunk.length;
50
- if (size > MAX_BODY_BYTES) {
51
- request.off("data", onData);
52
- request.pause();
53
- resolve(null);
54
- return;
55
- }
56
- chunks.push(chunk);
57
- };
58
- request.on("data", onData);
59
- request.once("end", () => resolve(Buffer.concat(chunks)));
60
- request.once("error", reject);
61
- });
62
- }
63
55
  function sameToken(sent, token) {
64
56
  if (typeof sent !== "string")
65
57
  return false;
@@ -67,8 +59,8 @@ function sameToken(sent, token) {
67
59
  const right = Buffer.from(token);
68
60
  return left.length === right.length && timingSafeEqual(left, right);
69
61
  }
70
- /** The note in a request body, or null when the body is not one. */
71
- function noteInput(body) {
62
+ /** The note request in a body, or null when the body is not one. */
63
+ function noteRequest(body) {
72
64
  let value;
73
65
  try {
74
66
  value = JSON.parse(body.toString("utf8"));
@@ -76,20 +68,8 @@ function noteInput(body) {
76
68
  catch {
77
69
  return null;
78
70
  }
79
- if (!isRecord(value) ||
80
- typeof value.runId !== "string" ||
81
- typeof value.atMs !== "number" ||
82
- typeof value.text !== "string" ||
83
- !(value.participant === undefined ||
84
- value.participant === null ||
85
- typeof value.participant === "string"))
86
- return null;
87
- return {
88
- runId: value.runId,
89
- atMs: value.atMs,
90
- participant: typeof value.participant === "string" ? value.participant : null,
91
- text: value.text,
92
- };
71
+ const parsed = noteRequestSchema.safeParse(value);
72
+ return parsed.success ? parsed.data : null;
93
73
  }
94
74
  export function createNotesWriter(options) {
95
75
  const token = randomBytes(32).toString("base64url");
@@ -111,17 +91,17 @@ export function createNotesWriter(options) {
111
91
  !hostAllowed(host, loopbackHosts(options.port())) ||
112
92
  request.headers.origin !== `http://${host}`)
113
93
  return refuse(response, 403, "HUMANISH_NOTES_ORIGIN", "A note can only come from the Observer page this server rendered, at the address it was opened from. Open the Observer from the address humanish printed and add the note there.");
114
- if (!sameToken(request.headers[TOKEN_HEADER], token))
94
+ if (!sameToken(request.headers[NOTES_TOKEN_HEADER], token))
115
95
  return refuse(response, 403, "HUMANISH_NOTES_TOKEN", "The request does not carry this server's note token, which changes each time the server starts. Reload the Observer page and add the note again.");
116
96
  if (!/^application\/json(?:\s*;|$)/i.test(request.headers["content-type"] ?? ""))
117
97
  return refuse(response, 415, "HUMANISH_NOTES_BODY", "A note is sent as JSON with runId, atMs, participant and text.");
118
98
  const tooLarge = () => refuse(response, 413, "HUMANISH_NOTES_TOO_LARGE", `The request is larger than ${MAX_BODY_BYTES / 1024} KiB. A note holds at most ${MAX_NOTE_TEXT} characters.`);
119
99
  if (Number(request.headers["content-length"] ?? 0) > MAX_BODY_BYTES)
120
100
  return tooLarge();
121
- const body = await readBody(request);
101
+ const body = await readBodyAtMost(request, MAX_BODY_BYTES);
122
102
  if (body === null)
123
103
  return tooLarge();
124
- const input = noteInput(body);
104
+ const input = noteRequest(body);
125
105
  if (input === null)
126
106
  return refuse(response, 400, "HUMANISH_NOTES_BODY", "A note is a JSON object with runId, atMs (milliseconds on the run clock), participant (a stream id or null) and text.");
127
107
  const prepared = await options.resolveRun(input.runId).catch(() => null);
@@ -5,6 +5,7 @@ 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
10
  /**
10
11
  * The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
@@ -18,7 +19,9 @@ export async function readContainedFile(root, filePathInput, options = {}) {
18
19
  try {
19
20
  const body = options.maxBytes === undefined
20
21
  ? await opened.handle.readFile()
21
- : await readAtMost(opened.handle, opened.size, options.maxBytes);
22
+ : opened.size > options.maxBytes
23
+ ? null
24
+ : await readOpenedAtMost(opened.handle, options.maxBytes);
22
25
  if (body === null)
23
26
  return null;
24
27
  // A write that landed during the read changed the file's mtime and ctime.
@@ -79,24 +82,6 @@ export async function openContainedFile(root, filePathInput) {
79
82
  return null;
80
83
  }
81
84
  }
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
- }
100
85
  /** The sha256 of an opened file, read by position so the handle's own offset is untouched. */
101
86
  export async function sha256OfOpenedFile(handle) {
102
87
  const hash = createHash("sha256");
@@ -12,14 +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/notes.js";
15
+ import { readRunNotes } from "../run/note-files.js";
16
16
  import { RUN_STATUS_FILE } from "../run/status.js";
17
17
  import { verifyRunPrepared } from "../verify/verify.js";
18
18
  import { renderObserverHtml } from "./artifact.js";
19
19
  import { buildObserverData } from "./data.js";
20
20
  import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin } from "./http.js";
21
21
  import { listenOnLoopback } from "./listen.js";
22
- import { createNotesWriter, NOTES_PATH } from "./notes-route.js";
22
+ import { NOTES_PATH } from "../run/note-shape.js";
23
+ import { createNotesWriter } from "./notes-route.js";
23
24
  import { pinDirectChildDirectory, pinDirectory, readContainedFile, } from "./pinned-files.js";
24
25
  import { buildHistoryIndex, matchRunRoute, serveRunPath, writeResponse, } from "./run-routes.js";
25
26
  import { cli } from "../cli/invocation.js";
@@ -9,7 +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 { decodeRunNote, listNoteEntries, MAX_NOTE_FILE_BYTES, noteListing, RUN_NOTES_DIR, runNoteFile, } from "../run/note-files.js";
12
+ import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-files.js";
13
13
  import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
14
14
  import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
15
15
  import { renderObserverHtml } from "./artifact.js";
@@ -59,33 +59,18 @@ async function readServedAnalysisSpend(runRoot) {
59
59
  }
60
60
  }
61
61
  /**
62
- * The run's reviewer notes as served: notes/ listed, and each note file read within
63
- * MAX_NOTE_FILE_BYTES through the root's checks, so a --safe root's admission and hash checks
64
- * apply to every note. A file that is not a readable note of the run is skipped and counted.
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.
65
64
  */
66
- async function readServedNotes(runRoot) {
67
- const runId = path.basename(runRoot.physicalPath);
68
- let listing;
69
- try {
70
- await assertPinnedDirectory(runRoot);
71
- listing = await listNoteEntries(path.join(runRoot.physicalPath, RUN_NOTES_DIR));
72
- }
73
- catch {
74
- return { runId, notes: [], skipped: [`${RUN_NOTES_DIR} is not a plain folder.`] };
75
- }
76
- if (listing === null)
77
- return { runId, notes: [], skipped: [] };
78
- const { ids, skipped } = noteListing(listing);
79
- const notes = [];
80
- for (const id of ids) {
81
- const bytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, runNoteFile(id)), { maxBytes: MAX_NOTE_FILE_BYTES });
82
- const note = bytes === null ? null : decodeRunNote(bytes, runId, id);
83
- if (note === null)
84
- skipped.push(`${runNoteFile(id)} was skipped.`);
85
- else
86
- notes.push(note);
87
- }
88
- return { runId, notes, skipped };
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
+ };
89
74
  }
90
75
  /**
91
76
  * internal: consumed by src/observer/serve.ts. `notesToken` goes into the run's Observer page so
@@ -120,7 +105,7 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
120
105
  return;
121
106
  }
122
107
  const analysis = await readObserverAnalysis(runRoot);
123
- const notes = await readServedNotes(runRoot);
108
+ const notes = await readRunNotes(servedNoteFiles(runRoot));
124
109
  writeResponse(response, 200, renderObserverHtml(observerData, {
125
110
  analysis,
126
111
  notes,
@@ -3,7 +3,8 @@ 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 { createNotesWriter, NOTES_PATH } from "./notes-route.js";
6
+ import { NOTES_PATH } from "../run/note-shape.js";
7
+ import { createNotesWriter } from "./notes-route.js";
7
8
  import { renderObserver } from "./render.js";
8
9
  import { buildHistoryIndex, matchRunRoute, servedRunPaths, serveRunPath } from "./run-routes.js";
9
10
  import { hashRunInventory, inventoryRoot, readRunInventory, } from "./run-inventory.js";