@angelky7/dsh-browser-control 0.0.0-stage → 1.1.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/lib/index.js ADDED
@@ -0,0 +1,2587 @@
1
+ import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ import http from "node:http";
6
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
7
+ /**
8
+ * Incremental frame parser over arbitrary TCP chunks. `drain` parses every
9
+ * complete frame currently buffered and keeps the remainder; an oversized or
10
+ * unmasked data frame throws, and the caller closes the link.
11
+ */
12
+ var FrameReader = class {
13
+ buffer = Buffer.alloc(0);
14
+ /** Append one TCP chunk to the parse buffer. */
15
+ push(chunk) {
16
+ this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
17
+ }
18
+ /** Parse and consume every complete frame, invoking `onFrame` per frame in order. */
19
+ drain(onFrame) {
20
+ for (;;) {
21
+ const frame = this.readFrame();
22
+ if (!frame) return;
23
+ onFrame(frame);
24
+ }
25
+ }
26
+ readFrame() {
27
+ const buf = this.buffer;
28
+ if (buf.length < 2) return null;
29
+ const fin = (buf[0] & 128) !== 0;
30
+ const opcode = buf[0] & 15;
31
+ const masked = (buf[1] & 128) !== 0;
32
+ let length = buf[1] & 127;
33
+ let offset = 2;
34
+ if (length === 126) {
35
+ if (buf.length < offset + 2) return null;
36
+ length = buf.readUInt16BE(offset);
37
+ offset += 2;
38
+ } else if (length === 127) {
39
+ if (buf.length < offset + 8) return null;
40
+ const extended = buf.readBigUInt64BE(offset);
41
+ offset += 8;
42
+ if (extended > BigInt(67108864)) throw new Error(`websocket frame too large: ${extended} bytes`);
43
+ length = Number(extended);
44
+ }
45
+ let maskKey = null;
46
+ if (masked) {
47
+ if (buf.length < offset + 4) return null;
48
+ maskKey = buf.subarray(offset, offset + 4);
49
+ offset += 4;
50
+ }
51
+ if (buf.length < offset + length) return null;
52
+ if (!masked && (opcode === 1 || opcode === 0 || opcode === 2)) throw new Error("websocket client sent an unmasked data frame");
53
+ const payload = Buffer.from(buf.subarray(offset, offset + length));
54
+ if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] = payload[i] ^ maskKey[i & 3];
55
+ this.buffer = buf.subarray(offset + length);
56
+ return {
57
+ fin,
58
+ opcode,
59
+ payload
60
+ };
61
+ }
62
+ };
63
+ /** Encode one unmasked text frame carrying a UTF-8 JSON message. */
64
+ function encodeTextFrame(text) {
65
+ const payload = Buffer.from(text, "utf8");
66
+ if (payload.length < 126) return Buffer.from([
67
+ 129,
68
+ payload.length,
69
+ ...payload
70
+ ]);
71
+ if (payload.length < 65536) {
72
+ const header = Buffer.alloc(4);
73
+ header[0] = 129;
74
+ header[1] = 126;
75
+ header.writeUInt16BE(payload.length, 2);
76
+ return Buffer.concat([header, payload]);
77
+ }
78
+ const header = Buffer.alloc(10);
79
+ header[0] = 129;
80
+ header[1] = 127;
81
+ header.writeBigUInt64BE(BigInt(payload.length), 2);
82
+ return Buffer.concat([header, payload]);
83
+ }
84
+ /** Encode one control frame (opcode 0x8 close, 0x9 ping, 0xA pong); payload stays ≤ 125 bytes. */
85
+ function encodeControlFrame(opcode, payload = Buffer.alloc(0)) {
86
+ return Buffer.concat([Buffer.from([128 | opcode, payload.length]), payload]);
87
+ }
88
+ /** Build the close-frame bytes for a status code plus a UTF-8 reason within the 125-byte limit. */
89
+ function encodeCloseFrame(code, reason) {
90
+ const reasonBytes = Buffer.from(reason, "utf8").subarray(0, 123);
91
+ const body = Buffer.alloc(2 + reasonBytes.length);
92
+ body.writeUInt16BE(code, 0);
93
+ reasonBytes.copy(body, 2);
94
+ return encodeControlFrame(8, body);
95
+ }
96
+ /**
97
+ * 会改变页面或不可逆地改变浏览器状态的命令;其余按只读处理。
98
+ *
99
+ * 表里绝大多数是**扩展命令名**(`classify()` 按调用方传进来的名字查表),但也包含
100
+ * 插件本地命令(见下面 `cleanup`):判据始终是同一条——失败之后重试会不会多造成一次损害。
101
+ *
102
+ * 判据是「重试会不会造成额外损害」,不是「有没有动到浏览器」。所以标签分组
103
+ * (`tabs.group` / `tabs.ungroup`)不在这里:把已经在组里的标签再归一次是空操作,
104
+ * 拆组失败后再拆一次也不会多拆坏东西,重试安全;而 `tabs.open` 重试会多出标签页、
105
+ * `tabs.close` 重试可能关掉用户正在看的页面,必须报 unknown。
106
+ */
107
+ const WRITE_COMMANDS = /* @__PURE__ */ new Set([
108
+ "nav",
109
+ "click",
110
+ "input",
111
+ "press",
112
+ "scroll",
113
+ "eval",
114
+ "emulate",
115
+ "tabs.open",
116
+ "tabs.close",
117
+ "tabs.closeMany",
118
+ "tabs.activate",
119
+ "dialog",
120
+ "cleanup",
121
+ "hold"
122
+ ]);
123
+ /** 该命令是否可能留下副作用。 */
124
+ function isWriteCommand(command) {
125
+ return WRITE_COMMANDS.has(command);
126
+ }
127
+ /** 每个 code 对应的下一步建议(写给 agent,不是写给用户看的日志)。 */
128
+ const HINTS = {
129
+ bridge_disabled: "桥接未启用:在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true 后重启 dsh;不要重试本命令。",
130
+ extension_offline: "浏览器扩展没连上:让用户打开装了 DSH Browser Control 的那个浏览器(扩展图标应为绿色),确认端口与 Token 两边一致后再试。",
131
+ command_timeout: "扩展超时未回应:先别重试。用 browser_find / browser_snapshot 看页面当前状态,判断动作是否其实已经生效。",
132
+ link_lost: "命令在途时链路断了:动作可能已经落到页面上。先用只读工具确认页面状态,再决定是否重做。",
133
+ cancelled: "调用被取消:按需重新发起,不必当作错误处理。",
134
+ not_configured: "插件还没读到配置:确认 dsh 已加载本插件(dsh plugin list)后再试。",
135
+ held_by_user: "这是用户在页面上的接管浮层里按下的「中止操作」,不是故障:命令一条都没有发出。先与用户确认;他同意继续之后调用 browser_hold action=off 解除,然后再继续原来的步骤。不要重试,也不要换成别的工具绕过它。",
136
+ blocked_by_policy: "这是本机策略拦下的,不是故障:命令没有发给浏览器(零副作用)。要放开改 profile 的 cordis.patch.yml(browser-bridge 的 readOnly / allowScript)并重启 dsh,或换成本机允许的只读工具;不要原地重试。",
137
+ locked_by_user: "这是用户在扩展弹窗里按下的「锁定」,不是故障:命令一条都没有发出。请先与用户确认,由他在同一个弹窗里点「已锁定」解除;不要重试,也不要试图改配置绕过它。",
138
+ lock_unreadable: "扩展这次读不出锁的状态,按冻结处理了:命令没有发出。下一条命令会重新读一次,所以可以再试一次,但不要循环重试;一直如此就请用户重启浏览器或重装扩展。",
139
+ command_failed: "扩展报回失败:按 error 文本处理,通常是选择器没命中、标签页已关闭或页面不允许该操作。"
140
+ };
141
+ /**
142
+ * 失败文案里「该不该重试」那半句。
143
+ *
144
+ * 宿主的失败内容是 `Error: ${message}`(`@deepseek-ai/dsh-tools` `lib/index.js:3616-3623`),
145
+ * `code` / `effectState` / `hint` 这些结构化字段只进 `error.info` 与 HTTP 面,
146
+ * **模型读不到**。所以决定下一步所需的两个信息——副作用状态与下一步动作——必须写进
147
+ * message 本身,否则整个错误契约对 agent 失效(和「render 只写摘要」是同一类缺陷)。
148
+ */
149
+ function effectClause(effectState) {
150
+ switch (effectState) {
151
+ case "unknown": return "effectState=unknown:动作可能已经落到页面上,先用只读工具看当前状态,再决定要不要重做,不要直接重试";
152
+ case "committed": return "effectState=committed:动作已经生效";
153
+ default: return "effectState=not_started:这次调用没有动到页面,重试安全";
154
+ }
155
+ }
156
+ /** 带分类的桥接错误;`message` 面向模型,`toJSON()` 面向程序。 */
157
+ var BridgeError = class extends Error {
158
+ code;
159
+ effectState;
160
+ hint;
161
+ command;
162
+ /** 原始失败描述:HTTP 面的 `error` 字段就是它,不含我们追加的契约文案。 */
163
+ detail;
164
+ constructor(command, code, effectState, message) {
165
+ super(`[${code}] ${message} — ${effectClause(effectState)}。下一步:${HINTS[code]}`);
166
+ this.name = "BridgeError";
167
+ this.code = code;
168
+ this.effectState = effectState;
169
+ this.hint = HINTS[code];
170
+ this.command = command;
171
+ this.detail = message;
172
+ }
173
+ /** 给 HTTP 面用的扁平 JSON 形状。 */
174
+ toJSON() {
175
+ return {
176
+ ok: false,
177
+ error: this.detail,
178
+ code: this.code,
179
+ effectState: this.effectState,
180
+ hint: this.hint
181
+ };
182
+ }
183
+ };
184
+ /**
185
+ * 把任意底层抛出的错误归类成 BridgeError。已经分类过的原样返回,
186
+ * 保证多次包裹不会把 code 洗掉。
187
+ */
188
+ function classify(command, error) {
189
+ if (error instanceof BridgeError) return error;
190
+ const raw = error instanceof Error ? error.message : String(error);
191
+ const locked = raw.startsWith("browser-locked-by-user:") ? "locked_by_user" : raw.startsWith("browser-lock-unreadable:") ? "lock_unreadable" : raw.startsWith("browser-hold-aborted:") ? "held_by_user" : void 0;
192
+ const markerLength = locked === "locked_by_user" ? 23 : locked === "lock_unreadable" ? 24 : locked === "held_by_user" ? 21 : 0;
193
+ const message = locked ? raw.slice(markerLength).trim() : raw;
194
+ const unknownIsPossible = isWriteCommand(command);
195
+ let code;
196
+ if (locked !== void 0) code = locked;
197
+ else if (/no browser extension connected/.test(message)) code = "extension_offline";
198
+ else if (/disconnected mid-command|browser-bridge stopped/.test(message)) code = "link_lost";
199
+ else if (/timed out after/.test(message)) code = "command_timeout";
200
+ else if (/cancelled/.test(message)) code = "cancelled";
201
+ else if (/尚未加载配置|not configured yet/.test(message)) code = "not_configured";
202
+ else if (/未启用|not enabled|桥接启动失败|listen EADDR|settings integration/.test(message)) code = "bridge_disabled";
203
+ else code = "command_failed";
204
+ return new BridgeError(command, code, code === "command_timeout" || code === "link_lost" ? unknownIsPossible ? "unknown" : "not_started" : code === "command_failed" ? unknownIsPossible ? "unknown" : "not_started" : "not_started", message);
205
+ }
206
+ //#endregion
207
+ //#region lib/types/server.js
208
+ /**
209
+ * Local WebSocket+HTTP bridge between one browser extension and dsh. The
210
+ * extension connects OUT to `ws://127.0.0.1:<port>/ws?token=...`, so no
211
+ * native-messaging host or registry setup exists; dsh drives commands through
212
+ * {@link BridgeServer.execute} and reads link state through {@link BridgeServer.status}.
213
+ * A small HTTP face (`/api/status`, `/api/command`, `/api/cleanup`, `/`) keeps
214
+ * the bridge usable from curl while the server runs. `/api/command` and
215
+ * `/api/cleanup` demand the same token as the WebSocket upgrade; `/` and
216
+ * `/api/status` stay readable because the status page needs them and they only
217
+ * report link state. No response carries `Access-Control-Allow-Origin`, and the
218
+ * upgrade checks `Host` plus `Origin` so neither a web page nor a rebound
219
+ * hostname can attach.
220
+ *
221
+ * Wire protocol (text frames, one JSON object each):
222
+ * - extension → server: `{type:'hello',…}`, `{type:'pong',t}`, `{type:'result',id,ok,result?|error}`
223
+ * - server → extension: `{type:'ping',t}`, `{type:'command',id,command,params}`
224
+ *
225
+ * Command names (sent in `command`) include: `ping`, `browser.info`, `tabs.list/open/close/activate`,
226
+ * `nav`, `eval`, `content`, `find`, `click`, `input`, `press`, `scroll`, `snapshot`, `screenshot`,
227
+ * `wait`, `dialog`, `console.log`, `network.log`, `network.clear`, `pdf`, `emulate`.
228
+ * @module @deepseek-ai/dsh-browser-bridge/server
229
+ */
230
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
231
+ /** Hard ceiling accepted from callers; longer waits cannot be expressed. */
232
+ const MAX_COMMAND_TIMEOUT_MS = 3e5;
233
+ const MAX_HTTP_BODY_BYTES = 2097152;
234
+ /** The published default. It ships in README and `cordis.patch.yml`, so it is public knowledge. */
235
+ const DEFAULT_TOKEN = "dsh-local";
236
+ /** Tokens shorter than this cannot be brute-forced meaningfully; the bridge warns at start. */
237
+ const MIN_RECOMMENDED_TOKEN_LENGTH = 16;
238
+ /** Agent scratch-file convention: `__name.mjs`-style temp artifacts at the top level only. */
239
+ const SCRATCH_FILE_PATTERN = /^__[^/\\]+\.(mjs|cjs|js|png|jpg|jpeg)$/i;
240
+ /**
241
+ * Clear the screenshots directory and delete agent scratch files. Only the
242
+ * top level of each location is touched; nested trees stay untouched.
243
+ * @param options - `shotsDir` is cleared of direct file children; `scratchDir`
244
+ * defaults to the process working directory and loses only scratch-named files.
245
+ * @returns counts and names of what was removed.
246
+ */
247
+ async function cleanupArtifacts(options) {
248
+ await mkdir(options.shotsDir, { recursive: true });
249
+ const shotsEntries = await readdir(options.shotsDir, { withFileTypes: true });
250
+ let shotsRemoved = 0;
251
+ for (const entry of shotsEntries) {
252
+ if (!entry.isFile()) continue;
253
+ await rm(path.join(options.shotsDir, entry.name));
254
+ shotsRemoved += 1;
255
+ }
256
+ const scratchDir = options.scratchDir ?? process.cwd();
257
+ const scratchEntries = await readdir(scratchDir, { withFileTypes: true }).catch(() => []);
258
+ const scratchRemoved = [];
259
+ for (const entry of scratchEntries) {
260
+ if (!entry.isFile() || !SCRATCH_FILE_PATTERN.test(entry.name)) continue;
261
+ await rm(path.join(scratchDir, entry.name));
262
+ scratchRemoved.push(entry.name);
263
+ }
264
+ return {
265
+ shotsRemoved,
266
+ scratchRemoved
267
+ };
268
+ }
269
+ /**
270
+ * One live bridge endpoint. Start/stop may cycle repeatedly on one instance;
271
+ * a fresh listener is built per start so a changed config reuses the class
272
+ * without re-allocation concerns. Exactly one extension link is held at a
273
+ * time; a newer WebSocket replaces the older one.
274
+ */
275
+ var BridgeServer = class {
276
+ options;
277
+ httpServer;
278
+ clientSocket;
279
+ hello;
280
+ connectedAt;
281
+ pending = /* @__PURE__ */ new Map();
282
+ lastError;
283
+ continuationRemainder = "";
284
+ continuationOpen = false;
285
+ constructor(options) {
286
+ this.options = options;
287
+ }
288
+ /** Current link state, also served verbatim as `/api/status`. */
289
+ get status() {
290
+ return {
291
+ listening: this.httpServer !== void 0,
292
+ port: this.httpServer !== void 0 ? this.options.port : void 0,
293
+ extensionConnected: this.clientSocket !== void 0,
294
+ connectedAt: this.connectedAt?.toISOString(),
295
+ hello: this.hello,
296
+ bridgeProtocol: 1,
297
+ extensionProtocol: this.hello?.protocol ?? 0,
298
+ protocolCompatible: this.protocolNote === null,
299
+ protocolNote: this.protocolNote,
300
+ pendingCommands: this.pending.size,
301
+ lastError: this.lastError,
302
+ insecureToken: this.insecureToken
303
+ };
304
+ }
305
+ get insecureToken() {
306
+ return this.options.token === DEFAULT_TOKEN || this.options.token.trim().length < MIN_RECOMMENDED_TOKEN_LENGTH;
307
+ }
308
+ /** Bind `127.0.0.1:<port>` and start accepting the extension plus HTTP calls. */
309
+ async start() {
310
+ if (this.httpServer !== void 0) return;
311
+ const server = http.createServer((req, res) => {
312
+ try {
313
+ this.handleHttp(req, res);
314
+ } catch (error) {
315
+ this.log(`http handler failed: ${errorMessage$1(error)}`);
316
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
317
+ res.end(JSON.stringify({
318
+ ok: false,
319
+ error: "internal error"
320
+ }));
321
+ }
322
+ });
323
+ server.on("upgrade", (req, socket) => {
324
+ try {
325
+ this.handleUpgrade(req, socket);
326
+ } catch (error) {
327
+ this.log(`upgrade failed: ${errorMessage$1(error)}`);
328
+ socket.destroy();
329
+ }
330
+ });
331
+ await new Promise((resolve, reject) => {
332
+ const onError = (error) => {
333
+ server.off("listening", onListening);
334
+ this.lastError = errorMessage$1(error);
335
+ reject(error);
336
+ };
337
+ const onListening = () => {
338
+ server.off("error", onError);
339
+ resolve();
340
+ };
341
+ server.once("error", onError);
342
+ server.once("listening", onListening);
343
+ server.listen(this.options.port, "127.0.0.1");
344
+ });
345
+ this.httpServer = server;
346
+ this.log(`listening on ws://127.0.0.1:${this.options.port}/ws`);
347
+ if (this.insecureToken) this.log(`insecure token (${this.options.token === DEFAULT_TOKEN ? "the published default" : "shorter than 16 chars"}) — any local process can present it; set a longer \`token\` in the browser-bridge config`);
348
+ }
349
+ /** Close the extension link, fail every pending command, and release the port. Idempotent. */
350
+ async stop() {
351
+ const server = this.httpServer;
352
+ if (server === void 0) return;
353
+ this.httpServer = void 0;
354
+ this.closeClientSocket(1001, "server stopping");
355
+ this.failAllPending(/* @__PURE__ */ new Error("browser-bridge stopped"));
356
+ await new Promise((resolve) => {
357
+ server.close(() => resolve());
358
+ });
359
+ this.log("stopped");
360
+ }
361
+ /**
362
+ * Send one command to the connected extension and await its result.
363
+ * Throws while no extension is linked; the rejection message names the fix.
364
+ */
365
+ async execute(command, params, options = {}) {
366
+ const socket = this.clientSocket;
367
+ if (socket === void 0) throw classify(command, /* @__PURE__ */ new Error("no browser extension connected — open the browser that has the DSH Browser Control extension installed"));
368
+ const timeoutMs = Math.min(MAX_COMMAND_TIMEOUT_MS, Math.max(1, options.timeoutMs ?? 6e4));
369
+ const signal = options.signal;
370
+ if (signal?.aborted) throw new Error(`browser command cancelled before send: ${command}`);
371
+ const id = randomUUID();
372
+ const entry = {
373
+ command,
374
+ resolve: () => {},
375
+ reject: () => {},
376
+ timer: setTimeout(() => {
377
+ if (!this.pending.delete(id)) return;
378
+ entry.reject(/* @__PURE__ */ new Error(`browser command timed out after ${timeoutMs}ms: ${command}`));
379
+ }, timeoutMs)
380
+ };
381
+ const result = new Promise((resolve, reject) => {
382
+ entry.resolve = resolve;
383
+ entry.reject = reject;
384
+ });
385
+ if (signal !== void 0) {
386
+ entry.onAbort = () => {
387
+ if (!this.pending.delete(id)) return;
388
+ entry.reject(/* @__PURE__ */ new Error(`browser command cancelled: ${command}`));
389
+ };
390
+ signal.addEventListener("abort", entry.onAbort, { once: true });
391
+ }
392
+ this.pending.set(id, entry);
393
+ this.log(`-> ${command} (${id})`);
394
+ socket.write(encodeTextFrame(JSON.stringify({
395
+ type: "command",
396
+ id,
397
+ command,
398
+ params
399
+ })));
400
+ try {
401
+ return await result;
402
+ } catch (error) {
403
+ throw classify(command, error);
404
+ } finally {
405
+ clearTimeout(entry.timer);
406
+ if (entry.onAbort !== void 0 && signal !== void 0) signal.removeEventListener("abort", entry.onAbort);
407
+ }
408
+ }
409
+ /**
410
+ * Delete generated screenshots and agent scratch files via
411
+ * {@link cleanupArtifacts} using this server's configured directories.
412
+ */
413
+ async cleanup() {
414
+ const result = await cleanupArtifacts({
415
+ shotsDir: this.options.shotsDir,
416
+ ...this.options.scratchDir === void 0 ? {} : { scratchDir: this.options.scratchDir }
417
+ });
418
+ this.log(`cleanup: ${result.shotsRemoved} screenshot(s), ${result.scratchRemoved.length} scratch file(s)`);
419
+ return result;
420
+ }
421
+ log(line) {
422
+ this.options.log?.(line);
423
+ }
424
+ /**
425
+ * Mismatch description, or null when the two sides can talk. An older
426
+ * extension that declares nothing is treated as v1-compatible: it still
427
+ * answers every command this bridge has ever sent, so failing the link would
428
+ * break working installs for a cosmetic gap.
429
+ */
430
+ get protocolNote() {
431
+ if (this.hello === void 0) return null;
432
+ const theirs = this.hello.protocol;
433
+ if (theirs === 0) return null;
434
+ if (theirs > 1) return `扩展声明协议 v${theirs},本桥只支持到 v1 —— 请升级 dsh 插件,否则新命令会报「未知命令」`;
435
+ if (theirs < 1) return `扩展协议 v${theirs} 低于本桥 v1 —— 旧扩展可能缺少新命令,必要时重新下载扩展包`;
436
+ return null;
437
+ }
438
+ /**
439
+ * Whether a request carries the bridge token, the same credential the WebSocket
440
+ * upgrade checks. Accepted forms: `Authorization: Bearer <token>`, `X-Token: <token>`
441
+ * and `?token=<token>` (the query form exists for curl and for the status page).
442
+ */
443
+ authorized(req, url) {
444
+ const authorization = req.headers.authorization;
445
+ const header = typeof authorization === "string" && /^bearer\s+/i.test(authorization) ? authorization.replace(/^bearer\s+/i, "") : typeof req.headers["x-token"] === "string" ? req.headers["x-token"] : "";
446
+ return tokenMatches(header.length > 0 ? header : url.searchParams.get("token") ?? "", this.options.token);
447
+ }
448
+ rejectUnauthenticated(res, route) {
449
+ this.log(`http ${route}: rejected (missing or wrong token)`);
450
+ this.respondJson(res, 401, {
451
+ ok: false,
452
+ error: "unauthorized: pass the bridge token as \"Authorization: Bearer <token>\", \"X-Token: <token>\" or ?token=<token>"
453
+ });
454
+ }
455
+ handleHttp(req, res) {
456
+ const url = new URL(req.url ?? "/", "http://localhost");
457
+ if (req.method === "OPTIONS") {
458
+ res.writeHead(204);
459
+ res.end();
460
+ return;
461
+ }
462
+ if (url.pathname === "/api/status" && req.method === "GET") {
463
+ this.respondJson(res, 200, {
464
+ ok: true,
465
+ ...this.status,
466
+ wsUrl: `ws://127.0.0.1:${this.options.port}/ws`,
467
+ shotsDir: path.resolve(this.options.shotsDir)
468
+ });
469
+ return;
470
+ }
471
+ if (url.pathname === "/api/cleanup" && req.method === "POST") {
472
+ if (!this.authorized(req, url)) {
473
+ this.rejectUnauthenticated(res, "POST /api/cleanup");
474
+ return;
475
+ }
476
+ this.cleanup().then((result) => this.respondJson(res, 200, {
477
+ ok: true,
478
+ ...result
479
+ }), (error) => this.respondJson(res, 500, {
480
+ ok: false,
481
+ error: errorMessage$1(error)
482
+ }));
483
+ return;
484
+ }
485
+ if (url.pathname === "/api/command" && req.method === "POST") {
486
+ if (!this.authorized(req, url)) {
487
+ this.rejectUnauthenticated(res, "POST /api/command");
488
+ return;
489
+ }
490
+ this.handleCommandRequest(req, res);
491
+ return;
492
+ }
493
+ if (url.pathname === "/" && req.method === "GET") {
494
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
495
+ res.end(statusPageHtml(this.options.port));
496
+ return;
497
+ }
498
+ this.respondJson(res, 404, {
499
+ ok: false,
500
+ error: `no route: ${req.method} ${url.pathname}`
501
+ });
502
+ }
503
+ respondJson(res, statusCode, body) {
504
+ res.writeHead(statusCode, {
505
+ "Content-Type": "application/json; charset=utf-8",
506
+ "Cache-Control": "no-store"
507
+ });
508
+ res.end(JSON.stringify(body));
509
+ }
510
+ handleCommandRequest(req, res) {
511
+ const chunks = [];
512
+ let size = 0;
513
+ let aborted = false;
514
+ req.on("data", (chunk) => {
515
+ if (aborted) return;
516
+ size += chunk.length;
517
+ if (size > MAX_HTTP_BODY_BYTES) {
518
+ aborted = true;
519
+ chunks.length = 0;
520
+ this.respondJson(res, 413, {
521
+ ok: false,
522
+ error: "body too large"
523
+ });
524
+ req.resume();
525
+ return;
526
+ }
527
+ chunks.push(chunk);
528
+ });
529
+ req.on("end", () => {
530
+ if (aborted) return;
531
+ let parsed;
532
+ try {
533
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
534
+ } catch (error) {
535
+ this.respondJson(res, 400, {
536
+ ok: false,
537
+ error: `invalid JSON body: ${errorMessage$1(error)}`
538
+ });
539
+ return;
540
+ }
541
+ if (typeof parsed.command !== "string" || parsed.command.length === 0) {
542
+ this.respondJson(res, 400, {
543
+ ok: false,
544
+ error: "body must be {\"command\": \"...\", \"params\": {...}}"
545
+ });
546
+ return;
547
+ }
548
+ const timeoutMs = typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : void 0;
549
+ this.execute(parsed.command, parsed.params ?? {}, timeoutMs === void 0 ? {} : { timeoutMs }).then((result) => this.respondJson(res, 200, {
550
+ ok: true,
551
+ result
552
+ }), (error) => {
553
+ if (error instanceof BridgeError) {
554
+ const status = error.code === "extension_offline" || error.code === "bridge_disabled" || error.code === "not_configured" ? 503 : error.code === "command_timeout" || error.code === "link_lost" ? 504 : 502;
555
+ this.respondJson(res, status, error.toJSON());
556
+ return;
557
+ }
558
+ this.respondJson(res, 502, {
559
+ ok: false,
560
+ error: errorMessage$1(error)
561
+ });
562
+ });
563
+ });
564
+ }
565
+ handleUpgrade(req, socket) {
566
+ const url = new URL(req.url ?? "/", "http://localhost");
567
+ if (url.pathname !== "/ws") {
568
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
569
+ socket.destroy();
570
+ return;
571
+ }
572
+ if (!tokenMatches(url.searchParams.get("token") ?? "", this.options.token)) {
573
+ this.log("websocket rejected: bad token");
574
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
575
+ socket.destroy();
576
+ return;
577
+ }
578
+ if (!isLoopbackHost(String(req.headers.host ?? ""))) {
579
+ this.log(`websocket rejected: host ${req.headers.host}`);
580
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
581
+ socket.destroy();
582
+ return;
583
+ }
584
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
585
+ if (!isTrustedUpgradeOrigin(origin)) {
586
+ this.log(`websocket rejected: origin ${origin}`);
587
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
588
+ socket.destroy();
589
+ return;
590
+ }
591
+ const key = req.headers["sec-websocket-key"];
592
+ if (typeof key !== "string" || String(req.headers.upgrade ?? "").toLowerCase() !== "websocket") {
593
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
594
+ socket.destroy();
595
+ return;
596
+ }
597
+ const accept = createHash("sha1").update(key + WS_GUID).digest("base64");
598
+ socket.write(`HTTP/1.1 101 Switching Protocols\r
599
+ Upgrade: websocket\r
600
+ Connection: Upgrade\r
601
+ Sec-WebSocket-Accept: ${accept}\r\n\r
602
+ `);
603
+ this.closeClientSocket(1e3, "replaced by a newer connection");
604
+ this.clientSocket = socket;
605
+ this.hello = void 0;
606
+ this.connectedAt = /* @__PURE__ */ new Date();
607
+ this.continuationRemainder = "";
608
+ this.continuationOpen = false;
609
+ this.log(`extension connected from ${socket.remoteAddress}:${socket.remotePort}`);
610
+ const reader = new FrameReader();
611
+ socket.on("data", (chunk) => {
612
+ reader.push(chunk);
613
+ try {
614
+ reader.drain((frame) => this.handleFrame(frame));
615
+ } catch (error) {
616
+ this.log(`protocol error: ${errorMessage$1(error)}`);
617
+ this.lastError = errorMessage$1(error);
618
+ this.closeClientSocket(1002, "protocol error");
619
+ }
620
+ });
621
+ socket.on("error", (error) => {
622
+ this.logVerbose(`socket error: ${errorMessage$1(error)}`);
623
+ this.closeClientSocket(1011, "server error");
624
+ });
625
+ socket.on("close", () => {
626
+ if (this.clientSocket !== socket) return;
627
+ this.closeClientSocket(1e3, "");
628
+ });
629
+ }
630
+ handleFrame(frame) {
631
+ switch (frame.opcode) {
632
+ case 1:
633
+ case 0:
634
+ this.handleText(frame.payload.toString("utf8"), frame.fin, frame.opcode);
635
+ return;
636
+ case 8:
637
+ this.closeClientSocket(1e3, "");
638
+ return;
639
+ case 9:
640
+ this.clientSocket?.write(encodeControlFrame(10, frame.payload));
641
+ return;
642
+ default: return;
643
+ }
644
+ }
645
+ handleText(text, fin, opcode) {
646
+ if (opcode === 0 && !this.continuationOpen) return;
647
+ if (opcode === 1) this.continuationRemainder = "";
648
+ this.continuationOpen = opcode === 0 || !fin;
649
+ this.continuationRemainder += text;
650
+ if (!fin) return;
651
+ const full = this.continuationRemainder;
652
+ this.continuationRemainder = "";
653
+ this.continuationOpen = false;
654
+ let msg;
655
+ try {
656
+ msg = JSON.parse(full);
657
+ } catch {
658
+ this.logVerbose("dropped a non-JSON extension message");
659
+ return;
660
+ }
661
+ if (msg.type === "hello") {
662
+ const declared = typeof msg.protocol === "number" ? msg.protocol : 0;
663
+ this.hello = {
664
+ client: typeof msg.client === "string" ? msg.client : "unknown",
665
+ version: typeof msg.version === "string" ? msg.version : "unknown",
666
+ protocol: declared,
667
+ browser: msg.browser ?? {
668
+ name: "unknown",
669
+ version: "unknown",
670
+ ua: ""
671
+ }
672
+ };
673
+ this.log(`hello: client=${this.hello.client} v${this.hello.version} protocol=v${declared || "?"} browser=${this.hello.browser.name} ${this.hello.browser.version}`);
674
+ const note = this.protocolNote;
675
+ if (note !== null) this.log(`protocol mismatch: ${note}`);
676
+ return;
677
+ }
678
+ if (msg.type === "pong") return;
679
+ if (msg.type === "result" && typeof msg.id === "string") {
680
+ const entry = this.pending.get(msg.id);
681
+ if (entry === void 0) {
682
+ this.logVerbose(`result for unknown id ${msg.id}`);
683
+ return;
684
+ }
685
+ this.pending.delete(msg.id);
686
+ clearTimeout(entry.timer);
687
+ if (msg.ok === true) entry.resolve(msg.result);
688
+ else entry.reject(new Error(typeof msg.error === "string" && msg.error.length > 0 ? msg.error : "unknown extension error"));
689
+ }
690
+ }
691
+ closeClientSocket(code, reason) {
692
+ const socket = this.clientSocket;
693
+ if (socket === void 0) return;
694
+ this.clientSocket = void 0;
695
+ this.hello = void 0;
696
+ this.connectedAt = void 0;
697
+ this.failAllPending(/* @__PURE__ */ new Error("the browser extension disconnected mid-command"));
698
+ try {
699
+ socket.write(encodeCloseFrame(code, reason));
700
+ socket.end();
701
+ setTimeout(() => socket.destroy(), 500).unref();
702
+ } catch {
703
+ socket.destroy();
704
+ }
705
+ if (reason.length > 0) this.log(`extension disconnected (${reason})`);
706
+ }
707
+ failAllPending(error) {
708
+ for (const [, entry] of this.pending) {
709
+ clearTimeout(entry.timer);
710
+ entry.reject(error);
711
+ }
712
+ this.pending.clear();
713
+ }
714
+ logVerbose(line) {
715
+ this.options.log?.(line);
716
+ }
717
+ };
718
+ function errorMessage$1(error) {
719
+ return error instanceof Error ? error.message : String(error);
720
+ }
721
+ /** Constant-time token comparison; a length mismatch is the only thing leaked. */
722
+ function tokenMatches(presented, expected) {
723
+ const a = Buffer.from(presented, "utf8");
724
+ const b = Buffer.from(expected, "utf8");
725
+ if (a.length !== b.length) return false;
726
+ return timingSafeEqual(a, b);
727
+ }
728
+ /**
729
+ * `Host` must name the loopback address this server binds. Rejecting anything else is
730
+ * what stops a rebinding setup (a public hostname resolving to 127.0.0.1) from reaching
731
+ * the bridge through the browser.
732
+ */
733
+ function isLoopbackHost(host) {
734
+ const withoutPort = host.replace(/:\d+$/, "");
735
+ return withoutPort === "127.0.0.1" || withoutPort === "localhost" || withoutPort === "[::1]" || withoutPort === "::1";
736
+ }
737
+ /**
738
+ * An `Origin` of a web scheme means a page opened the socket — WebSocket handshakes are
739
+ * not covered by CORS, so that has to be refused here. Extension schemes (`chrome-extension:`
740
+ * etc.) and non-browser clients that send no Origin at all are accepted.
741
+ */
742
+ function isTrustedUpgradeOrigin(origin) {
743
+ if (origin === "") return true;
744
+ if (/^(?:chrome|moz|ms|safari)-extension:\/\//.test(origin)) return true;
745
+ return /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/.test(origin);
746
+ }
747
+ /** Minimal self-refreshing status page with the cleanup action. */
748
+ function statusPageHtml(port) {
749
+ return `<!doctype html>
750
+ <html lang="zh-CN">
751
+ <head>
752
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
753
+ <title>DSH Browser Control — 状态</title>
754
+ <style>
755
+ :root{--bg:#0f172a;--card:#1e293b;--border:#334155;--text:#e2e8f0;--dim:#94a3b8;
756
+ --green:#22c55e;--green-bg:rgba(34,197,94,.12);--red:#ef4444;--red-bg:rgba(239,68,68,.10)}
757
+ *{box-sizing:border-box;margin:0;padding:0}
758
+ body{font-family:system-ui,"Segoe UI","Microsoft YaHei",sans-serif;background:var(--bg);
759
+ color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
760
+ .card{background:var(--card);border:1px solid var(--border);border-radius:16px;max-width:440px;width:100%;overflow:hidden}
761
+ .head{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px}
762
+ .dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;background:var(--red)}
763
+ .dot.on{background:var(--green);box-shadow:0 0 6px var(--green)}
764
+ .head h1{font-size:15px;font-weight:600}.head small{color:var(--dim);font-size:11px}
765
+ .rows{padding:16px 24px}
766
+ .row{display:flex;justify-content:space-between;align-items:baseline;padding:7px 0;
767
+ border-bottom:1px solid var(--border);font-size:13px}
768
+ .row:last-child{border-bottom:none}
769
+ .row .k{color:var(--dim)}.row .v{font-weight:500;text-align:right;max-width:260px;word-break:break-all}
770
+ .badge{display:inline-block;padding:2px 8px;border-radius:9999px;font-size:11px;font-weight:600}
771
+ .badge.on{background:var(--green-bg);color:var(--green)}.badge.off{background:var(--red-bg);color:var(--red)}
772
+ .actions{padding:8px 24px;display:flex;gap:8px}
773
+ .btn{padding:7px 14px;border:1px solid var(--border);border-radius:9px;background:var(--card);
774
+ color:var(--text);font-size:12px;cursor:pointer}.btn:hover{background:#334155}
775
+ .btn.danger{border-color:var(--red);color:var(--red)}.btn.danger:hover{background:var(--red-bg)}
776
+ .tok{flex:1;min-width:150px;padding:7px 10px;border:1px solid var(--border);border-radius:9px;
777
+ background:var(--bg);color:var(--text);font-size:12px}
778
+ .hint{color:var(--dim);font-size:11px;padding:4px 24px 8px}
779
+ .pre{background:var(--bg);border-top:1px solid var(--border);padding:12px 24px}
780
+ .pre code{color:var(--dim);font-size:11px;white-space:pre-wrap;word-break:break-all}
781
+ .foot{text-align:center;padding:12px;font-size:11px;color:var(--dim)}
782
+ </style>
783
+ </head>
784
+ <body>
785
+ <div class="card">
786
+ <div class="head">
787
+ <div class="dot" id="dot"></div>
788
+ <div><h1>DSH Browser Control</h1><small id="sub">加载中…</small></div>
789
+ </div>
790
+ <div class="rows" id="rows"></div>
791
+ <div class="actions">
792
+ <input class="tok" id="token" type="password" placeholder="桥接 Token(清理按钮需要)" autocomplete="off">
793
+ <button class="btn" onclick="refresh()">刷新</button>
794
+ <button class="btn danger" id="cleanup">🧹 清理截图 + 脚本草稿</button>
795
+ </div>
796
+ <div class="hint">Token 只保存在本次浏览器会话(sessionStorage),不写盘;留空则点按钮时询问。/api/command 与 /api/cleanup 现在都要求带 Token。</div>
797
+ <div class="pre"><code id="raw"> </code></div>
798
+ <div class="foot">DSH Browser Bridge · 127.0.0.1:${port}</div>
799
+ </div>
800
+ <script>
801
+ async function refresh(){
802
+ try{
803
+ const r=await fetch('/api/status');const j=await r.json();
804
+ const connected=j.extensionConnected;
805
+ document.getElementById('dot').className='dot'+(connected?' on':'');
806
+ document.getElementById('sub').textContent=connected
807
+ ?(j.hello?j.hello.client+' '+j.hello.browser.name+' '+j.hello.browser.version:'已连接')
808
+ :'等待浏览器扩展连接…';
809
+ const rows=[
810
+ ['监听端口','<span class="badge on">'+j.port+'</span>'],
811
+ ['扩展状态',connected?'<span class="badge on">已连接</span>':'<span class="badge off">未连接</span>'],
812
+ ['扩展类型',j.hello?(j.hello.client+' v'+j.hello.version):'—'],
813
+ ['浏览器',j.hello?(j.hello.browser.name+' '+j.hello.browser.version):'—'],
814
+ ['等待命令',String(j.pendingCommands??0)],
815
+ ['连接时间',j.connectedAt?new Date(j.connectedAt).toLocaleString():'—'],
816
+ ['截图目录',j.shotsDir||'—'],
817
+ ['令牌强度',j.insecureToken?'<span class="badge off">默认/过短,建议改了</span>':'<span class="badge on">已自定义</span>'],
818
+ ['协议版本','桥 v'+j.bridgeProtocol+' / 扩展 v'+(j.extensionProtocol||'?')+(j.protocolCompatible?'':' <span class="badge off">不匹配</span>')],
819
+ ];
820
+ if(j.protocolNote) rows.push(['协议提示','<span style="color:var(--red)">'+j.protocolNote+'</span>']);
821
+ document.getElementById('rows').innerHTML=rows.map(([k,v])=>'<div class="row"><span class="k">'+k+'</span><span class="v">'+v+'</span></div>').join('');
822
+ document.getElementById('raw').textContent=JSON.stringify(j,null,2);
823
+ }catch(e){document.getElementById('sub').textContent='连接失败: '+e.message}
824
+ }
825
+ function bridgeToken(){
826
+ const el=document.getElementById('token');
827
+ let t=(el.value||'').trim()||sessionStorage.getItem('dshBridgeToken')||'';
828
+ if(!t){ t=(prompt('桥接 Token(在 profile 的 browser-bridge 配置里,token 字段)')||'').trim(); el.value=t; }
829
+ if(t) sessionStorage.setItem('dshBridgeToken',t);
830
+ return t;
831
+ }
832
+ document.getElementById('cleanup').onclick=async()=>{
833
+ if(!confirm('确认清理截图与临时脚本?'))return;
834
+ const r=await fetch('/api/cleanup',{method:'POST',headers:{'Authorization':'Bearer '+bridgeToken()}});
835
+ const j=await r.json();
836
+ if(r.status===401){alert('Token 不对,清理没有执行。');return;}
837
+ alert(j.ok?('已删除 '+j.shotsRemoved+' 个截图,'+j.scratchRemoved.length+' 个草稿'):('失败:'+j.error));
838
+ refresh();
839
+ };
840
+ refresh();setInterval(refresh,2000);
841
+ <\/script>
842
+ </body></html>`;
843
+ }
844
+ //#endregion
845
+ //#region lib/types/policy.js
846
+ /**
847
+ * 本机策略闸门:只读档(`readOnly`)与脚本开关(`allowScript`)。
848
+ *
849
+ * 这两张表与 `errors.ts` 里的 `WRITE_COMMANDS` **故意不是同一张**,别合并:
850
+ *
851
+ * - `WRITE_COMMANDS` 回答「超时或掉线之后重试,会不会多留一次副作用」,那是
852
+ * `effectState` 的语义。所以 `tabs.group` / `tabs.ungroup` 在那里**刻意算只读**:
853
+ * 重复把标签归进同一个组是空操作,重试安全。
854
+ * - 这里回答的是「这条命令会不会改变页面、文件或浏览器状态」,用户开只读档时想要的
855
+ * 正是这个。所以 `tabs.group` / `tabs.ungroup` / `tabs.activate` / `emulate` /
856
+ * `dialog` / `network.clear` 都不在白名单里。
857
+ *
858
+ * 白名单是**默认拒绝**:扩展以后新增一条命令,它自动被只读档拦下,等人判断完再放进来。
859
+ * `verify/verify_policy.mjs` 会把扩展 `COMMANDS` 表逐条对账,让这个判断无法被跳过。
860
+ *
861
+ * 第三张表 `LOCAL_DESTRUCTIVE_COMMANDS` 是给**不经过扩展**的插件本地命令的:闸门如果只长在
862
+ * `execute()` 上,这类调用就整个绕开只读档(`browser_cleanup` 合并进来时正是这样)。
863
+ */
864
+ /** 只读档允许的命令:读页面、读扩展自己的缓冲、或者把页面**内容**落到本机文件。 */
865
+ const READ_ONLY_ALLOWED = /* @__PURE__ */ new Set([
866
+ "ping",
867
+ "browser.info",
868
+ "content",
869
+ "find",
870
+ "snapshot",
871
+ "screenshot",
872
+ "pdf",
873
+ "wait",
874
+ "tabs.list",
875
+ "console.log",
876
+ "network.log"
877
+ ]);
878
+ /**
879
+ * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
880
+ * 也可以点,但别在我页面里跑任意 JS」——`browser_evaluate` 能绕开其他所有工具做的事。
881
+ */
882
+ const SCRIPT_COMMANDS = /* @__PURE__ */ new Set(["eval"]);
883
+ /**
884
+ * 不经过扩展、直接在插件进程里改动本机文件的命令名。
885
+ *
886
+ * `browser_cleanup` 走 `BridgeController.cleanup()`,**不经过 `execute()`**,所以它天然
887
+ * 绕开了上面两张表管的那条路。只读档下仍然能删掉用户的整个截图目录,这正是「只读」
888
+ * 最不该允许的动作,所以单独列一张表,并且由 `cleanup()` 自己调闸门。
889
+ */
890
+ const LOCAL_DESTRUCTIVE_COMMANDS = /* @__PURE__ */ new Set(["cleanup"]);
891
+ /**
892
+ * 明确登记「会改页面 DOM」的命令 —— 只读档里必须拒,理由写在名字旁边。
893
+ *
894
+ * 现在只有 `hold`(接管浮层):它往页面 `<html>` 下插一个宿主元素。默认拒绝其实已经
895
+ * 盖住它(不在 `READ_ONLY_ALLOWED` 里),但「默认拒绝是对的」不等于「不需要解释」:
896
+ * 这一条要的不是安全性,是**拒绝理由**——告诉模型为什么一个「只是提示用户」的动作
897
+ * 在只读档里也算写操作。
898
+ *
899
+ * `hold` 是一条命令带 `action`(on/off/status):on/off 改 DOM,**status 只读**
900
+ * (它读 session 里的条目 + 探一下页面上还有没有那层宿主),所以只读档放行 status。
901
+ * 这也是 `policyRefusal` 唯一需要看参数的地方——判据是「这个动作改不改东西」,
902
+ * 不是「这个命令名字在不在表里」。
903
+ */
904
+ const PAGE_MUTATING_COMMANDS = /* @__PURE__ */ new Set(["hold"]);
905
+ const CONFIG_HINT = "开关在 profile 的 cordis.patch.yml 里(browser-bridge 的 config),改完重启 dsh;dsh 0.1.7+ 的宿主也会在「设置 → 插件」里直接列出这两项";
906
+ /**
907
+ * 判断一条命令是否被本机策略拦下。
908
+ * @param command - 扩展命令名(`click` / `eval` / `tabs.close` …),或 `LOCAL_DESTRUCTIVE_COMMANDS` 里的插件本地命令名。
909
+ * @param flags - 当前的两个闸门值。
910
+ * @param params - 这条命令的参数;只有带动作的命令(目前是 `hold`)需要它来分辨「这个动作改不改东西」。
911
+ * @returns 拒绝时写给 agent 的整句理由;放行时 `undefined`。
912
+ */
913
+ function policyRefusal(command, flags, params) {
914
+ if (!flags.allowScript && SCRIPT_COMMANDS.has(command)) return `在页面里执行任意脚本已被关闭(allowScript: false):命令 ${command} 被拒绝,没有向浏览器发出任何操作。要读页面内容请改用 browser_read / browser_snapshot / browser_find;确实需要执行脚本时,${CONFIG_HINT}之后再说。`;
915
+ if (flags.readOnly && LOCAL_DESTRUCTIVE_COMMANDS.has(command)) return `只读模式已开启(readOnly: true):命令 ${command} 会删除本机保存的截图与临时文件,删除不可逆,所以已被拒绝,没有任何文件被删除。只读档的含义是「什么都不要改」,清理属于改动;要释放磁盘请由用户自己关掉 readOnly,${CONFIG_HINT}。不要原地重试本命令。`;
916
+ const isReadOnlyAction = command === "hold" && params?.action === "status";
917
+ if (flags.readOnly && PAGE_MUTATING_COMMANDS.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会往页面里插入一层接管浮层(改页面 DOM),已被拒绝,没有向浏览器发出任何操作。浮层是给用户看的提示层,不是安全边界;只读档里连这种「只是提示」的改动也一并拒绝。要开浮层请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
918
+ if (flags.readOnly && !READ_ONLY_ALLOWED.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会改变页面或浏览器状态,已被拒绝,没有向浏览器发出任何操作。只读模式下可用的工具是读页面与读日志那几类;要放开写操作,${CONFIG_HINT}不要原地重试本命令。`;
919
+ }
920
+ //#endregion
921
+ //#region lib/types/index.js
922
+ /**
923
+ * Browser-bridge plugin: one local WebSocket endpoint the DSH Browser Control
924
+ * extension connects to, plus the model-facing `browser_*` tools that drive
925
+ * it. The Settings-managed `enabled` flag starts and stops the listener live
926
+ * through dsh-settings' change hook — no reload needed.
927
+ *
928
+ * Settings integration is feature-detected, never assumed. The plugin bypasses the
929
+ * higher-level `installSettingsSection` helper (importing it on a build that does not
930
+ * export it crashes the plugin at module load) and calls the lower-level
931
+ * `sctx.settings.register` only when the host actually has it — dsh-settings up to
932
+ * 0.1.2-rc.1. 0.1.7-rc.2 and 0.2.x export `SettingsForms` with no `register`, so there
933
+ * the bridge converges from the profile config alone and the host projects this
934
+ * plugin's own `Config` schema into the settings page.
935
+ *
936
+ * Tools stay mounted whenever the plugin does; calling one while the bridge
937
+ * is disabled or the extension is offline fails with a message naming the
938
+ * fix, so the model can tell the user what to do instead of hanging.
939
+ * @module @deepseek-ai/dsh-browser-bridge
940
+ */
941
+ /** Cordis plugin name used by loader diagnostics. */
942
+ const name = "browser-bridge";
943
+ /** The tool registry this plugin contributes `browser_*` tools to. */
944
+ const inject = ["tools", "systemPrompt"];
945
+ /** Settings namespace carrying the bridge switch and endpoint options. */
946
+ const BROWSER_BRIDGE_SETTINGS_NAMESPACE = "browser-bridge";
947
+ const Config = z.object({
948
+ enabled: z.boolean().default(true),
949
+ port: z.number().step(1).min(1024).max(65535).default(9777),
950
+ token: z.string().default("dsh-local"),
951
+ shotsDir: z.string().default("dsh-browser-shots"),
952
+ readOnly: z.boolean().default(false),
953
+ allowScript: z.boolean().default(true)
954
+ });
955
+ const SNAPSHOT_REF_SELECTOR_PATTERN = /^e\d+$/;
956
+ /** One `browser_read` window, in characters of page text. Longer pages are paged
957
+ * with `offset`, so no single call can put a whole site into the transcript. */
958
+ const READ_WINDOW_MAX_CHARS = 2e4;
959
+ /**
960
+ * Owns zero or one live {@link BridgeServer} and restarts it whenever the
961
+ * resolved settings change. Reconciles serialize through a promise chain so a
962
+ * burst of settings commits cannot interleave stop/start pairs.
963
+ */
964
+ var BridgeController = class {
965
+ log;
966
+ server;
967
+ serverKey = "";
968
+ lastError;
969
+ chain = Promise.resolve();
970
+ current;
971
+ constructor(log) {
972
+ this.log = log;
973
+ }
974
+ /** Resolved directory screenshots land in; defined once any config arrived. */
975
+ get shotsDir() {
976
+ return this.current === void 0 ? void 0 : path.resolve(this.current.shotsDir);
977
+ }
978
+ /**
979
+ * Converge the live server onto `config`. With `throwOnError`, an initial
980
+ * start failure rejects (fail-loud activation); later changes record the
981
+ * failure instead, so a bad port cannot tear down an otherwise running session.
982
+ * @param config - the freshly resolved settings snapshot.
983
+ * @param options - set `throwOnError` only for the activation-time call.
984
+ * @returns a promise settling once the convergence attempt finished.
985
+ */
986
+ reconcile(config, options = {}) {
987
+ this.current = config;
988
+ const run = this.chain.then(() => this.reconcileNow(config));
989
+ this.chain = run.catch(() => {});
990
+ if (options.throwOnError === true) return run.catch((error) => {
991
+ throw error instanceof Error ? error : new Error(String(error));
992
+ });
993
+ return Promise.resolve();
994
+ }
995
+ async reconcileNow(config) {
996
+ const shotsDir = path.resolve(config.shotsDir);
997
+ const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : "";
998
+ if (key === this.serverKey) return;
999
+ const previous = this.server;
1000
+ this.server = void 0;
1001
+ this.serverKey = "";
1002
+ await previous?.stop();
1003
+ if (!config.enabled) {
1004
+ this.lastError = void 0;
1005
+ return;
1006
+ }
1007
+ const server = new BridgeServer({
1008
+ port: config.port,
1009
+ token: config.token,
1010
+ shotsDir,
1011
+ log: this.log
1012
+ });
1013
+ try {
1014
+ await server.start();
1015
+ } catch (error) {
1016
+ const message = error instanceof Error ? error.message : String(error);
1017
+ this.lastError = `桥接启动失败(端口 ${config.port}): ${message}`;
1018
+ this.log(this.lastError);
1019
+ throw error instanceof Error ? error : new Error(message);
1020
+ }
1021
+ this.server = server;
1022
+ this.serverKey = key;
1023
+ this.lastError = void 0;
1024
+ }
1025
+ /**
1026
+ * Run one extension command over the live link.
1027
+ * @param command - extension command name (`nav`, `click`, …).
1028
+ * @param params - wire params passed through to the extension.
1029
+ * @param signal - tool-execution cancellation propagated to the pending command.
1030
+ * @param timeoutMs - per-command bridge ceiling; omit for the default. Commands
1031
+ * that wait inside the page (browser_wait) must pass a value larger than their
1032
+ * own page-level timeout, otherwise the bridge aborts first and the caller
1033
+ * cannot tell a slow page from a dead link.
1034
+ * @returns the extension's result payload verbatim.
1035
+ */
1036
+ async execute(command, params, signal, timeoutMs) {
1037
+ this.policyGate(command, params);
1038
+ const server = this.server;
1039
+ if (server === void 0) throw classify(command, new Error(this.lastError ?? "浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)"));
1040
+ return server.execute(command, params, timeoutMs === void 0 ? { signal } : {
1041
+ signal,
1042
+ timeoutMs
1043
+ });
1044
+ }
1045
+ /**
1046
+ * Refuse a command the local policy forbids, before anything is sent or deleted.
1047
+ *
1048
+ * The gate sits above the link and above the filesystem, not inside either: a
1049
+ * refused command must not leave a side effect behind. Reading `current` on every
1050
+ * call is what makes a settings edit take effect live.
1051
+ * @param command - extension command name, or a `LOCAL_DESTRUCTIVE_COMMANDS` name.
1052
+ * @param params - the command's params: action-bearing commands (`hold`) need them so a
1053
+ * read-only action (`status`) is not judged by the command name alone.
1054
+ */
1055
+ policyGate(command, params) {
1056
+ const refusal = policyRefusal(command, {
1057
+ readOnly: this.current?.readOnly === true,
1058
+ allowScript: this.current?.allowScript !== false
1059
+ }, params);
1060
+ if (refusal !== void 0) throw new BridgeError(command, "blocked_by_policy", "not_started", refusal);
1061
+ }
1062
+ /**
1063
+ * Delete generated artifacts using the currently resolved directories;
1064
+ * works while the bridge is stopped because it never touches the socket.
1065
+ * @returns counts and names of what was removed.
1066
+ */
1067
+ async cleanup() {
1068
+ this.policyGate("cleanup");
1069
+ const dir = this.shotsDir;
1070
+ if (dir === void 0) throw classify("cleanup", /* @__PURE__ */ new Error("browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录"));
1071
+ try {
1072
+ return await cleanupArtifacts({ shotsDir: dir });
1073
+ } catch (error) {
1074
+ throw classify("cleanup", error);
1075
+ }
1076
+ }
1077
+ /** Stop the listener; safe to call repeatedly and during teardown. */
1078
+ stop() {
1079
+ const previous = this.server;
1080
+ this.server = void 0;
1081
+ this.serverKey = "";
1082
+ return previous?.stop() ?? Promise.resolve();
1083
+ }
1084
+ };
1085
+ function errorMessage(error) {
1086
+ return error instanceof Error ? error.message : String(error);
1087
+ }
1088
+ /**
1089
+ * Cap one rendered payload and *say* it was cut. The previous fixed slices
1090
+ * (200 / 300 / 400 / 600 / 1200 chars) cut JSON mid-token, so the model received
1091
+ * malformed JSON with no sign that anything was missing. Click/type/press/scroll
1092
+ * results are well under this cap, so they now arrive complete; bigger payloads
1093
+ * are cut at a character boundary and marked twice: once for a human reading the
1094
+ * card, once as a stable `[renderedTruncated …]` token an agent can parse to
1095
+ * decide whether to re-issue a narrower query.
1096
+ */
1097
+ const MAX_RENDERED_CHARS = 4e3;
1098
+ function clampRendered(text, maxChars = MAX_RENDERED_CHARS) {
1099
+ if (text.length <= maxChars) return text;
1100
+ const omitted = text.length - maxChars;
1101
+ return `${text.slice(0, maxChars)}\n… [renderedTruncated omittedChars=${omitted} limitChars=${maxChars}] narrow the query and call again to see the rest`;
1102
+ }
1103
+ /** Model-facing budget for one snapshot listing; `limit` narrows the listing. */
1104
+ const SNAPSHOT_RENDER_MAX_CHARS = 8e3;
1105
+ /** Model-facing budget for one console/network log listing. */
1106
+ const LOG_RENDER_MAX_CHARS = 6e3;
1107
+ /** Collapse one page-supplied field to a single line inside a rendered listing. */
1108
+ function flattenOneLine(value, maxChars) {
1109
+ const flat = value.replace(/\s+/g, " ").trim();
1110
+ return flat.length <= maxChars ? flat : `${flat.slice(0, maxChars)}…`;
1111
+ }
1112
+ /** `HH:MM:SS` in UTC from an epoch-milliseconds stamp. */
1113
+ function formatClock(epochMs) {
1114
+ return new Date(epochMs).toISOString().slice(11, 19);
1115
+ }
1116
+ function formatBytes(bytes) {
1117
+ if (bytes < 1024) return `${bytes} B`;
1118
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
1119
+ return `${(bytes / 1048576).toFixed(1)} MB`;
1120
+ }
1121
+ /**
1122
+ * Reduce one captured network row to the fields this tool declares. The buffer
1123
+ * also carries request/response header maps and POST bodies; those stay out of
1124
+ * the value, because the value never reaches the model and the header dump is
1125
+ * what would blow the transcript.
1126
+ */
1127
+ function networkRow(row) {
1128
+ const out = {
1129
+ method: typeof row.method === "string" ? row.method : "GET",
1130
+ url: flattenOneLine(typeof row.url === "string" ? row.url : "", 200)
1131
+ };
1132
+ if (typeof row.resourceType === "string") out.resourceType = row.resourceType;
1133
+ if (typeof row.status === "number") out.status = row.status;
1134
+ if (typeof row.statusText === "string" && row.statusText.length > 0) out.statusText = flattenOneLine(row.statusText, 60);
1135
+ if (typeof row.mimeType === "string") out.mimeType = row.mimeType;
1136
+ if (typeof row.encodedDataLength === "number") out.bytes = row.encodedDataLength;
1137
+ if (typeof row.wallTime === "number") out.time = formatClock(row.wallTime * 1e3);
1138
+ if (row.finished === true) out.finished = true;
1139
+ if (row.failed === true) out.failed = true;
1140
+ if (row.canceled === true) out.canceled = true;
1141
+ if (typeof row.errorText === "string" && row.errorText.length > 0) out.errorText = flattenOneLine(row.errorText, 120);
1142
+ return out;
1143
+ }
1144
+ /**
1145
+ * Resolve the element target a click/type tool received.
1146
+ * @param args - validated tool arguments carrying at most one targeting field.
1147
+ * @returns the CSS selector to send on the wire, refs translated to their attribute form.
1148
+ */
1149
+ function targetSelector(args) {
1150
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
1151
+ const hasRef = typeof args.ref === "string" && args.ref.length > 0;
1152
+ if (hasSelector === hasRef) throw new Error("provide exactly one of selector or ref (ref comes from browser_snapshot)");
1153
+ if (hasRef) {
1154
+ const ref = args.ref;
1155
+ if (!SNAPSHOT_REF_SELECTOR_PATTERN.test(ref)) throw new Error(`invalid ref: ${ref}`);
1156
+ return `[data-dsh-ref="${ref}"]`;
1157
+ }
1158
+ return args.selector;
1159
+ }
1160
+ function requireTabId(args, action) {
1161
+ if (typeof args.tabId !== "number") throw new Error(`${action} requires tabId`);
1162
+ return args.tabId;
1163
+ }
1164
+ /**
1165
+ * Every tab the caller asked to close. `tabId` and `tabIds` are both accepted
1166
+ * (a batch call that repeats one id, or passes both, must not close it twice),
1167
+ * and the ids are deduplicated and normalised before they leave the host.
1168
+ */
1169
+ function closeTabIds(args) {
1170
+ const asked = [...Array.isArray(args.tabIds) ? args.tabIds : [], ...typeof args.tabId === "number" ? [args.tabId] : []];
1171
+ const ids = [...new Set(asked.map(Number).filter((id) => Number.isInteger(id) && id > 0))];
1172
+ if (ids.length === 0) throw new Error("close requires tabId or tabIds");
1173
+ return ids;
1174
+ }
1175
+ /** Write one screenshot payload to the shots directory and return its durable location. */
1176
+ async function saveScreenshot(controller, payload) {
1177
+ const dir = controller.shotsDir;
1178
+ if (dir === void 0) throw new Error("browser-bridge is not configured yet");
1179
+ await mkdir(dir, { recursive: true });
1180
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1181
+ const random = Math.random().toString(36).slice(2, 6);
1182
+ const file = path.join(dir, `${stamp}-${random}.${payload.format === "jpeg" ? "jpg" : "png"}`);
1183
+ const buffer = Buffer.from(payload.base64, "base64");
1184
+ await writeFile(file, buffer);
1185
+ return {
1186
+ file,
1187
+ bytes: buffer.length,
1188
+ tabId: payload.tabId,
1189
+ title: payload.tabTitle,
1190
+ url: payload.tabUrl
1191
+ };
1192
+ }
1193
+ /** Write one PDF payload to `path` (absolute or relative to `controller.shotsDir`)
1194
+ * and return the absolute path + size. Mirrors saveScreenshot's contract. */
1195
+ async function savePdf(controller, payload, requestedPath) {
1196
+ const dir = controller.shotsDir;
1197
+ if (dir === void 0) throw new Error("browser-bridge is not configured yet");
1198
+ let file;
1199
+ if (requestedPath && path.isAbsolute(requestedPath)) {
1200
+ file = requestedPath;
1201
+ await mkdir(path.dirname(file), { recursive: true });
1202
+ } else {
1203
+ await mkdir(dir, { recursive: true });
1204
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1205
+ const random = Math.random().toString(36).slice(2, 6);
1206
+ const name = requestedPath ? path.basename(requestedPath) : `${stamp}-${random}.pdf`;
1207
+ file = path.join(dir, name);
1208
+ }
1209
+ const buffer = Buffer.from(payload.base64, "base64");
1210
+ await writeFile(file, buffer);
1211
+ return {
1212
+ file,
1213
+ bytes: buffer.length,
1214
+ tabId: payload.tabId,
1215
+ ...payload.tabTitle === void 0 ? {} : { title: payload.tabTitle },
1216
+ ...payload.tabUrl === void 0 ? {} : { url: payload.tabUrl }
1217
+ };
1218
+ }
1219
+ /** Register every `browser_*` tool; each is a thin adapter over one extension command. */
1220
+ function applyBrowserTools(ctx, controller) {
1221
+ ctx.systemPrompt.section({
1222
+ name: "tool:browser",
1223
+ order: 112,
1224
+ text: "The browser_* tools drive the user's real, logged-in browser through the DSH Browser Control extension; they act on the active tab unless a tabId is passed. Prefer browser_snapshot first on unfamiliar pages: it numbers interactive elements, and browser_click/browser_type accept the returned ref instead of guessing CSS selectors. browser_read extracts page text in a window (offset/limit) and reports how much text is left, browser_screenshot saves a PNG/JPEG and returns its file path (view it with an image tool). Calls fail with actionable copy while the bridge is disabled or no browser is connected. When a result is cut to bound token cost it is marked \"[renderedTruncated omittedChars=N limitChars=L]\" — re-issue a narrower query (smaller limit, a pattern filter, or browser_read's offset) instead of assuming the page is shorter than it is."
1225
+ });
1226
+ ctx.tools.register(defineTool({
1227
+ name: "browser_navigate",
1228
+ description: "Navigate a browser tab to a URL and wait for the page load to settle.",
1229
+ parameters: {
1230
+ url: {
1231
+ type: "string",
1232
+ required: true,
1233
+ description: "Absolute URL to open in the tab."
1234
+ },
1235
+ tabId: {
1236
+ type: "number",
1237
+ description: "Target tab; defaults to the active tab."
1238
+ }
1239
+ },
1240
+ output: {
1241
+ schema: {
1242
+ type: "object",
1243
+ additionalProperties: false,
1244
+ properties: {
1245
+ tabId: {
1246
+ type: "number",
1247
+ required: true
1248
+ },
1249
+ url: { type: "string" },
1250
+ title: { type: "string" },
1251
+ pendingUrl: { type: "string" },
1252
+ loadPending: { type: "boolean" },
1253
+ siteUnreachable: { type: "boolean" }
1254
+ }
1255
+ },
1256
+ render: (_args, value) => {
1257
+ const label = [value.title, value.url].filter((part) => typeof part === "string" && part.length > 0).join(" — ");
1258
+ const shows = label.length > 0 ? label : "(untitled)";
1259
+ if (value.siteUnreachable === true) return [{
1260
+ type: "text",
1261
+ text: `Tab ${value.tabId} could not load ${String(value.url ?? "")} — the browser landed on its own error page (DNS failure or no connection), so browser_read/browser_snapshot return that error page, not the site.`
1262
+ }];
1263
+ if (value.loadPending === true) {
1264
+ const to = typeof value.pendingUrl === "string" && value.pendingUrl.length > 0 ? value.pendingUrl : "the requested URL";
1265
+ return [{
1266
+ type: "text",
1267
+ text: `Tab ${value.tabId} is still loading ${to} — the load had not settled when the wait budget ran out, so the tab may still be showing ${shows}. Re-check with browser_read before acting on it (or raise timeoutMs).`
1268
+ }];
1269
+ }
1270
+ if (typeof value.pendingUrl === "string" && value.pendingUrl.length > 0) return [{
1271
+ type: "text",
1272
+ text: `Tab ${value.tabId} shows ${shows} and is navigating to ${value.pendingUrl}`
1273
+ }];
1274
+ return [{
1275
+ type: "text",
1276
+ text: `Tab ${value.tabId} now shows ${shows}`
1277
+ }];
1278
+ }
1279
+ },
1280
+ isConcurrencySafe: () => false,
1281
+ presentCall: (args) => ({
1282
+ card: "generic",
1283
+ title: `Open ${args.url}`,
1284
+ kind: "other"
1285
+ }),
1286
+ async execute(args, exec) {
1287
+ const params = { url: args.url };
1288
+ if (args.tabId !== void 0) params.tabId = args.tabId;
1289
+ const raw = await controller.execute("nav", params, exec.signal);
1290
+ const out = { tabId: raw.tabId };
1291
+ if (typeof raw.url === "string") out.url = raw.url;
1292
+ if (typeof raw.title === "string") out.title = raw.title;
1293
+ if (typeof raw.pendingUrl === "string") out.pendingUrl = raw.pendingUrl;
1294
+ if (raw.loadPending === true) out.loadPending = true;
1295
+ if (raw.siteUnreachable === true || typeof raw.siteUnreachable === "object" && raw.siteUnreachable !== null) out.siteUnreachable = true;
1296
+ return out;
1297
+ }
1298
+ }));
1299
+ ctx.tools.register(defineTool({
1300
+ name: "browser_read",
1301
+ description: "Read the current page: title, URL and the page text itself (or full HTML) in a window you control with offset/limit. The returned text is what you see — no separate fetch step is needed.",
1302
+ parameters: {
1303
+ tabId: {
1304
+ type: "number",
1305
+ description: "Target tab; defaults to the active tab."
1306
+ },
1307
+ mode: {
1308
+ type: "string",
1309
+ description: "\"text\" (default) for visible text, \"html\" for the whole document."
1310
+ },
1311
+ offset: {
1312
+ type: "number",
1313
+ description: `Character index the window starts at; default 0. Use the "call again with offset=" value the previous result reported.`
1314
+ },
1315
+ limit: {
1316
+ type: "number",
1317
+ description: `Characters to return; default and cap ${READ_WINDOW_MAX_CHARS}.`
1318
+ }
1319
+ },
1320
+ output: {
1321
+ schema: {
1322
+ type: "object",
1323
+ additionalProperties: false,
1324
+ properties: {
1325
+ tabId: {
1326
+ type: "number",
1327
+ required: true
1328
+ },
1329
+ mode: {
1330
+ type: "string",
1331
+ required: true
1332
+ },
1333
+ title: {
1334
+ type: "string",
1335
+ required: true
1336
+ },
1337
+ url: {
1338
+ type: "string",
1339
+ required: true
1340
+ },
1341
+ content: {
1342
+ type: "string",
1343
+ required: true
1344
+ },
1345
+ truncated: {
1346
+ type: "boolean",
1347
+ required: true
1348
+ },
1349
+ offset: {
1350
+ type: "number",
1351
+ required: true
1352
+ },
1353
+ totalChars: {
1354
+ type: "number",
1355
+ required: true
1356
+ }
1357
+ }
1358
+ },
1359
+ render: (_args, value) => {
1360
+ const end = value.offset + value.content.length;
1361
+ return [{
1362
+ type: "text",
1363
+ text: `${`${value.title} (${value.url}) — ${value.mode} chars ${value.offset}-${end} of ${value.totalChars}` + (value.truncated ? `; call again with offset=${end} to continue` : "")}\n\n${value.content}`
1364
+ }];
1365
+ }
1366
+ },
1367
+ presentCall: () => ({
1368
+ card: "generic",
1369
+ title: "Read browser page",
1370
+ kind: "other"
1371
+ }),
1372
+ async execute(args, exec) {
1373
+ const mode = args.mode === "html" ? "html" : "text";
1374
+ const offset = Math.max(0, Math.floor(Number(args.offset ?? 0)) || 0);
1375
+ const limit = Math.min(READ_WINDOW_MAX_CHARS, Math.max(1, Math.floor(Number(args.limit ?? READ_WINDOW_MAX_CHARS)) || READ_WINDOW_MAX_CHARS));
1376
+ const raw = await controller.execute("content", args.tabId === void 0 ? { mode } : {
1377
+ mode,
1378
+ tabId: args.tabId
1379
+ }, exec.signal);
1380
+ const full = raw.content ?? "";
1381
+ const content = full.slice(offset, offset + limit);
1382
+ return {
1383
+ tabId: raw.tabId,
1384
+ mode,
1385
+ title: raw.title ?? "",
1386
+ url: raw.url ?? "",
1387
+ content,
1388
+ truncated: offset + content.length < full.length,
1389
+ offset,
1390
+ totalChars: full.length
1391
+ };
1392
+ }
1393
+ }));
1394
+ ctx.tools.register(defineTool({
1395
+ name: "browser_snapshot",
1396
+ description: "Inventory the page's interactive elements with stable refs; pass a ref to browser_click/browser_type afterwards. mode:\"ax\" numbers them by Chrome's accessibility tree (role + accessible name), which survives a DOM redesign; the default mode matches CSS selectors.",
1397
+ parameters: {
1398
+ tabId: {
1399
+ type: "number",
1400
+ description: "Target tab; defaults to the active tab."
1401
+ },
1402
+ limit: {
1403
+ type: "number",
1404
+ description: "Max elements returned; defaults to 120, capped at 200."
1405
+ },
1406
+ mode: {
1407
+ type: "string",
1408
+ description: "\"ax\" for the accessibility tree, \"dom\" (default) for the selector list. \"ax\" falls back to \"dom\" with axFallback set if the page gives no a11y tree."
1409
+ }
1410
+ },
1411
+ output: {
1412
+ schema: {
1413
+ type: "object",
1414
+ additionalProperties: false,
1415
+ properties: {
1416
+ tabId: {
1417
+ type: "number",
1418
+ required: true
1419
+ },
1420
+ title: {
1421
+ type: "string",
1422
+ required: true
1423
+ },
1424
+ url: {
1425
+ type: "string",
1426
+ required: true
1427
+ },
1428
+ mode: { type: "string" },
1429
+ axFallback: { type: "string" },
1430
+ items: {
1431
+ type: "array",
1432
+ required: true,
1433
+ items: {
1434
+ type: "object",
1435
+ additionalProperties: false,
1436
+ properties: {
1437
+ ref: {
1438
+ type: "string",
1439
+ required: true
1440
+ },
1441
+ tag: {
1442
+ type: "string",
1443
+ required: true
1444
+ },
1445
+ role: { type: "string" },
1446
+ name: { type: "string" },
1447
+ href: { type: "string" }
1448
+ }
1449
+ }
1450
+ }
1451
+ }
1452
+ },
1453
+ render: (_args, value) => {
1454
+ return [{
1455
+ type: "text",
1456
+ text: clampRendered([`${value.items.length} interactive elements on ${value.title} (mode ${value.mode ?? "dom"}); click or fill them by ref:` + (value.axFallback === void 0 ? "" : `\n(mode ax fell back to dom: ${value.axFallback})`), ...value.items.map((item) => {
1457
+ const name = item.name === void 0 ? "" : ` "${flattenOneLine(item.name, 80)}"`;
1458
+ const href = item.href === void 0 ? "" : ` -> ${flattenOneLine(item.href, 100)}`;
1459
+ return `[${item.ref}] ${item.role ?? item.tag}${name}${href}`;
1460
+ })].join("\n"), SNAPSHOT_RENDER_MAX_CHARS)
1461
+ }];
1462
+ }
1463
+ },
1464
+ presentCall: () => ({
1465
+ card: "generic",
1466
+ title: "Snapshot browser page",
1467
+ kind: "other"
1468
+ }),
1469
+ async execute(args, exec) {
1470
+ const params = { limit: Math.min(200, Math.max(1, args.limit ?? 120)) };
1471
+ if (typeof args.tabId === "number") params.tabId = args.tabId;
1472
+ if (typeof args.mode === "string") params.mode = args.mode;
1473
+ const raw = await controller.execute("snapshot", params, exec.signal);
1474
+ const requestedAx = args.mode === "ax";
1475
+ const gotAx = raw.mode === "ax" || raw.ax !== void 0;
1476
+ const axFallback = typeof raw.axFallback === "string" && raw.axFallback.length > 0 ? raw.axFallback : requestedAx && !gotAx ? "this extension ignored mode:\"ax\" and returned CSS-DOM matches (AX mode needs DSH Browser Control extension v1.0.11 or newer) — the names below are tag names, not accessible names" : void 0;
1477
+ return {
1478
+ tabId: raw.tabId,
1479
+ title: raw.title ?? "",
1480
+ url: raw.url ?? "",
1481
+ ...raw.mode === void 0 ? {} : { mode: raw.mode },
1482
+ ...axFallback === void 0 ? {} : { axFallback },
1483
+ items: (raw.items ?? []).map((item) => ({
1484
+ ref: item.ref,
1485
+ tag: item.tag,
1486
+ ...item.role === void 0 ? {} : { role: item.role },
1487
+ ...item.name === void 0 ? {} : { name: item.name },
1488
+ ...item.href === void 0 ? {} : { href: item.href }
1489
+ }))
1490
+ };
1491
+ }
1492
+ }));
1493
+ ctx.tools.register(defineTool({
1494
+ name: "browser_click",
1495
+ description: "Click a page element with real mouse events; target it by snapshot ref or CSS selector.",
1496
+ parameters: {
1497
+ ref: {
1498
+ type: "string",
1499
+ description: "Element ref from browser_snapshot (e.g. \"e3\"); wins over selector."
1500
+ },
1501
+ selector: {
1502
+ type: "string",
1503
+ description: "CSS selector; ignored when ref is given."
1504
+ },
1505
+ tabId: {
1506
+ type: "number",
1507
+ description: "Target tab; defaults to the active tab."
1508
+ },
1509
+ doubleClick: {
1510
+ type: "boolean",
1511
+ description: "Send a double click instead."
1512
+ }
1513
+ },
1514
+ output: {
1515
+ schema: {
1516
+ type: "object",
1517
+ additionalProperties: true
1518
+ },
1519
+ render: (_args, value) => [{
1520
+ type: "text",
1521
+ text: clampRendered(JSON.stringify(value))
1522
+ }]
1523
+ },
1524
+ presentCall: (args) => ({
1525
+ card: "generic",
1526
+ title: `Click ${args.ref ?? args.selector ?? ""}`,
1527
+ kind: "other"
1528
+ }),
1529
+ async execute(args, exec) {
1530
+ const params = { selector: targetSelector(args) };
1531
+ if (args.tabId !== void 0) params.tabId = args.tabId;
1532
+ if (args.doubleClick !== void 0) params.doubleClick = args.doubleClick;
1533
+ return await controller.execute("click", params, exec.signal);
1534
+ }
1535
+ }));
1536
+ ctx.tools.register(defineTool({
1537
+ name: "browser_type",
1538
+ description: "Fill an input/textarea/select/contentEditable (React-compatible events); optionally press Enter afterwards.",
1539
+ parameters: {
1540
+ value: {
1541
+ type: "string",
1542
+ required: true,
1543
+ description: "Text to put into the element."
1544
+ },
1545
+ ref: {
1546
+ type: "string",
1547
+ description: "Element ref from browser_snapshot; wins over selector."
1548
+ },
1549
+ selector: {
1550
+ type: "string",
1551
+ description: "CSS selector; ignored when ref is given."
1552
+ },
1553
+ tabId: {
1554
+ type: "number",
1555
+ description: "Target tab; defaults to the active tab."
1556
+ },
1557
+ submit: {
1558
+ type: "boolean",
1559
+ description: "Press Enter after filling."
1560
+ }
1561
+ },
1562
+ output: {
1563
+ schema: {
1564
+ type: "object",
1565
+ additionalProperties: true
1566
+ },
1567
+ render: (_args, value) => [{
1568
+ type: "text",
1569
+ text: clampRendered(JSON.stringify(value))
1570
+ }]
1571
+ },
1572
+ presentCall: (args) => ({
1573
+ card: "generic",
1574
+ title: `Type into ${args.ref ?? args.selector ?? "element"}`,
1575
+ kind: "other"
1576
+ }),
1577
+ async execute(args, exec) {
1578
+ const params = {
1579
+ selector: targetSelector(args),
1580
+ value: args.value
1581
+ };
1582
+ if (args.tabId !== void 0) params.tabId = args.tabId;
1583
+ const filled = await controller.execute("input", params, exec.signal);
1584
+ if (args.submit === true) await controller.execute("press", args.tabId === void 0 ? { key: "Enter" } : {
1585
+ key: "Enter",
1586
+ tabId: args.tabId
1587
+ }, exec.signal);
1588
+ return filled;
1589
+ }
1590
+ }));
1591
+ ctx.tools.register(defineTool({
1592
+ name: "browser_press",
1593
+ description: "Send a real keyboard event to the page: a named key (Enter, Tab, Escape, ArrowDown…), a single character, or a combination such as \"Ctrl+A\" / \"Shift+ArrowRight\".",
1594
+ parameters: {
1595
+ key: {
1596
+ type: "string",
1597
+ required: true,
1598
+ description: "Named key, a single character, or \"Modifier+Key\" (alt, ctrl/control, meta/command, shift). Unknown names now fail loudly instead of doing nothing."
1599
+ },
1600
+ modifiers: {
1601
+ type: "array",
1602
+ items: { type: "string" },
1603
+ description: "Alternative to the \"Ctrl+A\" spelling: [\"ctrl\", \"shift\"]. Shift alone still lets the character be typed; ctrl/meta suppress insertion."
1604
+ },
1605
+ tabId: {
1606
+ type: "number",
1607
+ description: "Target tab; defaults to the active tab."
1608
+ }
1609
+ },
1610
+ output: {
1611
+ schema: {
1612
+ type: "object",
1613
+ additionalProperties: true
1614
+ },
1615
+ render: (_args, value) => [{
1616
+ type: "text",
1617
+ text: clampRendered(JSON.stringify(value))
1618
+ }]
1619
+ },
1620
+ presentCall: (args) => ({
1621
+ card: "generic",
1622
+ title: `Press ${args.key}`,
1623
+ kind: "other"
1624
+ }),
1625
+ async execute(args, exec) {
1626
+ const params = { key: args.key };
1627
+ if (Array.isArray(args.modifiers)) params.modifiers = args.modifiers;
1628
+ if (args.tabId !== void 0) params.tabId = args.tabId;
1629
+ return await controller.execute("press", params, exec.signal);
1630
+ }
1631
+ }));
1632
+ ctx.tools.register(defineTool({
1633
+ name: "browser_scroll",
1634
+ description: "Scroll the page viewport by a delta and report the resulting position.",
1635
+ parameters: {
1636
+ x: {
1637
+ type: "number",
1638
+ description: "Horizontal delta in pixels; defaults to 0."
1639
+ },
1640
+ y: {
1641
+ type: "number",
1642
+ description: "Vertical delta in pixels; positive scrolls down."
1643
+ },
1644
+ tabId: {
1645
+ type: "number",
1646
+ description: "Target tab; defaults to the active tab."
1647
+ }
1648
+ },
1649
+ output: {
1650
+ schema: {
1651
+ type: "object",
1652
+ additionalProperties: true
1653
+ },
1654
+ render: (_args, value) => [{
1655
+ type: "text",
1656
+ text: clampRendered(JSON.stringify(value))
1657
+ }]
1658
+ },
1659
+ presentCall: () => ({
1660
+ card: "generic",
1661
+ title: "Scroll browser page",
1662
+ kind: "other"
1663
+ }),
1664
+ async execute(args, exec) {
1665
+ const params = {
1666
+ x: args.x ?? 0,
1667
+ y: args.y ?? 0
1668
+ };
1669
+ if (args.tabId !== void 0) params.tabId = args.tabId;
1670
+ return await controller.execute("scroll", params, exec.signal);
1671
+ }
1672
+ }));
1673
+ /** Page-side ceiling of `cmdWait`; the bridge budget adds slack on top. */
1674
+ const WAIT_MAX_MS = 12e4;
1675
+ ctx.tools.register(defineTool({
1676
+ name: "browser_wait",
1677
+ description: "Block until a selector appears, some text shows up in the body, or a page-side predicate turns true. Use it after navigation for SPA routes and lazy-rendered content, which browser_navigate does not wait for.",
1678
+ parameters: {
1679
+ selector: {
1680
+ type: "string",
1681
+ description: "CSS selector that must exist in the document."
1682
+ },
1683
+ text: {
1684
+ type: "string",
1685
+ description: "Literal substring that must appear in document.body.innerText."
1686
+ },
1687
+ fn: {
1688
+ type: "string",
1689
+ description: "JS predicate evaluated in the page: an expression (\"window.x === 3\") or a function body (\"() => !!document.querySelector('.row')\")."
1690
+ },
1691
+ timeoutMs: {
1692
+ type: "number",
1693
+ description: `Give up after this many milliseconds; defaults to 15000, capped at ${WAIT_MAX_MS}.`
1694
+ },
1695
+ tabId: {
1696
+ type: "number",
1697
+ description: "Target tab; defaults to the active tab."
1698
+ }
1699
+ },
1700
+ output: {
1701
+ schema: {
1702
+ type: "object",
1703
+ additionalProperties: true
1704
+ },
1705
+ render: (_args, value) => [{
1706
+ type: "text",
1707
+ text: clampRendered(JSON.stringify(value))
1708
+ }]
1709
+ },
1710
+ presentCall: (args) => ({
1711
+ card: "generic",
1712
+ title: `Wait for ${args.selector ?? args.text ?? "page condition"}`,
1713
+ kind: "other"
1714
+ }),
1715
+ async execute(args, exec) {
1716
+ const given = [
1717
+ "selector",
1718
+ "text",
1719
+ "fn"
1720
+ ].filter((k) => args[k] !== void 0 && String(args[k]).length > 0);
1721
+ if (given.length === 0) throw new Error("browser_wait needs exactly one of selector, text, fn — otherwise there is nothing to wait for");
1722
+ if (given.length > 1) throw new Error(`browser_wait takes one condition, got ${given.join(" + ")} — pick the one you actually mean`);
1723
+ const timeoutMs = Math.min(WAIT_MAX_MS, Math.max(100, Number(args.timeoutMs ?? 15e3)));
1724
+ const params = {
1725
+ [given[0]]: args[given[0]],
1726
+ timeoutMs
1727
+ };
1728
+ if (args.tabId !== void 0) params.tabId = args.tabId;
1729
+ return await controller.execute("wait", params, exec.signal, timeoutMs + 1e4);
1730
+ }
1731
+ }));
1732
+ ctx.tools.register(defineTool({
1733
+ name: "browser_find",
1734
+ description: "Describe the elements matching a CSS selector — count, tag, id, class, text, href and viewport rect. Read-only; use it to decide what to click and to verify the page state.",
1735
+ parameters: {
1736
+ selector: {
1737
+ type: "string",
1738
+ required: true,
1739
+ description: "CSS selector to query."
1740
+ },
1741
+ limit: {
1742
+ type: "number",
1743
+ description: "Maximum items to describe; defaults to 10, capped at 50 (count is always the full total)."
1744
+ },
1745
+ tabId: {
1746
+ type: "number",
1747
+ description: "Target tab; defaults to the active tab."
1748
+ }
1749
+ },
1750
+ output: {
1751
+ schema: {
1752
+ type: "object",
1753
+ additionalProperties: true
1754
+ },
1755
+ render: (_args, value) => [{
1756
+ type: "text",
1757
+ text: clampRendered(JSON.stringify(value))
1758
+ }]
1759
+ },
1760
+ presentCall: (args) => ({
1761
+ card: "generic",
1762
+ title: `Find ${args.selector}`,
1763
+ kind: "other"
1764
+ }),
1765
+ async execute(args, exec) {
1766
+ const params = { selector: args.selector };
1767
+ if (args.limit !== void 0) params.limit = args.limit;
1768
+ if (args.tabId !== void 0) params.tabId = args.tabId;
1769
+ return await controller.execute("find", params, exec.signal);
1770
+ }
1771
+ }));
1772
+ ctx.tools.register(defineTool({
1773
+ name: "browser_hold",
1774
+ description: "Put a cancellable take-over overlay on a tab so the user can see that you are working on this page and cannot mis-click. The card title is fixed by the extension; your `note` is the body text only (≤400 chars, newlines flattened). The user double-clicks the page (or presses Escape twice, or holds Escape) to close it — you can keep working. Pressing the card's abort button blocks your click/input/press/nav on that tab until you call action=\"off\" (which counts as confirming you continue). This is a soft layer, not a security boundary: the popup lock is the thing that actually freezes the browser. Returns `covered:false` with a `reason` when the page cannot be covered (browser pages, error pages, CSP-strict frames, PDF viewer, an open native dialog).",
1775
+ parameters: {
1776
+ action: {
1777
+ type: "string",
1778
+ enum: [
1779
+ "on",
1780
+ "off",
1781
+ "status"
1782
+ ],
1783
+ required: true,
1784
+ description: "\"on\" shows the overlay, \"off\" removes it (and clears the abort state), \"status\" reports what is actually on the page."
1785
+ },
1786
+ note: {
1787
+ type: "string",
1788
+ description: "on: body text — say what you are doing (e.g. \"正在把「局域网」档改成放行\"). The title cannot be set."
1789
+ },
1790
+ tabId: {
1791
+ type: "number",
1792
+ description: "Target tab; defaults to the active tab."
1793
+ }
1794
+ },
1795
+ output: {
1796
+ schema: {
1797
+ type: "object",
1798
+ additionalProperties: true
1799
+ },
1800
+ render: (_args, value) => [{
1801
+ type: "text",
1802
+ text: clampRendered(JSON.stringify(value))
1803
+ }]
1804
+ },
1805
+ presentCall: (args) => ({
1806
+ card: "generic",
1807
+ title: `Overlay ${args.action}`,
1808
+ kind: "other"
1809
+ }),
1810
+ async execute(args, exec) {
1811
+ const action = String(args.action);
1812
+ if (![
1813
+ "on",
1814
+ "off",
1815
+ "status"
1816
+ ].includes(action)) throw new Error("browser_hold action must be on, off or status");
1817
+ const params = { action };
1818
+ if (args.action === "on" && args.note !== void 0) params.note = args.note;
1819
+ if (args.tabId !== void 0) params.tabId = args.tabId;
1820
+ return await controller.execute("hold", params, exec.signal);
1821
+ }
1822
+ }));
1823
+ ctx.tools.register(defineTool({
1824
+ name: "browser_dialog",
1825
+ description: "Read or change how native alert/confirm/prompt dialogs are answered (accept, dismiss, or manual), and get the recent dialog log so you can prove one appeared.",
1826
+ parameters: { policy: {
1827
+ type: "string",
1828
+ enum: [
1829
+ "accept",
1830
+ "dismiss",
1831
+ "manual"
1832
+ ],
1833
+ description: "Set this policy; omit to only read the current one."
1834
+ } },
1835
+ output: {
1836
+ schema: {
1837
+ type: "object",
1838
+ additionalProperties: true
1839
+ },
1840
+ render: (_args, value) => [{
1841
+ type: "text",
1842
+ text: clampRendered(JSON.stringify(value))
1843
+ }]
1844
+ },
1845
+ presentCall: (args) => ({
1846
+ card: "generic",
1847
+ title: args.policy ? `Dialog policy → ${args.policy}` : "Read dialog policy",
1848
+ kind: "other"
1849
+ }),
1850
+ async execute(args, exec) {
1851
+ const params = {};
1852
+ if (args.policy !== void 0) params.policy = args.policy;
1853
+ return await controller.execute("dialog", params, exec.signal);
1854
+ }
1855
+ }));
1856
+ ctx.tools.register(defineTool({
1857
+ name: "browser_info",
1858
+ description: "Report which browser and which extension build is actually attached. Run it first when behaviour looks wrong — a stale extension or the wrong browser profile explains most of it.",
1859
+ parameters: {},
1860
+ output: {
1861
+ schema: {
1862
+ type: "object",
1863
+ additionalProperties: true
1864
+ },
1865
+ render: (_args, value) => [{
1866
+ type: "text",
1867
+ text: clampRendered(JSON.stringify(value))
1868
+ }]
1869
+ },
1870
+ presentCall: () => ({
1871
+ card: "generic",
1872
+ title: "Browser identity",
1873
+ kind: "other"
1874
+ }),
1875
+ async execute(_args, exec) {
1876
+ return await controller.execute("browser.info", {}, exec.signal);
1877
+ }
1878
+ }));
1879
+ ctx.tools.register(defineTool({
1880
+ name: "browser_tabs",
1881
+ description: "List tabs, or open/close/activate/group/ungroup. `close` takes one tab (`tabId`) or several (`tabIds`); the result accounts for every id, so a retry is safe.",
1882
+ parameters: {
1883
+ action: {
1884
+ type: "string",
1885
+ required: true,
1886
+ description: "One of: list, open, close, activate, group, ungroup."
1887
+ },
1888
+ url: {
1889
+ type: "string",
1890
+ description: "URL for the open action."
1891
+ },
1892
+ tabId: {
1893
+ type: "number",
1894
+ description: "Target tab for close/activate/group."
1895
+ },
1896
+ tabIds: {
1897
+ type: "array",
1898
+ items: { type: "number" },
1899
+ description: "For close: every tab to close in one call. The result reports each id (closed / alreadyGone / failed) instead of stopping at the first failure."
1900
+ },
1901
+ active: {
1902
+ type: "boolean",
1903
+ description: "Whether a newly opened tab becomes active; defaults to true."
1904
+ },
1905
+ group: {
1906
+ type: "boolean",
1907
+ description: "For open: put the new tab in the DSH tab group. Defaults to true; pass false to keep it loose."
1908
+ },
1909
+ windowId: {
1910
+ type: "number",
1911
+ description: "For ungroup: only dissolve that window’s DSH group; omit to clear every window."
1912
+ }
1913
+ },
1914
+ output: {
1915
+ schema: {
1916
+ type: "object",
1917
+ additionalProperties: true
1918
+ },
1919
+ render: (_args, value) => [{
1920
+ type: "text",
1921
+ text: clampRendered(JSON.stringify(value))
1922
+ }]
1923
+ },
1924
+ presentCall: (args) => ({
1925
+ card: "generic",
1926
+ title: `Browser tabs: ${args.action}`,
1927
+ kind: "other"
1928
+ }),
1929
+ async execute(args, exec) {
1930
+ switch (args.action) {
1931
+ case "list": return await controller.execute("tabs.list", {}, exec.signal);
1932
+ case "open": {
1933
+ if (typeof args.url !== "string" || args.url.length === 0) throw new Error("open requires url");
1934
+ const params = { url: args.url };
1935
+ if (args.active !== void 0) params.active = args.active;
1936
+ if (args.group !== void 0) params.group = args.group;
1937
+ return await controller.execute("tabs.open", params, exec.signal);
1938
+ }
1939
+ case "close": {
1940
+ const ids = closeTabIds(args);
1941
+ return ids.length === 1 ? await controller.execute("tabs.close", { tabId: ids[0] }, exec.signal) : await controller.execute("tabs.closeMany", { tabIds: ids }, exec.signal);
1942
+ }
1943
+ case "activate": return await controller.execute("tabs.activate", { tabId: requireTabId(args, "activate") }, exec.signal);
1944
+ case "group": return await controller.execute("tabs.group", { tabId: requireTabId(args, "group") }, exec.signal);
1945
+ case "ungroup": {
1946
+ const params = {};
1947
+ if (typeof args.windowId === "number") params.windowId = args.windowId;
1948
+ return await controller.execute("tabs.ungroup", params, exec.signal);
1949
+ }
1950
+ default: throw new Error(`unknown tabs action: ${String(args.action)} (use list|open|close|activate|group|ungroup)`);
1951
+ }
1952
+ }
1953
+ }));
1954
+ ctx.tools.register(defineTool({
1955
+ name: "browser_evaluate",
1956
+ description: "Run JavaScript in the page and get the JSON result back as a string. Prefer read-only inspection.",
1957
+ parameters: {
1958
+ expression: {
1959
+ type: "string",
1960
+ required: true,
1961
+ description: "JavaScript expression or statement sequence; awaited like a promise body."
1962
+ },
1963
+ tabId: {
1964
+ type: "number",
1965
+ description: "Target tab; defaults to the active tab."
1966
+ }
1967
+ },
1968
+ output: {
1969
+ schema: {
1970
+ type: "object",
1971
+ additionalProperties: false,
1972
+ properties: {
1973
+ tabId: {
1974
+ type: "number",
1975
+ required: true
1976
+ },
1977
+ json: {
1978
+ type: "string",
1979
+ required: true
1980
+ }
1981
+ }
1982
+ },
1983
+ render: (_args, value) => [{
1984
+ type: "text",
1985
+ text: clampRendered(value.json)
1986
+ }]
1987
+ },
1988
+ presentCall: () => ({
1989
+ card: "generic",
1990
+ title: "Evaluate in page",
1991
+ kind: "other"
1992
+ }),
1993
+ async execute(args, exec) {
1994
+ const raw = await controller.execute("eval", args.tabId === void 0 ? { expression: args.expression } : {
1995
+ expression: args.expression,
1996
+ tabId: args.tabId
1997
+ }, exec.signal);
1998
+ let json;
1999
+ try {
2000
+ json = JSON.stringify(raw.value) ?? String(raw.value);
2001
+ } catch {
2002
+ json = String(raw.value);
2003
+ }
2004
+ return {
2005
+ tabId: raw.tabId,
2006
+ json
2007
+ };
2008
+ }
2009
+ }));
2010
+ ctx.tools.register(defineTool({
2011
+ name: "browser_screenshot",
2012
+ description: "Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path.",
2013
+ parameters: {
2014
+ tabId: {
2015
+ type: "number",
2016
+ description: "Target tab; defaults to the active tab."
2017
+ },
2018
+ fullPage: {
2019
+ type: "boolean",
2020
+ description: "Capture beyond the viewport."
2021
+ },
2022
+ format: {
2023
+ type: "string",
2024
+ description: "\"png\" (default) or \"jpeg\"."
2025
+ }
2026
+ },
2027
+ output: {
2028
+ schema: {
2029
+ type: "object",
2030
+ additionalProperties: false,
2031
+ properties: {
2032
+ file: {
2033
+ type: "string",
2034
+ required: true
2035
+ },
2036
+ bytes: {
2037
+ type: "number",
2038
+ required: true
2039
+ },
2040
+ tabId: {
2041
+ type: "number",
2042
+ required: true
2043
+ },
2044
+ title: { type: "string" },
2045
+ url: { type: "string" }
2046
+ }
2047
+ },
2048
+ render: (_args, value) => [{
2049
+ type: "text",
2050
+ text: `Saved ${value.file} (${value.bytes} bytes)`
2051
+ }]
2052
+ },
2053
+ presentCall: () => ({
2054
+ card: "generic",
2055
+ title: "Browser screenshot",
2056
+ kind: "other"
2057
+ }),
2058
+ async execute(args, exec) {
2059
+ const params = { format: args.format === "jpeg" ? "jpeg" : "png" };
2060
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2061
+ if (args.fullPage !== void 0) params.fullPage = args.fullPage;
2062
+ return saveScreenshot(controller, await controller.execute("screenshot", params, exec.signal));
2063
+ }
2064
+ }));
2065
+ ctx.tools.register(defineTool({
2066
+ name: "browser_cleanup",
2067
+ description: "Delete generated screenshots and agent scratch files (__-prefixed temp scripts/artifacts) from their top-level directories.",
2068
+ parameters: {},
2069
+ output: {
2070
+ schema: {
2071
+ type: "object",
2072
+ additionalProperties: false,
2073
+ properties: {
2074
+ shotsRemoved: {
2075
+ type: "number",
2076
+ required: true
2077
+ },
2078
+ scratchRemoved: {
2079
+ type: "array",
2080
+ required: true,
2081
+ items: { type: "string" }
2082
+ }
2083
+ }
2084
+ },
2085
+ render: (_args, value) => [{
2086
+ type: "text",
2087
+ text: `Cleaned ${value.shotsRemoved} screenshot(s) and ${value.scratchRemoved.length} scratch file(s)`
2088
+ }]
2089
+ },
2090
+ presentCall: () => ({
2091
+ card: "generic",
2092
+ title: "Clean up browser artifacts",
2093
+ kind: "other"
2094
+ }),
2095
+ async execute() {
2096
+ const result = await controller.cleanup();
2097
+ return {
2098
+ shotsRemoved: result.shotsRemoved,
2099
+ scratchRemoved: Array.from(result.scratchRemoved)
2100
+ };
2101
+ }
2102
+ }));
2103
+ ctx.tools.register(defineTool({
2104
+ name: "browser_console_log",
2105
+ description: "Read the captured `console.log/info/warn/error` entries for a tab, with the entry text itself. Set `clear:true` to also empty the buffer so the next call shows only entries recorded after this one. Useful for \"what did the page log after I clicked submit\".",
2106
+ parameters: {
2107
+ tabId: {
2108
+ type: "number",
2109
+ description: "Target tab; defaults to the active tab."
2110
+ },
2111
+ levels: {
2112
+ type: "array",
2113
+ items: { type: "string" },
2114
+ description: "Filter to one or more of: log, info, warn, error, debug."
2115
+ },
2116
+ pattern: {
2117
+ type: "string",
2118
+ description: "Regex (case-insensitive) matched against the formatted text."
2119
+ },
2120
+ limit: {
2121
+ type: "number",
2122
+ description: "Maximum entries to return; default 100, capped at 500."
2123
+ },
2124
+ clear: {
2125
+ type: "boolean",
2126
+ description: "Empty the buffer after reading."
2127
+ }
2128
+ },
2129
+ output: {
2130
+ schema: {
2131
+ type: "object",
2132
+ additionalProperties: false,
2133
+ properties: {
2134
+ tabId: {
2135
+ type: "number",
2136
+ required: true
2137
+ },
2138
+ count: {
2139
+ type: "number",
2140
+ required: true
2141
+ },
2142
+ total: {
2143
+ type: "number",
2144
+ required: true
2145
+ },
2146
+ entries: {
2147
+ type: "array",
2148
+ required: true,
2149
+ items: {
2150
+ type: "object",
2151
+ additionalProperties: false,
2152
+ properties: {
2153
+ level: {
2154
+ type: "string",
2155
+ required: true
2156
+ },
2157
+ text: {
2158
+ type: "string",
2159
+ required: true
2160
+ },
2161
+ time: { type: "string" },
2162
+ location: { type: "string" }
2163
+ }
2164
+ }
2165
+ }
2166
+ }
2167
+ },
2168
+ render: (_args, value) => {
2169
+ return [{
2170
+ type: "text",
2171
+ text: clampRendered([`Tab ${value.tabId}: ${value.count} of ${value.total} console entries (times UTC)${value.count < value.total ? ` — showing the newest ${value.count}; raise limit to see more` : ""}:`, ...value.entries.map((entry, index) => `${index + 1}. [${entry.time ?? "?"}] ${entry.level}${entry.location === void 0 ? "" : ` @${entry.location}`} — ${entry.text}`)].join("\n"), LOG_RENDER_MAX_CHARS)
2172
+ }];
2173
+ }
2174
+ },
2175
+ presentCall: () => ({
2176
+ card: "generic",
2177
+ title: "Read browser console",
2178
+ kind: "other"
2179
+ }),
2180
+ async execute(args, exec) {
2181
+ const params = {};
2182
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2183
+ if (Array.isArray(args.levels)) params.levels = args.levels;
2184
+ if (typeof args.pattern === "string") params.pattern = args.pattern;
2185
+ if (typeof args.limit === "number") params.limit = args.limit;
2186
+ if (args.clear === true) params.clear = true;
2187
+ const raw = await controller.execute("console.log", params, exec.signal);
2188
+ return {
2189
+ tabId: raw.tabId,
2190
+ count: raw.count,
2191
+ total: raw.total,
2192
+ entries: (raw.entries ?? []).map((entry) => ({
2193
+ level: entry.level ?? "log",
2194
+ text: flattenOneLine(entry.text ?? "", 400),
2195
+ ...entry.t === void 0 ? {} : { time: formatClock(entry.t) },
2196
+ ...entry.location === void 0 ? {} : { location: entry.location }
2197
+ }))
2198
+ };
2199
+ }
2200
+ }));
2201
+ ctx.tools.register(defineTool({
2202
+ name: "browser_network_log",
2203
+ description: "Read captured HTTP request/response pairs for a tab. `includeStatic:true` adds images / fonts / stylesheets / scripts (filtered by default — they dominate the buffer). `methodPattern` / `urlPattern` / `status` filter server-side results; `clear:true` empties the buffer.",
2204
+ parameters: {
2205
+ tabId: {
2206
+ type: "number",
2207
+ description: "Target tab; defaults to the active tab."
2208
+ },
2209
+ includeStatic: {
2210
+ type: "boolean",
2211
+ description: "Include images / fonts / stylesheets / scripts. Default false."
2212
+ },
2213
+ methodPattern: {
2214
+ type: "string",
2215
+ description: "Regex (case-insensitive) matched against the HTTP method."
2216
+ },
2217
+ urlPattern: {
2218
+ type: "string",
2219
+ description: "Regex (case-insensitive) matched against the URL."
2220
+ },
2221
+ status: {
2222
+ type: "string",
2223
+ description: "One of: 2xx, 3xx, 4xx, 5xx, failed, pending."
2224
+ },
2225
+ limit: {
2226
+ type: "number",
2227
+ description: "Maximum entries to return; default 200, capped at 1000."
2228
+ },
2229
+ clear: {
2230
+ type: "boolean",
2231
+ description: "Empty the buffer after reading."
2232
+ }
2233
+ },
2234
+ output: {
2235
+ schema: {
2236
+ type: "object",
2237
+ additionalProperties: false,
2238
+ properties: {
2239
+ tabId: {
2240
+ type: "number",
2241
+ required: true
2242
+ },
2243
+ count: {
2244
+ type: "number",
2245
+ required: true
2246
+ },
2247
+ total: {
2248
+ type: "number",
2249
+ required: true
2250
+ },
2251
+ requests: {
2252
+ type: "array",
2253
+ required: true,
2254
+ items: {
2255
+ type: "object",
2256
+ additionalProperties: false,
2257
+ properties: {
2258
+ method: {
2259
+ type: "string",
2260
+ required: true
2261
+ },
2262
+ url: {
2263
+ type: "string",
2264
+ required: true
2265
+ },
2266
+ resourceType: { type: "string" },
2267
+ status: { type: "number" },
2268
+ statusText: { type: "string" },
2269
+ mimeType: { type: "string" },
2270
+ bytes: { type: "number" },
2271
+ time: { type: "string" },
2272
+ finished: { type: "boolean" },
2273
+ failed: { type: "boolean" },
2274
+ canceled: { type: "boolean" },
2275
+ errorText: { type: "string" }
2276
+ }
2277
+ }
2278
+ }
2279
+ }
2280
+ },
2281
+ render: (_args, value) => {
2282
+ return [{
2283
+ type: "text",
2284
+ text: clampRendered([`Tab ${value.tabId}: ${value.count} of ${value.total} network requests (times UTC)${value.count < value.total ? ` — showing the newest ${value.count}; raise limit or filter by urlPattern/status` : ""}:`, ...value.requests.map((row, index) => {
2285
+ const outcome = row.failed === true ? `FAILED ${row.errorText ?? ""}`.trim() : row.status === void 0 ? "PENDING" : `${row.status} ${row.statusText ?? ""}`.trim();
2286
+ const size = row.bytes === void 0 ? "" : ` ${formatBytes(row.bytes)}`;
2287
+ const type = row.resourceType === void 0 ? "" : ` ${row.resourceType}`;
2288
+ return `${index + 1}. [${row.time ?? "?"}] ${row.method} ${outcome}${type}${size} ${row.url}`;
2289
+ })].join("\n"), LOG_RENDER_MAX_CHARS)
2290
+ }];
2291
+ }
2292
+ },
2293
+ presentCall: () => ({
2294
+ card: "generic",
2295
+ title: "Read browser network log",
2296
+ kind: "other"
2297
+ }),
2298
+ async execute(args, exec) {
2299
+ const params = {};
2300
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2301
+ if (args.includeStatic === true) params.includeStatic = true;
2302
+ if (typeof args.methodPattern === "string") params.methodPattern = args.methodPattern;
2303
+ if (typeof args.urlPattern === "string") params.urlPattern = args.urlPattern;
2304
+ if (typeof args.status === "string") params.status = args.status;
2305
+ if (typeof args.limit === "number") params.limit = args.limit;
2306
+ if (args.clear === true) params.clear = true;
2307
+ const raw = await controller.execute("network.log", params, exec.signal);
2308
+ return {
2309
+ tabId: raw.tabId,
2310
+ count: raw.count,
2311
+ total: raw.total,
2312
+ requests: (raw.requests ?? []).map((row) => networkRow(row))
2313
+ };
2314
+ }
2315
+ }));
2316
+ ctx.tools.register(defineTool({
2317
+ name: "browser_network_clear",
2318
+ description: "Empty the per-tab network capture buffer without returning the rows.",
2319
+ parameters: { tabId: {
2320
+ type: "number",
2321
+ description: "Target tab; defaults to the active tab."
2322
+ } },
2323
+ output: {
2324
+ schema: {
2325
+ type: "object",
2326
+ additionalProperties: false,
2327
+ properties: {
2328
+ tabId: {
2329
+ type: "number",
2330
+ required: true
2331
+ },
2332
+ cleared: {
2333
+ type: "boolean",
2334
+ required: true
2335
+ }
2336
+ }
2337
+ },
2338
+ render: (_args, value) => [{
2339
+ type: "text",
2340
+ text: `Tab ${value.tabId}: network log cleared`
2341
+ }]
2342
+ },
2343
+ presentCall: () => ({
2344
+ card: "generic",
2345
+ title: "Clear browser network log",
2346
+ kind: "other"
2347
+ }),
2348
+ async execute(args, exec) {
2349
+ const params = {};
2350
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2351
+ return await controller.execute("network.clear", params, exec.signal);
2352
+ }
2353
+ }));
2354
+ ctx.tools.register(defineTool({
2355
+ name: "browser_pdf",
2356
+ description: "Export the current page to a PDF. `path` may be absolute (saved there) or omitted (saved under the configured shotsDir). Returns the absolute path + size; the PDF preserves text (selectable, searchable) and print-media CSS.",
2357
+ parameters: {
2358
+ tabId: {
2359
+ type: "number",
2360
+ description: "Target tab; defaults to the active tab."
2361
+ },
2362
+ path: {
2363
+ type: "string",
2364
+ description: "Absolute path. Omit to save under the configured shotsDir with a timestamped name."
2365
+ },
2366
+ landscape: {
2367
+ type: "boolean",
2368
+ description: "Use landscape orientation."
2369
+ },
2370
+ printBackground: {
2371
+ type: "boolean",
2372
+ description: "Render CSS backgrounds. Default true."
2373
+ },
2374
+ paperWidth: {
2375
+ type: "number",
2376
+ description: "Paper width in inches."
2377
+ },
2378
+ paperHeight: {
2379
+ type: "number",
2380
+ description: "Paper height in inches."
2381
+ },
2382
+ scale: {
2383
+ type: "number",
2384
+ description: "Page scale (0.1–2.0)."
2385
+ },
2386
+ pageRanges: {
2387
+ type: "string",
2388
+ description: "Sub-range, e.g. \"1-3\" or \"1,4-6\"."
2389
+ }
2390
+ },
2391
+ output: {
2392
+ schema: {
2393
+ type: "object",
2394
+ additionalProperties: false,
2395
+ properties: {
2396
+ file: {
2397
+ type: "string",
2398
+ required: true
2399
+ },
2400
+ bytes: {
2401
+ type: "number",
2402
+ required: true
2403
+ },
2404
+ tabId: {
2405
+ type: "number",
2406
+ required: true
2407
+ }
2408
+ }
2409
+ },
2410
+ render: (_args, value) => [{
2411
+ type: "text",
2412
+ text: `PDF written to ${value.file} (${(value.bytes / 1024).toFixed(1)} KB)`
2413
+ }]
2414
+ },
2415
+ presentCall: (args) => ({
2416
+ card: "generic",
2417
+ title: `Save PDF${args.path ? " → " + args.path : ""}`,
2418
+ kind: "other"
2419
+ }),
2420
+ async execute(args, exec) {
2421
+ const params = {};
2422
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2423
+ if (args.landscape === true) params.landscape = true;
2424
+ if (args.printBackground === false) params.printBackground = false;
2425
+ if (typeof args.paperWidth === "number") params.paperWidth = args.paperWidth;
2426
+ if (typeof args.paperHeight === "number") params.paperHeight = args.paperHeight;
2427
+ if (typeof args.scale === "number") params.scale = args.scale;
2428
+ if (typeof args.pageRanges === "string") params.pageRanges = args.pageRanges;
2429
+ return await savePdf(controller, await controller.execute("pdf", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
2430
+ }
2431
+ }));
2432
+ ctx.tools.register(defineTool({
2433
+ name: "browser_emulate",
2434
+ description: "Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing. `device:\"reset\"` restores the user's actual viewport. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.",
2435
+ parameters: {
2436
+ tabId: {
2437
+ type: "number",
2438
+ description: "Target tab; defaults to the active tab."
2439
+ },
2440
+ device: {
2441
+ type: "string",
2442
+ description: "Preset: desktop | mobile-iphone-13 | mobile-pixel-7 | tablet-ipad | reset. Or pass custom width/height below."
2443
+ },
2444
+ width: {
2445
+ type: "number",
2446
+ description: "Custom viewport width in CSS px."
2447
+ },
2448
+ height: {
2449
+ type: "number",
2450
+ description: "Custom viewport height in CSS px."
2451
+ },
2452
+ deviceScaleFactor: {
2453
+ type: "number",
2454
+ description: "Custom DPR (1 = standard, 2 = retina, 3 = super-retina)."
2455
+ },
2456
+ isMobile: {
2457
+ type: "boolean",
2458
+ description: "Pass as mobile to the page (affects responsive meta)."
2459
+ },
2460
+ hasTouch: {
2461
+ type: "boolean",
2462
+ description: "Enable touch event dispatch."
2463
+ },
2464
+ userAgent: {
2465
+ type: "string",
2466
+ description: "Custom User-Agent string. Empty string clears."
2467
+ }
2468
+ },
2469
+ output: {
2470
+ schema: {
2471
+ type: "object",
2472
+ additionalProperties: false,
2473
+ properties: {
2474
+ tabId: {
2475
+ type: "number",
2476
+ required: true
2477
+ },
2478
+ reset: { type: "boolean" },
2479
+ width: { type: "number" },
2480
+ height: { type: "number" },
2481
+ deviceScaleFactor: { type: "number" },
2482
+ isMobile: { type: "boolean" },
2483
+ hasTouch: { type: "boolean" },
2484
+ userAgent: { type: "string" }
2485
+ }
2486
+ },
2487
+ render: (_args, value) => {
2488
+ if (value.reset) return [{
2489
+ type: "text",
2490
+ text: `Tab ${value.tabId}: emulation reset to default`
2491
+ }];
2492
+ const ua = value.userAgent === void 0 ? "" : ` UA="${flattenOneLine(value.userAgent, 80)}"`;
2493
+ return [{
2494
+ type: "text",
2495
+ text: `Tab ${value.tabId}: ${value.width || "?"}×${value.height || "?"} DPR=${value.deviceScaleFactor ?? "?"} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}`
2496
+ }];
2497
+ }
2498
+ },
2499
+ presentCall: (args) => ({
2500
+ card: "generic",
2501
+ title: `Emulate${args.device ? " " + args.device : " device"}`,
2502
+ kind: "other"
2503
+ }),
2504
+ async execute(args, exec) {
2505
+ const params = {};
2506
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2507
+ if (typeof args.device === "string") params.device = args.device;
2508
+ if (typeof args.width === "number") params.width = args.width;
2509
+ if (typeof args.height === "number") params.height = args.height;
2510
+ if (typeof args.deviceScaleFactor === "number") params.deviceScaleFactor = args.deviceScaleFactor;
2511
+ if (typeof args.isMobile === "boolean") params.isMobile = args.isMobile;
2512
+ if (typeof args.hasTouch === "boolean") params.hasTouch = args.hasTouch;
2513
+ if (typeof args.userAgent === "string") params.userAgent = args.userAgent;
2514
+ const raw = await controller.execute("emulate", params, exec.signal);
2515
+ return {
2516
+ tabId: raw.tabId,
2517
+ ...raw.reset === true ? { reset: true } : {},
2518
+ ...typeof raw.width === "number" ? { width: raw.width } : {},
2519
+ ...typeof raw.height === "number" ? { height: raw.height } : {},
2520
+ ...typeof raw.deviceScaleFactor === "number" ? { deviceScaleFactor: raw.deviceScaleFactor } : {},
2521
+ ...typeof raw.isMobile === "boolean" ? { isMobile: raw.isMobile } : {},
2522
+ ...typeof raw.hasTouch === "boolean" ? { hasTouch: raw.hasTouch } : {},
2523
+ ...typeof raw.userAgent === "string" && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {}
2524
+ };
2525
+ }
2526
+ }));
2527
+ }
2528
+ /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
2529
+ function apply(ctx, config) {
2530
+ const resolved = config;
2531
+ if (resolved.enabled && resolved.token.trim().length === 0) throw new Error("browser-bridge: token must be a non-empty string when enabled");
2532
+ const controller = new BridgeController((line) => ctx.logger.info(line));
2533
+ let current = () => resolved;
2534
+ ctx.inject(["settings"], (sctx) => {
2535
+ const settings = sctx.settings;
2536
+ /** Converge without a namespace: the profile config stays authoritative. */
2537
+ const withoutNamespace = (why) => {
2538
+ current = () => resolved;
2539
+ ctx.logger.info(`browser-bridge: settings integration skipped (${why})`);
2540
+ controller.reconcile(current());
2541
+ };
2542
+ const register = settings.register;
2543
+ if (typeof register !== "function") {
2544
+ const configure = settings.configure;
2545
+ if (typeof configure === "function") try {
2546
+ ctx.effect(() => configure({ auto: true }), "browser-bridge: settings presentation");
2547
+ } catch (error) {
2548
+ ctx.logger.info(`browser-bridge: settings presentation skipped: ${errorMessage(error)}`);
2549
+ }
2550
+ withoutNamespace("no settings.register on this dsh-settings build");
2551
+ return;
2552
+ }
2553
+ let scope;
2554
+ try {
2555
+ scope = register(BROWSER_BRIDGE_SETTINGS_NAMESPACE, Config, {
2556
+ base: config,
2557
+ validate: (value) => {
2558
+ if (value.enabled && (value.token ?? "").trim().length === 0) throw new Error("browser-bridge: token must be a non-empty string when enabled");
2559
+ }
2560
+ });
2561
+ } catch (error) {
2562
+ ctx.logger.info(`browser-bridge: settings namespace failed: ${errorMessage(error)}`);
2563
+ withoutNamespace("register threw");
2564
+ return;
2565
+ }
2566
+ current = () => scope.get();
2567
+ ctx.effect(() => () => {
2568
+ if (ctx.fiber.state === 4 || ctx.fiber.state === 5) return;
2569
+ current = () => resolved;
2570
+ controller.reconcile(current());
2571
+ }, "browser-bridge: settings cleanup");
2572
+ controller.reconcile(current());
2573
+ scope.watch(() => {
2574
+ if (ctx.fiber.state === 4 || ctx.fiber.state === 5) return;
2575
+ controller.reconcile(current());
2576
+ });
2577
+ });
2578
+ controller.reconcile(resolved, { throwOnError: resolved.enabled }).catch((error) => {
2579
+ throw new Error(`browser-bridge: ${errorMessage(error)}`);
2580
+ });
2581
+ applyBrowserTools(ctx, controller);
2582
+ ctx.effect(() => () => {
2583
+ controller.stop();
2584
+ }, "browser-bridge: server lifecycle");
2585
+ }
2586
+ //#endregion
2587
+ export { BROWSER_BRIDGE_SETTINGS_NAMESPACE, Config, LOCAL_DESTRUCTIVE_COMMANDS, PAGE_MUTATING_COMMANDS, READ_ONLY_ALLOWED, SCRIPT_COMMANDS, apply, inject, name, policyRefusal };