humanish 0.114.0 → 0.116.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 -1
- package/README.md +7 -6
- package/TELEMETRY.md +6 -0
- 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/app-server-ui.js +11 -4
- 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 +68 -70
- 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 +3 -29
- package/dist/actors/computer-use/openai-provider.js +11 -5
- package/dist/actors/contract.d.ts +2 -20
- package/dist/actors/contract.js +0 -13
- package/dist/actors/scripted-browser/steps.js +6 -5
- package/dist/analysis/admission.d.ts +14 -4
- package/dist/analysis/admission.js +14 -9
- package/dist/analysis/analysis-limits.d.ts +35 -0
- package/dist/analysis/analysis-limits.js +41 -0
- package/dist/analysis/execute.d.ts +5 -0
- package/dist/analysis/execute.js +3 -0
- package/dist/analysis/finding-lead.d.ts +27 -0
- package/dist/analysis/finding-lead.js +23 -0
- package/dist/analysis/service.js +5 -3
- package/dist/analysis/validation.js +14 -21
- package/dist/cli/commands/analyze.js +2 -1
- package/dist/cli/commands/notes.js +5 -3
- package/dist/cli/commands/run-signals.js +5 -1
- package/dist/cli/commands/runs.js +2 -1
- package/dist/cli/commands/setup.js +3 -3
- package/dist/cli/commands/study-format.d.ts +5 -2
- package/dist/cli/commands/study-format.js +29 -9
- package/dist/cli/commands/study-route-computer-use.js +2 -2
- package/dist/cli/commands/study-route-scripted.js +1 -1
- package/dist/cli/commands/tui.js +59 -17
- package/dist/cli/doctor.js +9 -2
- package/dist/cli/findings-text.d.ts +5 -0
- package/dist/cli/findings-text.js +169 -0
- package/dist/cli/findings.d.ts +0 -4
- package/dist/cli/findings.js +11 -195
- package/dist/cli/io.js +10 -8
- package/dist/cli/telemetry.js +10 -22
- package/dist/cli/update-check.js +10 -19
- package/dist/cli/user-config.d.ts +11 -0
- package/dist/cli/user-config.js +25 -0
- package/dist/comms/connections.js +4 -3
- package/dist/comms/email-catch.js +7 -31
- package/dist/comms/setup.js +18 -12
- package/dist/evidence/encoded-text.d.ts +12 -11
- package/dist/evidence/encoded-text.js +81 -70
- package/dist/evidence/redaction.d.ts +11 -0
- package/dist/evidence/redaction.js +30 -11
- package/dist/evidence/secret-scrub.d.ts +26 -3
- package/dist/evidence/secret-scrub.js +172 -38
- package/dist/feedback/draft.d.ts +1 -1
- package/dist/feedback/draft.js +13 -16
- package/dist/feedback/export-bundle.js +21 -17
- package/dist/feedback/export.js +32 -46
- package/dist/feedback/feedback.js +31 -26
- package/dist/guest/desktop-keys.js +59 -5
- package/dist/keys/key-resolution.d.ts +9 -0
- package/dist/keys/key-resolution.js +62 -55
- package/dist/keys/key-status.d.ts +2 -0
- package/dist/keys/key-status.js +9 -16
- package/dist/observer/artifact.d.ts +1 -1
- package/dist/observer/http.d.ts +7 -0
- package/dist/observer/http.js +19 -1
- package/dist/observer/notes-route.d.ts +0 -1
- package/dist/observer/notes-route.js +24 -44
- package/dist/observer/pinned-files.js +4 -19
- package/dist/observer/render.js +6 -4
- package/dist/observer/run-routes.js +13 -28
- package/dist/observer/serve.js +2 -1
- package/dist/observer-app.html +7 -7
- package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
- package/dist/routes/computer-use/bundle-parts.js +2 -2
- package/dist/routes/computer-use/bundle.d.ts +4 -5
- package/dist/routes/computer-use/bundle.js +1 -3
- package/dist/routes/computer-use/fanout-records.js +10 -11
- package/dist/routes/computer-use/live-phase.d.ts +1 -1
- package/dist/routes/computer-use/live-phase.js +0 -1
- package/dist/routes/computer-use/participant-execution.d.ts +3 -2
- package/dist/routes/computer-use/participant-runs.d.ts +6 -2
- package/dist/routes/computer-use/participant-runs.js +9 -5
- package/dist/routes/computer-use/plan.d.ts +3 -0
- package/dist/routes/computer-use/plan.js +14 -11
- package/dist/routes/computer-use/rerun-selection.js +2 -2
- package/dist/routes/computer-use/result.js +3 -6
- package/dist/routes/computer-use/setup.d.ts +4 -6
- package/dist/routes/computer-use/setup.js +2 -6
- package/dist/routes/computer-use/single-bundle.d.ts +4 -5
- package/dist/routes/computer-use/single-bundle.js +22 -25
- package/dist/routes/computer-use/subject-projection.d.ts +4 -8
- package/dist/routes/computer-use/subject-projection.js +9 -31
- package/dist/routes/computer-use/types.d.ts +4 -41
- package/dist/routes/scripted/scenario.js +28 -12
- package/dist/routes/scripted/session-result.js +6 -3
- package/dist/routes/shared-world/bundle-records.js +2 -8
- package/dist/routes/shared-world/participant-specs.d.ts +0 -2
- package/dist/routes/shared-world/participant-specs.js +1 -20
- package/dist/routes/shared-world/plan.d.ts +3 -0
- package/dist/routes/shared-world/plan.js +23 -3
- package/dist/routes/shared-world/setup.js +2 -2
- package/dist/routes/terminal/ledger.d.ts +9 -9
- package/dist/routes/terminal/ledger.js +63 -40
- package/dist/routes/terminal/live-finish.js +7 -10
- package/dist/routes/terminal/plan.d.ts +3 -0
- package/dist/routes/terminal/plan.js +8 -5
- package/dist/routes/terminal/token-usage.d.ts +1 -2
- package/dist/routes/terminal/token-usage.js +4 -6
- package/dist/routes/terminal/types.d.ts +9 -1
- package/dist/run/bundle-shape.js +6 -1
- package/dist/run/bundle.d.ts +5 -3
- package/dist/run/contained-output.d.ts +59 -10
- package/dist/run/contained-output.js +160 -68
- package/dist/run/detail.d.ts +2 -1
- package/dist/run/detail.js +12 -7
- package/dist/run/escape-sequences.d.ts +11 -0
- package/dist/run/escape-sequences.js +93 -0
- package/dist/run/evidence-files.d.ts +8 -1
- package/dist/run/evidence-files.js +24 -18
- package/dist/run/locate.d.ts +32 -3
- package/dist/run/locate.js +45 -40
- package/dist/run/note-files.d.ts +26 -45
- package/dist/run/note-files.js +86 -58
- package/dist/run/note-shape.d.ts +35 -0
- package/dist/run/note-shape.js +20 -0
- package/dist/run/notes.d.ts +3 -14
- package/dist/run/notes.js +23 -75
- package/dist/run/outcomes.d.ts +24 -0
- package/dist/run/outcomes.js +19 -0
- package/dist/run/participant-records.d.ts +2 -0
- package/dist/run/participant-records.js +17 -2
- package/dist/run/paths.d.ts +2 -0
- package/dist/run/paths.js +5 -1
- package/dist/run/preflight-receipts.js +13 -8
- package/dist/run/project.js +7 -4
- package/dist/run/reclaim-outcomes.d.ts +8 -6
- package/dist/run/reclaim-outcomes.js +10 -6
- package/dist/run/reclaim.d.ts +5 -0
- package/dist/run/reclaim.js +43 -16
- package/dist/run/run-clock.d.ts +50 -0
- package/dist/run/run-clock.js +66 -0
- package/dist/run/run.js +5 -2
- package/dist/run/sandbox-ids.d.ts +7 -2
- package/dist/run/sandbox-ids.js +27 -11
- package/dist/run/secrets.d.ts +2 -1
- package/dist/run/secrets.js +18 -22
- package/dist/run/stored-runs.js +14 -7
- package/dist/run/transient-comms-secrets.d.ts +6 -5
- package/dist/run/transient-comms-secrets.js +23 -39
- package/dist/run-study.js +22 -13
- package/dist/study/adapter-scorer-loader.js +6 -4
- package/dist/study/discover.js +9 -6
- package/dist/study/init-paths.js +7 -5
- package/dist/study/persona-resolve.d.ts +2 -2
- package/dist/study/persona-resolve.js +9 -5
- package/dist/study/plan-types.d.ts +2 -2
- package/dist/study/plan.d.ts +5 -3
- package/dist/study/plan.js +18 -9
- package/dist/study/preflight-probes.js +11 -3
- package/dist/study/preflight.js +9 -5
- package/dist/study/summary.d.ts +12 -7
- package/dist/study/summary.js +24 -24
- package/dist/study/warnings.d.ts +19 -12
- package/dist/study/warnings.js +39 -17
- package/dist/substrates/e2b/connection.d.ts +12 -0
- package/dist/substrates/e2b/connection.js +35 -0
- package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
- package/dist/substrates/e2b/desktop-executor.js +42 -9
- package/dist/substrates/e2b/lifetime.d.ts +24 -4
- package/dist/substrates/e2b/lifetime.js +41 -4
- package/dist/substrates/e2b/sandbox.d.ts +4 -3
- package/dist/substrates/e2b/sandbox.js +13 -18
- package/dist/substrates/e2b/sdk.d.ts +20 -9
- package/dist/substrates/local/capacity.d.ts +18 -2
- package/dist/substrates/local/capacity.js +21 -2
- package/dist/substrates/local/runtime-host.js +21 -14
- package/dist/substrates/local/runtime.js +3 -11
- package/dist/tui/contract.d.ts +30 -5
- package/dist/tui/contract.js +4 -1
- package/dist/tui-app.js +149 -147
- package/dist/verify/artifacts.d.ts +9 -2
- package/dist/verify/artifacts.js +37 -13
- package/dist/verify/liveness.js +19 -5
- package/dist/verify/verify.js +54 -27
- package/docs/architecture/actor-contract.md +3 -3
- package/docs/architecture/guest-desktop.md +7 -2
- package/docs/architecture/restricted-codex-analysis.md +1 -1
- package/docs/architecture/state-driven-executor.md +5 -3
- package/docs/contracts/run-bundle.md +25 -13
- package/docs/contracts/schemas.md +38 -29
- package/docs/contracts/study-analysis.md +22 -5
- package/docs/product/automatic-analysis.md +7 -1
- package/package.json +8 -7
- package/skills/humanish/SKILL.md +1 -2
|
@@ -4,9 +4,9 @@ import { realpath } from "node:fs/promises";
|
|
|
4
4
|
import { runNotFoundMessage } from "../run/run-not-found.js";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
7
|
-
import { bindExistingManagedHumanishOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
|
|
7
|
+
import { bindExistingManagedHumanishOutputDirectory, ContainedReadRefusedError, LATEST_POINTER_MAX_BYTES, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "../run/contained-output.js";
|
|
8
8
|
import { loadRunBundlePrepared } from "../run/locate.js";
|
|
9
|
-
import { readNotesForSharing } from "../run/
|
|
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.
|
|
98
|
+
if (shared.problem !== null)
|
|
99
99
|
return {
|
|
100
100
|
context,
|
|
101
|
-
result: notesRefusal(cwd, runInput, context.storedRunId, verified, shared.
|
|
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,
|
|
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
|
-
|
|
166
|
-
|
|
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/.`,
|
|
@@ -221,7 +208,9 @@ async function isSafeFeedbackEvidenceFile(context, evidencePath) {
|
|
|
221
208
|
path.isAbsolute(relative)) {
|
|
222
209
|
return false;
|
|
223
210
|
}
|
|
224
|
-
|
|
211
|
+
// Evidence a draft cites must be readable: a refused file is listed with the missing ones.
|
|
212
|
+
const read = await readContainedRegularFile(context.preparedRunPaths, relative, RUN_ARTIFACT_MAX_BYTES);
|
|
213
|
+
return read.status === "read";
|
|
225
214
|
}
|
|
226
215
|
export async function renderIssueMarkdown(cwdInput, runInput, repo, options = {}) {
|
|
227
216
|
const verified = await verifyFeedbackBound(cwdInput, runInput, options);
|
|
@@ -266,9 +255,22 @@ export async function listFeedback(cwdInput, runInput) {
|
|
|
266
255
|
},
|
|
267
256
|
};
|
|
268
257
|
}
|
|
269
|
-
const
|
|
270
|
-
const draft = draftBytes === null ? undefined : JSON.parse(draftBytes.toString("utf8"));
|
|
258
|
+
const draftRead = await readContainedRegularFile(context.preparedRunPaths, path.join("feedback", "draft.json"), RUN_ARTIFACT_MAX_BYTES);
|
|
271
259
|
const draftPath = path.join(context.preparedRunPaths.relativeRunRoot, "feedback", "draft.json");
|
|
260
|
+
if (draftRead.status === "refused")
|
|
261
|
+
return {
|
|
262
|
+
schema: FEEDBACK_RESULT_SCHEMA,
|
|
263
|
+
ok: false,
|
|
264
|
+
cwd,
|
|
265
|
+
run: runInput,
|
|
266
|
+
error: {
|
|
267
|
+
code: "HUMANISH_INVALID_FEEDBACK_DRAFT",
|
|
268
|
+
message: `The feedback draft could not be read: ${refusalText(draftPath, draftRead)}.`,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
const draft = draftRead.status === "missing"
|
|
272
|
+
? undefined
|
|
273
|
+
: JSON.parse(draftRead.bytes.toString("utf8"));
|
|
272
274
|
return {
|
|
273
275
|
schema: FEEDBACK_RESULT_SCHEMA,
|
|
274
276
|
ok: true,
|
|
@@ -297,10 +299,13 @@ async function resolveFeedbackRunContext(cwd, runInput) {
|
|
|
297
299
|
const runsRoot = await bindExistingManagedHumanishOutputDirectory(physicalCwd, "runs");
|
|
298
300
|
if (!runsRoot)
|
|
299
301
|
return null;
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
302
|
+
const pointerRead = await readContainedRegularFile(runsRoot, "latest.json", LATEST_POINTER_MAX_BYTES);
|
|
303
|
+
if (pointerRead.status === "missing")
|
|
302
304
|
return null;
|
|
303
|
-
|
|
305
|
+
// A pointer that is there and refused names no run, and saying so beats "run not found".
|
|
306
|
+
if (pointerRead.status === "refused")
|
|
307
|
+
throw new ContainedReadRefusedError(path.join(".humanish", "runs", "latest.json"), pointerRead);
|
|
308
|
+
const pointer = JSON.parse(pointerRead.bytes.toString("utf8"));
|
|
304
309
|
if (typeof pointer.runId !== "string" ||
|
|
305
310
|
typeof pointer.path !== "string" ||
|
|
306
311
|
!resolveLatestRunDirectory(physicalCwd, { path: pointer.path, runId: pointer.runId })) {
|
|
@@ -3,18 +3,35 @@ import { ComputerUseExecutorError } from "../actors/computer-use/executor-error.
|
|
|
3
3
|
// hand these strings to xdotool, which has a command language, so only names from these tables
|
|
4
4
|
// ever reach it. The guest runtime ships this module, so it lives in src/guest/, which CI's
|
|
5
5
|
// guest-desktop job and scripts/guest-desktop-proof.mjs key on.
|
|
6
|
+
//
|
|
7
|
+
// The tables take the names in OpenAI's computer-use key map, the names @e2b/desktop's press()
|
|
8
|
+
// mapped (the hosted desktop sent keys through it before), and every printable ASCII
|
|
9
|
+
// punctuation character. xdotool finds the key and the Shift level for a keysym itself, so `?`
|
|
10
|
+
// arrives as `question` with Shift held on an American English keymap.
|
|
6
11
|
/** The modifiers a pointer action may hold, keyed by the upper-cased provider name. */
|
|
7
12
|
const modifierNames = Object.freeze({
|
|
8
13
|
CTRL: "ctrl",
|
|
9
14
|
CONTROL: "ctrl",
|
|
10
15
|
ALT: "alt",
|
|
16
|
+
OPTION: "alt",
|
|
11
17
|
SHIFT: "shift",
|
|
12
18
|
META: "super",
|
|
13
19
|
SUPER: "super",
|
|
14
20
|
CMD: "super",
|
|
21
|
+
COMMAND: "super",
|
|
22
|
+
WIN: "super",
|
|
23
|
+
WINDOWS: "super",
|
|
15
24
|
});
|
|
16
25
|
const keyNames = Object.freeze({
|
|
17
26
|
...modifierNames,
|
|
27
|
+
CONTROL_LEFT: "Control_L",
|
|
28
|
+
CONTROL_RIGHT: "Control_R",
|
|
29
|
+
ALT_LEFT: "Alt_L",
|
|
30
|
+
ALT_RIGHT: "Alt_R",
|
|
31
|
+
SHIFT_LEFT: "Shift_L",
|
|
32
|
+
SHIFT_RIGHT: "Shift_R",
|
|
33
|
+
SUPER_LEFT: "Super_L",
|
|
34
|
+
SUPER_RIGHT: "Super_R",
|
|
18
35
|
ENTER: "Return",
|
|
19
36
|
RETURN: "Return",
|
|
20
37
|
TAB: "Tab",
|
|
@@ -24,11 +41,14 @@ const keyNames = Object.freeze({
|
|
|
24
41
|
" ": "space",
|
|
25
42
|
BACKSPACE: "BackSpace",
|
|
26
43
|
DELETE: "Delete",
|
|
44
|
+
DEL: "Delete",
|
|
27
45
|
INSERT: "Insert",
|
|
28
46
|
HOME: "Home",
|
|
29
47
|
END: "End",
|
|
30
48
|
PAGEUP: "Prior",
|
|
31
49
|
PAGEDOWN: "Next",
|
|
50
|
+
PAGE_UP: "Prior",
|
|
51
|
+
PAGE_DOWN: "Next",
|
|
32
52
|
ARROWUP: "Up",
|
|
33
53
|
ARROWDOWN: "Down",
|
|
34
54
|
ARROWLEFT: "Left",
|
|
@@ -37,18 +57,45 @@ const keyNames = Object.freeze({
|
|
|
37
57
|
DOWN: "Down",
|
|
38
58
|
LEFT: "Left",
|
|
39
59
|
RIGHT: "Right",
|
|
60
|
+
CAPS_LOCK: "Caps_Lock",
|
|
61
|
+
NUM_LOCK: "Num_Lock",
|
|
62
|
+
SCROLL_LOCK: "Scroll_Lock",
|
|
63
|
+
PAUSE: "Pause",
|
|
64
|
+
BREAK: "Pause",
|
|
65
|
+
PRINT: "Print",
|
|
66
|
+
MENU: "Menu",
|
|
67
|
+
"!": "exclam",
|
|
68
|
+
'"': "quotedbl",
|
|
69
|
+
"#": "numbersign",
|
|
70
|
+
$: "dollar",
|
|
71
|
+
"%": "percent",
|
|
72
|
+
"&": "ampersand",
|
|
73
|
+
"'": "apostrophe",
|
|
74
|
+
"(": "parenleft",
|
|
75
|
+
")": "parenright",
|
|
76
|
+
"*": "asterisk",
|
|
40
77
|
"+": "plus",
|
|
41
|
-
"-": "minus",
|
|
42
|
-
"=": "equal",
|
|
43
78
|
",": "comma",
|
|
79
|
+
"-": "minus",
|
|
44
80
|
".": "period",
|
|
45
81
|
"/": "slash",
|
|
46
|
-
"
|
|
82
|
+
":": "colon",
|
|
47
83
|
";": "semicolon",
|
|
48
|
-
"
|
|
84
|
+
"<": "less",
|
|
85
|
+
"=": "equal",
|
|
86
|
+
">": "greater",
|
|
87
|
+
"?": "question",
|
|
88
|
+
"@": "at",
|
|
49
89
|
"[": "bracketleft",
|
|
90
|
+
"\\": "backslash",
|
|
50
91
|
"]": "bracketright",
|
|
92
|
+
"^": "asciicircum",
|
|
93
|
+
_: "underscore",
|
|
51
94
|
"`": "grave",
|
|
95
|
+
"{": "braceleft",
|
|
96
|
+
"|": "bar",
|
|
97
|
+
"}": "braceright",
|
|
98
|
+
"~": "asciitilde",
|
|
52
99
|
});
|
|
53
100
|
function lookup(table, key) {
|
|
54
101
|
const upper = key.toUpperCase();
|
|
@@ -59,9 +106,16 @@ function chord(names) {
|
|
|
59
106
|
throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
|
|
60
107
|
return names.join("+");
|
|
61
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* A name that is itself a chord (`Control+a`, `ctrl++`) split at each plus sign that joins two
|
|
111
|
+
* names. A lone `+` and a trailing `+` after a joining one are the plus key.
|
|
112
|
+
*/
|
|
113
|
+
function chordParts(key) {
|
|
114
|
+
return key.length > 1 ? key.split(/\+(?!$)/) : [key];
|
|
115
|
+
}
|
|
62
116
|
/** A keypress chord in xdotool syntax. Unknown or repeated keys are refused before dispatch. */
|
|
63
117
|
export function xdotoolChord(keys) {
|
|
64
|
-
return chord(keys.map((key) => {
|
|
118
|
+
return chord(keys.flatMap(chordParts).map((key) => {
|
|
65
119
|
if (/^[a-z0-9]$/i.test(key))
|
|
66
120
|
return key.toLowerCase();
|
|
67
121
|
if (/^F(?:[1-9]|1[0-2])$/i.test(key))
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/** The vendor aliases `humanish keys set` accepts. A raw ENV_NAME is also accepted. */
|
|
2
|
+
export declare const KEY_VENDORS: string[];
|
|
3
|
+
/** The keys `humanish keys` lists, in its order, each with what humanish uses it for. */
|
|
4
|
+
export declare const LISTED_KEYS: {
|
|
5
|
+
name: string;
|
|
6
|
+
use: string;
|
|
7
|
+
}[];
|
|
8
|
+
/** The keys `humanish doctor` reports on every run, in its row order. */
|
|
9
|
+
export declare const DOCTOR_KEYS: string[];
|
|
1
10
|
export interface ResolvedKeyFill {
|
|
2
11
|
name: string;
|
|
3
12
|
/** Human-readable source label (a path or command name), never a value. */
|
|
@@ -22,43 +22,61 @@ 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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The provider keys, in the order `humanish keys set` names their vendors. Implicit discovery fills
|
|
29
|
+
* only these names and the user store holds only these. Everything else in an overlay or store file
|
|
30
|
+
* is ignored and named: a repo-planted NODE_OPTIONS/LD_PRELOAD must never enter process env off a
|
|
31
|
+
* file the operator did not explicitly pass (an explicit --dotenv remains the operator's own
|
|
32
|
+
* full-file load). ANTHROPIC_API_KEY is not listed, and doctor does not report it: a Claude Code
|
|
33
|
+
* participant runs on its own login and never receives it. CODEX_API_KEY is an alternative spelling
|
|
34
|
+
* of the OpenAI key for terminal studies. Doctor reports AGENTMAIL_API_KEY only under `--study`, for
|
|
35
|
+
* a study whose email connection names it, since a connection may name another variable.
|
|
36
|
+
*/
|
|
37
|
+
const PROVIDER_KEYS = [
|
|
38
|
+
{
|
|
39
|
+
name: "OPENAI_API_KEY",
|
|
40
|
+
vendor: "openai",
|
|
41
|
+
listed: { order: 2, use: "participant model and analysis" },
|
|
42
|
+
doctor: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "E2B_API_KEY",
|
|
46
|
+
vendor: "e2b",
|
|
47
|
+
login: "e2b auth login",
|
|
48
|
+
listed: { order: 1, use: "hosted desktops" },
|
|
49
|
+
doctor: true,
|
|
50
|
+
},
|
|
51
|
+
{ name: "ANTHROPIC_API_KEY", vendor: "anthropic" },
|
|
52
|
+
{
|
|
53
|
+
name: "GH_TOKEN",
|
|
54
|
+
vendor: "github",
|
|
55
|
+
login: "gh auth login",
|
|
56
|
+
listed: { order: 3, use: "private repository subjects" },
|
|
57
|
+
doctor: true,
|
|
58
|
+
},
|
|
59
|
+
{ name: "GITHUB_TOKEN", spellingOf: "GH_TOKEN" },
|
|
60
|
+
{ name: "CODEX_API_KEY", doctor: true },
|
|
61
|
+
{ name: "AGENTMAIL_API_KEY", vendor: "agentmail", listed: { order: 4, use: "email in studies" } },
|
|
38
62
|
];
|
|
39
|
-
const PROVIDER_KEY_SET = new Set(
|
|
63
|
+
const PROVIDER_KEY_SET = new Set(PROVIDER_KEYS.map((key) => key.name));
|
|
64
|
+
const providerKey = (name) => PROVIDER_KEYS.find((key) => key.name === name);
|
|
65
|
+
/** The vendor aliases `humanish keys set` accepts. A raw ENV_NAME is also accepted. */
|
|
66
|
+
export const KEY_VENDORS = PROVIDER_KEYS.flatMap((key) => key.vendor === undefined ? [] : [key.vendor]);
|
|
67
|
+
/** The keys `humanish keys` lists, in its order, each with what humanish uses it for. */
|
|
68
|
+
export const LISTED_KEYS = PROVIDER_KEYS.flatMap(({ name, listed }) => listed === undefined ? [] : [{ name, ...listed }])
|
|
69
|
+
.sort((a, b) => a.order - b.order)
|
|
70
|
+
.map(({ name, use }) => ({ name, use }));
|
|
71
|
+
/** The keys `humanish doctor` reports on every run, in its row order. */
|
|
72
|
+
export const DOCTOR_KEYS = PROVIDER_KEYS.flatMap(({ name, doctor }) => (doctor ? [name] : []));
|
|
40
73
|
/** An env for a vendor CLI that discovery runs: the caller's, without any provider key name. */
|
|
41
74
|
function withoutProviderKeys(env) {
|
|
42
75
|
return Object.fromEntries(Object.entries(env).filter(([name]) => !PROVIDER_KEY_SET.has(name)));
|
|
43
76
|
}
|
|
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
|
-
};
|
|
52
77
|
const PROJECT_OVERLAY_RELATIVE = path.join(".humanish", "local", "provider.env");
|
|
53
78
|
export function userKeyStorePath(env, deps = {}) {
|
|
54
|
-
|
|
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");
|
|
79
|
+
return humanishConfigFile(env, "keys.env", deps.homeDir);
|
|
62
80
|
}
|
|
63
81
|
function e2bConfigPath(deps) {
|
|
64
82
|
return path.join(deps.homeDir ?? homedir(), ".e2b", "config.json");
|
|
@@ -235,21 +253,10 @@ function storeLabel(env, deps) {
|
|
|
235
253
|
/** The nearest fill instruction for a missing key. Doctor rows use it, and it is appended to
|
|
236
254
|
* *_KEYS_MISSING errors so the failure names the fix. */
|
|
237
255
|
export function missingKeyHint(name) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
case "GITHUB_TOKEN":
|
|
243
|
-
return `run \`gh auth login\`, or \`${cli("keys set github")}\``;
|
|
244
|
-
case "OPENAI_API_KEY":
|
|
245
|
-
return `run \`${cli("keys set openai")}\``;
|
|
246
|
-
case "ANTHROPIC_API_KEY":
|
|
247
|
-
return `run \`${cli("keys set anthropic")}\``;
|
|
248
|
-
case "AGENTMAIL_API_KEY":
|
|
249
|
-
return `run \`${cli("keys set agentmail")}\``;
|
|
250
|
-
default:
|
|
251
|
-
return `run \`${cli(`keys set ${name}`)}\``;
|
|
252
|
-
}
|
|
256
|
+
const named = providerKey(name);
|
|
257
|
+
const key = named?.spellingOf === undefined ? named : providerKey(named.spellingOf);
|
|
258
|
+
const set = `\`${cli(`keys set ${key?.vendor ?? name}`)}\``;
|
|
259
|
+
return key?.login === undefined ? `run ${set}` : `run \`${key.login}\`, or ${set}`;
|
|
253
260
|
}
|
|
254
261
|
/** One shared suffix for *_KEYS_MISSING messages: where discovery looked, and what fills each
|
|
255
262
|
* missing key. Pure text; never values. */
|
|
@@ -281,19 +288,18 @@ export async function probeKeySources(names, args) {
|
|
|
281
288
|
...(args.deps === undefined ? {} : { deps: args.deps }),
|
|
282
289
|
});
|
|
283
290
|
const bySource = new Map(fills.map((fill) => [fill.name, fill.source]));
|
|
291
|
+
const setInEnv = (name) => args.env[name] !== undefined && args.env[name]?.trim() !== "";
|
|
284
292
|
return names.map((name) => {
|
|
285
|
-
const inEnv =
|
|
286
|
-
//
|
|
287
|
-
// "missing" while
|
|
288
|
-
const
|
|
289
|
-
args.env.GITHUB_TOKEN !== undefined &&
|
|
290
|
-
args.env.GITHUB_TOKEN.trim() !== "";
|
|
293
|
+
const inEnv = setInEnv(name) && !placeholder(name);
|
|
294
|
+
// A second spelling of the key in the env, such as GITHUB_TOKEN for GH_TOKEN, is the same
|
|
295
|
+
// credential; a doctor row that says "missing" while it sits in the env would be wrong.
|
|
296
|
+
const spelling = PROVIDER_KEYS.find((key) => key.spellingOf === name && setInEnv(key.name))?.name;
|
|
291
297
|
const source = inEnv
|
|
292
298
|
? args.dotenv?.names.includes(name)
|
|
293
299
|
? `--dotenv ${args.dotenv.path}`
|
|
294
300
|
: "process env"
|
|
295
|
-
:
|
|
296
|
-
?
|
|
301
|
+
: spelling !== undefined
|
|
302
|
+
? `process env (${spelling})`
|
|
297
303
|
: (bySource.get(name) ?? null);
|
|
298
304
|
return {
|
|
299
305
|
name,
|
|
@@ -306,9 +312,10 @@ export async function probeKeySources(names, args) {
|
|
|
306
312
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
307
313
|
/** Resolve a `humanish keys set` target: a vendor alias or a raw env name. Null = invalid. */
|
|
308
314
|
export function resolveKeyName(vendorOrName) {
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
315
|
+
const vendor = vendorOrName.toLowerCase();
|
|
316
|
+
const key = PROVIDER_KEYS.find((candidate) => candidate.vendor === vendor);
|
|
317
|
+
if (key !== undefined)
|
|
318
|
+
return key.name;
|
|
312
319
|
return ENV_NAME.test(vendorOrName) ? vendorOrName : null;
|
|
313
320
|
}
|
|
314
321
|
/** Write one key into the user store (0700 dir, 0600 file). Only allowlisted provider names
|
|
@@ -10,6 +10,8 @@ export declare function keyStatus(args: {
|
|
|
10
10
|
}): Promise<KeyStatusRow[]>;
|
|
11
11
|
/** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
|
|
12
12
|
export declare function otherStoredKeys(env: NodeJS.ProcessEnv, deps?: KeyResolutionDeps): string[];
|
|
13
|
+
/** The line `humanish keys` prints for one key: what it is for, and its source or how to add it. */
|
|
14
|
+
export declare function keyStatusLine(row: KeyStatusRow): string;
|
|
13
15
|
export declare function formatKeyStatus(rows: readonly KeyStatusRow[], otherStored?: readonly string[]): string;
|
|
14
16
|
interface MissingKeysOutcome {
|
|
15
17
|
stored: string[];
|
package/dist/keys/key-status.js
CHANGED
|
@@ -1,33 +1,26 @@
|
|
|
1
1
|
// `humanish keys`: which provider keys are set and where each comes from, through the same chain a
|
|
2
2
|
// live command resolves. Never a value.
|
|
3
3
|
import { cli } from "../cli/invocation.js";
|
|
4
|
-
import { listUserKeys, probeKeySources, setUserKey, } from "./key-resolution.js";
|
|
5
|
-
/**
|
|
6
|
-
* The keys a person sets for humanish, in the order the status lists them and `keys set` asks for
|
|
7
|
-
* them. ANTHROPIC_API_KEY is left out: a Claude Code participant runs on its own login and never
|
|
8
|
-
* receives it. CODEX_API_KEY is an alternative spelling of the OpenAI key for terminal studies.
|
|
9
|
-
*/
|
|
10
|
-
const STATUS_KEYS = [
|
|
11
|
-
{ name: "E2B_API_KEY", use: "hosted desktops" },
|
|
12
|
-
{ name: "OPENAI_API_KEY", use: "participant model and analysis" },
|
|
13
|
-
{ name: "GH_TOKEN", use: "private repository subjects" },
|
|
14
|
-
{ name: "AGENTMAIL_API_KEY", use: "email in studies" },
|
|
15
|
-
];
|
|
4
|
+
import { LISTED_KEYS, listUserKeys, probeKeySources, setUserKey, } from "./key-resolution.js";
|
|
16
5
|
export async function keyStatus(args) {
|
|
17
|
-
const probes = await probeKeySources(
|
|
6
|
+
const probes = await probeKeySources(LISTED_KEYS.map((key) => key.name), {
|
|
18
7
|
cwd: args.cwd,
|
|
19
8
|
env: args.env,
|
|
20
9
|
...(args.deps === undefined ? {} : { deps: args.deps }),
|
|
21
10
|
});
|
|
22
|
-
return probes.map((probe, index) => ({ ...probe, use:
|
|
11
|
+
return probes.map((probe, index) => ({ ...probe, use: LISTED_KEYS[index].use }));
|
|
23
12
|
}
|
|
24
13
|
/** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
|
|
25
14
|
export function otherStoredKeys(env, deps = {}) {
|
|
26
|
-
const listed = new Set(
|
|
15
|
+
const listed = new Set(LISTED_KEYS.map((key) => key.name));
|
|
27
16
|
return listUserKeys(env, deps).filter((name) => !listed.has(name));
|
|
28
17
|
}
|
|
18
|
+
/** The line `humanish keys` prints for one key: what it is for, and its source or how to add it. */
|
|
19
|
+
export function keyStatusLine(row) {
|
|
20
|
+
return `${row.name} (${row.use}): ${row.source === null ? `missing; ${row.hint}` : `set, from ${row.source}`}`;
|
|
21
|
+
}
|
|
29
22
|
export function formatKeyStatus(rows, otherStored = []) {
|
|
30
|
-
const lines = rows.map(
|
|
23
|
+
const lines = rows.map(keyStatusLine);
|
|
31
24
|
if (otherStored.length > 0)
|
|
32
25
|
lines.push(`Also in the user store: ${otherStored.join(", ")}. humanish does not use ${otherStored.length === 1 ? "it" : "them"} today.`);
|
|
33
26
|
const missing = rows.some((row) => row.source === null);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LoadedAnalysis } from "../analysis/types.js";
|
|
2
|
-
import type { RunNotes } from "../run/
|
|
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
|
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
|
+
}
|
|
@@ -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 {
|
|
9
|
-
import {
|
|
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
|
|
71
|
-
function
|
|
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
|
-
|
|
80
|
-
|
|
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[
|
|
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
|
|
101
|
+
const body = await readBodyAtMost(request, MAX_BODY_BYTES);
|
|
122
102
|
if (body === null)
|
|
123
103
|
return tooLarge();
|
|
124
|
-
const input =
|
|
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);
|