humanish 0.112.0 → 0.114.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/CONTEXT.md +1 -0
- package/README.md +12 -1
- package/TELEMETRY.md +21 -0
- package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
- package/dist/actors/codex/restricted-participant-policy.js +20 -2
- package/dist/actors/codex/restricted-participant.d.ts +1 -1
- package/dist/actors/codex/restricted-participant.js +2 -1
- package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
- package/dist/actors/computer-use/loop/debrief.js +57 -20
- package/dist/actors/computer-use/loop/ending.d.ts +5 -2
- package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
- package/dist/actors/computer-use/loop/impressions.js +40 -0
- package/dist/actors/computer-use/loop/session.d.ts +3 -1
- package/dist/actors/computer-use/loop/session.js +2 -0
- package/dist/actors/computer-use/loop/trace.js +2 -0
- package/dist/actors/computer-use/loop/types.d.ts +8 -1
- package/dist/actors/computer-use/loop.d.ts +2 -1
- package/dist/actors/computer-use/loop.js +15 -3
- package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
- package/dist/actors/computer-use/openai-conversation.js +60 -29
- package/dist/actors/computer-use/openai-provider.js +18 -12
- package/dist/actors/contract.d.ts +47 -5
- package/dist/actors/contract.js +13 -0
- package/dist/analysis/admission.d.ts +37 -0
- package/dist/analysis/admission.js +60 -0
- package/dist/analysis/automatic-completion.d.ts +0 -6
- package/dist/analysis/automatic-completion.js +3 -3
- package/dist/analysis/automatic-config.d.ts +10 -2
- package/dist/analysis/automatic-config.js +29 -14
- package/dist/analysis/automatic.js +7 -0
- package/dist/analysis/evidence-sources.d.ts +3 -1
- package/dist/analysis/evidence-sources.js +10 -1
- package/dist/analysis/evidence.js +2 -2
- package/dist/analysis/execute.d.ts +20 -6
- package/dist/analysis/execute.js +61 -28
- package/dist/analysis/image-tokens.d.ts +2 -0
- package/dist/analysis/image-tokens.js +1 -1
- package/dist/analysis/job.d.ts +9 -1
- package/dist/analysis/job.js +11 -2
- package/dist/analysis/service.js +6 -3
- package/dist/cli/commands/analyze.js +7 -2
- package/dist/cli/commands/notes.d.ts +3 -0
- package/dist/cli/commands/notes.js +144 -0
- package/dist/cli/commands/observe.js +1 -1
- package/dist/cli/commands/runs.js +33 -10
- package/dist/cli/commands/setup.js +95 -6
- package/dist/cli/commands/study-format.js +7 -4
- package/dist/cli/commands/study-run.js +2 -1
- package/dist/cli/commands/study.js +1 -1
- package/dist/cli/commands/tui.js +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +18 -0
- package/dist/cli/findings.js +4 -1
- package/dist/cli/io.d.ts +3 -0
- package/dist/cli/io.js +11 -12
- package/dist/cli/program.js +37 -1
- package/dist/cli/secret-prompt.d.ts +5 -1
- package/dist/cli/secret-prompt.js +6 -2
- package/dist/cli/update-check-worker.d.ts +1 -0
- package/dist/cli/update-check-worker.js +5 -0
- package/dist/cli/update-check.d.ts +46 -0
- package/dist/cli/update-check.js +203 -0
- package/dist/evidence/encoded-text.d.ts +15 -2
- package/dist/evidence/encoded-text.js +68 -4
- package/dist/evidence/secret-scrub.d.ts +1 -1
- package/dist/evidence/secret-scrub.js +23 -2
- package/dist/feedback/draft.d.ts +19 -0
- package/dist/feedback/draft.js +69 -1
- package/dist/feedback/export-bundle.js +2 -2
- package/dist/feedback/export.js +47 -3
- package/dist/feedback/feedback.js +42 -2
- package/dist/keys/key-resolution.js +2 -0
- package/dist/keys/key-status.d.ts +37 -0
- package/dist/keys/key-status.js +68 -0
- package/dist/observer/artifact.d.ts +5 -0
- package/dist/observer/artifact.js +18 -2
- package/dist/observer/notes-route.d.ts +21 -0
- package/dist/observer/notes-route.js +140 -0
- package/dist/observer/pinned-files.d.ts +8 -1
- package/dist/observer/pinned-files.js +32 -3
- package/dist/observer/render.js +38 -14
- package/dist/observer/run-routes.d.ts +8 -2
- package/dist/observer/run-routes.js +47 -6
- package/dist/observer/serve.d.ts +3 -0
- package/dist/observer/serve.js +28 -3
- package/dist/observer-app.html +7 -6
- package/dist/routes/computer-use/local-vm.js +9 -2
- package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
- package/dist/routes/computer-use/participant-execution.js +9 -1
- package/dist/routes/computer-use/rerun-selection.js +23 -21
- package/dist/routes/computer-use/result.js +3 -2
- package/dist/routes/computer-use/self-report.js +7 -1
- package/dist/routes/computer-use/setup.js +14 -0
- package/dist/routes/computer-use/types.d.ts +4 -1
- package/dist/routes/shared-world/bundle-records.js +8 -7
- package/dist/routes/shared-world/bundle.js +1 -1
- package/dist/run/contained-output.d.ts +7 -0
- package/dist/run/contained-output.js +35 -1
- package/dist/run/detail.js +4 -4
- package/dist/run/note-files.d.ts +73 -0
- package/dist/run/note-files.js +184 -0
- package/dist/run/notes.d.ts +37 -0
- package/dist/run/notes.js +198 -0
- package/dist/run/participant-caption.d.ts +11 -1
- package/dist/run/participant-caption.js +30 -1
- package/dist/run/participant-records.d.ts +3 -1
- package/dist/run/participant-records.js +6 -0
- package/dist/run-study.js +17 -1
- package/dist/study/discover.js +6 -1
- package/dist/study/doctor.d.ts +2 -0
- package/dist/study/doctor.js +46 -8
- package/dist/study/init-templates.js +10 -5
- package/dist/study/plan.js +3 -2
- package/dist/study/preflight-result.d.ts +1 -0
- package/dist/study/preflight-result.js +3 -2
- package/dist/study/preflight.d.ts +2 -0
- package/dist/study/preflight.js +54 -14
- package/dist/study/summary.js +2 -2
- package/dist/study/warnings.d.ts +5 -0
- package/dist/study/warnings.js +79 -2
- package/dist/substrates/local/capacity.d.ts +75 -0
- package/dist/substrates/local/capacity.js +143 -0
- package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
- package/dist/substrates/local/firecracker-desktop.js +34 -3
- package/dist/substrates/local/runtime-host.d.ts +16 -3
- package/dist/substrates/local/runtime-host.js +49 -4
- package/dist/substrates/local/runtime.d.ts +10 -0
- package/dist/substrates/local/runtime.js +44 -5
- package/dist/tui-app.js +58 -58
- package/docs/architecture/actor-contract.md +20 -6
- package/docs/contracts/errors.md +2 -2
- package/docs/contracts/feedback.md +8 -1
- package/docs/contracts/run-bundle.md +57 -0
- package/docs/contracts/schemas.md +62 -36
- package/docs/contracts/study-analysis.md +41 -8
- package/docs/principles/invariants-and-defaults.md +2 -2
- package/docs/product/automatic-analysis.md +22 -16
- package/package.json +2 -2
- package/skills/humanish/SKILL.md +26 -10
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Tells a person when a newer humanish is published, as npm, gh and the Vercel CLI do. A command
|
|
2
|
+
// only reads a small cache and prints at most one line from it; the registry request runs in a
|
|
3
|
+
// detached process that writes the cache for a later command, so the check never delays or fails
|
|
4
|
+
// the command that started it.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { disabledByEnvironment } from "./telemetry.js";
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const REGISTRY_DIST_TAGS = "https://registry.npmjs.org/-/package/humanish/dist-tags";
|
|
13
|
+
/** Next to telemetry.json, under the same XDG rule the key store uses. */
|
|
14
|
+
function updateCachePath(env, home = homedir()) {
|
|
15
|
+
const declared = env.XDG_CONFIG_HOME?.trim();
|
|
16
|
+
const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
|
|
17
|
+
? declared
|
|
18
|
+
: path.join(home, ".config");
|
|
19
|
+
return path.join(configHome, "humanish", "update-check.json");
|
|
20
|
+
}
|
|
21
|
+
function readUpdateCache(cachePath) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
24
|
+
if (parsed === null || typeof parsed !== "object")
|
|
25
|
+
return {};
|
|
26
|
+
const record = parsed;
|
|
27
|
+
const text = (field) => typeof record[field] === "string" ? record[field] : undefined;
|
|
28
|
+
const cache = {};
|
|
29
|
+
const checkedAt = text("checkedAt");
|
|
30
|
+
const latest = text("latest");
|
|
31
|
+
const notifiedAt = text("notifiedAt");
|
|
32
|
+
if (checkedAt !== undefined)
|
|
33
|
+
cache.checkedAt = checkedAt;
|
|
34
|
+
if (latest !== undefined && parseVersion(latest) !== undefined)
|
|
35
|
+
cache.latest = latest;
|
|
36
|
+
if (notifiedAt !== undefined)
|
|
37
|
+
cache.notifiedAt = notifiedAt;
|
|
38
|
+
return cache;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function writeUpdateCache(cachePath, cache) {
|
|
45
|
+
mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
46
|
+
writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
47
|
+
}
|
|
48
|
+
function parseVersion(version) {
|
|
49
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
50
|
+
return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
51
|
+
}
|
|
52
|
+
/** True when `candidate` is a higher release than `installed`. */
|
|
53
|
+
function isNewer(candidate, installed) {
|
|
54
|
+
const a = parseVersion(candidate);
|
|
55
|
+
const b = parseVersion(installed);
|
|
56
|
+
if (a === undefined || b === undefined)
|
|
57
|
+
return false;
|
|
58
|
+
for (let index = 0; index < 3; index += 1)
|
|
59
|
+
if (a[index] !== b[index])
|
|
60
|
+
return a[index] > b[index];
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
/** True when `timestamp` is missing, unreadable, or at least a day before `now`. */
|
|
64
|
+
function dayPassed(timestamp, now) {
|
|
65
|
+
const then = timestamp === undefined ? Number.NaN : Date.parse(timestamp);
|
|
66
|
+
return Number.isNaN(then) || now - then >= DAY_MS;
|
|
67
|
+
}
|
|
68
|
+
const truthy = (value) => value !== undefined &&
|
|
69
|
+
value.trim() !== "" &&
|
|
70
|
+
value.trim() !== "0" &&
|
|
71
|
+
value.trim().toLowerCase() !== "false";
|
|
72
|
+
/**
|
|
73
|
+
* Only a person at a terminal is told, and only when nothing has switched the check off. A pipe,
|
|
74
|
+
* CI and a JSON document have a program reading them. A study participant runs the version the
|
|
75
|
+
* study chose, and a notice would send it to another. The telemetry switches turn this off too:
|
|
76
|
+
* someone who opted out of telemetry expects no unprompted requests.
|
|
77
|
+
*/
|
|
78
|
+
function checkAllowed(context) {
|
|
79
|
+
const env = context.env;
|
|
80
|
+
return (context.terminal &&
|
|
81
|
+
!context.json &&
|
|
82
|
+
!context.ownCheckout &&
|
|
83
|
+
!truthy(env.CI) &&
|
|
84
|
+
!truthy(env.HUMANISH_NO_UPDATE_CHECK) &&
|
|
85
|
+
!truthy(env.HUMANISH_STUDY_PARTICIPANT) &&
|
|
86
|
+
!disabledByEnvironment(env));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The one line to print after a command when the cache names a newer humanish, at most once a day.
|
|
90
|
+
* Records that it was shown. Every failure inside is swallowed: an update notice is never a reason
|
|
91
|
+
* for a command to fail.
|
|
92
|
+
*/
|
|
93
|
+
export function checkForUpdate(context) {
|
|
94
|
+
try {
|
|
95
|
+
if (!checkAllowed(context))
|
|
96
|
+
return undefined;
|
|
97
|
+
const cachePath = updateCachePath(context.env, context.home);
|
|
98
|
+
const cache = readUpdateCache(cachePath);
|
|
99
|
+
const stamp = new Date(context.now).toISOString();
|
|
100
|
+
const next = { ...cache };
|
|
101
|
+
// Stamped before the request starts, so an offline machine asks once a day rather than on
|
|
102
|
+
// every command. The request writes `latest` when it succeeds.
|
|
103
|
+
const refresh = dayPassed(cache.checkedAt, context.now);
|
|
104
|
+
if (refresh)
|
|
105
|
+
next.checkedAt = stamp;
|
|
106
|
+
const latest = cache.latest;
|
|
107
|
+
const notice = latest !== undefined &&
|
|
108
|
+
isNewer(latest, context.installed) &&
|
|
109
|
+
dayPassed(cache.notifiedAt, context.now)
|
|
110
|
+
? `humanish ${context.installed} is out of date; the latest is ${latest}. Run npx humanish@latest to use it.`
|
|
111
|
+
: undefined;
|
|
112
|
+
if (notice !== undefined)
|
|
113
|
+
next.notifiedAt = stamp;
|
|
114
|
+
if (refresh || notice !== undefined)
|
|
115
|
+
writeUpdateCache(cachePath, next);
|
|
116
|
+
if (refresh)
|
|
117
|
+
context.startRefresh(cachePath);
|
|
118
|
+
return notice;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The version the last successful check recorded, compared with `installed`, for `doctor`. Reads the
|
|
126
|
+
* cache only; undefined until a check has succeeded on this machine.
|
|
127
|
+
*/
|
|
128
|
+
export function recordedVersion(installed, env, home) {
|
|
129
|
+
const cache = readUpdateCache(updateCachePath(env, home));
|
|
130
|
+
if (cache.latest === undefined)
|
|
131
|
+
return undefined;
|
|
132
|
+
const checked = cache.checkedAt === undefined ? "" : ` (checked ${cache.checkedAt.slice(0, 10)})`;
|
|
133
|
+
return isNewer(cache.latest, installed)
|
|
134
|
+
? {
|
|
135
|
+
newer: true,
|
|
136
|
+
message: `${installed} installed; ${cache.latest} is the latest${checked}. Run npx humanish@latest to use it.`,
|
|
137
|
+
}
|
|
138
|
+
: { newer: false, message: `${installed} installed, the latest published${checked}` };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Ask the npm registry for humanish's `latest` dist-tag and record it in the cache. The detached
|
|
142
|
+
* worker runs this. The request is a GET with an Accept header and nothing else: no machine id,
|
|
143
|
+
* installed version, command or path. A failed or malformed answer leaves the cache as it was.
|
|
144
|
+
*/
|
|
145
|
+
export async function refreshUpdateCache(args) {
|
|
146
|
+
try {
|
|
147
|
+
const fetchFn = args.fetchFn ?? globalThis.fetch;
|
|
148
|
+
const response = await fetchFn(REGISTRY_DIST_TAGS, {
|
|
149
|
+
headers: { accept: "application/json" },
|
|
150
|
+
signal: AbortSignal.timeout(args.timeoutMs ?? 3_000),
|
|
151
|
+
});
|
|
152
|
+
if (!response.ok)
|
|
153
|
+
return;
|
|
154
|
+
const body = await response.json();
|
|
155
|
+
const latest = body !== null && typeof body === "object"
|
|
156
|
+
? body.latest
|
|
157
|
+
: undefined;
|
|
158
|
+
if (typeof latest !== "string" || parseVersion(latest) === undefined)
|
|
159
|
+
return;
|
|
160
|
+
const now = (args.now ?? Date.now)();
|
|
161
|
+
writeUpdateCache(args.cachePath, {
|
|
162
|
+
...readUpdateCache(args.cachePath),
|
|
163
|
+
checkedAt: new Date(now).toISOString(),
|
|
164
|
+
latest,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Offline, slow or refused: the next check is a day away either way.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** What the worker's environment keeps: how this machine reaches the network, and no credentials. */
|
|
172
|
+
const WORKER_ENV_NAMES = [
|
|
173
|
+
"HTTPS_PROXY",
|
|
174
|
+
"HTTP_PROXY",
|
|
175
|
+
"NO_PROXY",
|
|
176
|
+
"https_proxy",
|
|
177
|
+
"http_proxy",
|
|
178
|
+
"no_proxy",
|
|
179
|
+
"NODE_USE_ENV_PROXY",
|
|
180
|
+
"NODE_EXTRA_CA_CERTS",
|
|
181
|
+
"SYSTEMROOT",
|
|
182
|
+
];
|
|
183
|
+
/**
|
|
184
|
+
* Start the worker that runs refreshUpdateCache, detached and unreferenced so this process exits
|
|
185
|
+
* without waiting for it. A source checkout has no built worker, and nothing starts.
|
|
186
|
+
*/
|
|
187
|
+
export function startRefreshWorker(cachePath) {
|
|
188
|
+
const worker = fileURLToPath(new URL("./update-check-worker.js", import.meta.url));
|
|
189
|
+
if (!existsSync(worker))
|
|
190
|
+
return;
|
|
191
|
+
const env = Object.fromEntries(WORKER_ENV_NAMES.flatMap((name) => {
|
|
192
|
+
const value = process.env[name];
|
|
193
|
+
return value === undefined ? [] : [[name, value]];
|
|
194
|
+
}));
|
|
195
|
+
const child = spawn(process.execPath, [worker, cachePath], {
|
|
196
|
+
detached: true,
|
|
197
|
+
stdio: "ignore",
|
|
198
|
+
windowsHide: true,
|
|
199
|
+
env,
|
|
200
|
+
});
|
|
201
|
+
child.on("error", () => { });
|
|
202
|
+
child.unref();
|
|
203
|
+
}
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Undoes JSON and JS escapes, percent-encoding and HTML character references, one pass each. Each
|
|
3
|
+
* percent escape is one character, the byte's code, so `caf%C3%A9` reads as `café`;
|
|
4
|
+
* decodeEscapesUtf8 reads it as `café`. A scan checks both: either reading can split a value that
|
|
5
|
+
* the other keeps whole, as `%E2%80%80` (U+2000, a space, in UTF-8) does.
|
|
6
|
+
*/
|
|
2
7
|
export declare function decodeEscapes(text: string): string;
|
|
8
|
+
/** decodeEscapes with each run of percent escapes read as UTF-8. */
|
|
9
|
+
export declare function decodeEscapesUtf8(text: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The UTF-8 reading of text's escapes when it differs from decodeEscapes, which it can only where
|
|
12
|
+
* a percent escape holds a byte of 0x80 or more.
|
|
13
|
+
*/
|
|
14
|
+
export declare function utf8ReadingOf(text: string, decoded: string): string | undefined;
|
|
3
15
|
/** How scanEncodedText reads text. */
|
|
4
16
|
export interface EncodedTextScanOptions {
|
|
5
17
|
/** Do not count an encoded binary run as opaque. */
|
|
@@ -14,7 +26,8 @@ export interface EncodedTextScan {
|
|
|
14
26
|
readonly opaque: boolean;
|
|
15
27
|
}
|
|
16
28
|
/**
|
|
17
|
-
* Scans text for secrets as written, after decodeEscapes
|
|
29
|
+
* Scans text for secrets as written, after decodeEscapes (also with percent escapes read as UTF-8)
|
|
30
|
+
* and transfer escapes, and inside each
|
|
18
31
|
* base64 (standard, URL-safe or line-wrapped) and hex run. A run that decodes to text is scanned in
|
|
19
32
|
* turn. A run that decodes to an archive, or to other binary past MIN_OPAQUE_BASE64_RUN characters,
|
|
20
33
|
* is opaque unless the caller allows opaque runs.
|
|
@@ -44,13 +44,74 @@ function codePoint(value, original) {
|
|
|
44
44
|
? String.fromCodePoint(value)
|
|
45
45
|
: original;
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
// The length of the UTF-8 sequence a lead byte starts, or 0 for a byte that starts none.
|
|
48
|
+
function sequenceLength(lead) {
|
|
49
|
+
if (lead < 0x80)
|
|
50
|
+
return 1;
|
|
51
|
+
if (lead >= 0xc2 && lead <= 0xdf)
|
|
52
|
+
return 2;
|
|
53
|
+
if (lead >= 0xe0 && lead <= 0xef)
|
|
54
|
+
return 3;
|
|
55
|
+
if (lead >= 0xf0 && lead <= 0xf4)
|
|
56
|
+
return 4;
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A run of percent escapes read as UTF-8, as a browser reads a URL. A byte that starts no valid
|
|
61
|
+
* sequence stands for itself, the character with that code, as decodeEscapes reads every byte.
|
|
62
|
+
*/
|
|
63
|
+
function decodePercentRun(run) {
|
|
64
|
+
const bytes = Buffer.from(run.replace(/%/g, ""), "hex");
|
|
65
|
+
let text = "";
|
|
66
|
+
for (let at = 0; at < bytes.length;) {
|
|
67
|
+
const length = sequenceLength(bytes[at]);
|
|
68
|
+
const sequence = length > 0 ? bytes.subarray(at, at + length) : undefined;
|
|
69
|
+
const decoded = sequence?.length === length ? sequence.toString("utf8") : undefined;
|
|
70
|
+
// Node writes the replacement character for an overlong form, a surrogate half or a bad
|
|
71
|
+
// continuation byte, so a sequence counts only when its character encodes back to its bytes.
|
|
72
|
+
if (decoded !== undefined && Buffer.from(decoded, "utf8").equals(sequence)) {
|
|
73
|
+
text += decoded;
|
|
74
|
+
at += length;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
text += String.fromCharCode(bytes[at]);
|
|
78
|
+
at += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return text;
|
|
82
|
+
}
|
|
83
|
+
function decodePercentBytes(run) {
|
|
84
|
+
return Buffer.from(run.replace(/%/g, ""), "hex").toString("latin1");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Undoes JSON and JS escapes, percent-encoding and HTML character references, one pass each. Each
|
|
88
|
+
* percent escape is one character, the byte's code, so `caf%C3%A9` reads as `café`;
|
|
89
|
+
* decodeEscapesUtf8 reads it as `café`. A scan checks both: either reading can split a value that
|
|
90
|
+
* the other keeps whole, as `%E2%80%80` (U+2000, a space, in UTF-8) does.
|
|
91
|
+
*/
|
|
48
92
|
export function decodeEscapes(text) {
|
|
93
|
+
return decodeWith(text, decodePercentBytes);
|
|
94
|
+
}
|
|
95
|
+
/** decodeEscapes with each run of percent escapes read as UTF-8. */
|
|
96
|
+
export function decodeEscapesUtf8(text) {
|
|
97
|
+
return decodeWith(text, decodePercentRun);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The UTF-8 reading of text's escapes when it differs from decodeEscapes, which it can only where
|
|
101
|
+
* a percent escape holds a byte of 0x80 or more.
|
|
102
|
+
*/
|
|
103
|
+
export function utf8ReadingOf(text, decoded) {
|
|
104
|
+
if (!/%[89a-f][0-9a-f]/i.test(text))
|
|
105
|
+
return undefined;
|
|
106
|
+
const utf8 = decodeEscapesUtf8(text);
|
|
107
|
+
return utf8 === decoded ? undefined : utf8;
|
|
108
|
+
}
|
|
109
|
+
function decodeWith(text, percent) {
|
|
49
110
|
return text
|
|
50
111
|
.replace(/\\u([0-9a-f]{4})/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
|
|
51
112
|
.replace(/\\x([0-9a-f]{2})/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
|
|
52
113
|
.replace(/\\\//g, "/")
|
|
53
|
-
.replace(
|
|
114
|
+
.replace(/(?:%[0-9a-f]{2})+/gi, percent)
|
|
54
115
|
.replace(/&#x([0-9a-f]{1,6});?/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
|
|
55
116
|
.replace(/&#(\d{1,7});?/g, (match, digits) => codePoint(Number.parseInt(digits, 10), match))
|
|
56
117
|
.replace(/&([a-z]+);/gi, (match, name) => ASCII_ENTITIES[name.toLowerCase()] ?? match);
|
|
@@ -215,7 +276,8 @@ function inspectDecoded(bytes, run, options, depth) {
|
|
|
215
276
|
};
|
|
216
277
|
}
|
|
217
278
|
/**
|
|
218
|
-
* Scans text for secrets as written, after decodeEscapes
|
|
279
|
+
* Scans text for secrets as written, after decodeEscapes (also with percent escapes read as UTF-8)
|
|
280
|
+
* and transfer escapes, and inside each
|
|
219
281
|
* base64 (standard, URL-safe or line-wrapped) and hex run. A run that decodes to text is scanned in
|
|
220
282
|
* turn. A run that decodes to an archive, or to other binary past MIN_OPAQUE_BASE64_RUN characters,
|
|
221
283
|
* is opaque unless the caller allows opaque runs.
|
|
@@ -223,10 +285,12 @@ function inspectDecoded(bytes, run, options, depth) {
|
|
|
223
285
|
export function scanEncodedText(text, options = {}, depth = 0) {
|
|
224
286
|
const matches = matcherOf(options);
|
|
225
287
|
const decoded = decodeEscapes(text);
|
|
288
|
+
const utf8 = utf8ReadingOf(text, decoded);
|
|
226
289
|
const expanded = decodeTransferEscapes(decoded);
|
|
227
290
|
// A decoding that returns the same string would match the same way, so it is not matched again.
|
|
228
291
|
if (matches(text) ||
|
|
229
292
|
(decoded !== text && matches(decoded)) ||
|
|
293
|
+
(utf8 !== undefined && matches(utf8)) ||
|
|
230
294
|
(expanded !== decoded && matches(expanded)))
|
|
231
295
|
return SENSITIVE;
|
|
232
296
|
const runs = [];
|
|
@@ -277,7 +341,7 @@ export function scanEncodedText(text, options = {}, depth = 0) {
|
|
|
277
341
|
// Bump when scanEncodedText, decodeEscapes or the sensitive patterns change what they return. The
|
|
278
342
|
// cache lives in one process, so the version guards results across a hot reload or a test that
|
|
279
343
|
// swaps the scanner.
|
|
280
|
-
const ENCODED_SCAN_VERSION =
|
|
344
|
+
const ENCODED_SCAN_VERSION = 4;
|
|
281
345
|
// Distinct files one process verifies in a burst (a run's files, a serve library's runs).
|
|
282
346
|
const SCAN_CACHE_LIMIT = 256;
|
|
283
347
|
const scanCache = new Map();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A value as written, and percent-encoded, JSON-escaped once (also with non-ASCII characters as
|
|
3
|
-
* `\u` escapes) and twice, base64 at each byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as a string. An
|
|
3
|
+
* `\u` escapes) and twice, its UTF-8 bytes read one per character, base64 at each byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as a string. An
|
|
4
4
|
* escaped form is searched for at any length, as the value is: it holds a backslash or a `%`, so it
|
|
5
5
|
* is not ordinary text.
|
|
6
6
|
*/
|
|
@@ -27,7 +27,7 @@ function base64Middles(bytes, encoding) {
|
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* A value as written, and percent-encoded, JSON-escaped once (also with non-ASCII characters as
|
|
30
|
-
* `\u` escapes) and twice, base64 at each byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as a string. An
|
|
30
|
+
* `\u` escapes) and twice, its UTF-8 bytes read one per character, base64 at each byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as a string. An
|
|
31
31
|
* escaped form is searched for at any length, as the value is: it holds a backslash or a `%`, so it
|
|
32
32
|
* is not ordinary text.
|
|
33
33
|
*/
|
|
@@ -52,8 +52,29 @@ export function encodedForms(value) {
|
|
|
52
52
|
...base64Middles(bytes, "base64"),
|
|
53
53
|
...base64Middles(bytes, "base64url"),
|
|
54
54
|
];
|
|
55
|
+
// decodeEscapes reads each percent escape as one character, so a percent-encoded non-ASCII value
|
|
56
|
+
// reads as its UTF-8 bytes one per character, é as é.
|
|
57
|
+
const bytewise = bytes.toString("latin1");
|
|
58
|
+
if (bytewise !== value)
|
|
59
|
+
escaped.push(bytewise);
|
|
55
60
|
return [value, ...escaped, ...binary.filter((form) => form.length >= MIN_ENCODED_FORM)];
|
|
56
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Whether [at, end) overlaps a marker. The markers are one regex's matches, so they are sorted and
|
|
64
|
+
* disjoint: only the first marker that ends after `at` can also start before `end`.
|
|
65
|
+
*/
|
|
66
|
+
function overlapsMarker(markers, at, end) {
|
|
67
|
+
let low = 0;
|
|
68
|
+
let high = markers.length;
|
|
69
|
+
while (low < high) {
|
|
70
|
+
const middle = (low + high) >> 1;
|
|
71
|
+
if (markers[middle][1] > at)
|
|
72
|
+
high = middle;
|
|
73
|
+
else
|
|
74
|
+
low = middle + 1;
|
|
75
|
+
}
|
|
76
|
+
return low < markers.length && markers[low][0] < end;
|
|
77
|
+
}
|
|
57
78
|
/** Every occurrence of every form, outside the markers, merged where they overlap or touch. */
|
|
58
79
|
function secretSpans(text, forms) {
|
|
59
80
|
const markers = [];
|
|
@@ -65,7 +86,7 @@ function secretSpans(text, forms) {
|
|
|
65
86
|
for (const form of forms) {
|
|
66
87
|
for (let at = text.indexOf(form); at !== -1; at = text.indexOf(form, at + 1)) {
|
|
67
88
|
const end = at + form.length;
|
|
68
|
-
if (!markers
|
|
89
|
+
if (!overlapsMarker(markers, at, end))
|
|
69
90
|
spans.push([at, end]);
|
|
70
91
|
}
|
|
71
92
|
}
|
package/dist/feedback/draft.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RunBundle, RunFeedbackCandidate } from "../run/bundle.js";
|
|
2
2
|
import type { PreparedRunArtifactPaths } from "../run/paths.js";
|
|
3
3
|
import { loadRunBundlePrepared } from "../run/locate.js";
|
|
4
|
+
import { type RunNotes } from "../run/notes.js";
|
|
4
5
|
export declare const FEEDBACK_SCHEMA = "humanish.feedback.v1";
|
|
5
6
|
export interface FeedbackDraft {
|
|
6
7
|
schema: typeof FEEDBACK_SCHEMA;
|
|
@@ -35,6 +36,19 @@ export interface FeedbackDraft {
|
|
|
35
36
|
idempotency_key: string;
|
|
36
37
|
proposed_next_state: RunFeedbackCandidate["proposed_next_state"];
|
|
37
38
|
acceptance_proof: string[];
|
|
39
|
+
/** Notes a person added while reviewing the recording, in run clock order. Absent with none. */
|
|
40
|
+
reviewer_notes?: Array<{
|
|
41
|
+
id: string;
|
|
42
|
+
/** The moment on the run clock, as the Observer shows it: `02:31`. */
|
|
43
|
+
at: string;
|
|
44
|
+
at_ms: number;
|
|
45
|
+
/** The stream id the note file records, or null for a note on the whole run. */
|
|
46
|
+
participant: string | null;
|
|
47
|
+
/** The participant's caption, as the Observer names it. */
|
|
48
|
+
participant_caption: string | null;
|
|
49
|
+
author: string;
|
|
50
|
+
text: string;
|
|
51
|
+
}>;
|
|
38
52
|
}
|
|
39
53
|
export interface FeedbackDraftOptions {
|
|
40
54
|
/** A candidate id from `feedback list`. Absent: the first usable candidate, as before. */
|
|
@@ -53,6 +67,11 @@ export interface FeedbackRunContext {
|
|
|
53
67
|
}
|
|
54
68
|
export declare function buildDraft(bundle: RunBundle, bundlePath: string, candidateId?: string): FeedbackDraft;
|
|
55
69
|
export declare function buildAnalysisDraft(context: FeedbackRunContext, options: FeedbackDraftOptions): Promise<FeedbackDraft | null>;
|
|
70
|
+
/**
|
|
71
|
+
* The draft with the run's reviewer notes and each note's file cited. The caller passes the set it
|
|
72
|
+
* read and checked for sharing (readNotesForSharing).
|
|
73
|
+
*/
|
|
74
|
+
export declare function withReviewerNotes(draft: FeedbackDraft, notes: RunNotes | null, bundle: RunBundle, runRoot: string): FeedbackDraft;
|
|
56
75
|
export declare function isUsableFeedbackCandidate(candidate: unknown): candidate is RunFeedbackCandidate;
|
|
57
76
|
export declare function renderMarkdown(draft: FeedbackDraft, repo: string): string;
|
|
58
77
|
export {};
|
package/dist/feedback/draft.js
CHANGED
|
@@ -8,6 +8,8 @@ import { hashAnalysisValue } from "../analysis/validation.js";
|
|
|
8
8
|
import { formatParticipantOutcomes, formatRunTaskFunnel, participantOutcomeDetails, withCuaReviewProvenance, } from "../run/outcomes.js";
|
|
9
9
|
import { loadRunBundlePrepared } from "../run/locate.js";
|
|
10
10
|
import { isFeedbackIdempotencyKey } from "../run/feedback-shape.js";
|
|
11
|
+
import { runNoteFile } from "../run/note-files.js";
|
|
12
|
+
import { formatRunTime, runParticipantCaptions } from "../run/notes.js";
|
|
11
13
|
import { isRecord } from "../run/type-guards.js";
|
|
12
14
|
import { feedbackProofCommands, projectFeedbackAcceptanceProof } from "./proof.js";
|
|
13
15
|
export const FEEDBACK_SCHEMA = "humanish.feedback.v1";
|
|
@@ -161,6 +163,7 @@ export async function buildAnalysisDraft(context, options) {
|
|
|
161
163
|
`Impact: ${finding.impact}. ${finding.affectedStreamIds.length} affected / ${finding.exposedStreamIds.length} observed exposed participants. ${finding.exposureReason}`,
|
|
162
164
|
`Recovery: ${finding.recovery}. Confidence: ${finding.confidence}.`,
|
|
163
165
|
...finding.observations.map((item) => `${item.basis}: ${item.claim}${item.limitation ? ` Limitation: ${item.limitation}` : ""}`),
|
|
166
|
+
...citedImpressions(bundle, evidence).map(({ streamId, kind, text }) => `Participant ${streamId} said at the end (${kind.replaceAll("_", " ")}): ${text}`),
|
|
164
167
|
`Next check: ${finding.nextStep}`,
|
|
165
168
|
correction
|
|
166
169
|
? `Human review: ${correction.status}. ${correction.reason}`
|
|
@@ -206,6 +209,71 @@ export async function buildAnalysisDraft(context, options) {
|
|
|
206
209
|
],
|
|
207
210
|
};
|
|
208
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* The participant impressions among a finding's cited evidence. Only an impression whose text the
|
|
214
|
+
* cited entry carries whole is quoted, so the draft repeats nothing the analysis share check did
|
|
215
|
+
* not read.
|
|
216
|
+
*/
|
|
217
|
+
function citedImpressions(bundle, evidence) {
|
|
218
|
+
return evidence.flatMap((item) => {
|
|
219
|
+
const impressions = bundle.streams.find((stream) => stream.id === item.streamId)?.actor
|
|
220
|
+
?.impressions;
|
|
221
|
+
const impression = impressions?.status === "collected"
|
|
222
|
+
? impressions.items.find((entry) => entry.messageId === item.eventId)
|
|
223
|
+
: undefined;
|
|
224
|
+
return impression === undefined || !item.text.includes(impression.text)
|
|
225
|
+
? []
|
|
226
|
+
: [{ streamId: item.streamId, kind: impression.kind, text: impression.text }];
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The draft with the run's reviewer notes and each note's file cited. The caller passes the set it
|
|
231
|
+
* read and checked for sharing (readNotesForSharing).
|
|
232
|
+
*/
|
|
233
|
+
export function withReviewerNotes(draft, notes, bundle, runRoot) {
|
|
234
|
+
if (notes === null || notes.notes.length === 0)
|
|
235
|
+
return draft;
|
|
236
|
+
const captions = runParticipantCaptions(bundle);
|
|
237
|
+
const ordered = [...notes.notes].sort((left, right) => left.atMs - right.atMs || left.createdAt.localeCompare(right.createdAt));
|
|
238
|
+
return {
|
|
239
|
+
...draft,
|
|
240
|
+
evidence: [
|
|
241
|
+
...draft.evidence,
|
|
242
|
+
...ordered.map((note) => ({
|
|
243
|
+
path: path.join(runRoot, runNoteFile(note.id)),
|
|
244
|
+
kind: "review",
|
|
245
|
+
note: `Reviewer note at ${formatRunTime(note.atMs)}, added while reviewing the recording.`,
|
|
246
|
+
})),
|
|
247
|
+
],
|
|
248
|
+
reviewer_notes: ordered.map((note) => ({
|
|
249
|
+
id: note.id,
|
|
250
|
+
at: formatRunTime(note.atMs),
|
|
251
|
+
at_ms: note.atMs,
|
|
252
|
+
participant: note.participant,
|
|
253
|
+
participant_caption: note.participant === null ? null : (captions.get(note.participant) ?? note.participant),
|
|
254
|
+
author: note.author,
|
|
255
|
+
text: note.text,
|
|
256
|
+
})),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function reviewerNotesSection(draft) {
|
|
260
|
+
if (!draft.reviewer_notes?.length)
|
|
261
|
+
return "";
|
|
262
|
+
const items = draft.reviewer_notes.map((note) => {
|
|
263
|
+
const [first = "", ...rest] = note.text.split("\n");
|
|
264
|
+
return [
|
|
265
|
+
`- ${note.at}, ${note.participant_caption ?? "whole run"}, ${note.author}: ${first}`,
|
|
266
|
+
...rest.map((line) => ` ${line}`),
|
|
267
|
+
].join("\n");
|
|
268
|
+
});
|
|
269
|
+
return `## Reviewer notes
|
|
270
|
+
|
|
271
|
+
Notes a person added while reviewing the recording. They are not participant feedback.
|
|
272
|
+
|
|
273
|
+
${items.join("\n")}
|
|
274
|
+
|
|
275
|
+
`;
|
|
276
|
+
}
|
|
209
277
|
export function isUsableFeedbackCandidate(candidate) {
|
|
210
278
|
if (!isRecord(candidate) ||
|
|
211
279
|
candidate.schema !== "humanish.feedback-candidate.v1" ||
|
|
@@ -244,7 +312,7 @@ ${draft.expected}
|
|
|
244
312
|
|
|
245
313
|
${draft.actual}
|
|
246
314
|
|
|
247
|
-
## Evidence
|
|
315
|
+
${reviewerNotesSection(draft)}## Evidence
|
|
248
316
|
|
|
249
317
|
${draft.evidence.map((item) => `- ${item.kind} ${runRelative(draft, item.path)}: ${item.note}`).join("\n")}
|
|
250
318
|
|
|
@@ -10,7 +10,7 @@ import { renderObserver } from "../observer/render.js";
|
|
|
10
10
|
import { buildObserverData } from "../observer/data.js";
|
|
11
11
|
import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
|
|
12
12
|
import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
|
|
13
|
-
import { decodeEscapes } from "../evidence/encoded-text.js";
|
|
13
|
+
import { decodeEscapes, utf8ReadingOf } from "../evidence/encoded-text.js";
|
|
14
14
|
import { readPlainText } from "../evidence/plain-text.js";
|
|
15
15
|
import { streamScreenshotPaths } from "../verify/artifacts.js";
|
|
16
16
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
@@ -149,7 +149,7 @@ function assertNoInlineRaster(text) {
|
|
|
149
149
|
// Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
|
|
150
150
|
// refused: copying a text extension must not smuggle untransformed pixels.
|
|
151
151
|
const decoded = decodeEscapes(text);
|
|
152
|
-
if (
|
|
152
|
+
if ([text, decoded, utf8ReadingOf(text, decoded) ?? ""].some(containsSensitive))
|
|
153
153
|
throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
|
|
154
154
|
if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
|
|
155
155
|
/iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
|
package/dist/feedback/export.js
CHANGED
|
@@ -19,6 +19,8 @@ import { verifyRun } from "../verify/verify.js";
|
|
|
19
19
|
import {} from "../run/bundle.js";
|
|
20
20
|
import { exportRedactedBundle } from "./export-bundle.js";
|
|
21
21
|
import { loadAnalysis } from "../analysis/load.js";
|
|
22
|
+
import { readNotesForSharing } from "../run/notes.js";
|
|
23
|
+
import { cli } from "../cli/invocation.js";
|
|
22
24
|
import { analysisSharingProblems } from "../analysis/sharing.js";
|
|
23
25
|
import { EVIDENCE_LIMITS, validateAnalysisEvidence } from "../analysis/evidence.js";
|
|
24
26
|
import { readBoundedFile } from "../run/evidence-files.js";
|
|
@@ -96,8 +98,13 @@ export async function exportRun(cwdInput, runInput, options = {}, deps = {}) {
|
|
|
96
98
|
if (analysisCheck === "downgraded")
|
|
97
99
|
shareReady = false;
|
|
98
100
|
warnings.push(...analysis.warnings);
|
|
101
|
+
const notes = await checkedNotes(runPaths, verified, inlined, options, warnings);
|
|
102
|
+
if (notes.check === "blocked")
|
|
103
|
+
return exportFailure(cwd, runInput, "HUMANISH_EXPORT_SHARE_SAFETY_BLOCKED", `The run's reviewer notes hold text that looks like a secret, a token or a local path, which verify may not have seen if a note was added or changed after it ran. Run \`${cli(`verify --run ${runId}`)}\`, fix the note files in notes/, and export again.`, verified.shareSafety);
|
|
104
|
+
if (notes.check === "downgraded")
|
|
105
|
+
shareReady = false;
|
|
99
106
|
const watermarked = !shareReady || omittedRecording;
|
|
100
|
-
const output = renderExportHtml(inlined, analysis, assets, verified, watermarked, omittedRecording);
|
|
107
|
+
const output = renderExportHtml(inlined, { analysis, notes: notes.notes }, assets, verified, watermarked, omittedRecording);
|
|
101
108
|
const bytes = Buffer.byteLength(output, "utf8");
|
|
102
109
|
const maxBytes = options.maxBytes ?? DEFAULT_EXPORT_MAX_BYTES;
|
|
103
110
|
if (bytes > maxBytes) {
|
|
@@ -334,11 +341,48 @@ async function recheckAnalysis(runPaths, analysis, verified, inlined, options) {
|
|
|
334
341
|
};
|
|
335
342
|
return "downgraded";
|
|
336
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* The run's notes for the export, read once within the notes size limit. That snapshot is scanned
|
|
346
|
+
* as verify scans run text and is the one rendered, so a note added or changed after verify ran cannot
|
|
347
|
+
* reach the file unchecked. A finding blocks the export, or downgrades it under --local-only.
|
|
348
|
+
*/
|
|
349
|
+
async function checkedNotes(runPaths, verified, inlined, options, warnings) {
|
|
350
|
+
const { notes, finding } = await readNotesForSharing(runPaths);
|
|
351
|
+
for (const skipped of notes.skipped)
|
|
352
|
+
warnings.push(`Reviewer notes: ${skipped}`);
|
|
353
|
+
if (finding === null)
|
|
354
|
+
return { notes, check: "ok" };
|
|
355
|
+
verified.shareSafety = {
|
|
356
|
+
status: finding === "sensitive" || verified.shareSafety.status === "blocked"
|
|
357
|
+
? "blocked"
|
|
358
|
+
: "local_only",
|
|
359
|
+
reasons: [
|
|
360
|
+
...verified.shareSafety.reasons,
|
|
361
|
+
finding === "sensitive"
|
|
362
|
+
? {
|
|
363
|
+
code: "PUBLIC_SAFETY_FINDINGS",
|
|
364
|
+
message: "The reviewer notes being exported match secret, token or local-path patterns.",
|
|
365
|
+
}
|
|
366
|
+
: {
|
|
367
|
+
code: "UNSCANNED_ARTIFACT",
|
|
368
|
+
message: "The reviewer notes being exported hold encoded text the scan cannot read.",
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
};
|
|
372
|
+
if (options.localOnly !== true)
|
|
373
|
+
return { notes, check: "blocked" };
|
|
374
|
+
inlined.publicSafety.share = {
|
|
375
|
+
status: verified.shareSafety.status,
|
|
376
|
+
verifiedAt: new Date().toISOString(),
|
|
377
|
+
reasons: verified.shareSafety.reasons.map((reason) => reason.code),
|
|
378
|
+
};
|
|
379
|
+
return { notes, check: "downgraded" };
|
|
380
|
+
}
|
|
337
381
|
/** The portable Observer HTML, with the local-only banner when the export is watermarked. */
|
|
338
|
-
function renderExportHtml(inlined,
|
|
382
|
+
function renderExportHtml(inlined, review, assets, verified, watermarked, omittedRecording) {
|
|
339
383
|
const output = renderObserverHtml(inlined, {
|
|
340
384
|
snapshot: true,
|
|
341
|
-
|
|
385
|
+
...review,
|
|
342
386
|
assets,
|
|
343
387
|
});
|
|
344
388
|
if (!watermarked)
|