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.
- package/CHANGELOG.md +22 -0
- package/CONTEXT.md +1 -0
- package/README.md +1 -1
- package/dist/actors/computer-use/openai-conversation.js +9 -7
- package/dist/actors/contract.d.ts +4 -2
- package/dist/analysis/admission.d.ts +37 -0
- package/dist/analysis/admission.js +60 -0
- package/dist/analysis/automatic-completion.d.ts +0 -6
- package/dist/analysis/automatic-completion.js +3 -3
- package/dist/analysis/automatic-config.d.ts +10 -2
- package/dist/analysis/automatic-config.js +29 -14
- package/dist/analysis/automatic.js +7 -0
- package/dist/analysis/execute.d.ts +19 -5
- package/dist/analysis/execute.js +58 -27
- package/dist/analysis/image-tokens.d.ts +2 -0
- package/dist/analysis/image-tokens.js +1 -1
- package/dist/analysis/job.d.ts +9 -1
- package/dist/analysis/job.js +11 -2
- package/dist/analysis/service.js +6 -3
- package/dist/cli/commands/analyze.js +7 -2
- package/dist/cli/commands/notes.d.ts +3 -0
- package/dist/cli/commands/notes.js +144 -0
- package/dist/cli/commands/observe.js +1 -1
- package/dist/cli/commands/study-run.js +2 -1
- package/dist/cli/findings.js +4 -1
- package/dist/cli/io.d.ts +1 -0
- package/dist/cli/io.js +11 -12
- package/dist/cli/program.js +2 -0
- package/dist/evidence/encoded-text.d.ts +15 -2
- package/dist/evidence/encoded-text.js +68 -4
- package/dist/evidence/secret-scrub.d.ts +1 -1
- package/dist/evidence/secret-scrub.js +23 -2
- package/dist/feedback/draft.d.ts +19 -0
- package/dist/feedback/draft.js +51 -1
- package/dist/feedback/export-bundle.js +2 -2
- package/dist/feedback/export.js +47 -3
- package/dist/feedback/feedback.js +42 -2
- package/dist/observer/artifact.d.ts +5 -0
- package/dist/observer/artifact.js +18 -2
- package/dist/observer/notes-route.d.ts +21 -0
- package/dist/observer/notes-route.js +140 -0
- package/dist/observer/pinned-files.d.ts +8 -1
- package/dist/observer/pinned-files.js +32 -3
- package/dist/observer/render.js +38 -14
- package/dist/observer/run-routes.d.ts +8 -2
- package/dist/observer/run-routes.js +47 -6
- package/dist/observer/serve.d.ts +3 -0
- package/dist/observer/serve.js +28 -3
- package/dist/observer-app.html +7 -6
- package/dist/run/contained-output.d.ts +7 -0
- package/dist/run/contained-output.js +35 -1
- package/dist/run/note-files.d.ts +73 -0
- package/dist/run/note-files.js +184 -0
- package/dist/run/notes.d.ts +37 -0
- package/dist/run/notes.js +198 -0
- package/dist/run/participant-caption.d.ts +9 -0
- package/dist/run/participant-caption.js +19 -0
- package/dist/study/doctor.js +1 -1
- package/dist/study/preflight-result.js +2 -1
- package/dist/study/summary.js +2 -2
- package/docs/contracts/errors.md +2 -2
- package/docs/contracts/run-bundle.md +57 -0
- package/docs/contracts/schemas.md +5 -2
- package/docs/contracts/study-analysis.md +26 -8
- package/docs/principles/invariants-and-defaults.md +2 -2
- package/docs/product/automatic-analysis.md +22 -16
- package/package.json +2 -2
- package/skills/humanish/SKILL.md +5 -0
package/dist/feedback/draft.js
CHANGED
|
@@ -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 (
|
|
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)) {
|
package/dist/feedback/export.js
CHANGED
|
@@ -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,
|
|
382
|
+
function renderExportHtml(inlined, review, assets, verified, watermarked, omittedRecording) {
|
|
339
383
|
const output = renderObserverHtml(inlined, {
|
|
340
384
|
snapshot: true,
|
|
341
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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");
|