@gleapai/kai-bridge 0.9.1 → 0.10.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/README.md +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/runner/acp-runner.mjs +7 -51
- package/runner/lib/acp/mapper.mjs +1 -31
- package/runner/lib/contract.mjs +1 -16
- package/scripts/postinstall.mjs +7 -0
- package/src/api.mjs +16 -102
- package/src/companions.mjs +3 -2
- package/src/daemon.mjs +607 -1054
- package/src/executor.mjs +2 -2
- package/src/gateway.mjs +217 -0
- package/src/harnesses.mjs +15 -2
- package/src/playwright-patch.mjs +84 -0
- package/src/preview-errors.mjs +0 -29
- package/src/preview.mjs +291 -101
- package/src/service.mjs +0 -11
- package/src/setup.mjs +5 -5
- package/src/tunnel-binary.mjs +109 -0
- package/src/tunnel.mjs +227 -0
- package/src/workspace.mjs +1 -1
- package/runner/personas/claude/kai-verifier.md +0 -84
- package/runner/personas/codex/kai-verifier.md +0 -84
- package/runner/tools/verify-mcp.mjs +0 -442
- package/src/preview-login.mjs +0 -610
- package/src/verify.mjs +0 -387
|
@@ -1,442 +0,0 @@
|
|
|
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();
|