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