@mmerterden/multi-agent-toolkit-mcp 3.14.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +192 -18
- package/README.md +78 -7
- package/README.tr.md +102 -7
- package/index.js +520 -160
- package/package.json +4 -4
- package/tools/context/index.js +34 -18
- package/tools/design-check/component-walk.js +359 -0
- package/tools/design-check/content-cardinality.js +3 -3
- package/tools/design-check/index.js +78 -11
- package/tools/design-check/report.js +152 -10
- package/tools/design-check/scan.js +1 -1
- package/tools/design-check/scenario-inventory.js +404 -50
- package/tools/design-check/visual-compare.js +17 -2
- package/tools/ios-app-store-audit/context.js +3 -3
- package/tools/ios-app-store-audit/exec.js +17 -0
- package/tools/ios-app-store-audit/index.js +0 -15
- package/tools/ios-app-store-audit/rules/code-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/dead-reference.js +2 -2
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +2 -2
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +3 -3
- package/tools/ios-app-store-audit/rules/extension-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +2 -2
- package/tools/ios-app-store-audit/rules/production-hygiene.js +2 -2
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +3 -3
- package/tools/ios-app-store-audit/rules/required-reason-api.js +2 -2
- package/tools/offload/index.js +7 -3
- package/tools/policy/egress-proxy.js +268 -0
- package/tools/policy/index.js +283 -0
- package/tools/spawn-collect/index.js +141 -0
package/index.js
CHANGED
|
@@ -21,7 +21,6 @@ import { join, dirname, basename, isAbsolute, resolve, sep } from "path";
|
|
|
21
21
|
import { homedir, tmpdir } from "os";
|
|
22
22
|
import { createHash } from "crypto";
|
|
23
23
|
import { createRequire } from "node:module";
|
|
24
|
-
import * as ctxIndex from "./tools/context/index.js";
|
|
25
24
|
|
|
26
25
|
// This file is ESM ("type": "module"), so `require` does not exist here. The
|
|
27
26
|
// optional readability helpers are resolved through createRequire: absent, the
|
|
@@ -52,6 +51,9 @@ import { parseLeaksOutput, parseMeminfoOutput, diffMeminfo } from "./tools/memor
|
|
|
52
51
|
import { auditIosTree, auditAndroidDump, parseAuditResults } from "./tools/a11y/index.js";
|
|
53
52
|
import { interactiveElements } from "./tools/ui-inspect/index.js";
|
|
54
53
|
import { selectCrashReports } from "./tools/crash-logs/index.js";
|
|
54
|
+
import { urlPolicy, urlPolicyError, urlAllowList, indexDenyList, indexDenied as pathDenied, strictLaunchOptions } from "./tools/policy/index.js";
|
|
55
|
+
import { startEgressProxy } from "./tools/policy/egress-proxy.js";
|
|
56
|
+
import { spawnCollect, killAllSpawned } from "./tools/spawn-collect/index.js";
|
|
55
57
|
import {
|
|
56
58
|
offloadLargeText,
|
|
57
59
|
queryOffloadedOutput,
|
|
@@ -67,6 +69,14 @@ if (!existsSync(SCREENSHOT_DIR)) mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
|
67
69
|
// ever removed them.
|
|
68
70
|
pruneWorkDir(SCREENSHOT_DIR);
|
|
69
71
|
|
|
72
|
+
// Read once at startup; see tools/policy for what strict refuses. Unset, both
|
|
73
|
+
// checks return early and nothing is refused.
|
|
74
|
+
const URL_POLICY = urlPolicy();
|
|
75
|
+
const URL_ALLOW = URL_POLICY === "strict" ? urlAllowList() : [];
|
|
76
|
+
const INDEX_DENY = indexDenyList({ policy: URL_POLICY });
|
|
77
|
+
const checkUrl = (url) => urlPolicyError(url, { policy: URL_POLICY, allow: URL_ALLOW });
|
|
78
|
+
const indexDenied = (path) => pathDenied(path, INDEX_DENY);
|
|
79
|
+
|
|
70
80
|
// ── Helpers ──
|
|
71
81
|
|
|
72
82
|
// Marker prefix for command failures. The CallTool dispatch turns any result
|
|
@@ -157,67 +167,6 @@ function runAsync(cmd, opts = {}) {
|
|
|
157
167
|
});
|
|
158
168
|
}
|
|
159
169
|
|
|
160
|
-
// spawn-based collector for the long-runners that also want incremental output
|
|
161
|
-
// (progress heartbeats read the last line). Interleaves stdout+stderr the way a
|
|
162
|
-
// terminal would, honors an AbortSignal, and never rejects.
|
|
163
|
-
//
|
|
164
|
-
// Output is capped at the 64MB the execSync it replaced enforced via maxBuffer:
|
|
165
|
-
// unbounded `output += chunk` on a verbose xcodebuild can exceed V8's max
|
|
166
|
-
// string length, and that throw fires inside a stream 'data' handler - outside
|
|
167
|
-
// the CallTool try/catch - killing the whole stdio server. The oldest chunks
|
|
168
|
-
// are dropped; errors and test verdicts land at the tail of a build log.
|
|
169
|
-
const SPAWN_OUTPUT_CAP = 64 * 1024 * 1024;
|
|
170
|
-
|
|
171
|
-
function spawnCollect(cmd, { timeout = 600000, signal, env, onLine } = {}) {
|
|
172
|
-
return new Promise((resolve) => {
|
|
173
|
-
const child = spawn("/bin/sh", ["-c", cmd], { env });
|
|
174
|
-
const chunks = [];
|
|
175
|
-
let size = 0;
|
|
176
|
-
let truncated = false;
|
|
177
|
-
let timedOut = false;
|
|
178
|
-
let aborted = false;
|
|
179
|
-
const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeout);
|
|
180
|
-
const onAbort = () => { aborted = true; child.kill("SIGTERM"); };
|
|
181
|
-
if (signal) {
|
|
182
|
-
if (signal.aborted) onAbort();
|
|
183
|
-
else signal.addEventListener("abort", onAbort, { once: true });
|
|
184
|
-
}
|
|
185
|
-
const collect = (chunk) => {
|
|
186
|
-
const s = String(chunk);
|
|
187
|
-
chunks.push(s);
|
|
188
|
-
size += s.length;
|
|
189
|
-
while (size > SPAWN_OUTPUT_CAP && chunks.length > 1) {
|
|
190
|
-
size -= chunks[0].length;
|
|
191
|
-
chunks.shift();
|
|
192
|
-
truncated = true;
|
|
193
|
-
}
|
|
194
|
-
if (size > SPAWN_OUTPUT_CAP) {
|
|
195
|
-
chunks[0] = chunks[0].slice(size - SPAWN_OUTPUT_CAP);
|
|
196
|
-
size = SPAWN_OUTPUT_CAP;
|
|
197
|
-
truncated = true;
|
|
198
|
-
}
|
|
199
|
-
if (onLine) {
|
|
200
|
-
const lines = s.split("\n");
|
|
201
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
202
|
-
const line = lines[i].trim();
|
|
203
|
-
if (line) { onLine(line); break; }
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
child.stdout.on("data", collect);
|
|
208
|
-
child.stderr.on("data", collect);
|
|
209
|
-
child.on("error", (e) => {
|
|
210
|
-
clearTimeout(timer);
|
|
211
|
-
resolve({ code: 127, output: `${chunks.join("")}\n${e.message}`, truncated, timedOut, aborted });
|
|
212
|
-
});
|
|
213
|
-
child.on("close", (code) => {
|
|
214
|
-
clearTimeout(timer);
|
|
215
|
-
if (signal) signal.removeEventListener("abort", onAbort);
|
|
216
|
-
resolve({ code: code ?? 1, output: chunks.join(""), truncated, timedOut, aborted });
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
170
|
function startHeartbeat(ctx, label, detail) {
|
|
222
171
|
if (!ctx?.progress) return () => {};
|
|
223
172
|
const started = Date.now();
|
|
@@ -245,11 +194,31 @@ function safeRelOutput(name, baseDir) {
|
|
|
245
194
|
return full;
|
|
246
195
|
}
|
|
247
196
|
|
|
197
|
+
// Bundle IDs / package names: reverse-DNS characters only (alphanumeric, dots,
|
|
198
|
+
// underscores, hyphens). Absent (undefined/null) passes through for the callers
|
|
199
|
+
// that treat it as optional, but a PRESENT value must name something: an empty
|
|
200
|
+
// string makes `simctl privacy <d> reset all <id>` reset every app on the
|
|
201
|
+
// device, and a leading dash reaches the CLI as an option instead of an
|
|
202
|
+
// operand. A slash is refused because `defaults write <id>` reads a value
|
|
203
|
+
// containing one as a plist path, so a bundle id of /tmp/x writes a file. The
|
|
204
|
+
// one place a slash is legitimate, an Android package/activity component, has
|
|
205
|
+
// its own validator.
|
|
248
206
|
function sanitizeId(id) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (!/^[a-zA-Z0-9._
|
|
252
|
-
return
|
|
207
|
+
if (id === undefined || id === null) return id;
|
|
208
|
+
const s = String(id);
|
|
209
|
+
if (!/^[a-zA-Z0-9._][a-zA-Z0-9._-]*$/.test(s)) throw new Error(`Invalid bundle id or package name: ${JSON.stringify(s)}`);
|
|
210
|
+
return s;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// An Android activity: a class name, relative (.Main) or qualified, optionally
|
|
214
|
+
// prefixed with its package as package/activity. At most one slash, never at
|
|
215
|
+
// either end.
|
|
216
|
+
function androidActivity(activity) {
|
|
217
|
+
const s = String(activity ?? "");
|
|
218
|
+
if (!/^[a-zA-Z0-9._][a-zA-Z0-9._-]*(\/[a-zA-Z0-9._][a-zA-Z0-9._-]*)?$/.test(s)) {
|
|
219
|
+
throw new Error(`Invalid activity: ${JSON.stringify(s)}`);
|
|
220
|
+
}
|
|
221
|
+
return s;
|
|
253
222
|
}
|
|
254
223
|
|
|
255
224
|
// Single-quote a value for POSIX sh. Every command here is built as a string and
|
|
@@ -432,6 +401,17 @@ const HAS_ADB = hasCommand("adb");
|
|
|
432
401
|
// two simctl recorders on one simulator fight over the io channel.
|
|
433
402
|
const RECORDINGS = new Map();
|
|
434
403
|
|
|
404
|
+
// Resolves once the child has either started or failed to. A spawn failure
|
|
405
|
+
// (the binary is gone, not executable) arrives as an 'error' event, and an
|
|
406
|
+
// 'error' nobody listens for is thrown as an uncaught exception - which takes
|
|
407
|
+
// the whole stdio server down one tick after the start call answered "started".
|
|
408
|
+
function spawnStarted(child) {
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
child.once("spawn", () => resolve(null));
|
|
411
|
+
child.once("error", (e) => resolve(e));
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
435
415
|
function fileResult(text, ...paths) {
|
|
436
416
|
return {
|
|
437
417
|
type: "file",
|
|
@@ -660,6 +640,8 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
660
640
|
const recParent = dirname(f);
|
|
661
641
|
if (!existsSync(recParent)) return `${ERROR_PREFIX}directory does not exist: ${recParent}`;
|
|
662
642
|
const child = spawn("xcrun", ["simctl", "io", d, "recordVideo", `--codec=${args.codec || "hevc"}`, "--force", f], { stdio: "ignore" });
|
|
643
|
+
const spawnError = await spawnStarted(child);
|
|
644
|
+
if (spawnError) return `${ERROR_PREFIX}could not start xcrun simctl io recordVideo: ${spawnError.message}`;
|
|
663
645
|
child.once("close", () => { if (RECORDINGS.get(key)?.child === child) RECORDINGS.delete(key); });
|
|
664
646
|
RECORDINGS.set(key, { child, path: f });
|
|
665
647
|
return `Recording started on ${d} (pid ${child.pid}) -> ${f}\nCall ios_record_video with action:"stop" to finish.`;
|
|
@@ -1122,7 +1104,7 @@ function adbFlag(id) { return id ? `-s ${deviceSerial(id)}` : ""; }
|
|
|
1122
1104
|
// carries the package (com.x/.Main) is used as it is.
|
|
1123
1105
|
function androidComponent(packageName, activity) {
|
|
1124
1106
|
const pkg = sanitizeId(packageName);
|
|
1125
|
-
const act =
|
|
1107
|
+
const act = androidActivity(activity);
|
|
1126
1108
|
return act.includes("/") ? act : `${pkg}/${act}`;
|
|
1127
1109
|
}
|
|
1128
1110
|
|
|
@@ -1204,7 +1186,14 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1204
1186
|
case "android_key_event": return run(`adb ${df} shell input keyevent ${token(args.keycode, "keycode")}`) || `Key ${args.keycode}`;
|
|
1205
1187
|
case "android_launch_app": return args.activity ? run(`adb ${df} shell am start -n ${androidComponent(args.package_name, args.activity)}`) : run(`adb ${df} shell monkey -p ${sanitizeId(args.package_name)} -c android.intent.category.LAUNCHER 1`) || `Launched`;
|
|
1206
1188
|
case "android_stop_app": return run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`) || "Stopped";
|
|
1207
|
-
case "android_list_packages": {
|
|
1189
|
+
case "android_list_packages": {
|
|
1190
|
+
const out = run(`adb ${df} shell pm list packages`);
|
|
1191
|
+
// The filter applies to package lines, not to a failure: filtering the
|
|
1192
|
+
// ERROR text by a package name would answer with an empty success.
|
|
1193
|
+
if (isFailure(out) || !args.filter) return out;
|
|
1194
|
+
const wanted = String(args.filter).toLowerCase();
|
|
1195
|
+
return out.split("\n").filter((l) => l.toLowerCase().includes(wanted)).join("\n");
|
|
1196
|
+
}
|
|
1208
1197
|
case "android_go_home": return run(`adb ${df} shell input keyevent 3`) || "Home";
|
|
1209
1198
|
case "android_go_back": return run(`adb ${df} shell input keyevent 4`) || "Back";
|
|
1210
1199
|
case "android_get_ui_tree": {
|
|
@@ -1315,6 +1304,8 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1315
1304
|
if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
|
|
1316
1305
|
const argv = [...(args.device_id ? ["-s", deviceSerial(args.device_id)] : []), "shell", "screenrecord", "--time-limit", String(dur), remotePath];
|
|
1317
1306
|
const child = spawn("adb", argv, { stdio: "ignore" });
|
|
1307
|
+
const spawnError = await spawnStarted(child);
|
|
1308
|
+
if (spawnError) return `${ERROR_PREFIX}could not start adb screenrecord: ${spawnError.message}`;
|
|
1318
1309
|
const entry = { child, remotePath, path: localPath, exited: false };
|
|
1319
1310
|
child.once("close", () => { if (RECORDINGS.get(key) === entry) entry.exited = true; });
|
|
1320
1311
|
RECORDINGS.set(key, entry);
|
|
@@ -1486,13 +1477,121 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1486
1477
|
let _browser = null;
|
|
1487
1478
|
let _page = null;
|
|
1488
1479
|
let _engine = null;
|
|
1480
|
+
let _userAgent = null;
|
|
1489
1481
|
|
|
1490
1482
|
// A request for a different engine closes the current browser and relaunches;
|
|
1491
1483
|
// a partial launch (newContext/newPage threw) is closed rather than left as a
|
|
1492
1484
|
// browser with no page that the next call would launch beside.
|
|
1493
|
-
|
|
1485
|
+
//
|
|
1486
|
+
// The user agent belongs to the browser context: set there, it reaches every
|
|
1487
|
+
// request and navigator.userAgent alike. A header override would change only
|
|
1488
|
+
// the former. So a web_goto naming a DIFFERENT user_agent starts a fresh
|
|
1489
|
+
// context, and with it drops the previous one's cookies, storage and tabs; one
|
|
1490
|
+
// that omits it keeps whatever context is open.
|
|
1491
|
+
// ── Strict URL policy inside the browser ─────────────────────────────
|
|
1492
|
+
//
|
|
1493
|
+
// Checking the URL handed to web_goto is not enough: a script, a click, a
|
|
1494
|
+
// redirect, a popup, a fetch or an <img> reaches a host without passing
|
|
1495
|
+
// through it. Under the strict policy every context gets a route that runs the
|
|
1496
|
+
// same check on every request the context makes - navigations, subresources,
|
|
1497
|
+
// popups, WebSockets - and aborts the refused ones before they leave the
|
|
1498
|
+
// browser. Service workers are blocked, because their requests bypass routing.
|
|
1499
|
+
// Routing disables the HTTP cache, which is why it is not installed by default.
|
|
1500
|
+
const REFUSAL_CAP = 50;
|
|
1501
|
+
let _refusals = [];
|
|
1502
|
+
let _refusalSeq = 0;
|
|
1503
|
+
|
|
1504
|
+
function recordRefusal(url, reason, navigation) {
|
|
1505
|
+
_refusals.push({ seq: ++_refusalSeq, url: String(url).slice(0, 300), reason, navigation });
|
|
1506
|
+
if (_refusals.length > REFUSAL_CAP) _refusals.shift();
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const refusalsSince = (seq) => _refusals.filter((r) => r.seq > seq);
|
|
1510
|
+
|
|
1511
|
+
// data:, blob: and about: never reach the network.
|
|
1512
|
+
const LOCAL_SCHEME = /^(data|blob|about):/i;
|
|
1513
|
+
|
|
1514
|
+
async function guardContext(ctx) {
|
|
1515
|
+
await ctx.route("**/*", async (route) => {
|
|
1516
|
+
const req = route.request();
|
|
1517
|
+
const url = req.url();
|
|
1518
|
+
const refused = LOCAL_SCHEME.test(url) ? null : await checkUrl(url);
|
|
1519
|
+
if (refused) {
|
|
1520
|
+
recordRefusal(url, refused, req.isNavigationRequest());
|
|
1521
|
+
return route.abort("blockedbyclient").catch(() => {});
|
|
1522
|
+
}
|
|
1523
|
+
return route.fallback().catch(() => {});
|
|
1524
|
+
});
|
|
1525
|
+
await ctx.routeWebSocket(/.*/, async (ws) => {
|
|
1526
|
+
const url = ws.url();
|
|
1527
|
+
const refused = await checkUrl(url.replace(/^ws(s?):/i, "http$1:"));
|
|
1528
|
+
if (refused) {
|
|
1529
|
+
recordRefusal(url, refused, false);
|
|
1530
|
+
await ws.close({ code: 1008, reason: "refused by the URL policy" }).catch(() => {});
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
ws.connectToServer();
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// The route above never sees the hops of a redirect chain; the proxy does.
|
|
1538
|
+
// Started with the first strict browser and kept for the life of the process
|
|
1539
|
+
// (it is unref'd and listens on 127.0.0.1 only).
|
|
1540
|
+
let _egressProxy = null;
|
|
1541
|
+
async function egressProxy() {
|
|
1542
|
+
_egressProxy ??= startEgressProxy({
|
|
1543
|
+
check: (url, lookup) => urlPolicyError(url, { policy: URL_POLICY, allow: URL_ALLOW, lookup }),
|
|
1544
|
+
onRefuse: (url, reason) => recordRefusal(url, reason, null),
|
|
1545
|
+
}).catch((e) => { _egressProxy = null; throw e; });
|
|
1546
|
+
return _egressProxy;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
async function launchOptions(engineName) {
|
|
1550
|
+
if (URL_POLICY !== "strict") return { headless: true };
|
|
1551
|
+
const proxy = await egressProxy();
|
|
1552
|
+
return strictLaunchOptions(engineName, proxy.url);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// WebKit has no launch switch for WebRTC, so under the strict policy its pages
|
|
1556
|
+
// get no peer-connection constructors at all.
|
|
1557
|
+
const NO_PEER_CONNECTION = `for (const k of ["RTCPeerConnection", "webkitRTCPeerConnection", "RTCDataChannel"]) { try { delete window[k]; } catch {} }`;
|
|
1558
|
+
|
|
1559
|
+
async function newContext(agent) {
|
|
1560
|
+
const strict = URL_POLICY === "strict";
|
|
1561
|
+
const ctx = await _browser.newContext({
|
|
1562
|
+
...(agent ? { userAgent: agent } : {}),
|
|
1563
|
+
...(strict ? { serviceWorkers: "block" } : {}),
|
|
1564
|
+
});
|
|
1565
|
+
if (strict) {
|
|
1566
|
+
try {
|
|
1567
|
+
if (_browser.browserType().name() === "webkit") await ctx.addInitScript(NO_PEER_CONNECTION);
|
|
1568
|
+
await guardContext(ctx);
|
|
1569
|
+
} catch (e) {
|
|
1570
|
+
await ctx.close().catch(() => {});
|
|
1571
|
+
throw e;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return ctx;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
async function ensureBrowser(browserType, userAgent) {
|
|
1494
1578
|
const wanted = browserType || _engine || "chromium";
|
|
1495
|
-
|
|
1579
|
+
const agent = userAgent === undefined || userAgent === null || userAgent === "" ? _userAgent : String(userAgent);
|
|
1580
|
+
if (_browser && _page && _engine === wanted && agent === _userAgent) return _page;
|
|
1581
|
+
if (_browser && _page && _engine === wanted) {
|
|
1582
|
+
try { await _page.context().close(); } catch {}
|
|
1583
|
+
_page = null;
|
|
1584
|
+
try {
|
|
1585
|
+
const ctx = await newContext(agent);
|
|
1586
|
+
_page = await ctx.newPage();
|
|
1587
|
+
observePage(_page);
|
|
1588
|
+
} catch (e) {
|
|
1589
|
+
await closeBrowser();
|
|
1590
|
+
throw e;
|
|
1591
|
+
}
|
|
1592
|
+
_userAgent = agent;
|
|
1593
|
+
return _page;
|
|
1594
|
+
}
|
|
1496
1595
|
if (_browser || _page) await closeBrowser();
|
|
1497
1596
|
let pw;
|
|
1498
1597
|
try {
|
|
@@ -1503,9 +1602,9 @@ async function ensureBrowser(browserType) {
|
|
|
1503
1602
|
const engines = { chromium: pw.chromium, webkit: pw.webkit, firefox: pw.firefox };
|
|
1504
1603
|
const engine = engines[wanted];
|
|
1505
1604
|
if (!engine) throw new Error(`unknown browser engine: ${wanted}`);
|
|
1506
|
-
_browser = await engine.launch(
|
|
1605
|
+
_browser = await engine.launch(await launchOptions(wanted));
|
|
1507
1606
|
try {
|
|
1508
|
-
const ctx = await
|
|
1607
|
+
const ctx = await newContext(agent);
|
|
1509
1608
|
_page = await ctx.newPage();
|
|
1510
1609
|
observePage(_page);
|
|
1511
1610
|
} catch (e) {
|
|
@@ -1513,6 +1612,7 @@ async function ensureBrowser(browserType) {
|
|
|
1513
1612
|
throw e;
|
|
1514
1613
|
}
|
|
1515
1614
|
_engine = wanted;
|
|
1615
|
+
_userAgent = agent;
|
|
1516
1616
|
return _page;
|
|
1517
1617
|
}
|
|
1518
1618
|
|
|
@@ -1522,6 +1622,7 @@ async function closeBrowser() {
|
|
|
1522
1622
|
_page = null;
|
|
1523
1623
|
_browser = null;
|
|
1524
1624
|
_engine = null;
|
|
1625
|
+
_userAgent = null;
|
|
1525
1626
|
}
|
|
1526
1627
|
|
|
1527
1628
|
// ── Page observation ─────────────────────────────────────────────────
|
|
@@ -1767,6 +1868,10 @@ function turndownBundlePath(resolved) {
|
|
|
1767
1868
|
function blankPageError(page, tool) {
|
|
1768
1869
|
const url = page.url();
|
|
1769
1870
|
if (url && url !== "about:blank" && !url.startsWith("chrome-error://")) return null;
|
|
1871
|
+
// Under strict, a navigation the policy aborted leaves the browser's error
|
|
1872
|
+
// page behind; naming the refusal says why better than "nothing opened".
|
|
1873
|
+
const refused = url.startsWith("chrome-error://") ? [..._refusals].reverse().find((r) => r.navigation) : null;
|
|
1874
|
+
if (refused) return `ERROR: ${tool} has no page to read: the last navigation, to ${refused.url}, was refused. ${refused.reason}`;
|
|
1770
1875
|
return `ERROR: ${tool} reads the page that is currently open, and nothing has been opened yet (${url || "no url"}). Call web_goto first.`;
|
|
1771
1876
|
}
|
|
1772
1877
|
|
|
@@ -1868,7 +1973,7 @@ function locate(page, args) {
|
|
|
1868
1973
|
}
|
|
1869
1974
|
|
|
1870
1975
|
const WEB_TOOLS = [
|
|
1871
|
-
{ name: "web_goto", description: "Open a URL in a headless browser (Playwright). Reuses a single browser instance across calls.", inputSchema: { type: "object", properties: { url: { type: "string" }, browser: { type: "string", enum: ["chromium", "webkit", "firefox"], description: "Default chromium; use webkit for Safari-like behavior" }, wait_until: { type: "string", enum: ["load", "domcontentloaded", "networkidle"] }, timeout_ms: { type: "number", description: "Navigation timeout. Default 30000." }, viewport: { type: "string", description: "WIDTHxHEIGHT, e.g. 390x844 for a phone-sized layout." }, locale: { type: "string", description: "Accept-Language for this navigation, e.g. tr-TR." }, user_agent: { type: "string" } }, required: ["url"] } },
|
|
1976
|
+
{ name: "web_goto", description: "Open a URL in a headless browser (Playwright). Reuses a single browser instance across calls.", inputSchema: { type: "object", properties: { url: { type: "string" }, browser: { type: "string", enum: ["chromium", "webkit", "firefox"], description: "Default chromium; use webkit for Safari-like behavior" }, wait_until: { type: "string", enum: ["load", "domcontentloaded", "networkidle"] }, timeout_ms: { type: "number", description: "Navigation timeout. Default 30000." }, viewport: { type: "string", description: "WIDTHxHEIGHT, e.g. 390x844 for a phone-sized layout." }, locale: { type: "string", description: "Accept-Language for this navigation, e.g. tr-TR." }, user_agent: { type: "string", description: "User-Agent for the browser context, seen by requests and navigator.userAgent alike. A value different from the current one starts a fresh context, discarding its cookies, storage and tabs; omit it to keep the current context." } }, required: ["url"] } },
|
|
1872
1977
|
{ name: "web_screenshot", description: "Capture a screenshot of the current page. Returns a base64 PNG by default; pass `path` to write the file and return only its location.", inputSchema: { type: "object", properties: { full_page: { type: "boolean" }, path: { type: "string", description: "Absolute file path to write the PNG to. The parent directory must already exist. Returns the path instead of the image." } } } },
|
|
1873
1978
|
{ name: "web_click", description: "Click an element by CSS selector, text, or a [ref=eN] from web_snapshot. Auto-waits for the element.", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector, or 'text=...' / 'role=...'" }, ref: { type: "string", description: "A ref from the most recent web_snapshot, e.g. e7. Use this instead of selector." }, timeout_ms: { type: "number" } } } },
|
|
1874
1979
|
{ name: "web_type", description: "Type text into an input matched by a selector or a [ref=eN] from web_snapshot.", inputSchema: { type: "object", properties: { selector: { type: "string" }, ref: { type: "string", description: "A ref from the most recent web_snapshot." }, text: { type: "string" }, clear_first: { type: "boolean" } }, required: ["text"] } },
|
|
@@ -1889,9 +1994,121 @@ const WEB_TOOLS = [
|
|
|
1889
1994
|
{ name: "web_select_option", description: "Choose an option in a <select>, by value or by visible label.", inputSchema: { type: "object", properties: { selector: { type: "string" }, ref: { type: "string" }, value: { type: "string" }, label: { type: "string" } } } },
|
|
1890
1995
|
];
|
|
1891
1996
|
|
|
1997
|
+
// The policy verdict on where the page is now. Only a network or file URL is
|
|
1998
|
+
// judged: about:blank and the browser's own error page hold nothing a refused
|
|
1999
|
+
// host served.
|
|
2000
|
+
async function landedRefusal(page) {
|
|
2001
|
+
const url = page.url();
|
|
2002
|
+
if (!/^(https?|wss?|ftp|file):/i.test(url)) return null;
|
|
2003
|
+
return checkUrl(url);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// The crawl loop proper. handleWeb routes the crawl's User-Agent around it and
|
|
2007
|
+
// removes the route afterwards, whatever this returns or throws.
|
|
2008
|
+
async function crawlFrom(page, { start, origin, maxPages, maxDepth, delay, respectRobots, wantIndex, indexDb, crawlDir, ctxIndex }) {
|
|
2009
|
+
let indexed = 0;
|
|
2010
|
+
const disallowed = respectRobots ? await robotsDisallow(page, origin) : [];
|
|
2011
|
+
const seen = new Set([start]);
|
|
2012
|
+
const queue = [{ url: start, depth: 0 }];
|
|
2013
|
+
const out = [];
|
|
2014
|
+
while (queue.length && out.length < maxPages) {
|
|
2015
|
+
const { url, depth } = queue.shift();
|
|
2016
|
+
if (disallowed.some((rule) => url.startsWith(origin + rule))) continue;
|
|
2017
|
+
const refused = await checkUrl(url);
|
|
2018
|
+
if (refused) {
|
|
2019
|
+
out.push(`--- ${url}\nERROR: ${refused}`);
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
let gotoError = null;
|
|
2023
|
+
try {
|
|
2024
|
+
await page.goto(url, { waitUntil: "load", timeout: 15000 });
|
|
2025
|
+
} catch (e) {
|
|
2026
|
+
gotoError = e;
|
|
2027
|
+
}
|
|
2028
|
+
// A redirect can land somewhere the queued URL did not name. Such a page is
|
|
2029
|
+
// neither read, indexed nor followed.
|
|
2030
|
+
const landed = await landedRefusal(page);
|
|
2031
|
+
if (landed) {
|
|
2032
|
+
out.push(`--- ${url}\nERROR: redirected to a refused address. ${landed}`);
|
|
2033
|
+
await page.goto("about:blank").catch(() => {});
|
|
2034
|
+
continue;
|
|
2035
|
+
}
|
|
2036
|
+
if (gotoError) {
|
|
2037
|
+
out.push(`--- ${url}\nERROR: ${gotoError.message}`);
|
|
2038
|
+
continue;
|
|
2039
|
+
}
|
|
2040
|
+
const doc = await extractReadable(page);
|
|
2041
|
+
out.push(`--- ${url}\n${doc.markdown}`);
|
|
2042
|
+
// A crawl that cannot be searched afterwards is a wall of text: twenty
|
|
2043
|
+
// pages arrive at once and the caller has no way to ask which of them
|
|
2044
|
+
// answers the question. Each page is written beside the index and
|
|
2045
|
+
// indexed under its URL, so context_search ranks the crawl the same way
|
|
2046
|
+
// it ranks an offloaded payload. Failure here never fails the crawl -
|
|
2047
|
+
// the pages are already in the reply.
|
|
2048
|
+
if (wantIndex && indexDb) {
|
|
2049
|
+
try {
|
|
2050
|
+
const slug = url.replace(/[^a-z0-9]+/gi, "-").slice(0, 120);
|
|
2051
|
+
const file = join(crawlDir, `${slug || "page"}.md`);
|
|
2052
|
+
writeFileSync(file, `# ${url}\n\n${doc.markdown}\n`);
|
|
2053
|
+
ctxIndex.indexFile(indexDb, file);
|
|
2054
|
+
indexed += 1;
|
|
2055
|
+
} catch {
|
|
2056
|
+
/* the page is in the reply; the index is the bonus */
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
if (depth < maxDepth) {
|
|
2060
|
+
for (const link of await sameOriginLinks(page)) {
|
|
2061
|
+
if (seen.has(link) || seen.size >= maxPages * 4) continue;
|
|
2062
|
+
seen.add(link);
|
|
2063
|
+
queue.push({ url: link, depth: depth + 1 });
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
// One request at a time, with a pause. A crawler that opens a site in
|
|
2067
|
+
// parallel is a load test nobody asked for.
|
|
2068
|
+
if (queue.length && out.length < maxPages) await page.waitForTimeout(delay);
|
|
2069
|
+
}
|
|
2070
|
+
resetRefs();
|
|
2071
|
+
const indexNote = indexed
|
|
2072
|
+
? ` - ${indexed} indexed, searchable with context_search`
|
|
2073
|
+
: wantIndex && indexDb === null
|
|
2074
|
+
? " - the index could not be opened, so these pages are not searchable"
|
|
2075
|
+
: "";
|
|
2076
|
+
return `${out.length} page(s)${indexNote}\n\n${fenceUntrusted(`${out.length} page(s) from ${origin && origin !== "null" ? origin : start}`, out.join("\n\n"))}`;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// web_crawl announces itself on every request it makes, so a site operator
|
|
2080
|
+
// reading the access log can tell the crawl from a visitor and find its owner.
|
|
2081
|
+
const crawlUserAgent = () => `multi-agent-toolkit-mcp/${PKG.version} (+https://github.com/mmerterden/multi-agent-toolkit-mcp)`;
|
|
2082
|
+
|
|
2083
|
+
// Non-negative integer when given, the default when absent. An explicit 0 is a
|
|
2084
|
+
// value (max_depth 0 means the start page only, delay_ms 0 means no pause), so
|
|
2085
|
+
// it cannot be read as "not given".
|
|
2086
|
+
function countOr(value, fallback) {
|
|
2087
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
2088
|
+
const n = Math.trunc(Number(value));
|
|
2089
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
const PAGE_READING_TOOLS = new Set([
|
|
2093
|
+
"web_get_text", "web_extract", "web_snapshot", "web_screenshot", "web_eval",
|
|
2094
|
+
"web_map", "web_storage_state", "web_select_option", "web_type", "web_click", "web_press_key", "web_wait_for",
|
|
2095
|
+
]);
|
|
2096
|
+
|
|
1892
2097
|
async function handleWeb(name, args) {
|
|
1893
2098
|
if (name === "web_close") { await closeBrowser(); resetRefs(); return "Browser closed"; }
|
|
1894
|
-
|
|
2099
|
+
// Before the browser launches: a refused URL costs nothing.
|
|
2100
|
+
if (name === "web_goto") {
|
|
2101
|
+
const refused = await checkUrl(args.url);
|
|
2102
|
+
if (refused) return `${ERROR_PREFIX}${refused}`;
|
|
2103
|
+
}
|
|
2104
|
+
const page = await ensureBrowser(args.browser, name === "web_goto" ? args.user_agent : undefined);
|
|
2105
|
+
// The route guard stops a refused page from loading; this is the second
|
|
2106
|
+
// line, for a page that got there anyway (a DNS answer that changed between
|
|
2107
|
+
// two lookups): nothing is read from a page whose address is refused now.
|
|
2108
|
+
if (URL_POLICY === "strict" && PAGE_READING_TOOLS.has(name)) {
|
|
2109
|
+
const refused = await landedRefusal(page);
|
|
2110
|
+
if (refused) return `${ERROR_PREFIX}the current page is at a refused address. ${refused}`;
|
|
2111
|
+
}
|
|
1895
2112
|
switch (name) {
|
|
1896
2113
|
case "web_goto": {
|
|
1897
2114
|
if (args.viewport) {
|
|
@@ -1911,10 +2128,33 @@ async function handleWeb(name, args) {
|
|
|
1911
2128
|
// Every ref from before this navigation points at a page that is gone,
|
|
1912
2129
|
// so they are dropped rather than left to resolve by accident.
|
|
1913
2130
|
resetRefs();
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2131
|
+
const seq = _refusalSeq;
|
|
2132
|
+
let gotoError = null;
|
|
2133
|
+
try {
|
|
2134
|
+
await page.goto(args.url, {
|
|
2135
|
+
waitUntil: args.wait_until || "load",
|
|
2136
|
+
timeout: args.timeout_ms || 30000,
|
|
2137
|
+
});
|
|
2138
|
+
} catch (e) {
|
|
2139
|
+
gotoError = e;
|
|
2140
|
+
}
|
|
2141
|
+
// A redirect can land somewhere the requested URL did not name, and a
|
|
2142
|
+
// goto that threw (a timeout after the redirect) may still have landed.
|
|
2143
|
+
const landed = await landedRefusal(page);
|
|
2144
|
+
if (landed) {
|
|
2145
|
+
await page.goto("about:blank").catch(() => {});
|
|
2146
|
+
resetRefs();
|
|
2147
|
+
return `${ERROR_PREFIX}${args.url} redirected to a refused address. ${landed}`;
|
|
2148
|
+
}
|
|
2149
|
+
if (gotoError) {
|
|
2150
|
+
const refused = refusalsSince(seq).find((r) => r.navigation);
|
|
2151
|
+
if (refused) {
|
|
2152
|
+
await page.goto("about:blank").catch(() => {});
|
|
2153
|
+
resetRefs();
|
|
2154
|
+
return `${ERROR_PREFIX}${args.url} was refused on the way to ${refused.url}. ${refused.reason}`;
|
|
2155
|
+
}
|
|
2156
|
+
throw gotoError;
|
|
2157
|
+
}
|
|
1918
2158
|
return `Opened ${args.url} (title: "${await page.title()}")`;
|
|
1919
2159
|
}
|
|
1920
2160
|
case "web_screenshot": {
|
|
@@ -2064,74 +2304,44 @@ async function handleWeb(name, args) {
|
|
|
2064
2304
|
const blank = blankPageError(page, "web_crawl");
|
|
2065
2305
|
if (blank) return blank;
|
|
2066
2306
|
const maxPages = Math.min(Number(args.max_pages) || 20, 200);
|
|
2067
|
-
const maxDepth =
|
|
2068
|
-
const delay =
|
|
2307
|
+
const maxDepth = countOr(args.max_depth, 2);
|
|
2308
|
+
const delay = countOr(args.delay_ms, 250);
|
|
2069
2309
|
const respectRobots = args.respect_robots !== false;
|
|
2070
2310
|
const start = page.url();
|
|
2311
|
+
// The start page can be reached without web_goto (a click, a script),
|
|
2312
|
+
// so it is checked here as well as every page the crawl opens.
|
|
2313
|
+
const refusedStart = await checkUrl(start);
|
|
2314
|
+
if (refusedStart) return `${ERROR_PREFIX}${refusedStart}`;
|
|
2071
2315
|
const origin = new URL(start).origin;
|
|
2072
2316
|
const wantIndex = args.index !== false;
|
|
2073
|
-
let indexed = 0;
|
|
2074
2317
|
let indexDb = null;
|
|
2075
2318
|
let crawlDir = null;
|
|
2319
|
+
let ctxIndex = null;
|
|
2076
2320
|
if (wantIndex) {
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
crawlDir = join(ctxIndex.INDEX_DIR, "crawl");
|
|
2080
|
-
if (!existsSync(crawlDir)) mkdirSync(crawlDir, { recursive: true });
|
|
2081
|
-
} catch {
|
|
2082
|
-
indexDb = null;
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
const disallowed = respectRobots ? await robotsDisallow(page, origin) : [];
|
|
2086
|
-
const seen = new Set([start]);
|
|
2087
|
-
const queue = [{ url: start, depth: 0 }];
|
|
2088
|
-
const out = [];
|
|
2089
|
-
while (queue.length && out.length < maxPages) {
|
|
2090
|
-
const { url, depth } = queue.shift();
|
|
2091
|
-
if (disallowed.some((rule) => url.startsWith(origin + rule))) continue;
|
|
2092
|
-
try {
|
|
2093
|
-
await page.goto(url, { waitUntil: "load", timeout: 15000 });
|
|
2094
|
-
} catch (e) {
|
|
2095
|
-
out.push(`--- ${url}\nERROR: ${e.message}`);
|
|
2096
|
-
continue;
|
|
2097
|
-
}
|
|
2098
|
-
const doc = await extractReadable(page);
|
|
2099
|
-
out.push(`--- ${url}\n${doc.markdown}`);
|
|
2100
|
-
// A crawl that cannot be searched afterwards is a wall of text: twenty
|
|
2101
|
-
// pages arrive at once and the caller has no way to ask which of them
|
|
2102
|
-
// answers the question. Each page is written beside the index and
|
|
2103
|
-
// indexed under its URL, so context_search ranks the crawl the same way
|
|
2104
|
-
// it ranks an offloaded payload. Failure here never fails the crawl -
|
|
2105
|
-
// the pages are already in the reply.
|
|
2106
|
-
if (wantIndex && indexDb) {
|
|
2321
|
+
const opened = await openContextDb();
|
|
2322
|
+
if (!opened.error) {
|
|
2107
2323
|
try {
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
indexed += 1;
|
|
2324
|
+
ctxIndex = opened.ctx;
|
|
2325
|
+
crawlDir = join(ctxIndex.INDEX_DIR, "crawl");
|
|
2326
|
+
if (!existsSync(crawlDir)) mkdirSync(crawlDir, { recursive: true });
|
|
2327
|
+
indexDb = opened.db;
|
|
2113
2328
|
} catch {
|
|
2114
|
-
|
|
2329
|
+
indexDb = null;
|
|
2115
2330
|
}
|
|
2116
2331
|
}
|
|
2117
|
-
if (depth < maxDepth) {
|
|
2118
|
-
for (const link of await sameOriginLinks(page)) {
|
|
2119
|
-
if (seen.has(link) || seen.size >= maxPages * 4) continue;
|
|
2120
|
-
seen.add(link);
|
|
2121
|
-
queue.push({ url: link, depth: depth + 1 });
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
// One request at a time, with a pause. A crawler that opens a site in
|
|
2125
|
-
// parallel is a load test nobody asked for.
|
|
2126
|
-
if (queue.length && out.length < maxPages) await page.waitForTimeout(delay);
|
|
2127
2332
|
}
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2333
|
+
// A route rather than a header override: a context created with a
|
|
2334
|
+
// user_agent wins over extra headers, and the crawl has to identify
|
|
2335
|
+
// itself whatever the session was set to.
|
|
2336
|
+
// fallback, not continue: continue would send the request straight to the
|
|
2337
|
+
// network and skip the context's URL-policy route behind it.
|
|
2338
|
+
const identify = (route) => route.fallback({ headers: { ...route.request().headers(), "user-agent": crawlUserAgent() } }).catch(() => {});
|
|
2339
|
+
await page.route("**/*", identify);
|
|
2340
|
+
try {
|
|
2341
|
+
return await crawlFrom(page, { start, origin, maxPages, maxDepth, delay, respectRobots, wantIndex, indexDb, crawlDir, ctxIndex });
|
|
2342
|
+
} finally {
|
|
2343
|
+
await page.unroute("**/*", identify).catch(() => {});
|
|
2344
|
+
}
|
|
2135
2345
|
}
|
|
2136
2346
|
|
|
2137
2347
|
case "web_press_key": {
|
|
@@ -2319,33 +2529,66 @@ const CONTEXT_TOOLS = [
|
|
|
2319
2529
|
},
|
|
2320
2530
|
];
|
|
2321
2531
|
|
|
2322
|
-
|
|
2323
|
-
|
|
2532
|
+
// node:sqlite is unflagged from Node 22.13, and a static import of it fails the
|
|
2533
|
+
// whole module graph, so every tool would go down with the three that need it.
|
|
2534
|
+
// The context module is imported on first use instead; when it cannot load,
|
|
2535
|
+
// context_* and crawl indexing answer with the reason and nothing else changes.
|
|
2536
|
+
let contextModule = null;
|
|
2537
|
+
function loadContext() {
|
|
2538
|
+
contextModule ??= import("./tools/context/index.js").then(
|
|
2539
|
+
(mod) => ({ mod }),
|
|
2540
|
+
(e) => ({ error: `the context index needs node:sqlite, available unflagged from Node 22.13 (this is ${process.version}): ${e.message}` }),
|
|
2541
|
+
);
|
|
2542
|
+
return contextModule;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// One handle for the life of the server. A DatabaseSync per call keeps every
|
|
2546
|
+
// earlier handle open until the garbage collector happens to run.
|
|
2547
|
+
let contextDb = null;
|
|
2548
|
+
async function openContextDb() {
|
|
2549
|
+
const ctx = await loadContext();
|
|
2550
|
+
if (ctx.error) return { error: ctx.error };
|
|
2324
2551
|
try {
|
|
2325
|
-
|
|
2552
|
+
contextDb ??= ctx.mod.openIndex();
|
|
2326
2553
|
} catch (e) {
|
|
2327
|
-
return
|
|
2554
|
+
return { error: `could not open the index: ${e.message}` };
|
|
2328
2555
|
}
|
|
2556
|
+
return { ctx: ctx.mod, db: contextDb };
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function closeContextDb() {
|
|
2560
|
+
try { contextDb?.close(); } catch {}
|
|
2561
|
+
contextDb = null;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
async function handleContext(name, args) {
|
|
2565
|
+
const opened = await openContextDb();
|
|
2566
|
+
if (opened.error) return `${ERROR_PREFIX}${opened.error}`;
|
|
2567
|
+
const { ctx, db } = opened;
|
|
2329
2568
|
switch (name) {
|
|
2330
2569
|
case "context_index": {
|
|
2331
|
-
const
|
|
2570
|
+
const denied = indexDenied(String(args.path));
|
|
2571
|
+
if (denied) return `${ERROR_PREFIX}${denied}`;
|
|
2572
|
+
const out = ctx.indexFile(db, String(args.path));
|
|
2332
2573
|
if (out.reason === "no such file") return `${ERROR_PREFIX}no such file: ${args.path}`;
|
|
2333
2574
|
if (!out.indexed) return `Already indexed and unchanged: ${out.chunks} passage(s)`;
|
|
2334
2575
|
return `Indexed ${args.path}: ${out.chunks} passage(s)`;
|
|
2335
2576
|
}
|
|
2336
2577
|
case "context_search": {
|
|
2337
|
-
const hits =
|
|
2578
|
+
const hits = ctx.search(db, String(args.query), {
|
|
2338
2579
|
limit: Number(args.limit) || 5,
|
|
2339
2580
|
path: args.path ? String(args.path) : null,
|
|
2340
|
-
});
|
|
2581
|
+
}).filter((h) => !indexDenied(h.path));
|
|
2341
2582
|
if (!hits.length) return "(no match in the index; run context_index on the file first)";
|
|
2342
2583
|
return hits
|
|
2343
2584
|
.map((h) => `[id ${h.id}] ${h.path}:${h.firstLine}-${h.lastLine}\n${h.snippet}`)
|
|
2344
2585
|
.join("\n\n");
|
|
2345
2586
|
}
|
|
2346
2587
|
case "context_get": {
|
|
2347
|
-
const row =
|
|
2588
|
+
const row = ctx.getChunk(db, args.id);
|
|
2348
2589
|
if (!row) return `${ERROR_PREFIX}no passage with id ${args.id}`;
|
|
2590
|
+
const denied = indexDenied(row.path);
|
|
2591
|
+
if (denied) return `${ERROR_PREFIX}${denied}`;
|
|
2349
2592
|
return `${row.path} lines ${row.first_line}-${row.last_line}\n\n${row.body}`;
|
|
2350
2593
|
}
|
|
2351
2594
|
default:
|
|
@@ -2376,21 +2619,51 @@ const RESEARCH_TOOLS = [
|
|
|
2376
2619
|
},
|
|
2377
2620
|
];
|
|
2378
2621
|
|
|
2622
|
+
const RESEARCH_PROVIDERS = ["brave", "perplexity"];
|
|
2623
|
+
const RESEARCH_KEYS = { brave: "BRAVE_API_KEY", perplexity: "PERPLEXITY_API_KEY" };
|
|
2624
|
+
// Brave's web search answers at most 20 results per request.
|
|
2625
|
+
const RESEARCH_MAX_RESULTS = 20;
|
|
2626
|
+
|
|
2627
|
+
// The provider decides both the endpoint and the key, so it is resolved once,
|
|
2628
|
+
// normalized, and checked against the same list the schema declares. A value
|
|
2629
|
+
// from RESEARCH_PROVIDER never passes through the schema, and deriving the key
|
|
2630
|
+
// and the endpoint separately lets a near miss ("Brave") send one provider's
|
|
2631
|
+
// key to the other.
|
|
2632
|
+
function researchProvider(args) {
|
|
2633
|
+
const raw = args.provider || process.env.RESEARCH_PROVIDER || "brave";
|
|
2634
|
+
const provider = String(raw).trim().toLowerCase();
|
|
2635
|
+
if (RESEARCH_PROVIDERS.includes(provider)) return { provider };
|
|
2636
|
+
return { error: `${ERROR_PREFIX}unknown research provider "${String(raw).trim()}"; expected one of ${RESEARCH_PROVIDERS.join(", ")}` };
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2379
2639
|
function researchKey(provider) {
|
|
2380
|
-
const name = provider
|
|
2381
|
-
const value = process.env[name];
|
|
2640
|
+
const name = RESEARCH_KEYS[provider];
|
|
2641
|
+
const value = name ? process.env[name] : undefined;
|
|
2382
2642
|
// The NAME is safe to say; the value never is. A caller who has not set it
|
|
2383
2643
|
// needs to know which variable to set.
|
|
2384
2644
|
return value ? { value, name } : { error: `${ERROR_PREFIX}${name} is not set in this environment` };
|
|
2385
2645
|
}
|
|
2386
2646
|
|
|
2647
|
+
function researchLimit(value) {
|
|
2648
|
+
const n = Math.trunc(Number(value));
|
|
2649
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, RESEARCH_MAX_RESULTS) : 5;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
// One shape for every provider: title, url and snippet, one block per result.
|
|
2653
|
+
function researchRows(rows) {
|
|
2654
|
+
if (!rows.length) return "(no results)";
|
|
2655
|
+
return rows.map((r) => `${r.title ?? ""}\n${r.url ?? ""}\n${r.snippet ?? ""}`).join("\n\n");
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2387
2658
|
async function handleResearch(name, args) {
|
|
2388
2659
|
const timeout = AbortSignal.timeout(20000);
|
|
2389
2660
|
if (name === "research_search") {
|
|
2390
|
-
const
|
|
2661
|
+
const chosen = researchProvider(args);
|
|
2662
|
+
if (chosen.error) return chosen.error;
|
|
2663
|
+
const { provider } = chosen;
|
|
2391
2664
|
const key = researchKey(provider);
|
|
2392
2665
|
if (key.error) return key.error;
|
|
2393
|
-
const limit =
|
|
2666
|
+
const limit = researchLimit(args.limit);
|
|
2394
2667
|
try {
|
|
2395
2668
|
if (provider === "brave") {
|
|
2396
2669
|
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(args.query)}&count=${limit}`;
|
|
@@ -2401,8 +2674,7 @@ async function handleResearch(name, args) {
|
|
|
2401
2674
|
if (!res.ok) return `${ERROR_PREFIX}brave returned ${res.status}`;
|
|
2402
2675
|
const body = await res.json();
|
|
2403
2676
|
const rows = (body?.web?.results || []).slice(0, limit);
|
|
2404
|
-
|
|
2405
|
-
return rows.map((r) => `${r.title}\n${r.url}\n${r.description ?? ""}`).join("\n\n");
|
|
2677
|
+
return researchRows(rows.map((r) => ({ title: r.title, url: r.url, snippet: r.description })));
|
|
2406
2678
|
}
|
|
2407
2679
|
const res = await fetch("https://api.perplexity.ai/chat/completions", {
|
|
2408
2680
|
method: "POST",
|
|
@@ -2415,10 +2687,14 @@ async function handleResearch(name, args) {
|
|
|
2415
2687
|
});
|
|
2416
2688
|
if (!res.ok) return `${ERROR_PREFIX}perplexity returned ${res.status}`;
|
|
2417
2689
|
const body = await res.json();
|
|
2418
|
-
|
|
2419
|
-
|
|
2690
|
+
// search_results carries titles and snippets; an older response has only
|
|
2691
|
+
// the citation URLs, which still fill the url line.
|
|
2692
|
+
const rows = Array.isArray(body?.search_results) && body.search_results.length
|
|
2693
|
+
? body.search_results.map((r) => ({ title: r.title, url: r.url, snippet: r.snippet ?? "" }))
|
|
2694
|
+
: (body?.citations || []).map((c) => ({ title: "", url: c, snippet: "" }));
|
|
2695
|
+
return researchRows(rows.slice(0, limit));
|
|
2420
2696
|
} catch (e) {
|
|
2421
|
-
return `${ERROR_PREFIX}${String(e.message || e).slice(0, 200)}`;
|
|
2697
|
+
return `${ERROR_PREFIX}${provider}: ${String(e.message || e).slice(0, 200)}`;
|
|
2422
2698
|
}
|
|
2423
2699
|
}
|
|
2424
2700
|
if (name === "research_ask") {
|
|
@@ -2460,6 +2736,8 @@ const MEDIA_TOOLS = [
|
|
|
2460
2736
|
},
|
|
2461
2737
|
];
|
|
2462
2738
|
|
|
2739
|
+
const FRAME_NAME = /^frame-\d{3,}\.png$/;
|
|
2740
|
+
|
|
2463
2741
|
async function handleMedia(name, args) {
|
|
2464
2742
|
if (name !== "media_frames") return null;
|
|
2465
2743
|
if (!existsSync(String(args.path))) return `${ERROR_PREFIX}no such file: ${args.path}`;
|
|
@@ -2472,11 +2750,17 @@ async function handleMedia(name, args) {
|
|
|
2472
2750
|
const threshold = Number(args.threshold) > 0 ? Number(args.threshold) : 0.25;
|
|
2473
2751
|
const outDir = args.out_dir ? String(args.out_dir) : join(dirname(String(args.path)), `${basename(String(args.path), ".mp4")}-frames`);
|
|
2474
2752
|
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
2753
|
+
// Frames from an earlier run into the same directory would be counted as
|
|
2754
|
+
// this run's, and ffmpeg refuses to overwrite them without -y. Only the
|
|
2755
|
+
// names this tool writes are removed.
|
|
2756
|
+
for (const f of readdirSync(outDir)) {
|
|
2757
|
+
if (FRAME_NAME.test(f)) unlinkSync(join(outDir, f));
|
|
2758
|
+
}
|
|
2475
2759
|
// The scene filter is what drops near-duplicates: it emits a frame only when
|
|
2476
2760
|
// enough of the picture changed, which is the difference between twelve
|
|
2477
2761
|
// useful screens and six hundred copies of the same one.
|
|
2478
2762
|
const args2 = [
|
|
2479
|
-
"-hide_banner", "-loglevel", "error",
|
|
2763
|
+
"-y", "-hide_banner", "-loglevel", "error",
|
|
2480
2764
|
"-i", String(args.path),
|
|
2481
2765
|
"-vf", `select='gt(scene,${threshold})',showinfo`,
|
|
2482
2766
|
"-vsync", "vfr",
|
|
@@ -2485,7 +2769,7 @@ async function handleMedia(name, args) {
|
|
|
2485
2769
|
];
|
|
2486
2770
|
const r = spawnSync("ffmpeg", args2, { encoding: "utf8", timeout: 120000 });
|
|
2487
2771
|
if (r.status !== 0) return `${ERROR_PREFIX}ffmpeg failed: ${String(r.stderr || "").slice(0, 200)}`;
|
|
2488
|
-
const frames = readdirSync(outDir).filter((f) =>
|
|
2772
|
+
const frames = readdirSync(outDir).filter((f) => FRAME_NAME.test(f)).sort();
|
|
2489
2773
|
if (!frames.length) {
|
|
2490
2774
|
return `No scene changes above ${threshold} in ${args.path}. A lower threshold keeps more frames.`;
|
|
2491
2775
|
}
|
|
@@ -2556,7 +2840,7 @@ const TOOL_SCHEMAS = new Map(ALL_TOOLS.map((t) => [t.name, t.inputSchema || {}])
|
|
|
2556
2840
|
// number, which is the root cause of the shell-injection class. This enforces
|
|
2557
2841
|
// the declared contract at the one boundary every tool passes through. Zero
|
|
2558
2842
|
// dependencies (no ajv): supports type (string/number/integer/boolean/array/
|
|
2559
|
-
// object), required,
|
|
2843
|
+
// object), required, enum and additionalProperties:false at any depth.
|
|
2560
2844
|
function schemaTypeOk(value, type) {
|
|
2561
2845
|
switch (type) {
|
|
2562
2846
|
// A scalar is a fine string (paths/schemes have always arrived as strings,
|
|
@@ -2613,6 +2897,25 @@ function checkConstraints(key, value, spec) {
|
|
|
2613
2897
|
return `argument '${key}' must be <= ${spec.maximum}`;
|
|
2614
2898
|
}
|
|
2615
2899
|
}
|
|
2900
|
+
// Declared object fields are enforced at every depth, not only at the top
|
|
2901
|
+
// level: a target list whose items declare additionalProperties:false must
|
|
2902
|
+
// refuse a misspelt field inside an item the same way a tool refuses a
|
|
2903
|
+
// misspelt argument.
|
|
2904
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value) && (spec.properties || spec.required)) {
|
|
2905
|
+
for (const req of spec.required || []) {
|
|
2906
|
+
if (value[req] === undefined || value[req] === null) return `argument '${key}' is missing required field: ${req}`;
|
|
2907
|
+
}
|
|
2908
|
+
const props = spec.properties || {};
|
|
2909
|
+
if (spec.additionalProperties === false) {
|
|
2910
|
+
const extra = Object.keys(value).find((k) => !Object.hasOwn(props, k));
|
|
2911
|
+
if (extra) return `argument '${key}' has unexpected field: ${extra}`;
|
|
2912
|
+
}
|
|
2913
|
+
for (const [k, sub] of Object.entries(props)) {
|
|
2914
|
+
if (value[k] === undefined || value[k] === null) continue;
|
|
2915
|
+
const subErr = checkConstraints(`${key}.${k}`, value[k], sub);
|
|
2916
|
+
if (subErr) return subErr;
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2616
2919
|
if (Array.isArray(value)) {
|
|
2617
2920
|
if (Number.isFinite(spec.minItems) && value.length < spec.minItems) {
|
|
2618
2921
|
return `argument '${key}' must have at least ${spec.minItems} item(s)`;
|
|
@@ -2652,6 +2955,12 @@ function validateArgs(name, args) {
|
|
|
2652
2955
|
return `missing required argument: ${req}`;
|
|
2653
2956
|
}
|
|
2654
2957
|
}
|
|
2958
|
+
// Only where a schema says so. Most tools declare no additionalProperties and
|
|
2959
|
+
// keep accepting extras, as they always have.
|
|
2960
|
+
if (schema.additionalProperties === false) {
|
|
2961
|
+
const extra = Object.keys(args).find((k) => !Object.hasOwn(props, k));
|
|
2962
|
+
if (extra) return `unexpected argument: ${extra}`;
|
|
2963
|
+
}
|
|
2655
2964
|
for (const [key, spec] of Object.entries(props)) {
|
|
2656
2965
|
if (args[key] === undefined || args[key] === null) continue;
|
|
2657
2966
|
const err = checkConstraints(key, args[key], spec);
|
|
@@ -2673,6 +2982,7 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
2673
2982
|
"android_list_crashes",
|
|
2674
2983
|
"web_screenshot", "web_get_text",
|
|
2675
2984
|
"design_mock_detect", "design_scenario_inventory", "design_ui_geometry", "design_visual_compare",
|
|
2985
|
+
"design_component_variants",
|
|
2676
2986
|
// Seven of the eight. Every code_* tool reads source and answers; the one
|
|
2677
2987
|
// that is not here stops a cached server.
|
|
2678
2988
|
...CODE_READ_ONLY,
|
|
@@ -2980,7 +3290,13 @@ if (SERVED_TOOLS.length !== ANNOTATED_TOOLS.length) {
|
|
|
2980
3290
|
}
|
|
2981
3291
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: SERVED_TOOLS }));
|
|
2982
3292
|
|
|
2983
|
-
|
|
3293
|
+
// Never written to the offload directory. The query tool reads that directory,
|
|
3294
|
+
// and web_storage_state returns session cookies and localStorage: a file on
|
|
3295
|
+
// disk would outlive the session it authenticates, and the reply is JSON the
|
|
3296
|
+
// caller restores from, which a head+tail window would break.
|
|
3297
|
+
const OFFLOAD_EXEMPT = new Set(["agent_query_output", "web_storage_state"]);
|
|
3298
|
+
|
|
3299
|
+
async function handleCallTool(request, extra) {
|
|
2984
3300
|
const { name, arguments: args } = request.params;
|
|
2985
3301
|
// Long-running tools (xcodebuild, export, validate, install) report progress
|
|
2986
3302
|
// when the client sent a progressToken, and abandon their child process when
|
|
@@ -3068,9 +3384,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
3068
3384
|
return { content: [{ type: "text", text: String(result) }], isError: true };
|
|
3069
3385
|
}
|
|
3070
3386
|
// Prose payloads only: a tool with an outputSchema answers with JSON the
|
|
3071
|
-
// caller parses, and a head+tail summary would break that parse.
|
|
3072
|
-
|
|
3073
|
-
if (!OUTPUT_SCHEMAS[name] && name !== "agent_query_output") {
|
|
3387
|
+
// caller parses, and a head+tail summary would break that parse.
|
|
3388
|
+
if (!OUTPUT_SCHEMAS[name] && !OFFLOAD_EXEMPT.has(name)) {
|
|
3074
3389
|
const { text, offloaded } = offloadLargeText(name, String(result));
|
|
3075
3390
|
if (offloaded) return { content: [{ type: "text", text }] };
|
|
3076
3391
|
return withStructured(name, text);
|
|
@@ -3082,6 +3397,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
3082
3397
|
isError: true,
|
|
3083
3398
|
};
|
|
3084
3399
|
}
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// Counted so a host that closes stdin still gets the answers it is owed before
|
|
3403
|
+
// the server exits.
|
|
3404
|
+
let inFlight = 0;
|
|
3405
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
3406
|
+
inFlight += 1;
|
|
3407
|
+
try {
|
|
3408
|
+
return await handleCallTool(request, extra);
|
|
3409
|
+
} finally {
|
|
3410
|
+
inFlight -= 1;
|
|
3411
|
+
}
|
|
3085
3412
|
});
|
|
3086
3413
|
|
|
3087
3414
|
// Recorder children must not outlive the server: `simctl io recordVideo` has no
|
|
@@ -3093,8 +3420,41 @@ function stopRecorders() {
|
|
|
3093
3420
|
}
|
|
3094
3421
|
}
|
|
3095
3422
|
|
|
3096
|
-
|
|
3097
|
-
|
|
3423
|
+
// One path for every way the server is told to go: children first, then the
|
|
3424
|
+
// browser (bounded, a wedged browser must not keep the process alive), then
|
|
3425
|
+
// the index handle.
|
|
3426
|
+
const BROWSER_CLOSE_BUDGET_MS = 3000;
|
|
3427
|
+
let shuttingDown = null;
|
|
3428
|
+
function shutdown() {
|
|
3429
|
+
shuttingDown ??= (async () => {
|
|
3430
|
+
stopRecorders();
|
|
3431
|
+
shutdownAllLsp();
|
|
3432
|
+
killAllSpawned("SIGTERM");
|
|
3433
|
+
await Promise.race([closeBrowser(), new Promise((r) => setTimeout(r, BROWSER_CLOSE_BUDGET_MS).unref())]);
|
|
3434
|
+
closeContextDb();
|
|
3435
|
+
process.exit(0);
|
|
3436
|
+
})();
|
|
3437
|
+
return shuttingDown;
|
|
3438
|
+
}
|
|
3439
|
+
process.on("SIGTERM", shutdown);
|
|
3440
|
+
process.on("SIGINT", shutdown);
|
|
3441
|
+
|
|
3442
|
+
// A host that closes stdin is gone, and nobody is left to send action:"stop"
|
|
3443
|
+
// or close the browser. An open browser, an LSP server or a recorder would keep
|
|
3444
|
+
// this process alive indefinitely. Recorders stop at once so their files are
|
|
3445
|
+
// finalised; calls already in flight get up to STDIN_END_GRACE_MS to answer,
|
|
3446
|
+
// and then the same shutdown as SIGTERM runs.
|
|
3447
|
+
const STDIN_END_GRACE_MS = 5000;
|
|
3448
|
+
process.stdin.once("end", async () => {
|
|
3449
|
+
stopRecorders();
|
|
3450
|
+
const deadline = Date.now() + STDIN_END_GRACE_MS;
|
|
3451
|
+
while (inFlight > 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
|
|
3452
|
+
// The SDK writes a response after its handler settles; let that write reach
|
|
3453
|
+
// stdout before exiting.
|
|
3454
|
+
await new Promise((r) => setImmediate(r));
|
|
3455
|
+
await new Promise((r) => process.stdout.write("", () => r()));
|
|
3456
|
+
await shutdown();
|
|
3457
|
+
});
|
|
3098
3458
|
|
|
3099
3459
|
const transport = new StdioServerTransport();
|
|
3100
3460
|
await server.connect(transport);
|