humanish 0.114.0 → 0.115.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/CONTEXT.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 +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/analysis/admission.d.ts +14 -4
- package/dist/analysis/admission.js +14 -9
- 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/cli/commands/notes.js +4 -3
- package/dist/cli/findings.js +10 -32
- package/dist/cli/io.js +4 -7
- 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 +12 -11
- package/dist/evidence/encoded-text.js +81 -70
- 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 +108 -32
- package/dist/feedback/draft.d.ts +1 -1
- package/dist/feedback/draft.js +13 -16
- package/dist/feedback/export-bundle.js +3 -3
- package/dist/feedback/export.js +32 -46
- package/dist/feedback/feedback.js +6 -19
- package/dist/keys/key-resolution.js +12 -17
- 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 +3 -2
- package/dist/observer/run-routes.js +13 -28
- package/dist/observer/serve.js +2 -1
- package/dist/observer-app.html +3 -3
- package/dist/run/contained-output.d.ts +6 -5
- package/dist/run/contained-output.js +19 -26
- package/dist/run/evidence-files.d.ts +7 -0
- package/dist/run/evidence-files.js +24 -14
- 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 -6
- package/dist/run/notes.js +18 -24
- 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/summary.d.ts +4 -5
- package/dist/study/summary.js +4 -2
- 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/run-bundle.md +7 -3
- package/docs/contracts/schemas.md +12 -12
- package/docs/contracts/study-analysis.md +12 -4
- package/package.json +7 -6
- package/skills/humanish/SKILL.md +1 -2
package/dist/feedback/draft.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// participant's feedback candidate or from a reviewed analysis finding, and its markdown rendering.
|
|
3
3
|
// The commands that resolve the run and read or write drafts are in feedback.ts.
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { findingLead } from "../analysis/finding-lead.js";
|
|
5
6
|
import { analysisSharingProblems } from "../analysis/sharing.js";
|
|
6
7
|
import { loadAnalysis } from "../analysis/load.js";
|
|
7
8
|
import { hashAnalysisValue } from "../analysis/validation.js";
|
|
@@ -9,7 +10,9 @@ import { formatParticipantOutcomes, formatRunTaskFunnel, participantOutcomeDetai
|
|
|
9
10
|
import { loadRunBundlePrepared } from "../run/locate.js";
|
|
10
11
|
import { isFeedbackIdempotencyKey } from "../run/feedback-shape.js";
|
|
11
12
|
import { runNoteFile } from "../run/note-files.js";
|
|
12
|
-
import {
|
|
13
|
+
import { byRunTime } from "../run/note-shape.js";
|
|
14
|
+
import { formatRunTime } from "../run/notes.js";
|
|
15
|
+
import { runParticipantCaptions } from "../run/participant-records.js";
|
|
13
16
|
import { isRecord } from "../run/type-guards.js";
|
|
14
17
|
import { feedbackProofCommands, projectFeedbackAcceptanceProof } from "./proof.js";
|
|
15
18
|
export const FEEDBACK_SCHEMA = "humanish.feedback.v1";
|
|
@@ -122,8 +125,10 @@ export async function buildAnalysisDraft(context, options) {
|
|
|
122
125
|
const root = path.dirname(context.loaded.bundlePath);
|
|
123
126
|
const evidenceIds = new Set(finding.observations.flatMap((item) => item.evidenceIds));
|
|
124
127
|
const evidence = analysis.evidence.filter((item) => evidenceIds.has(item.id));
|
|
125
|
-
const
|
|
126
|
-
|
|
128
|
+
const lead = findingLead(finding, correction);
|
|
129
|
+
// An analysis written before headlines has no lead, so the amended claim or the title names it.
|
|
130
|
+
const amendedClaim = correction?.status === "amended" ? correction.replacementClaim : null;
|
|
131
|
+
const claim = lead?.headline ?? amendedClaim ?? finding.title;
|
|
127
132
|
const firstLine = claim.trim().split(/\r?\n/)[0] || `Reviewed finding ${finding.id}`;
|
|
128
133
|
const summary = Array.from(firstLine).length > 160
|
|
129
134
|
? Array.from(firstLine).slice(0, 159).join("") + "…"
|
|
@@ -147,19 +152,11 @@ export async function buildAnalysisDraft(context, options) {
|
|
|
147
152
|
summary,
|
|
148
153
|
expected: "Review the cited behavior against the participant assignment and confirm the expected product behavior.",
|
|
149
154
|
actual: [
|
|
150
|
-
|
|
151
|
-
...(finding.headline === undefined
|
|
152
|
-
? []
|
|
153
|
-
: amended
|
|
154
|
-
? [
|
|
155
|
-
"This finding was corrected in human review. The reviewer's claim above replaces its original headline and account.",
|
|
156
|
-
"",
|
|
157
|
-
]
|
|
158
|
-
: finding.experience === undefined
|
|
159
|
-
? []
|
|
160
|
-
: [finding.experience, ""]),
|
|
155
|
+
...(lead?.account ? [lead.account, ""] : []),
|
|
161
156
|
"Independent study analysis; does not replace participant feedback or recorded completion outcomes.",
|
|
162
|
-
|
|
157
|
+
amendedClaim === null
|
|
158
|
+
? finding.summary
|
|
159
|
+
: `Amended claim: ${amendedClaim}. Original analysis: ${finding.summary}`,
|
|
163
160
|
`Impact: ${finding.impact}. ${finding.affectedStreamIds.length} affected / ${finding.exposedStreamIds.length} observed exposed participants. ${finding.exposureReason}`,
|
|
164
161
|
`Recovery: ${finding.recovery}. Confidence: ${finding.confidence}.`,
|
|
165
162
|
...finding.observations.map((item) => `${item.basis}: ${item.claim}${item.limitation ? ` Limitation: ${item.limitation}` : ""}`),
|
|
@@ -234,7 +231,7 @@ export function withReviewerNotes(draft, notes, bundle, runRoot) {
|
|
|
234
231
|
if (notes === null || notes.notes.length === 0)
|
|
235
232
|
return draft;
|
|
236
233
|
const captions = runParticipantCaptions(bundle);
|
|
237
|
-
const ordered =
|
|
234
|
+
const ordered = byRunTime(notes.notes);
|
|
238
235
|
return {
|
|
239
236
|
...draft,
|
|
240
237
|
evidence: [
|
|
@@ -10,7 +10,7 @@ import { renderObserver } from "../observer/render.js";
|
|
|
10
10
|
import { buildObserverData } from "../observer/data.js";
|
|
11
11
|
import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
|
|
12
12
|
import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
|
|
13
|
-
import { decodeEscapes,
|
|
13
|
+
import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
|
|
14
14
|
import { readPlainText } from "../evidence/plain-text.js";
|
|
15
15
|
import { streamScreenshotPaths } from "../verify/artifacts.js";
|
|
16
16
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
@@ -148,9 +148,9 @@ async function prospectivePhysicalPath(requested) {
|
|
|
148
148
|
function assertNoInlineRaster(text) {
|
|
149
149
|
// Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
|
|
150
150
|
// refused: copying a text extension must not smuggle untransformed pixels.
|
|
151
|
-
|
|
152
|
-
if ([text, decoded, utf8ReadingOf(text, decoded) ?? ""].some(containsSensitive))
|
|
151
|
+
if (readingsOf(text).some(containsSensitive))
|
|
153
152
|
throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
|
|
153
|
+
const decoded = decodeEscapes(text);
|
|
154
154
|
if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
|
|
155
155
|
/iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
|
|
156
156
|
throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
|
package/dist/feedback/export.js
CHANGED
|
@@ -19,7 +19,7 @@ import { verifyRun } from "../verify/verify.js";
|
|
|
19
19
|
import {} from "../run/bundle.js";
|
|
20
20
|
import { exportRedactedBundle } from "./export-bundle.js";
|
|
21
21
|
import { loadAnalysis } from "../analysis/load.js";
|
|
22
|
-
import { readNotesForSharing } from "../run/
|
|
22
|
+
import { readNotesForSharing } from "../run/note-files.js";
|
|
23
23
|
import { cli } from "../cli/invocation.js";
|
|
24
24
|
import { analysisSharingProblems } from "../analysis/sharing.js";
|
|
25
25
|
import { EVIDENCE_LIMITS, validateAnalysisEvidence } from "../analysis/evidence.js";
|
|
@@ -288,7 +288,10 @@ function makePortable(inlined, verified) {
|
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
|
-
|
|
291
|
+
recordShareSafety(inlined, verified);
|
|
292
|
+
}
|
|
293
|
+
/** What verify said, in the file, so the chrome can agree with the result envelope. */
|
|
294
|
+
function recordShareSafety(inlined, verified) {
|
|
292
295
|
const publicSafety = (inlined.publicSafety ?? {});
|
|
293
296
|
inlined.publicSafety = {
|
|
294
297
|
...publicSafety,
|
|
@@ -299,6 +302,23 @@ function makePortable(inlined, verified) {
|
|
|
299
302
|
},
|
|
300
303
|
};
|
|
301
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Adds a failed sharing check to verify's result. The reason joins verify's reasons and the status
|
|
307
|
+
* is the worse of the two, as verify grades an analysis. The export is refused unless --local-only,
|
|
308
|
+
* which writes the new status into the file.
|
|
309
|
+
*/
|
|
310
|
+
function applySharingProblem(verified, inlined, options, problem) {
|
|
311
|
+
verified.shareSafety = {
|
|
312
|
+
status: problem.status === "blocked" || verified.shareSafety.status === "blocked"
|
|
313
|
+
? "blocked"
|
|
314
|
+
: "local_only",
|
|
315
|
+
reasons: [...verified.shareSafety.reasons, problem.reason],
|
|
316
|
+
};
|
|
317
|
+
if (options.localOnly !== true)
|
|
318
|
+
return "blocked";
|
|
319
|
+
recordShareSafety(inlined, verified);
|
|
320
|
+
return "downgraded";
|
|
321
|
+
}
|
|
302
322
|
/**
|
|
303
323
|
* Revalidates the analysis against source evidence and its sharing checks. A failed check
|
|
304
324
|
* downgrades share safety, which only --local-only may export.
|
|
@@ -322,24 +342,13 @@ async function recheckAnalysis(runPaths, analysis, verified, inlined, options) {
|
|
|
322
342
|
const analysisSharing = analysisSharingProblems(analysis);
|
|
323
343
|
if (!analysisSharing.sensitive && !analysisSharing.unverified)
|
|
324
344
|
return "ok";
|
|
325
|
-
verified
|
|
345
|
+
return applySharingProblem(verified, inlined, options, {
|
|
326
346
|
status: analysisSharing.sensitive ? "blocked" : "local_only",
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
},
|
|
333
|
-
],
|
|
334
|
-
};
|
|
335
|
-
if (options.localOnly !== true)
|
|
336
|
-
return "blocked";
|
|
337
|
-
inlined.publicSafety.share = {
|
|
338
|
-
status: verified.shareSafety.status,
|
|
339
|
-
verifiedAt: new Date().toISOString(),
|
|
340
|
-
reasons: verified.shareSafety.reasons.map((reason) => reason.code),
|
|
341
|
-
};
|
|
342
|
-
return "downgraded";
|
|
347
|
+
reason: {
|
|
348
|
+
code: "ANALYSIS_UNVERIFIED",
|
|
349
|
+
message: "The exact analysis snapshot being exported did not pass sharing checks.",
|
|
350
|
+
},
|
|
351
|
+
});
|
|
343
352
|
}
|
|
344
353
|
/**
|
|
345
354
|
* The run's notes for the export, read once within the notes size limit. That snapshot is scanned
|
|
@@ -347,36 +356,13 @@ async function recheckAnalysis(runPaths, analysis, verified, inlined, options) {
|
|
|
347
356
|
* reach the file unchecked. A finding blocks the export, or downgrades it under --local-only.
|
|
348
357
|
*/
|
|
349
358
|
async function checkedNotes(runPaths, verified, inlined, options, warnings) {
|
|
350
|
-
const { notes,
|
|
359
|
+
const { notes, problem } = await readNotesForSharing(runPaths);
|
|
351
360
|
for (const skipped of notes.skipped)
|
|
352
361
|
warnings.push(`Reviewer notes: ${skipped}`);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
status: finding === "sensitive" || verified.shareSafety.status === "blocked"
|
|
357
|
-
? "blocked"
|
|
358
|
-
: "local_only",
|
|
359
|
-
reasons: [
|
|
360
|
-
...verified.shareSafety.reasons,
|
|
361
|
-
finding === "sensitive"
|
|
362
|
-
? {
|
|
363
|
-
code: "PUBLIC_SAFETY_FINDINGS",
|
|
364
|
-
message: "The reviewer notes being exported match secret, token or local-path patterns.",
|
|
365
|
-
}
|
|
366
|
-
: {
|
|
367
|
-
code: "UNSCANNED_ARTIFACT",
|
|
368
|
-
message: "The reviewer notes being exported hold encoded text the scan cannot read.",
|
|
369
|
-
},
|
|
370
|
-
],
|
|
371
|
-
};
|
|
372
|
-
if (options.localOnly !== true)
|
|
373
|
-
return { notes, check: "blocked" };
|
|
374
|
-
inlined.publicSafety.share = {
|
|
375
|
-
status: verified.shareSafety.status,
|
|
376
|
-
verifiedAt: new Date().toISOString(),
|
|
377
|
-
reasons: verified.shareSafety.reasons.map((reason) => reason.code),
|
|
362
|
+
return {
|
|
363
|
+
notes,
|
|
364
|
+
check: problem === null ? "ok" : applySharingProblem(verified, inlined, options, problem),
|
|
378
365
|
};
|
|
379
|
-
return { notes, check: "downgraded" };
|
|
380
366
|
}
|
|
381
367
|
/** The portable Observer HTML, with the local-only banner when the export is watermarked. */
|
|
382
368
|
function renderExportHtml(inlined, review, assets, verified, watermarked, omittedRecording) {
|
|
@@ -6,7 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
7
7
|
import { bindExistingManagedHumanishOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
|
|
8
8
|
import { loadRunBundlePrepared } from "../run/locate.js";
|
|
9
|
-
import { readNotesForSharing } from "../run/
|
|
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/.`,
|
|
@@ -22,6 +22,7 @@ import { homedir } from "node:os";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { loadEnvFile } from "./env-file.js";
|
|
24
24
|
import { cli } from "../cli/invocation.js";
|
|
25
|
+
import { humanishConfigFile } from "../cli/user-config.js";
|
|
25
26
|
import { OPENAI_EGRESS_PLACEHOLDER } from "../routes/terminal/runtime-auth.js";
|
|
26
27
|
/** The only names implicit discovery may fill (and `humanish keys set` may store). Everything
|
|
27
28
|
* else in an overlay/store file is ignored-and-named: a repo-planted NODE_OPTIONS/LD_PRELOAD
|
|
@@ -41,24 +42,18 @@ const PROVIDER_KEY_SET = new Set(KNOWN_PROVIDER_KEYS);
|
|
|
41
42
|
function withoutProviderKeys(env) {
|
|
42
43
|
return Object.fromEntries(Object.entries(env).filter(([name]) => !PROVIDER_KEY_SET.has(name)));
|
|
43
44
|
}
|
|
44
|
-
/** `humanish keys set <vendor>` aliases; a raw ENV_NAME is also accepted.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
/** `humanish keys set <vendor>` aliases; a raw ENV_NAME is also accepted. A Map, so a name such as
|
|
46
|
+
* `constructor` is no alias. */
|
|
47
|
+
const KEY_VENDOR_ALIASES = new Map([
|
|
48
|
+
["openai", "OPENAI_API_KEY"],
|
|
49
|
+
["e2b", "E2B_API_KEY"],
|
|
50
|
+
["anthropic", "ANTHROPIC_API_KEY"],
|
|
51
|
+
["github", "GH_TOKEN"],
|
|
52
|
+
["agentmail", "AGENTMAIL_API_KEY"],
|
|
53
|
+
]);
|
|
52
54
|
const PROJECT_OVERLAY_RELATIVE = path.join(".humanish", "local", "provider.env");
|
|
53
55
|
export function userKeyStorePath(env, deps = {}) {
|
|
54
|
-
|
|
55
|
-
const declared = env.XDG_CONFIG_HOME?.trim();
|
|
56
|
-
// The XDG spec: a relative XDG_CONFIG_HOME must be ignored. Honoring one would make the
|
|
57
|
-
// key store cwd-relative: `humanish keys set` would write a secret into the current repo.
|
|
58
|
-
const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
|
|
59
|
-
? declared
|
|
60
|
-
: path.join(home, ".config");
|
|
61
|
-
return path.join(configHome, "humanish", "keys.env");
|
|
56
|
+
return humanishConfigFile(env, "keys.env", deps.homeDir);
|
|
62
57
|
}
|
|
63
58
|
function e2bConfigPath(deps) {
|
|
64
59
|
return path.join(deps.homeDir ?? homedir(), ".e2b", "config.json");
|
|
@@ -306,7 +301,7 @@ export async function probeKeySources(names, args) {
|
|
|
306
301
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
307
302
|
/** Resolve a `humanish keys set` target: a vendor alias or a raw env name. Null = invalid. */
|
|
308
303
|
export function resolveKeyName(vendorOrName) {
|
|
309
|
-
const alias = KEY_VENDOR_ALIASES
|
|
304
|
+
const alias = KEY_VENDOR_ALIASES.get(vendorOrName.toLowerCase());
|
|
310
305
|
if (alias !== undefined)
|
|
311
306
|
return alias;
|
|
312
307
|
return ENV_NAME.test(vendorOrName) ? vendorOrName : null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LoadedAnalysis } from "../analysis/types.js";
|
|
2
|
-
import type { RunNotes } from "../run/
|
|
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);
|
|
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
|
|
|
5
5
|
import { constants as fsConstants } from "node:fs";
|
|
6
6
|
import { lstat, open, realpath } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import { readOpenedAtMost } from "../run/evidence-files.js";
|
|
8
9
|
import { isPathInside, isSafeRunIdSegment } from "../run/paths.js";
|
|
9
10
|
/**
|
|
10
11
|
* The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
|
|
@@ -18,7 +19,9 @@ export async function readContainedFile(root, filePathInput, options = {}) {
|
|
|
18
19
|
try {
|
|
19
20
|
const body = options.maxBytes === undefined
|
|
20
21
|
? await opened.handle.readFile()
|
|
21
|
-
:
|
|
22
|
+
: opened.size > options.maxBytes
|
|
23
|
+
? null
|
|
24
|
+
: await readOpenedAtMost(opened.handle, options.maxBytes);
|
|
22
25
|
if (body === null)
|
|
23
26
|
return null;
|
|
24
27
|
// A write that landed during the read changed the file's mtime and ctime.
|
|
@@ -79,24 +82,6 @@ export async function openContainedFile(root, filePathInput) {
|
|
|
79
82
|
return null;
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
|
-
/** At most `maxBytes` from the start of an opened file, or null when it holds more. */
|
|
83
|
-
async function readAtMost(handle, size, maxBytes) {
|
|
84
|
-
if (size > maxBytes)
|
|
85
|
-
return null;
|
|
86
|
-
const chunks = [];
|
|
87
|
-
let total = 0;
|
|
88
|
-
for (;;) {
|
|
89
|
-
const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total));
|
|
90
|
-
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
|
|
91
|
-
if (bytesRead === 0)
|
|
92
|
-
return Buffer.concat(chunks, total);
|
|
93
|
-
total += bytesRead;
|
|
94
|
-
// The file grew past the limit after it was opened.
|
|
95
|
-
if (total > maxBytes)
|
|
96
|
-
return null;
|
|
97
|
-
chunks.push(chunk.subarray(0, bytesRead));
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
85
|
/** The sha256 of an opened file, read by position so the handle's own offset is untouched. */
|
|
101
86
|
export async function sha256OfOpenedFile(handle) {
|
|
102
87
|
const hash = createHash("sha256");
|
package/dist/observer/render.js
CHANGED
|
@@ -12,14 +12,15 @@ import { loadAnalysis } from "../analysis/load.js";
|
|
|
12
12
|
import { physicalCwdOf, runIdOf, bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
13
13
|
import { writeContainedOutputFile } from "../run/contained-output.js";
|
|
14
14
|
import { loadRunBundlePrepared, readRunJsonIfExists } from "../run/locate.js";
|
|
15
|
-
import { readRunNotes } from "../run/
|
|
15
|
+
import { readRunNotes } from "../run/note-files.js";
|
|
16
16
|
import { RUN_STATUS_FILE } from "../run/status.js";
|
|
17
17
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
18
18
|
import { renderObserverHtml } from "./artifact.js";
|
|
19
19
|
import { buildObserverData } from "./data.js";
|
|
20
20
|
import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin } from "./http.js";
|
|
21
21
|
import { listenOnLoopback } from "./listen.js";
|
|
22
|
-
import {
|
|
22
|
+
import { NOTES_PATH } from "../run/note-shape.js";
|
|
23
|
+
import { createNotesWriter } from "./notes-route.js";
|
|
23
24
|
import { pinDirectChildDirectory, pinDirectory, readContainedFile, } from "./pinned-files.js";
|
|
24
25
|
import { buildHistoryIndex, matchRunRoute, serveRunPath, writeResponse, } from "./run-routes.js";
|
|
25
26
|
import { cli } from "../cli/invocation.js";
|
|
@@ -9,7 +9,7 @@ import { listRuns } from "../run/stored-runs.js";
|
|
|
9
9
|
import { readAnalysisAccounting } from "../run/costs.js";
|
|
10
10
|
import { bindExistingRunArtifactPaths, isPathInside, isSafeRunIdSegment, } from "../run/paths.js";
|
|
11
11
|
import { isRunOutcome } from "../run/bundle-shape.js";
|
|
12
|
-
import {
|
|
12
|
+
import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-files.js";
|
|
13
13
|
import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
|
|
14
14
|
import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
|
|
15
15
|
import { renderObserverHtml } from "./artifact.js";
|
|
@@ -59,33 +59,18 @@ async function readServedAnalysisSpend(runRoot) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
|
-
* The run's
|
|
63
|
-
*
|
|
64
|
-
* apply to every note. A file that is not a readable note of the run is skipped and counted.
|
|
62
|
+
* The run's note files as served: each read through the root's checks, so a --safe root's
|
|
63
|
+
* admission and hash checks apply to every note.
|
|
65
64
|
*/
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
if (listing === null)
|
|
77
|
-
return { runId, notes: [], skipped: [] };
|
|
78
|
-
const { ids, skipped } = noteListing(listing);
|
|
79
|
-
const notes = [];
|
|
80
|
-
for (const id of ids) {
|
|
81
|
-
const bytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, runNoteFile(id)), { maxBytes: MAX_NOTE_FILE_BYTES });
|
|
82
|
-
const note = bytes === null ? null : decodeRunNote(bytes, runId, id);
|
|
83
|
-
if (note === null)
|
|
84
|
-
skipped.push(`${runNoteFile(id)} was skipped.`);
|
|
85
|
-
else
|
|
86
|
-
notes.push(note);
|
|
87
|
-
}
|
|
88
|
-
return { runId, notes, skipped };
|
|
65
|
+
function servedNoteFiles(runRoot) {
|
|
66
|
+
return {
|
|
67
|
+
runId: path.basename(runRoot.physicalPath),
|
|
68
|
+
async directory() {
|
|
69
|
+
await assertPinnedDirectory(runRoot);
|
|
70
|
+
return runRoot.physicalPath;
|
|
71
|
+
},
|
|
72
|
+
read: (relativePath, maxBytes) => readContainedFile(runRoot, path.join(runRoot.physicalPath, relativePath), { maxBytes }),
|
|
73
|
+
};
|
|
89
74
|
}
|
|
90
75
|
/**
|
|
91
76
|
* internal: consumed by src/observer/serve.ts. `notesToken` goes into the run's Observer page so
|
|
@@ -120,7 +105,7 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
|
|
|
120
105
|
return;
|
|
121
106
|
}
|
|
122
107
|
const analysis = await readObserverAnalysis(runRoot);
|
|
123
|
-
const notes = await
|
|
108
|
+
const notes = await readRunNotes(servedNoteFiles(runRoot));
|
|
124
109
|
writeResponse(response, 200, renderObserverHtml(observerData, {
|
|
125
110
|
analysis,
|
|
126
111
|
notes,
|
package/dist/observer/serve.js
CHANGED
|
@@ -3,7 +3,8 @@ import { runNotFoundMessage } from "../run/run-not-found.js";
|
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { pinDirectChildDirectory, pinDirectory } from "./pinned-files.js";
|
|
6
|
-
import {
|
|
6
|
+
import { NOTES_PATH } from "../run/note-shape.js";
|
|
7
|
+
import { createNotesWriter } from "./notes-route.js";
|
|
7
8
|
import { renderObserver } from "./render.js";
|
|
8
9
|
import { buildHistoryIndex, matchRunRoute, servedRunPaths, serveRunPath } from "./run-routes.js";
|
|
9
10
|
import { hashRunInventory, inventoryRoot, readRunInventory, } from "./run-inventory.js";
|