@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/LICENSE +662 -0
- package/README.en.md +317 -0
- package/README.md +310 -2
- package/cordis.patch.yml +14 -0
- package/lib/index.js +2587 -0
- package/lib/invariant.js +25 -0
- package/lib/types/errors.d.ts +64 -0
- package/lib/types/errors.js +161 -0
- package/lib/types/index.d.ts +62 -0
- package/lib/types/index.js +1284 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/invariant.js +23 -0
- package/lib/types/policy.d.ts +62 -0
- package/lib/types/policy.js +85 -0
- package/lib/types/server.d.ts +158 -0
- package/lib/types/server.js +678 -0
- package/lib/types/ws.d.ts +35 -0
- package/lib/types/ws.js +106 -0
- package/llms.txt +43 -0
- package/package.json +83 -4
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 };
|