@gleapai/kai-bridge 0.2.9 → 0.5.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/package.json +1 -1
- package/runner/acp-runner.mjs +69 -10
- package/runner/lib/acp/mapper.mjs +31 -1
- package/runner/lib/contract.mjs +21 -2
- package/runner/personas/claude/kai-verifier.md +84 -0
- package/runner/personas/codex/kai-verifier.md +84 -0
- package/runner/tools/verify-mcp.mjs +442 -0
- package/src/api.mjs +102 -1
- package/src/companions.mjs +101 -0
- package/src/daemon.mjs +1502 -147
- package/src/deps.mjs +113 -0
- package/src/executor.mjs +16 -4
- package/src/ports.mjs +134 -0
- package/src/preview-errors.mjs +283 -0
- package/src/preview-login.mjs +610 -0
- package/src/preview.mjs +777 -106
- package/src/selfupdate.mjs +26 -0
- package/src/service.mjs +11 -0
- package/src/setup.mjs +30 -2
- package/src/verify.mjs +387 -0
- package/src/workspace.mjs +60 -1
package/src/selfupdate.mjs
CHANGED
|
@@ -71,6 +71,32 @@ export function decideUpdate({ current, latest, running = 0, autoUpdate = true,
|
|
|
71
71
|
return { action: "update", reason: "newer" };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A daemon that keeps running after `npm i -g` replaced its files executes
|
|
76
|
+
* the OLD code until something restarts it — Luca's machine ran 0.2.1
|
|
77
|
+
* for a week with 0.2.7 on disk (2026-09-08): every fix shipped in
|
|
78
|
+
* between was invisible, and this very self-updater never ran. Decide
|
|
79
|
+
* whether the loaded version and the version on disk disagree and, if so,
|
|
80
|
+
* whether we may restart right now.
|
|
81
|
+
* skip — same version (or one side unknown)
|
|
82
|
+
* defer — differ, but a turn is running: restart when it ends
|
|
83
|
+
* restart — differ and idle: hand the machine to the code on disk
|
|
84
|
+
*/
|
|
85
|
+
export function decideRestart({ loaded, installed, running = 0 }) {
|
|
86
|
+
if (!loaded || !installed || String(loaded) === String(installed)) return { action: "skip", reason: "same_version" };
|
|
87
|
+
if (running > 0) return { action: "defer", reason: "busy" };
|
|
88
|
+
return { action: "restart", reason: "stale_code" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** installedVersion() that answers null instead of throwing (mid-install, deleted prefix). */
|
|
92
|
+
export function installedVersionOrNull(root = PACKAGE_ROOT) {
|
|
93
|
+
try {
|
|
94
|
+
return installedVersion(root);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
/** The npm that belongs to the node running us — same global prefix as the install. */
|
|
75
101
|
export function npmBinary(execPath = process.execPath, os = platform()) {
|
|
76
102
|
const candidate = join(dirname(execPath), os === "win32" ? "npm.cmd" : "npm");
|
package/src/service.mjs
CHANGED
|
@@ -60,6 +60,13 @@ ${envXml}
|
|
|
60
60
|
`;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** The desktop-session variables a headed browser needs (Linux only). */
|
|
64
|
+
export function displayEnv(env = process.env) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]) if (env[k]) out[k] = env[k];
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
export function renderSystemdUnit({ program, args, env = {} }) {
|
|
64
71
|
const q = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
|
|
65
72
|
return `[Unit]
|
|
@@ -95,6 +102,10 @@ export function install({ binPath, logDir, env = {} }) {
|
|
|
95
102
|
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
|
96
103
|
HOME: homedir(),
|
|
97
104
|
...(process.env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK } : {}),
|
|
105
|
+
// Linux: a systemd --user unit starts without the desktop session's
|
|
106
|
+
// display — the headed sign-in window (preview-login.mjs) needs it.
|
|
107
|
+
// Captured at install time, like PATH.
|
|
108
|
+
...(os === "linux" ? displayEnv(process.env) : {}),
|
|
98
109
|
...env,
|
|
99
110
|
};
|
|
100
111
|
if (os === "darwin") {
|
package/src/setup.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// 1. pair this machine with the user's Gleap account
|
|
4
4
|
// 2. offer the background service (start at login, restart on crash)
|
|
5
5
|
// 3. offer harness sign-ins (Claude Code / Codex / Cursor), inline
|
|
6
|
-
// 4.
|
|
6
|
+
// 4. make sure a browser exists for previews / Verify (Chrome, else Chromium)
|
|
7
|
+
// 5. say it's done, nicely
|
|
7
8
|
//
|
|
8
9
|
// Runs from `kai-bridge setup`, from a bare `kai-bridge` on an unpaired
|
|
9
10
|
// machine, and (when npm shows script output — `--foreground-scripts`)
|
|
@@ -245,7 +246,34 @@ export async function runSetup({ binPath, prompter = makePrompter() } = {}) {
|
|
|
245
246
|
}
|
|
246
247
|
out("");
|
|
247
248
|
|
|
248
|
-
// 4 ──
|
|
249
|
+
// 4 ── Browser for previews --------------------------------------
|
|
250
|
+
// Verify turns drive the preview in a real browser. System Chrome
|
|
251
|
+
// needs nothing; otherwise Playwright's Chromium is downloaded once
|
|
252
|
+
// here (the daemon would do it lazily before the first verify turn,
|
|
253
|
+
// which is a bad moment to wait for 150 MB).
|
|
254
|
+
out("Step 4 — browser for previews:");
|
|
255
|
+
try {
|
|
256
|
+
const { ensurePreviewBrowser } = await import("./preview.mjs");
|
|
257
|
+
const { createLogger } = await import("./daemon.mjs");
|
|
258
|
+
let announced = false;
|
|
259
|
+
const res = await ensurePreviewBrowser({
|
|
260
|
+
log: (level, event, data) => {
|
|
261
|
+
createLogger()(level, event, data);
|
|
262
|
+
if (event === "browser.install.start" && !announced) {
|
|
263
|
+
announced = true;
|
|
264
|
+
out(" Downloading Chromium for previews (one time, ~150 MB)…");
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
if (res.browser === "chrome") out(" Using Google Chrome on this machine.");
|
|
269
|
+
else if (res.ok) out(res.installed ? " Chromium installed." : " Bundled Chromium already installed.");
|
|
270
|
+
else out(` No browser yet — previews will try again later (${res.error}).`);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
out(` Skipped (${err?.message || err}).`);
|
|
273
|
+
}
|
|
274
|
+
out("");
|
|
275
|
+
|
|
276
|
+
// 5 ── Done -------------------------------------------------------
|
|
249
277
|
const name = config.device?.name ?? "This machine";
|
|
250
278
|
out("──────────────────────────────────────────────────────────────");
|
|
251
279
|
out(`🎉 All set. ${name} now shows up in Kai Code.`);
|
package/src/verify.mjs
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
}
|
package/src/workspace.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { execFileSync } from "node:child_process";
|
|
|
16
16
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
|
|
19
|
+
import { seedNodeModules } from "./deps.mjs";
|
|
20
|
+
|
|
19
21
|
function git(cwd, args, opts = {}) {
|
|
20
22
|
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts }).trim();
|
|
21
23
|
}
|
|
@@ -82,6 +84,10 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
|
|
|
82
84
|
mkdirSync(join(kaiHome, "worktrees", repo.name), { recursive: true });
|
|
83
85
|
git(repo.primaryPath, ["fetch", "origin", base, "--quiet"]);
|
|
84
86
|
git(repo.primaryPath, ["worktree", "add", "-b", branch, dir, `origin/${base}`]);
|
|
87
|
+
// A fresh worktree has no node_modules; clone the primary checkout's
|
|
88
|
+
// when the lockfiles match so the agent's tests and the preview boot
|
|
89
|
+
// don't each start with a 2-minute install (see deps.mjs).
|
|
90
|
+
const deps = seedNodeModules({ primaryPath: repo.primaryPath, cwd: dir });
|
|
85
91
|
if (binding?.carryUncommitted) {
|
|
86
92
|
// Tracked changes as a patch; untracked (non-ignored) files copied.
|
|
87
93
|
const patch = git(repo.primaryPath, ["diff", "HEAD"], { maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -104,7 +110,7 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
|
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
|
-
return { cwd: dir, mode, branch, base, resumed: false };
|
|
113
|
+
return { cwd: dir, mode, branch, base, resumed: false, deps };
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
/** The branch checked out at `cwd` (null when it is not a git checkout). */
|
|
@@ -117,6 +123,15 @@ export function currentBranch(cwd) {
|
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
/** Diff of what the turn changed (for the dashboard's file-changes panel). */
|
|
126
|
+
/** HEAD sha of a checkout, or null (verify reports stamp what was tested). */
|
|
127
|
+
export function currentHead(cwd) {
|
|
128
|
+
try {
|
|
129
|
+
return git(cwd, ["rev-parse", "HEAD"]) || null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
export function collectChanges(cwd) {
|
|
121
136
|
// NOT via git(): its trailing .trim() also strips the LEADING space of
|
|
122
137
|
// the first porcelain line, so a first entry that is a tracked
|
|
@@ -178,6 +193,50 @@ export function removeWorktree({ kaiHome, repo, sessionId, title }) {
|
|
|
178
193
|
* A failed push is NOT fatal: the dashboard still shows the diff, and the
|
|
179
194
|
* result carries the reason (no credentials, protected branch, …).
|
|
180
195
|
*/
|
|
196
|
+
/**
|
|
197
|
+
* What a pushed session branch contains, for the pull request description:
|
|
198
|
+
* the commits since `base` (newest first, ≤ 20) and the diff stat. `base`
|
|
199
|
+
* is tried as `origin/<base>` first (the worktree may have no local ref).
|
|
200
|
+
* Never throws — an empty description beats a lost turn result.
|
|
201
|
+
*/
|
|
202
|
+
export function describeBranchChanges(cwd, base) {
|
|
203
|
+
const out = { commits: [], stat: null };
|
|
204
|
+
const candidates = [base && `origin/${base}`, base].filter(Boolean);
|
|
205
|
+
let range = null;
|
|
206
|
+
for (const ref of candidates) {
|
|
207
|
+
try {
|
|
208
|
+
git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
209
|
+
range = `${ref}...HEAD`;
|
|
210
|
+
break;
|
|
211
|
+
} catch {
|
|
212
|
+
/* try the next ref */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!range) return out;
|
|
216
|
+
try {
|
|
217
|
+
const log = git(cwd, ["log", "--no-merges", "--format=%h%x1f%s", "-n", "20", `${range.replace("...", "..")}`]);
|
|
218
|
+
out.commits = log
|
|
219
|
+
.split("\n")
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.map((line) => {
|
|
222
|
+
const [sha, subject] = line.split("\x1f");
|
|
223
|
+
return { sha, subject: (subject || "").trim().slice(0, 200) };
|
|
224
|
+
});
|
|
225
|
+
} catch {
|
|
226
|
+
/* no commits */
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const short = git(cwd, ["diff", "--shortstat", range]);
|
|
230
|
+
const files = Number(/(\d+) files? changed/.exec(short)?.[1] ?? 0);
|
|
231
|
+
const insertions = Number(/(\d+) insertions?/.exec(short)?.[1] ?? 0);
|
|
232
|
+
const deletions = Number(/(\d+) deletions?/.exec(short)?.[1] ?? 0);
|
|
233
|
+
if (files) out.stat = { files, insertions, deletions };
|
|
234
|
+
} catch {
|
|
235
|
+
/* no stat */
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
181
240
|
/**
|
|
182
241
|
* Git-level guard rails for what a turn may publish. Agent state dirs
|
|
183
242
|
* never enter commits; the preview config (.gleap/dev.yaml) stays out
|