@gleapai/kai-bridge 0.9.1 → 0.10.1

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/src/verify.mjs DELETED
@@ -1,387 +0,0 @@
1
- // Kai Code "Verify" — the daemon side of a kai-verifier turn.
2
- //
3
- // The runner's `verify_report` event carries LOCAL artifact paths (what
4
- // the Playwright MCP wrote into the turn's `--output-dir`). Nothing here
5
- // talks to the network: these are the pure pieces the daemon composes —
6
- // sweep the output dir, union it with what the agent listed (the agent may
7
- // forget the recording; the sweep guarantees it), derive labels / kinds /
8
- // content types, build the payload the Server's
9
- // `POST /gleapcode/bridge/turns/:turnId/verification` expects, and write
10
- // the dotenv secrets file for `--secrets`.
11
-
12
- import { readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
13
- import { basename, extname, isAbsolute, join, resolve } from "node:path";
14
-
15
- import { redactDeep } from "./preview-login.mjs";
16
-
17
- export const ARTIFACT_EXTENSIONS = new Set([".webm", ".png", ".jpg", ".jpeg", ".zip", ".jsonl"]);
18
- export const VERIFY_STATUSES = new Set(["passed", "failed", "blocked"]);
19
- /** What the AGENT may say about a `blocked` verdict (V3: `not_verifiable` = nothing to exercise). `stopped` is derived server-side on turn end. */
20
- export const REPORT_BLOCKED_CODES = new Set(["needs_login", "preview_unreachable", "not_verifiable", "other"]);
21
- /** The daemon's own blocked code for a report that carries no check at all (V3 honesty: green only with proof). */
22
- export const NO_REPORT_CODE = "no_report";
23
- /** Evidence dirs older than this are swept on daemon start (kept only for failed uploads / doctor). */
24
- export const ARTIFACT_MAX_AGE_MS = 48 * 60 * 60_000;
25
-
26
- /** `https://x/login?next=/a#f` or `/login?x` → `/login`; null for junk. Never carries a query (tokens ride there). */
27
- export function sanitizeLoginPath(raw) {
28
- if (typeof raw !== "string" || !raw.trim()) return null;
29
- const text = raw.trim();
30
- try {
31
- const path = /^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? new URL(text).pathname : new URL(text, "http://localhost").pathname;
32
- return path && path.length <= 200 ? path : null;
33
- } catch {
34
- return null;
35
- }
36
- }
37
-
38
- const CONTENT_TYPES = {
39
- ".webm": "video/webm",
40
- ".mp4": "video/mp4",
41
- ".png": "image/png",
42
- ".jpg": "image/jpeg",
43
- ".jpeg": "image/jpeg",
44
- ".webp": "image/webp",
45
- ".zip": "application/zip",
46
- ".jsonl": "application/x-ndjson",
47
- };
48
-
49
- export function contentTypeFor(filePath) {
50
- return CONTENT_TYPES[extname(String(filePath)).toLowerCase()] ?? "application/octet-stream";
51
- }
52
-
53
- /** Artifact kind by extension — the report's own `kind` wins when present. */
54
- export function kindFor(filePath, declared) {
55
- const ext = extname(String(filePath)).toLowerCase();
56
- // The HTTP transcript is always `requests`, whatever the agent called it.
57
- if (ext === ".jsonl") return "requests";
58
- if (declared === "screenshot" || declared === "video" || declared === "trace") return declared;
59
- if (ext === ".webm" || ext === ".mp4") return "video";
60
- if (ext === ".zip") return "trace";
61
- return "screenshot";
62
- }
63
-
64
- /** `requests.jsonl` → "HTTP transcript (12 requests)". */
65
- export function requestsLabel(filePath) {
66
- try {
67
- const rows = readFileSync(filePath, "utf8").split("\n").filter((l) => l.trim()).length;
68
- return `HTTP transcript (${rows} request${rows === 1 ? "" : "s"})`;
69
- } catch {
70
- return "HTTP transcript";
71
- }
72
- }
73
-
74
- /**
75
- * Redact every JSON line of a `.jsonl` transcript in place (bodies and
76
- * headers may echo an injected cookie / token). Unparseable lines are
77
- * scrubbed as plain text. No-op without secrets.
78
- */
79
- export function redactJsonlFile(filePath, secrets) {
80
- if (!secrets || secrets.size === 0) return false;
81
- let text;
82
- try {
83
- text = readFileSync(filePath, "utf8");
84
- } catch {
85
- return false;
86
- }
87
- const out = text
88
- .split("\n")
89
- .map((line) => {
90
- if (!line.trim()) return line;
91
- try {
92
- return JSON.stringify(redactDeep(JSON.parse(line), secrets));
93
- } catch {
94
- return redactDeep(line, secrets);
95
- }
96
- })
97
- .join("\n");
98
- if (out !== text) writeFileSync(filePath, out);
99
- return out !== text;
100
- }
101
-
102
- /**
103
- * The `KAI_VERIFY_AUTH_HEADER` value for `http_request`: dev.yaml
104
- * `auth.apiToken` says where the app keeps its API credential (a
105
- * localStorage key or a cookie on one of the preview services); the value
106
- * is read from the INJECTED storage state. Returns `{ header, value, token }`
107
- * or null (no token config, no state, key absent). `services` =
108
- * `[{ name, url }]` of the running preview (to find the origin by name).
109
- */
110
- export function deriveApiAuthHeader(apiToken, state, services = []) {
111
- if (!apiToken?.key || !state) return null;
112
- let token = null;
113
- if (apiToken.from === "cookie") {
114
- const c = (state.cookies || []).find((x) => x?.name === apiToken.key && typeof x.value === "string" && x.value);
115
- token = c?.value ?? null;
116
- } else {
117
- const svc = (services || []).find((s) => s?.name === apiToken.origin) ?? null;
118
- const origin = svc?.url ? new URL(svc.url).origin.toLowerCase() : null;
119
- const candidates = (state.origins || []).filter((o) => !origin || String(o?.origin || "").toLowerCase() === origin);
120
- for (const o of candidates) {
121
- const entry = (o?.localStorage || []).find((e) => e?.name === apiToken.key && typeof e.value === "string" && e.value);
122
- if (entry) {
123
- token = entry.value;
124
- break;
125
- }
126
- }
127
- // Apps often persist `{ token: "…" }` JSON under the key — unwrap one level.
128
- if (token && /^\s*[{\[]/.test(token)) {
129
- try {
130
- const parsed = JSON.parse(token);
131
- const inner = parsed && typeof parsed === "object" ? parsed.token ?? parsed.accessToken ?? parsed.access_token ?? parsed.jwt ?? null : null;
132
- if (typeof inner === "string" && inner) token = inner;
133
- } catch {
134
- /* keep the raw string */
135
- }
136
- }
137
- }
138
- if (!token) return null;
139
- const prefix = typeof apiToken.prefix === "string" ? apiToken.prefix : "Bearer ";
140
- return { header: apiToken.header || "Authorization", value: `${apiToken.header || "Authorization"}: ${prefix}${token}`, token };
141
- }
142
-
143
- /**
144
- * Remove evidence dirs under `root` whose mtime is older than `maxAgeMs`
145
- * (kept dirs from failed uploads that nobody retried). Returns the removed
146
- * names. Never throws.
147
- */
148
- export function sweepOldArtifacts(root, { maxAgeMs = ARTIFACT_MAX_AGE_MS, now = Date.now() } = {}) {
149
- const removed = [];
150
- let names;
151
- try {
152
- names = readdirSync(root, { withFileTypes: true });
153
- } catch {
154
- return removed;
155
- }
156
- for (const d of names) {
157
- if (!d.isDirectory()) continue;
158
- const path = join(root, d.name);
159
- try {
160
- if (now - statSync(path).mtimeMs > maxAgeMs) {
161
- rmSync(path, { recursive: true, force: true });
162
- removed.push(d.name);
163
- }
164
- } catch {
165
- /* vanished */
166
- }
167
- }
168
- return removed;
169
- }
170
-
171
- /** `02-after-reload.png` → "after reload"; `verification.webm` → "verification". */
172
- export function labelFor(filePath) {
173
- if (extname(String(filePath)).toLowerCase() === ".jsonl") return requestsLabel(filePath);
174
- const stem = basename(String(filePath)).replace(/\.[^.]+$/, "");
175
- const words = stem.replace(/^\d+[-_ ]*/, "").replace(/[-_]+/g, " ").trim();
176
- return words || stem || basename(String(filePath));
177
- }
178
-
179
- /**
180
- * Every uploadable file under `dir` (one level — the MCP writes flat),
181
- * oldest first so a multi-video run keeps its order. Missing dir → [].
182
- */
183
- export function sweepArtifacts(dir) {
184
- let names;
185
- try {
186
- names = readdirSync(dir);
187
- } catch {
188
- return [];
189
- }
190
- const files = [];
191
- for (const name of names) {
192
- if (!ARTIFACT_EXTENSIONS.has(extname(name).toLowerCase())) continue;
193
- const path = join(dir, name);
194
- try {
195
- const st = statSync(path);
196
- if (!st.isFile() || st.size === 0) continue;
197
- files.push({ path, mtimeMs: st.mtimeMs, size: st.size });
198
- } catch {
199
- /* vanished between readdir and stat */
200
- }
201
- }
202
- return files.sort((a, b) => a.mtimeMs - b.mtimeMs || a.path.localeCompare(b.path)).map((f) => f.path);
203
- }
204
-
205
- /** `{ count }` for a requests transcript = recorded calls (non-empty lines); nothing for other kinds. */
206
- function requestCount(path, kind) {
207
- if (kind !== "requests") return {};
208
- try {
209
- const count = readFileSync(path, "utf8").split("\n").filter((l) => l.trim()).length;
210
- return count > 0 ? { count } : {};
211
- } catch {
212
- return {};
213
- }
214
- }
215
-
216
- /**
217
- * The report's `artifacts[]` (agent-declared) ∪ the swept files, matched
218
- * by resolved path. The MCP prints paths RELATIVE TO THE CLIENT WORKSPACE
219
- * (the repo worktree — `../../.kai/artifacts/<turn>/01-home.png`), the
220
- * agent may echo an absolute path or just the name: a declared path is
221
- * tried as absolute, relative to `workDir`, relative to `outputDir`, and
222
- * finally by basename against the sweep (the MCP writes flat). Declared
223
- * entries keep their order, label and kind; files the agent did not list
224
- * follow, labelled from their filename. Declared paths that match nothing
225
- * on disk, or that are not media (`CONTENT_TYPES`), are dropped. Returns
226
- * `[{ path, label, kind, contentType, declared }]`.
227
- */
228
- export function unionArtifacts(report, sweptPaths, { outputDir, workDir } = {}) {
229
- const out = [];
230
- const seen = new Set();
231
- const swept = new Set(sweptPaths.map((p) => resolve(p)));
232
- const byName = new Map();
233
- for (const p of swept) byName.set(basename(p), byName.has(basename(p)) ? null : p); // null = ambiguous
234
- const isFile = (p) => {
235
- try {
236
- return statSync(p).isFile();
237
- } catch {
238
- return false;
239
- }
240
- };
241
- const locate = (raw) => {
242
- const candidates = isAbsolute(raw)
243
- ? [resolve(raw)]
244
- : [workDir ? resolve(workDir, raw) : null, outputDir ? resolve(outputDir, raw) : null].filter(Boolean);
245
- for (const c of candidates) if (swept.has(c) || isFile(c)) return c;
246
- return byName.get(basename(raw)) ?? null;
247
- };
248
- for (const a of Array.isArray(report?.artifacts) ? report.artifacts : []) {
249
- const raw = typeof a?.path === "string" ? a.path.trim() : "";
250
- if (!raw) continue;
251
- const path = locate(raw);
252
- if (!path || seen.has(path)) continue;
253
- // Media only: a declared path that exists but is not evidence (the
254
- // final browser-state JSON in the same dir, a log) never uploads.
255
- if (!(extname(path).toLowerCase() in CONTENT_TYPES)) continue;
256
- seen.add(path);
257
- out.push({
258
- path,
259
- label: typeof a.label === "string" && a.label.trim() ? a.label.trim().slice(0, 120) : labelFor(path),
260
- kind: kindFor(path, a.kind),
261
- contentType: contentTypeFor(path),
262
- ...requestCount(path, kindFor(path, a.kind)),
263
- declared: true,
264
- });
265
- }
266
- for (const p of sweptPaths) {
267
- const path = resolve(p);
268
- if (seen.has(path)) continue;
269
- seen.add(path);
270
- out.push({ path, label: labelFor(path), kind: kindFor(path), contentType: contentTypeFor(path), ...requestCount(path, kindFor(path)), declared: false });
271
- }
272
- return out;
273
- }
274
-
275
- /**
276
- * The Server payload: the agent's report (clamped, status coerced) with
277
- * `artifacts` rewritten to the uploaded `{label, url, contentType, kind}`.
278
- * `uploads` = `[{ artifact, url }]` for the files that made it up. No
279
- * report (agent never filed one, turn died) → `blocked` with the given
280
- * fallback reason and NO `blockedCode` — the swept evidence still travels.
281
- */
282
- export function buildVerificationPayload(report, uploads, { fallbackReason = "Kai finished without filing a verification report", evidence = null, readOnly = null } = {}) {
283
- const artifacts = (uploads || [])
284
- .filter((u) => u && typeof u.url === "string" && u.url)
285
- .map((u) => ({ label: u.artifact.label, url: u.url, contentType: u.artifact.contentType, kind: u.artifact.kind, ...(u.artifact.count > 0 ? { count: u.artifact.count } : {}) }));
286
- // `evidence` = what could not be uploaded; `readOnly` = the run's write policy (dev.yaml `verify.readOnly`, auto for API runs).
287
- const withEvidence = (payload) => ({ ...payload, ...(evidence && typeof evidence === "object" ? { evidence } : {}), ...(typeof readOnly === "boolean" ? { readOnly } : {}) });
288
- if (!report || typeof report !== "object") {
289
- // No report from the agent. The CODE is the Server's to derive on turn
290
- // end (`stopped` / `no_report` from the turn outcome); a cancelled turn
291
- // differs only in the reason text the caller passes.
292
- return withEvidence({ status: "blocked", reason: fallbackReason, checks: [], untested: [], artifacts });
293
- }
294
- const checks = (Array.isArray(report.checks) ? report.checks : [])
295
- .filter((c) => c && typeof c.label === "string" && c.label.trim())
296
- .slice(0, 50)
297
- .map((c) => ({ label: c.label.trim().slice(0, 200), status: c.status === "failed" ? "failed" : "passed" }));
298
- const untested = (Array.isArray(report.untested) ? report.untested : [])
299
- .filter((u) => typeof u === "string" && u.trim())
300
- .slice(0, 50)
301
- .map((u) => u.trim().slice(0, 200));
302
- // Verdict: the agent's `blocked` stands; otherwise a failed check fails
303
- // the run even when the agent said `passed`; and a report without a
304
- // single check is never green — it is `blocked` / `no_report` (V3
305
- // honesty: proof or nothing).
306
- const noChecks = checks.length === 0 && report.status !== "blocked";
307
- const status = report.status === "blocked" || noChecks ? "blocked" : checks.some((c) => c.status === "failed") ? "failed" : VERIFY_STATUSES.has(report.status) ? report.status : "passed";
308
- const payload = { status, checks, untested, artifacts };
309
- if (typeof report.scope === "string" && report.scope.trim()) payload.scope = report.scope.trim().slice(0, 300);
310
- if (noChecks) payload.reason = `Kai reported ${report.status === "failed" ? "a failure" : "success"} without a single check — nothing was verified.${typeof report.reason === "string" && report.reason.trim() ? ` ${report.reason.trim().slice(0, 800)}` : ""}`;
311
- else if (typeof report.reason === "string" && report.reason.trim()) payload.reason = report.reason.trim().slice(0, 1000);
312
- else if (status === "blocked") payload.reason = fallbackReason;
313
- if (status === "blocked") {
314
- // The V2 sign-in flow keys off these: a login wall the agent hit is
315
- // `needs_login` + the wall's path (path only — never a URL with tokens).
316
- payload.blockedCode = noChecks ? NO_REPORT_CODE : REPORT_BLOCKED_CODES.has(report.blockedCode) ? report.blockedCode : "other";
317
- const loginPath = sanitizeLoginPath(report.loginPath);
318
- if (loginPath && !noChecks) payload.loginPath = loginPath;
319
- }
320
- return withEvidence(payload);
321
- }
322
-
323
- /**
324
- * dotenv for the Playwright MCP's `--secrets`: one `NAME="value"` per line,
325
- * double-quoted with `\`, `"` and newlines escaped (the dotenv grammar the
326
- * MCP parses). Names are restricted to identifier characters — anything
327
- * else is dropped rather than risk a malformed file. Mode 0600. Returns the
328
- * names written (what the task note tells the agent to type).
329
- */
330
- export function writeSecretsFile(path, secrets) {
331
- const names = [];
332
- const lines = [];
333
- for (const [rawName, rawValue] of Object.entries(secrets && typeof secrets === "object" ? secrets : {})) {
334
- const name = String(rawName).trim();
335
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
336
- const value = rawValue == null ? "" : String(rawValue);
337
- if (!value) continue;
338
- lines.push(`${name}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, "\\n")}"`);
339
- names.push(name);
340
- }
341
- if (names.length === 0) return [];
342
- writeFileSync(path, lines.join("\n") + "\n", { mode: 0o600 });
343
- return names;
344
- }
345
-
346
- /**
347
- * Booting heartbeat: the runner's status lines (installing, still
348
- * starting, ready) become `stage: booting` notes on the Verify card, at
349
- * most one every `minIntervalMs` (the latest note wins, posted on a
350
- * trailing timer), so a 4-minute install still moves the card every ≤ 60 s
351
- * and a chatty boot does not flood the Server. `post(note)` returns a
352
- * promise; failures are the caller's. Injected timers for tests.
353
- */
354
- export function createStageHeartbeat(post, { minIntervalMs = 5_000, setTimer = setTimeout, clearTimer = clearTimeout, now = Date.now } = {}) {
355
- let lastAt = 0;
356
- let pending = null;
357
- let timer = null;
358
- let stopped = false;
359
- const flush = () => {
360
- timer = null;
361
- if (stopped || pending == null) return;
362
- const note = pending;
363
- pending = null;
364
- lastAt = now();
365
- void Promise.resolve(post(note)).catch(() => {});
366
- };
367
- return {
368
- note(message) {
369
- if (stopped) return;
370
- const text = String(message || "").slice(0, 300);
371
- if (!text) return;
372
- pending = text;
373
- const wait = Math.max(0, minIntervalMs - (now() - lastAt));
374
- if (wait === 0) return flush();
375
- if (!timer) {
376
- timer = setTimer(flush, wait);
377
- timer?.unref?.();
378
- }
379
- },
380
- stop() {
381
- stopped = true;
382
- if (timer) clearTimer(timer);
383
- timer = null;
384
- pending = null;
385
- },
386
- };
387
- }