@gleapai/kai-bridge 0.2.9 → 0.6.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
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Minimal stdio MCP server for the `kai-verifier` persona (Kai Code
|
|
3
|
+
// "Verify"). Two tools, zero dependencies; speaks newline-delimited
|
|
4
|
+
// JSON-RPC 2.0 per the MCP stdio transport (same skeleton as todo-mcp.mjs
|
|
5
|
+
// / ask-user-mcp.mjs).
|
|
6
|
+
//
|
|
7
|
+
// report_verification — the verdict channel. The verifier drives the
|
|
8
|
+
// running preview with the Playwright MCP, records a video +
|
|
9
|
+
// screenshots, and must hand the host a STRUCTURED result — pass/fail
|
|
10
|
+
// checks, what stayed untested, and the local path of every artifact
|
|
11
|
+
// the browser tools wrote. Prose in the final message can't be
|
|
12
|
+
// uploaded or rendered as an evidence card. The runner's mapper
|
|
13
|
+
// (lib/acp/mapper.mjs) sniffs calls to this tool into the
|
|
14
|
+
// `verify_report` contract event; the host (kai-bridge daemon) uploads
|
|
15
|
+
// the artifacts and replaces the paths with URLs. The tool itself is a
|
|
16
|
+
// signalling no-op — the INPUT is the product. Not turn-ending: the
|
|
17
|
+
// persona wraps up normally after reporting.
|
|
18
|
+
//
|
|
19
|
+
// http_request — the ONLY HTTP path for API changes (no curl). Performs
|
|
20
|
+
// the call in-process against the preview / external origins the host
|
|
21
|
+
// allows, appends one JSON line per call to
|
|
22
|
+
// `<evidence dir>/requests.jsonl` (the transcript IS the evidence — the
|
|
23
|
+
// host uploads it as a `requests` artifact) and refuses writes when the
|
|
24
|
+
// run is read-only. The host derives an auth header from the app's own
|
|
25
|
+
// sign-in and hands it over by env — the model never sees the value.
|
|
26
|
+
//
|
|
27
|
+
// Env (all set by the host; every one optional):
|
|
28
|
+
// KAI_VERIFY_READ_ONLY=1 non-GET/HEAD/OPTIONS refused, recorded as `refused: "read_only"`
|
|
29
|
+
// KAI_VERIFY_ORIGINS allowed origins, `;`/`,`/space separated (`http://localhost:*` = any port)
|
|
30
|
+
// KAI_VERIFY_AUTH_HEADER `Header-Name: value` (bare value = Authorization); sent to LOCAL origins only, masked everywhere
|
|
31
|
+
// KAI_VERIFY_EVIDENCE_DIR where `requests.jsonl` is appended (no dir → nothing written)
|
|
32
|
+
|
|
33
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
34
|
+
import { join, resolve } from "node:path";
|
|
35
|
+
import { createInterface } from "node:readline";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
|
|
38
|
+
const CHECK_STATUS = ["passed", "failed"];
|
|
39
|
+
export const BLOCKED_CODES = ["needs_login", "preview_unreachable", "not_verifiable", "other"];
|
|
40
|
+
export const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
41
|
+
export const RESPONSE_BODY_CAP = 8 * 1024;
|
|
42
|
+
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
43
|
+
export const REQUESTS_FILE = "requests.jsonl";
|
|
44
|
+
const MASKED = "[masked]";
|
|
45
|
+
const MASKED_REQUEST_HEADERS = new Set(["authorization", "cookie", "proxy-authorization", "x-api-key"]);
|
|
46
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
47
|
+
|
|
48
|
+
const REPORT_TOOL = {
|
|
49
|
+
name: "report_verification",
|
|
50
|
+
description:
|
|
51
|
+
"File the verification report for this run. Call it EXACTLY ONCE, as " +
|
|
52
|
+
"the last thing you do: list every check you performed with its " +
|
|
53
|
+
"result, everything you could not test, and the path of EVERY " +
|
|
54
|
+
"artifact (video, screenshots, traces) the browser tools returned. " +
|
|
55
|
+
"A `passed` report needs at least one check. " +
|
|
56
|
+
"Status `blocked` = you could not verify (preview unreachable, a login " +
|
|
57
|
+
"wall, nothing to exercise from the app, …) — explain why in `reason`, " +
|
|
58
|
+
"classify it in `blockedCode` and still list the partial evidence you " +
|
|
59
|
+
"have. Never include cookies, tokens, response bodies or other secret " +
|
|
60
|
+
"values anywhere in the report.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
status: {
|
|
65
|
+
type: "string",
|
|
66
|
+
enum: ["passed", "failed", "blocked"],
|
|
67
|
+
description:
|
|
68
|
+
"Overall verdict: passed = every check passed (at least one check); failed = at least " +
|
|
69
|
+
"one check failed; blocked = verification could not be carried out.",
|
|
70
|
+
},
|
|
71
|
+
scope: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "One line: what was tested (feature / flow / pages / endpoints).",
|
|
74
|
+
},
|
|
75
|
+
reason: {
|
|
76
|
+
type: "string",
|
|
77
|
+
description: "For failed/blocked: the explanation in one or two sentences.",
|
|
78
|
+
},
|
|
79
|
+
blockedCode: {
|
|
80
|
+
type: "string",
|
|
81
|
+
enum: BLOCKED_CODES,
|
|
82
|
+
description:
|
|
83
|
+
"For blocked only: `needs_login` = a sign-in wall stopped you, or an API answered 401/403 without credentials (set " +
|
|
84
|
+
"`loginPath`; do NOT ask the user for credentials — the host " +
|
|
85
|
+
"arranges the sign-in and re-runs you); `preview_unreachable` = " +
|
|
86
|
+
"the preview never answered / the page never loaded; `not_verifiable` = " +
|
|
87
|
+
"the change has nothing a tester can exercise from the running app (build tooling, " +
|
|
88
|
+
"comments, types only) — never fabricate a check instead; `other` = anything else (explain in `reason`).",
|
|
89
|
+
},
|
|
90
|
+
loginPath: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description:
|
|
93
|
+
"With blockedCode needs_login: the URL path of the login wall or the endpoint that refused you " +
|
|
94
|
+
"(e.g. `/login`, `/api/v1/me`) — never the full URL with tokens, never credentials.",
|
|
95
|
+
},
|
|
96
|
+
checks: {
|
|
97
|
+
type: "array",
|
|
98
|
+
items: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
label: { type: "string", description: "What was checked, in plain words." },
|
|
102
|
+
status: { type: "string", enum: CHECK_STATUS },
|
|
103
|
+
},
|
|
104
|
+
required: ["label", "status"],
|
|
105
|
+
},
|
|
106
|
+
description: "Every check performed, in order. Only checks you actually performed.",
|
|
107
|
+
},
|
|
108
|
+
untested: {
|
|
109
|
+
type: "array",
|
|
110
|
+
items: { type: "string" },
|
|
111
|
+
description: "What was NOT verified and why (honest gaps — a 404 on a guessed API path belongs here, not in checks).",
|
|
112
|
+
},
|
|
113
|
+
artifacts: {
|
|
114
|
+
type: "array",
|
|
115
|
+
items: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
label: { type: "string", description: "Short caption shown under the artifact." },
|
|
119
|
+
path: { type: "string", description: "Local file path exactly as the browser tool returned it." },
|
|
120
|
+
kind: { type: "string", enum: ["screenshot", "video", "trace"] },
|
|
121
|
+
},
|
|
122
|
+
required: ["label", "path", "kind"],
|
|
123
|
+
},
|
|
124
|
+
description: "Every recording, screenshot and trace produced in this run (the HTTP transcript is collected by the host — do not list it).",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
required: ["status", "checks", "artifacts"],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const HTTP_TOOL = {
|
|
132
|
+
name: "http_request",
|
|
133
|
+
description:
|
|
134
|
+
"Perform ONE HTTP request against the running preview (or an external origin the task lists) and record it " +
|
|
135
|
+
"in the verification transcript. This is the only way to call an API in a verification run — never use curl. " +
|
|
136
|
+
"The host adds the app's own authentication automatically when it has one; never guess or paste tokens. " +
|
|
137
|
+
"In a read-only run (shared database) anything but GET/HEAD/OPTIONS is refused and recorded as skipped — " +
|
|
138
|
+
"put such endpoints in `untested`. A 401/403 without credentials is a `needs_login` block, never a failed check; " +
|
|
139
|
+
"a 404 on a path you guessed is `untested`. Resolve real paths from the OpenAPI spec first.",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {
|
|
143
|
+
method: { type: "string", description: "HTTP method (GET, HEAD, OPTIONS, POST, PUT, PATCH, DELETE). Default GET." },
|
|
144
|
+
url: { type: "string", description: "Absolute URL on an allowed origin, or a path (`/api/v1/tickets`) resolved against the first allowed origin." },
|
|
145
|
+
headers: { type: "object", additionalProperties: { type: "string" }, description: "Extra request headers (Accept, Content-Type, …). Authentication is added by the host." },
|
|
146
|
+
body: { description: "Request body: a string is sent as-is; an object is sent as JSON." },
|
|
147
|
+
check: { type: "string", description: "What this request verifies, in plain words (shown next to the row in the transcript)." },
|
|
148
|
+
},
|
|
149
|
+
required: ["url"],
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const TOOLS = [REPORT_TOOL, HTTP_TOOL];
|
|
154
|
+
|
|
155
|
+
/** `KAI_VERIFY_ORIGINS` → normalised origins (`http://localhost:*` kept as a wildcard). */
|
|
156
|
+
export function parseOrigins(raw) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const part of String(raw || "").split(/[;,\s]+/)) {
|
|
159
|
+
const text = part.trim();
|
|
160
|
+
if (!text) continue;
|
|
161
|
+
const wildcard = /^(https?):\/\/([^/:]+):\*$/i.exec(text);
|
|
162
|
+
if (wildcard) {
|
|
163
|
+
out.push(`${wildcard[1].toLowerCase()}://${wildcard[2].toLowerCase()}:*`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
out.push(new URL(text).origin.toLowerCase());
|
|
168
|
+
} catch {
|
|
169
|
+
/* junk entry */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return [...new Set(out)];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Is `url`'s origin one of the allowed ones (wildcard ports included)? */
|
|
176
|
+
export function isAllowedOrigin(url, allowed) {
|
|
177
|
+
let u;
|
|
178
|
+
try {
|
|
179
|
+
u = new URL(url);
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
|
184
|
+
const origin = u.origin.toLowerCase();
|
|
185
|
+
for (const a of allowed || []) {
|
|
186
|
+
if (a === origin) return true;
|
|
187
|
+
if (a.endsWith(":*") && origin.startsWith(a.slice(0, -1))) return true;
|
|
188
|
+
if (a.endsWith(":*")) {
|
|
189
|
+
const [proto, host] = a.slice(0, -2).split("://");
|
|
190
|
+
if (u.protocol === `${proto}:` && u.hostname.toLowerCase() === host) return true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Absolute URL for the request: paths resolve against the first allowed origin. */
|
|
197
|
+
export function resolveRequestUrl(raw, allowed) {
|
|
198
|
+
const text = String(raw || "").trim();
|
|
199
|
+
if (!text) return null;
|
|
200
|
+
if (/^https?:\/\//i.test(text)) return text;
|
|
201
|
+
const base = (allowed || []).find((o) => !o.endsWith(":*"));
|
|
202
|
+
if (!base) return null;
|
|
203
|
+
try {
|
|
204
|
+
return new URL(text.startsWith("/") ? text : `/${text}`, base).toString();
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `Authorization: Bearer x` → { name, value }; a bare value is an Authorization header. Null when empty. */
|
|
211
|
+
export function parseAuthHeader(raw) {
|
|
212
|
+
const text = String(raw || "").trim();
|
|
213
|
+
if (!text) return null;
|
|
214
|
+
const m = /^([A-Za-z0-9-]+):\s*(.+)$/.exec(text);
|
|
215
|
+
if (m) return { name: m[1], value: m[2].trim() };
|
|
216
|
+
return { name: "Authorization", value: text };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The host-derived auth header only ever goes to the preview itself, never to an external origin. */
|
|
220
|
+
export function isLocalOrigin(url) {
|
|
221
|
+
try {
|
|
222
|
+
return LOCAL_HOSTS.has(new URL(url).hostname.toLowerCase());
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Request headers as recorded: credentials masked, names lower-cased. */
|
|
229
|
+
export function maskHeaders(headers) {
|
|
230
|
+
const out = {};
|
|
231
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
232
|
+
const key = String(k).toLowerCase();
|
|
233
|
+
out[key] = MASKED_REQUEST_HEADERS.has(key) ? MASKED : String(v);
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Response body capped at 8 KB for the transcript. */
|
|
239
|
+
export function capBody(text) {
|
|
240
|
+
const body = String(text ?? "");
|
|
241
|
+
if (body.length <= RESPONSE_BODY_CAP) return { body, truncated: false };
|
|
242
|
+
return { body: body.slice(0, RESPONSE_BODY_CAP), truncated: true };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Pure: decide what to do with an `http_request` call. Returns
|
|
247
|
+
* `{ refused: "origin" | "read_only" | "invalid", url, method, headers }`
|
|
248
|
+
* or `{ url, method, headers, body }` ready for fetch. `headers` already
|
|
249
|
+
* carries the host auth header when it applies.
|
|
250
|
+
*/
|
|
251
|
+
export function planRequest(args, { readOnly = false, allowed = [], auth = null } = {}) {
|
|
252
|
+
const method = String(args?.method || "GET").trim().toUpperCase() || "GET";
|
|
253
|
+
const url = resolveRequestUrl(args?.url, allowed);
|
|
254
|
+
const userHeaders = {};
|
|
255
|
+
for (const [k, v] of Object.entries(args?.headers && typeof args.headers === "object" ? args.headers : {})) {
|
|
256
|
+
if (typeof v === "string" || typeof v === "number") userHeaders[String(k)] = String(v);
|
|
257
|
+
}
|
|
258
|
+
if (!url) return { refused: "invalid", method, url: String(args?.url || ""), headers: userHeaders, reason: "not a valid URL (absolute http(s) URL or a path on the preview)" };
|
|
259
|
+
if (!isAllowedOrigin(url, allowed)) return { refused: "origin", method, url, headers: userHeaders, reason: `origin not allowed for this run (allowed: ${allowed.join(", ") || "none"})` };
|
|
260
|
+
if (readOnly && !READ_METHODS.has(method)) return { refused: "read_only", method, url, headers: userHeaders, reason: "this is a read-only run — writes are skipped; list the endpoint under untested" };
|
|
261
|
+
const headers = { ...userHeaders };
|
|
262
|
+
let body = args?.body;
|
|
263
|
+
if (body !== undefined && body !== null && !READ_METHODS.has(method)) {
|
|
264
|
+
if (typeof body !== "string") {
|
|
265
|
+
body = JSON.stringify(body);
|
|
266
|
+
if (!Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) headers["Content-Type"] = "application/json";
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
body = undefined;
|
|
270
|
+
}
|
|
271
|
+
if (auth && isLocalOrigin(url)) {
|
|
272
|
+
// The host's header wins: a guessed Authorization from the model would
|
|
273
|
+
// only ever hide the real sign-in state.
|
|
274
|
+
for (const k of Object.keys(headers)) if (k.toLowerCase() === auth.name.toLowerCase()) delete headers[k];
|
|
275
|
+
headers[auth.name] = auth.value;
|
|
276
|
+
}
|
|
277
|
+
return { method, url, headers, body };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One transcript row (`requests.jsonl` line). Credentials are masked, never the row's URL query (the model chose it). */
|
|
281
|
+
export function buildRequestRow({ plan, check, status = null, ms = null, responseHeaders = null, responseBody = null, truncated = false, error = null, now = new Date() }) {
|
|
282
|
+
const row = {
|
|
283
|
+
t: now.toISOString(),
|
|
284
|
+
method: plan.method,
|
|
285
|
+
url: plan.url,
|
|
286
|
+
status,
|
|
287
|
+
ms,
|
|
288
|
+
requestHeaders: maskHeaders(plan.headers),
|
|
289
|
+
requestBody: plan.body === undefined ? null : capBody(plan.body).body,
|
|
290
|
+
responseHeaders,
|
|
291
|
+
responseBody,
|
|
292
|
+
truncated: !!truncated,
|
|
293
|
+
check: typeof check === "string" && check.trim() ? check.trim().slice(0, 300) : null,
|
|
294
|
+
};
|
|
295
|
+
if (plan.refused) row.refused = plan.refused;
|
|
296
|
+
if (error) row.error = String(error).slice(0, 300);
|
|
297
|
+
return row;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function appendRow(dir, row) {
|
|
301
|
+
if (!dir) return;
|
|
302
|
+
try {
|
|
303
|
+
mkdirSync(dir, { recursive: true });
|
|
304
|
+
appendFileSync(join(dir, REQUESTS_FILE), `${JSON.stringify(row)}\n`);
|
|
305
|
+
} catch {
|
|
306
|
+
/* the transcript is best-effort; the tool result still reaches the model */
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** What the model sees for a refused call — plain words, an action to take. */
|
|
311
|
+
function describeRefusal(plan) {
|
|
312
|
+
if (plan.refused === "read_only") return `Skipped: ${plan.method} ${plan.url} — ${plan.reason}. Recorded as skipped in the transcript.`;
|
|
313
|
+
if (plan.refused === "origin") return `Refused: ${plan.method} ${plan.url} — ${plan.reason}.`;
|
|
314
|
+
return `Refused: ${plan.url || "(empty)"} — ${plan.reason}.`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Perform the call (injectable fetch for tests). Returns the tool result
|
|
319
|
+
* text + the transcript row; the auth header value never appears in either.
|
|
320
|
+
*/
|
|
321
|
+
export async function performHttpRequest(args, { readOnly, allowed, auth, evidenceDir, fetchImpl = fetch, now = () => new Date(), timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
322
|
+
const plan = planRequest(args, { readOnly, allowed, auth });
|
|
323
|
+
if (plan.refused) {
|
|
324
|
+
const row = buildRequestRow({ plan, check: args?.check, now: now() });
|
|
325
|
+
appendRow(evidenceDir, row);
|
|
326
|
+
return { text: describeRefusal(plan), row, isError: plan.refused !== "read_only" };
|
|
327
|
+
}
|
|
328
|
+
const startedAt = Date.now();
|
|
329
|
+
try {
|
|
330
|
+
const res = await fetchImpl(plan.url, {
|
|
331
|
+
method: plan.method,
|
|
332
|
+
headers: plan.headers,
|
|
333
|
+
body: plan.body,
|
|
334
|
+
redirect: "manual",
|
|
335
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
336
|
+
});
|
|
337
|
+
const ms = Date.now() - startedAt;
|
|
338
|
+
const responseHeaders = {};
|
|
339
|
+
for (const [k, v] of res.headers?.entries?.() ?? []) responseHeaders[k.toLowerCase()] = v;
|
|
340
|
+
const raw = plan.method === "HEAD" ? "" : await res.text().catch(() => "");
|
|
341
|
+
const { body, truncated } = capBody(raw);
|
|
342
|
+
const row = buildRequestRow({ plan, check: args?.check, status: res.status, ms, responseHeaders, responseBody: body, truncated, now: now() });
|
|
343
|
+
appendRow(evidenceDir, row);
|
|
344
|
+
const headLine = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""} (${ms} ms) ${plan.method} ${plan.url}`;
|
|
345
|
+
const shown = ["content-type", "content-length", "location"].filter((h) => responseHeaders[h]).map((h) => `${h}: ${responseHeaders[h]}`);
|
|
346
|
+
const text = [headLine, ...shown, "", body || "(empty body)", truncated ? `\n[response truncated to ${RESPONSE_BODY_CAP} bytes in the transcript]` : ""].join("\n").trimEnd();
|
|
347
|
+
return { text, row, isError: false };
|
|
348
|
+
} catch (err) {
|
|
349
|
+
const ms = Date.now() - startedAt;
|
|
350
|
+
const message = err?.name === "TimeoutError" ? `timed out after ${Math.round(timeoutMs / 1000)}s` : err?.cause?.code || err?.message || String(err);
|
|
351
|
+
const row = buildRequestRow({ plan, check: args?.check, ms, error: message, now: now() });
|
|
352
|
+
appendRow(evidenceDir, row);
|
|
353
|
+
return { text: `Request failed: ${plan.method} ${plan.url} — ${message}. If the preview never answers, report blocked (preview_unreachable).`, row, isError: true };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Env → the request policy for this run. */
|
|
358
|
+
export function policyFromEnv(env = process.env) {
|
|
359
|
+
return {
|
|
360
|
+
readOnly: env.KAI_VERIFY_READ_ONLY === "1" || env.KAI_VERIFY_READ_ONLY === "true",
|
|
361
|
+
allowed: parseOrigins(env.KAI_VERIFY_ORIGINS),
|
|
362
|
+
auth: parseAuthHeader(env.KAI_VERIFY_AUTH_HEADER),
|
|
363
|
+
evidenceDir: env.KAI_VERIFY_EVIDENCE_DIR || null,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function send(message) {
|
|
368
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function reply(id, result) {
|
|
372
|
+
send({ jsonrpc: "2.0", id, result });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function replyError(id, code, message) {
|
|
376
|
+
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function serve() {
|
|
380
|
+
const policy = policyFromEnv();
|
|
381
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
382
|
+
rl.on("line", (line) => {
|
|
383
|
+
const trimmed = line.trim();
|
|
384
|
+
if (!trimmed) return;
|
|
385
|
+
let msg;
|
|
386
|
+
try {
|
|
387
|
+
msg = JSON.parse(trimmed);
|
|
388
|
+
} catch {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const { id, method } = msg ?? {};
|
|
392
|
+
if (typeof method !== "string") return;
|
|
393
|
+
|
|
394
|
+
if (method === "initialize") {
|
|
395
|
+
reply(id, {
|
|
396
|
+
protocolVersion: msg.params?.protocolVersion ?? "2025-06-18",
|
|
397
|
+
capabilities: { tools: {} },
|
|
398
|
+
serverInfo: { name: "kai-verify", version: "1.1.0" },
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (method === "notifications/initialized" || id == null) {
|
|
403
|
+
return; // notifications need no response
|
|
404
|
+
}
|
|
405
|
+
if (method === "tools/list") {
|
|
406
|
+
reply(id, { tools: TOOLS });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (method === "tools/call") {
|
|
410
|
+
const name = msg.params?.name;
|
|
411
|
+
const args = msg.params?.arguments ?? {};
|
|
412
|
+
if (name === REPORT_TOOL.name) {
|
|
413
|
+
// Loose validation: the host normalises the report; a malformed call
|
|
414
|
+
// must not stall the turn with an error the model then retries.
|
|
415
|
+
const checks = Array.isArray(args.checks) ? args.checks.length : 0;
|
|
416
|
+
const artifacts = Array.isArray(args.artifacts) ? args.artifacts.length : 0;
|
|
417
|
+
reply(id, {
|
|
418
|
+
content: [
|
|
419
|
+
{
|
|
420
|
+
type: "text",
|
|
421
|
+
text: `Verification report received (${args.status ?? "unknown"}, ${checks} check${checks === 1 ? "" : "s"}, ${artifacts} artifact${artifacts === 1 ? "" : "s"}). You are done — end your turn with a one-paragraph summary.`,
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
});
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (name === HTTP_TOOL.name) {
|
|
428
|
+
void performHttpRequest(args, policy).then(
|
|
429
|
+
({ text, isError }) => reply(id, { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) }),
|
|
430
|
+
(err) => reply(id, { content: [{ type: "text", text: `Request failed: ${err?.message || err}` }], isError: true }),
|
|
431
|
+
);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
replyError(id, -32602, `unknown tool: ${name}`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
replyError(id, -32601, `unknown method: ${method}`);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Runs as a server when spawned directly; importable for its pure helpers.
|
|
442
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) serve();
|
package/src/api.mjs
CHANGED
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
// POST /gleapcode/bridge/devices/me/heartbeat { running: [turnIds] }
|
|
7
7
|
// POST /gleapcode/bridge/turns/:id/events { events: [contract lines] }
|
|
8
8
|
// POST /gleapcode/bridge/turns/:id/result { result, changes, status }
|
|
9
|
+
// POST /gleapcode/bridge/turns/:id/artifacts multipart `file` → { url } (verify evidence)
|
|
10
|
+
// POST /gleapcode/bridge/turns/:id/verification{ status, scope, reason, checks, untested, artifacts, evidence?, revisions? }
|
|
11
|
+
// PUT /gleapcode/bridge/turns/:id/verification/artifacts { artifacts, evidence } (evidence retry — union by URL, clears evidenceMissing)
|
|
12
|
+
// POST /gleapcode/bridge/turns/:id/verification/stage { stage, note? } (setup|booting|login|verifying|fixing|saving)
|
|
13
|
+
// GET /gleapcode/bridge/turns/:id/preview-login → the saved sign-in record for this verify turn (one GET per turn)
|
|
14
|
+
// POST /gleapcode/bridge/preview-logins/:requestId { storageState, origins, services, landingUrl, loginPaths } (capture upload)
|
|
15
|
+
// POST /gleapcode/bridge/preview-logins/:requestId/status { status: opened|waiting_signin|detected|failed|cancelled, error? }
|
|
16
|
+
// PUT /gleapcode/bridge/preview-logins/:id { storageState, basedOnVersion } → 409 on a stale version
|
|
9
17
|
// POST /users/me/pusher { socket_id, channel_name } (channel auth)
|
|
10
18
|
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { basename } from "node:path";
|
|
21
|
+
|
|
11
22
|
export class BridgeApi {
|
|
12
23
|
constructor({ apiBase, token, fetchImpl = fetch }) {
|
|
13
24
|
this.apiBase = String(apiBase).replace(/\/+$/, "");
|
|
@@ -105,7 +116,97 @@ export class BridgeApi {
|
|
|
105
116
|
);
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Upload one verify artifact (recording / screenshot / trace) as
|
|
121
|
+
* multipart `file`; resolves the Server's `{ url }`. Separate from the
|
|
122
|
+
* JSON-only `request()`: the body is a FormData (fetch sets the boundary
|
|
123
|
+
* header itself — never set content-type here), a video can take a
|
|
124
|
+
* while (5-min cap), and it retries transient failures 3× — a dropped
|
|
125
|
+
* screenshot is missing evidence, not a broken turn.
|
|
126
|
+
*/
|
|
127
|
+
async uploadArtifact(turnId, filePath, contentType, { tries = 3, timeoutMs = 5 * 60_000, onRetry } = {}) {
|
|
128
|
+
const bytes = await readFile(filePath);
|
|
129
|
+
const path = `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/artifacts`;
|
|
130
|
+
let delay = 1_000;
|
|
131
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
132
|
+
try {
|
|
133
|
+
const form = new FormData();
|
|
134
|
+
form.append("file", new Blob([bytes], { type: contentType }), basename(filePath));
|
|
135
|
+
const res = await this.fetch(`${this.apiBase}${path}`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { ...(this.token ? { authorization: `Bearer ${this.token}` } : {}) },
|
|
138
|
+
body: form,
|
|
139
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
140
|
+
});
|
|
141
|
+
const text = await res.text();
|
|
142
|
+
let data = null;
|
|
143
|
+
try {
|
|
144
|
+
data = text ? JSON.parse(text) : null;
|
|
145
|
+
} catch {
|
|
146
|
+
data = { raw: text };
|
|
147
|
+
}
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
const err = new Error(`POST ${path} → ${res.status}: ${data?.message ?? data?.error ?? text.slice(0, 200)}`);
|
|
150
|
+
err.status = res.status;
|
|
151
|
+
err.data = data;
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
if (typeof data?.url !== "string" || !data.url) throw new Error(`POST ${path} → no url in response`);
|
|
155
|
+
return data;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const permanent = err.status >= 400 && err.status < 500 && err.status !== 429;
|
|
158
|
+
if (permanent || attempt >= tries) throw err;
|
|
159
|
+
onRetry?.(err, attempt, delay);
|
|
160
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
161
|
+
delay = Math.min(delay * 2, 30_000);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The verification report for a verify turn (artifacts already uploaded → URLs). */
|
|
167
|
+
turnVerification(turnId, payload, opts) {
|
|
168
|
+
return this.requestWithRetry("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/verification`, payload, opts);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Late evidence (`bridge.verify.evidence.retry`): the Server unions by URL and clears `evidenceMissing`. */
|
|
172
|
+
putVerificationArtifacts(turnId, payload, opts) {
|
|
173
|
+
return this.requestWithRetry("PUT", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/verification/artifacts`, payload, opts);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Stage transition of a running verify turn (best-effort UI state — callers swallow errors). */
|
|
177
|
+
turnVerificationStage(turnId, payload) {
|
|
178
|
+
return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/verification/stage`, payload);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The saved sign-in for this verify turn: `{ id, version, storageState,
|
|
182
|
+
* origins, services, landingUrl, loginPaths, optional }` (or `null` —
|
|
183
|
+
* a 404 is "no record", not an error). The Server allows ONE successful
|
|
184
|
+
* GET per turn and never caches it.
|
|
185
|
+
*/
|
|
186
|
+
async turnPreviewLogin(turnId) {
|
|
187
|
+
try {
|
|
188
|
+
const data = await this.request("GET", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/preview-login`);
|
|
189
|
+
const record = data && typeof data === "object" && "record" in data ? data.record : data;
|
|
190
|
+
return record && typeof record === "object" && record.storageState ? record : null;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (err?.status === 404) return null;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Capture upload against a pending login request (the ONLY thing a device may upload a sign-in against). */
|
|
197
|
+
uploadPreviewLogin(requestId, payload, opts) {
|
|
198
|
+
return this.requestWithRetry("POST", `/gleapcode/bridge/preview-logins/${encodeURIComponent(requestId)}`, payload, opts);
|
|
199
|
+
}
|
|
200
|
+
/** Capture progress for the dashboard's sign-in card. */
|
|
201
|
+
previewLoginStatus(requestId, payload) {
|
|
202
|
+
return this.request("POST", `/gleapcode/bridge/preview-logins/${encodeURIComponent(requestId)}/status`, payload);
|
|
203
|
+
}
|
|
204
|
+
/** Refresh a record after a verify run (CAS on `basedOnVersion`; 409 = someone else refreshed first → discard). */
|
|
205
|
+
refreshPreviewLogin(recordId, payload) {
|
|
206
|
+
return this.request("PUT", `/gleapcode/bridge/preview-logins/${encodeURIComponent(recordId)}`, payload);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Turns (and pending sign-in requests) the server still believes this device is running. */
|
|
109
210
|
pendingTurns() {
|
|
110
211
|
return this.request("GET", "/gleapcode/bridge/devices/me/pending");
|
|
111
212
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Companion repos for a preview (kai-bridge 0.5.0) — the pure parts.
|
|
2
|
+
//
|
|
3
|
+
// A frontend's dev.yaml names the API it needs (`companions:`), that API
|
|
4
|
+
// may name a worker, and so on: a BFS up to depth 3 with a visited set
|
|
5
|
+
// collects them all; boot order is deepest-first so `${port:x}` references
|
|
6
|
+
// and `requires:` checks find their targets running. Cloning a missing
|
|
7
|
+
// companion needs a remote: the Server resolves one per key from the
|
|
8
|
+
// connected repository (`companionRemotes`) — the daemon never guesses
|
|
9
|
+
// `https://github.com/<key>` for a non-github host — and the clone scheme
|
|
10
|
+
// follows the machine's existing checkouts (SSH when any of them is).
|
|
11
|
+
|
|
12
|
+
/** `https://github.com/o/n(.git)` ↔ `git@github.com:o/n.git`, by preference. */
|
|
13
|
+
export function withCloneScheme(remote, { useSsh = false } = {}) {
|
|
14
|
+
const text = String(remote || "").trim();
|
|
15
|
+
if (!text) return null;
|
|
16
|
+
const https = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(text);
|
|
17
|
+
const ssh = /^(?:ssh:\/\/)?git@([^:/]+)[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(text);
|
|
18
|
+
const parts = https ? { host: https[1], owner: https[2], name: https[3] } : ssh ? { host: ssh[1], owner: ssh[2], name: ssh[3] } : null;
|
|
19
|
+
if (!parts) return text; // a local path or an exotic remote — as given
|
|
20
|
+
return useSsh ? `git@${parts.host}:${parts.owner}/${parts.name}.git` : `https://${parts.host}/${parts.owner}/${parts.name}.git`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The remote to clone `key` from: the Server-resolved `remotes[key]`
|
|
25
|
+
* (any provider), else — for github.com keys only — the key itself.
|
|
26
|
+
* Null means the daemon cannot know (→ `companion_missing`).
|
|
27
|
+
*/
|
|
28
|
+
export function resolveCompanionRemote({ key, remotes = {}, useSsh = false } = {}) {
|
|
29
|
+
const lc = String(key || "").toLowerCase();
|
|
30
|
+
const given = remotes?.[key] ?? remotes?.[lc] ?? null;
|
|
31
|
+
if (given) return withCloneScheme(given, { useSsh });
|
|
32
|
+
const [host, owner, name] = lc.split("/");
|
|
33
|
+
if (host === "github.com" && owner && name) return withCloneScheme(`https://github.com/${owner}/${name}.git`, { useSsh });
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Do the machine's checkouts use SSH remotes? (`repoGroups[].checkouts[].remote`) */
|
|
38
|
+
export function prefersSsh(repoGroups) {
|
|
39
|
+
for (const g of repoGroups || []) {
|
|
40
|
+
for (const c of g?.checkouts || [g?.primary]) {
|
|
41
|
+
if (c?.remote && /^(?:ssh:\/\/)?git@/i.test(String(c.remote))) return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The exact command shown on the `companion_clone_failed` card (copyable). */
|
|
48
|
+
export function buildCloneCommand({ remote, target }) {
|
|
49
|
+
const q = (s) => (/[\s"']/.test(String(s)) ? JSON.stringify(String(s)) : String(s));
|
|
50
|
+
return `git clone ${q(remote)} ${q(target)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Boot order for a resolved companion set: deepest dependencies first,
|
|
55
|
+
* stable within a depth (insertion order). `entries` = `[{ key, depth }]`.
|
|
56
|
+
*/
|
|
57
|
+
export function orderCompanions(entries) {
|
|
58
|
+
return [...entries].sort((a, b) => b.depth - a.depth || a.index - b.index).map((e) => e.key);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* BFS over companion declarations. `roots` = `[{ key, config }]` (the
|
|
63
|
+
* session repos); `loadConfig(key)` resolves a companion's config
|
|
64
|
+
* (`{ config }`, `{ missing: true }` when not on the device and not
|
|
65
|
+
* clonable, `{ unconfigured: true }` when checked out but without a dev
|
|
66
|
+
* config, `{ error }`). Async because loading may clone. Returns
|
|
67
|
+
* `{ companions: [{ key, optional, depth, requiredBy, config?, missing?, unconfigured?, error? }] }`
|
|
68
|
+
* in BOOT order (deepest first). Session repos are never companions; a
|
|
69
|
+
* repo required by any config is required overall; depth is capped at
|
|
70
|
+
* `maxDepth`; a visited set keeps cycles harmless.
|
|
71
|
+
*/
|
|
72
|
+
export async function collectCompanions({ roots, loadConfig, maxDepth = 3 } = {}) {
|
|
73
|
+
const sessionKeys = new Set((roots || []).map((r) => String(r.key).toLowerCase()));
|
|
74
|
+
const seen = new Map(); // key → entry
|
|
75
|
+
let queue = (roots || []).map((r) => ({ key: String(r.key).toLowerCase(), config: r.config, depth: 0 }));
|
|
76
|
+
let index = 0;
|
|
77
|
+
for (let depth = 1; depth <= maxDepth && queue.length > 0; depth += 1) {
|
|
78
|
+
const next = [];
|
|
79
|
+
for (const node of queue) {
|
|
80
|
+
for (const c of node.config?.companions || []) {
|
|
81
|
+
const key = String(c.repo).toLowerCase();
|
|
82
|
+
if (sessionKeys.has(key)) continue; // already part of the session
|
|
83
|
+
const prev = seen.get(key);
|
|
84
|
+
if (prev) {
|
|
85
|
+
// Required by any → required overall; keep the shallowest depth.
|
|
86
|
+
prev.optional = prev.optional && !!c.optional;
|
|
87
|
+
if (!prev.requiredBy.includes(node.key)) prev.requiredBy.push(node.key);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const entry = { key, optional: !!c.optional, depth, requiredBy: [node.key], index: index++ };
|
|
91
|
+
seen.set(key, entry);
|
|
92
|
+
const loaded = await loadConfig(key, entry);
|
|
93
|
+
Object.assign(entry, loaded && typeof loaded === "object" ? loaded : {});
|
|
94
|
+
if (entry.config) next.push({ key, config: entry.config, depth });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
queue = next;
|
|
98
|
+
}
|
|
99
|
+
const entries = [...seen.values()];
|
|
100
|
+
return { companions: orderCompanions(entries).map((key) => seen.get(key)) };
|
|
101
|
+
}
|