@angelky7/dsh-browser-control 0.0.0-stage → 1.2.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.
@@ -0,0 +1,1343 @@
1
+ /**
2
+ * Browser-bridge plugin: one local WebSocket endpoint the DSH Browser Control
3
+ * extension connects to, plus the model-facing `browser_*` tools that drive
4
+ * it. The Settings-managed `enabled` flag starts and stops the listener live
5
+ * through dsh-settings' change hook — no reload needed.
6
+ *
7
+ * Settings integration is feature-detected, never assumed. The plugin bypasses the
8
+ * higher-level `installSettingsSection` helper (importing it on a build that does not
9
+ * export it crashes the plugin at module load) and calls the lower-level
10
+ * `sctx.settings.register` only when the host actually has it — dsh-settings up to
11
+ * 0.1.2-rc.1. 0.1.7-rc.2 and 0.2.x export `SettingsForms` with no `register`, so there
12
+ * the bridge converges from the profile config alone and the host projects this
13
+ * plugin's own `Config` schema into the settings page.
14
+ *
15
+ * Tools stay mounted whenever the plugin does; calling one while the bridge
16
+ * is disabled or the extension is offline fails with a message naming the
17
+ * fix, so the model can tell the user what to do instead of hanging.
18
+ * @module @deepseek-ai/dsh-browser-bridge
19
+ */
20
+ import { mkdir, writeFile } from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import z from '@deepseek-ai/schemastery';
23
+ import { defineTool } from '@deepseek-ai/dsh-tools';
24
+ import { BridgeServer, cleanupArtifacts } from "./server.js";
25
+ import { BridgeError, classify } from "./errors.js";
26
+ import { policyRefusal } from "./policy.js";
27
+ // Re-exported so the policy tables stay testable from the published bundle
28
+ // (verify/verify_policy.mjs reconciles them against the extension's COMMANDS).
29
+ export { LOCAL_DESTRUCTIVE_COMMANDS, PAGE_MUTATING_COMMANDS, READ_ONLY_ALLOWED, SCRIPT_COMMANDS, policyRefusal } from "./policy.js";
30
+ /** Cordis plugin name used by loader diagnostics. */
31
+ export const name = 'browser-bridge';
32
+ /** The tool registry this plugin contributes `browser_*` tools to. */
33
+ export const inject = ['tools', 'systemPrompt'];
34
+ /** Settings namespace carrying the bridge switch and endpoint options. */
35
+ export const BROWSER_BRIDGE_SETTINGS_NAMESPACE = 'browser-bridge';
36
+ export const Config = z.object({
37
+ enabled: z.boolean().default(true),
38
+ port: z.number().step(1).min(1024).max(65_535).default(9777),
39
+ token: z.string().default('dsh-local'),
40
+ shotsDir: z.string().default('dsh-browser-shots'),
41
+ readOnly: z.boolean().default(false),
42
+ allowScript: z.boolean().default(true),
43
+ });
44
+ const SNAPSHOT_REF_SELECTOR_PATTERN = /^e\d+$/;
45
+ /** One `browser_read` window, in characters of page text. Longer pages are paged
46
+ * with `offset`, so no single call can put a whole site into the transcript. */
47
+ const READ_WINDOW_MAX_CHARS = 20_000;
48
+ /**
49
+ * Owns zero or one live {@link BridgeServer} and restarts it whenever the
50
+ * resolved settings change. Reconciles serialize through a promise chain so a
51
+ * burst of settings commits cannot interleave stop/start pairs.
52
+ */
53
+ class BridgeController {
54
+ log;
55
+ server;
56
+ serverKey = '';
57
+ lastError;
58
+ chain = Promise.resolve();
59
+ current;
60
+ constructor(log) {
61
+ this.log = log;
62
+ }
63
+ /** Resolved directory screenshots land in; defined once any config arrived. */
64
+ get shotsDir() {
65
+ return this.current === undefined ? undefined : path.resolve(this.current.shotsDir);
66
+ }
67
+ /**
68
+ * Converge the live server onto `config`. With `throwOnError`, an initial
69
+ * start failure rejects (fail-loud activation); later changes record the
70
+ * failure instead, so a bad port cannot tear down an otherwise running session.
71
+ * @param config - the freshly resolved settings snapshot.
72
+ * @param options - set `throwOnError` only for the activation-time call.
73
+ * @returns a promise settling once the convergence attempt finished.
74
+ */
75
+ reconcile(config, options = {}) {
76
+ this.current = config;
77
+ const run = this.chain.then(() => this.reconcileNow(config));
78
+ this.chain = run.catch(() => { });
79
+ if (options.throwOnError === true) {
80
+ return run.catch((error) => {
81
+ throw error instanceof Error ? error : new Error(String(error));
82
+ });
83
+ }
84
+ return Promise.resolve();
85
+ }
86
+ async reconcileNow(config) {
87
+ const shotsDir = path.resolve(config.shotsDir);
88
+ const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : '';
89
+ if (key === this.serverKey)
90
+ return;
91
+ const previous = this.server;
92
+ this.server = undefined;
93
+ this.serverKey = '';
94
+ await previous?.stop();
95
+ if (!config.enabled) {
96
+ this.lastError = undefined;
97
+ return;
98
+ }
99
+ const server = new BridgeServer({ port: config.port, token: config.token, shotsDir, log: this.log });
100
+ try {
101
+ await server.start();
102
+ }
103
+ catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ this.lastError = `桥接启动失败(端口 ${config.port}): ${message}`;
106
+ this.log(this.lastError);
107
+ throw error instanceof Error ? error : new Error(message);
108
+ }
109
+ this.server = server;
110
+ this.serverKey = key;
111
+ this.lastError = undefined;
112
+ }
113
+ /**
114
+ * Run one extension command over the live link.
115
+ * @param command - extension command name (`nav`, `click`, …).
116
+ * @param params - wire params passed through to the extension.
117
+ * @param signal - tool-execution cancellation propagated to the pending command.
118
+ * @param timeoutMs - per-command bridge ceiling; omit for the default. Commands
119
+ * that wait inside the page (browser_wait) must pass a value larger than their
120
+ * own page-level timeout, otherwise the bridge aborts first and the caller
121
+ * cannot tell a slow page from a dead link.
122
+ * @returns the extension's result payload verbatim.
123
+ */
124
+ async execute(command, params, signal, timeoutMs) {
125
+ this.policyGate(command, params);
126
+ const server = this.server;
127
+ if (server === undefined) {
128
+ throw classify(command, new Error(this.lastError ?? '浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)'));
129
+ }
130
+ return server.execute(command, params, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
131
+ }
132
+ /**
133
+ * Refuse a command the local policy forbids, before anything is sent or deleted.
134
+ *
135
+ * The gate sits above the link and above the filesystem, not inside either: a
136
+ * refused command must not leave a side effect behind. Reading `current` on every
137
+ * call is what makes a settings edit take effect live.
138
+ * @param command - extension command name, or a `LOCAL_DESTRUCTIVE_COMMANDS` name.
139
+ * @param params - the command's params: action-bearing commands (`hold`) need them so a
140
+ * read-only action (`status`) is not judged by the command name alone.
141
+ */
142
+ policyGate(command, params) {
143
+ const refusal = policyRefusal(command, {
144
+ readOnly: this.current?.readOnly === true,
145
+ allowScript: this.current?.allowScript !== false,
146
+ }, params);
147
+ if (refusal !== undefined)
148
+ throw new BridgeError(command, 'blocked_by_policy', 'not_started', refusal);
149
+ }
150
+ /**
151
+ * Delete generated artifacts using the currently resolved directories;
152
+ * works while the bridge is stopped because it never touches the socket.
153
+ * @returns counts and names of what was removed.
154
+ */
155
+ async cleanup() {
156
+ // cleanup() 不经过 execute(),所以它必须自己站一次闸门:否则只读档下
157
+ // 模型仍能用 browser_cleanup 删掉用户的整个截图目录。
158
+ this.policyGate('cleanup');
159
+ const dir = this.shotsDir;
160
+ if (dir === undefined)
161
+ throw classify('cleanup', new Error('browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录'));
162
+ try {
163
+ return await cleanupArtifacts({ shotsDir: dir });
164
+ }
165
+ catch (error) {
166
+ // 删除是逐条进行的,中途失败时前面的文件已经没了;不包一层分类,
167
+ // 模型拿到的就是一句没有 code / effectState 的裸 fs 错误。
168
+ throw classify('cleanup', error);
169
+ }
170
+ }
171
+ /** Stop the listener; safe to call repeatedly and during teardown. */
172
+ stop() {
173
+ const previous = this.server;
174
+ this.server = undefined;
175
+ this.serverKey = '';
176
+ return previous?.stop() ?? Promise.resolve();
177
+ }
178
+ }
179
+ function errorMessage(error) {
180
+ return error instanceof Error ? error.message : String(error);
181
+ }
182
+ /**
183
+ * Cap one rendered payload and *say* it was cut. The previous fixed slices
184
+ * (200 / 300 / 400 / 600 / 1200 chars) cut JSON mid-token, so the model received
185
+ * malformed JSON with no sign that anything was missing. Click/type/press/scroll
186
+ * results are well under this cap, so they now arrive complete; bigger payloads
187
+ * are cut at a character boundary and marked twice: once for a human reading the
188
+ * card, once as a stable `[renderedTruncated …]` token an agent can parse to
189
+ * decide whether to re-issue a narrower query.
190
+ */
191
+ const MAX_RENDERED_CHARS = 4_000;
192
+ function clampRendered(text, maxChars = MAX_RENDERED_CHARS) {
193
+ if (text.length <= maxChars)
194
+ return text;
195
+ const omitted = text.length - maxChars;
196
+ return `${text.slice(0, maxChars)}\n… [renderedTruncated omittedChars=${omitted} limitChars=${maxChars}] narrow the query and call again to see the rest`;
197
+ }
198
+ /** Model-facing budget for one snapshot listing; `limit` narrows the listing. */
199
+ const SNAPSHOT_RENDER_MAX_CHARS = 8_000;
200
+ /** Model-facing budget for one console/network log listing. */
201
+ const LOG_RENDER_MAX_CHARS = 6_000;
202
+ /** Collapse one page-supplied field to a single line inside a rendered listing. */
203
+ function flattenOneLine(value, maxChars) {
204
+ const flat = value.replace(/\s+/g, ' ').trim();
205
+ return flat.length <= maxChars ? flat : `${flat.slice(0, maxChars)}…`;
206
+ }
207
+ /** `HH:MM:SS` in UTC from an epoch-milliseconds stamp. */
208
+ function formatClock(epochMs) {
209
+ return new Date(epochMs).toISOString().slice(11, 19);
210
+ }
211
+ function formatBytes(bytes) {
212
+ if (bytes < 1024)
213
+ return `${bytes} B`;
214
+ if (bytes < 1024 * 1024)
215
+ return `${(bytes / 1024).toFixed(1)} KB`;
216
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
217
+ }
218
+ /**
219
+ * Reduce one captured network row to the fields this tool declares. The buffer
220
+ * also carries request/response header maps and POST bodies; those stay out of
221
+ * the value, because the value never reaches the model and the header dump is
222
+ * what would blow the transcript.
223
+ */
224
+ function networkRow(row) {
225
+ const out = {
226
+ method: typeof row.method === 'string' ? row.method : 'GET',
227
+ url: flattenOneLine(typeof row.url === 'string' ? row.url : '', 200),
228
+ };
229
+ if (typeof row.resourceType === 'string')
230
+ out.resourceType = row.resourceType;
231
+ if (typeof row.status === 'number')
232
+ out.status = row.status;
233
+ if (typeof row.statusText === 'string' && row.statusText.length > 0)
234
+ out.statusText = flattenOneLine(row.statusText, 60);
235
+ if (typeof row.mimeType === 'string')
236
+ out.mimeType = row.mimeType;
237
+ if (typeof row.encodedDataLength === 'number')
238
+ out.bytes = row.encodedDataLength;
239
+ // CDP `wallTime` is a Timestamp in seconds, not milliseconds.
240
+ if (typeof row.wallTime === 'number')
241
+ out.time = formatClock(row.wallTime * 1000);
242
+ if (row.finished === true)
243
+ out.finished = true;
244
+ if (row.failed === true)
245
+ out.failed = true;
246
+ if (row.canceled === true)
247
+ out.canceled = true;
248
+ if (typeof row.errorText === 'string' && row.errorText.length > 0)
249
+ out.errorText = flattenOneLine(row.errorText, 120);
250
+ return out;
251
+ }
252
+ /**
253
+ * Resolve the element target a click/type tool received.
254
+ * @param args - validated tool arguments carrying at most one targeting field.
255
+ * @returns the CSS selector to send on the wire, refs translated to their attribute form.
256
+ */
257
+ function targetSelector(args) {
258
+ const hasSelector = typeof args.selector === 'string' && args.selector.length > 0;
259
+ const hasRef = typeof args.ref === 'string' && args.ref.length > 0;
260
+ if (hasSelector === hasRef) {
261
+ throw new Error('provide exactly one of selector or ref (ref comes from browser_snapshot)');
262
+ }
263
+ if (hasRef) {
264
+ const ref = args.ref;
265
+ if (!SNAPSHOT_REF_SELECTOR_PATTERN.test(ref))
266
+ throw new Error(`invalid ref: ${ref}`);
267
+ return `[data-dsh-ref="${ref}"]`;
268
+ }
269
+ return args.selector;
270
+ }
271
+ function requireTabId(args, action) {
272
+ if (typeof args.tabId !== 'number')
273
+ throw new Error(`${action} requires tabId`);
274
+ return args.tabId;
275
+ }
276
+ /**
277
+ * Every tab the caller asked to close. `tabId` and `tabIds` are both accepted
278
+ * (a batch call that repeats one id, or passes both, must not close it twice),
279
+ * and the ids are deduplicated and normalised before they leave the host.
280
+ */
281
+ function closeTabIds(args) {
282
+ const asked = [
283
+ ...(Array.isArray(args.tabIds) ? args.tabIds : []),
284
+ ...(typeof args.tabId === 'number' ? [args.tabId] : []),
285
+ ];
286
+ const ids = [...new Set(asked.map(Number).filter(id => Number.isInteger(id) && id > 0))];
287
+ if (ids.length === 0)
288
+ throw new Error('close requires tabId or tabIds');
289
+ return ids;
290
+ }
291
+ /** Write one screenshot payload to the shots directory and return its durable location. */
292
+ async function saveScreenshot(controller, payload) {
293
+ const dir = controller.shotsDir;
294
+ if (dir === undefined)
295
+ throw new Error('browser-bridge is not configured yet');
296
+ await mkdir(dir, { recursive: true });
297
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
298
+ const random = Math.random().toString(36).slice(2, 6);
299
+ const file = path.join(dir, `${stamp}-${random}.${payload.format === 'jpeg' ? 'jpg' : 'png'}`);
300
+ const buffer = Buffer.from(payload.base64, 'base64');
301
+ await writeFile(file, buffer);
302
+ return {
303
+ file,
304
+ bytes: buffer.length,
305
+ tabId: payload.tabId,
306
+ title: payload.tabTitle,
307
+ url: payload.tabUrl,
308
+ };
309
+ }
310
+ /** Write one PDF payload to `path` (absolute or relative to `controller.shotsDir`)
311
+ * and return the absolute path + size. Mirrors saveScreenshot's contract. */
312
+ async function savePdf(controller, payload, requestedPath) {
313
+ const dir = controller.shotsDir;
314
+ if (dir === undefined)
315
+ throw new Error('browser-bridge is not configured yet');
316
+ let file;
317
+ if (requestedPath && path.isAbsolute(requestedPath)) {
318
+ file = requestedPath;
319
+ await mkdir(path.dirname(file), { recursive: true });
320
+ }
321
+ else {
322
+ await mkdir(dir, { recursive: true });
323
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
324
+ const random = Math.random().toString(36).slice(2, 6);
325
+ const name = requestedPath ? path.basename(requestedPath) : `${stamp}-${random}.pdf`;
326
+ file = path.join(dir, name);
327
+ }
328
+ const buffer = Buffer.from(payload.base64, 'base64');
329
+ await writeFile(file, buffer);
330
+ return {
331
+ file,
332
+ bytes: buffer.length,
333
+ tabId: payload.tabId,
334
+ ...(payload.tabTitle === undefined ? {} : { title: payload.tabTitle }),
335
+ ...(payload.tabUrl === undefined ? {} : { url: payload.tabUrl }),
336
+ };
337
+ }
338
+ /** Register every `browser_*` tool; each is a thin adapter over one extension command. */
339
+ function applyBrowserTools(ctx, controller) {
340
+ ctx.systemPrompt.section({
341
+ name: 'tool:browser',
342
+ order: 112,
343
+ text: 'The browser_* tools drive the user\'s real, logged-in browser through the DSH '
344
+ + 'Browser Control extension; they act on the active tab unless a tabId is passed. '
345
+ + 'Prefer browser_snapshot first on unfamiliar pages: it numbers interactive elements, '
346
+ + 'and browser_click/browser_type accept the returned ref instead of guessing CSS selectors. '
347
+ + 'browser_read extracts page text in a window (offset/limit) and reports how much text is '
348
+ + 'left, browser_screenshot saves a PNG/JPEG and returns its file '
349
+ + 'path (view it with an image tool). Calls fail with actionable copy while the bridge is '
350
+ + 'disabled or no browser is connected. When a result is cut to bound token cost it is '
351
+ + 'marked "[renderedTruncated omittedChars=N limitChars=L]" — re-issue a narrower query '
352
+ + '(smaller limit, a pattern filter, or browser_read\'s offset) instead of assuming the '
353
+ + 'page is shorter than it is.',
354
+ });
355
+ ctx.tools.register(defineTool({
356
+ name: 'browser_navigate',
357
+ description: 'Navigate a browser tab to a URL and wait for the page load to settle.',
358
+ parameters: {
359
+ url: { type: 'string', required: true, description: 'Absolute URL to open in the tab.' },
360
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
361
+ },
362
+ output: {
363
+ schema: {
364
+ type: 'object',
365
+ additionalProperties: false,
366
+ properties: {
367
+ tabId: { type: 'number', required: true },
368
+ url: { type: 'string' },
369
+ title: { type: 'string' },
370
+ pendingUrl: { type: 'string' },
371
+ loadPending: { type: 'boolean' },
372
+ siteUnreachable: { type: 'boolean' },
373
+ },
374
+ },
375
+ render: (_args, value) => {
376
+ const label = [value.title, value.url].filter(part => typeof part === 'string' && part.length > 0).join(' — ');
377
+ const shows = label.length > 0 ? label : '(untitled)';
378
+ // Nothing was navigated to: say that instead of "now shows <the URL you asked for>".
379
+ if (value.siteUnreachable === true) {
380
+ return [{ type: 'text', 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.` }];
381
+ }
382
+ // The wait budget ran out before the new document settled: the tab may still
383
+ // be showing the previous page, so do not call it loaded.
384
+ if (value.loadPending === true) {
385
+ const to = typeof value.pendingUrl === 'string' && value.pendingUrl.length > 0 ? value.pendingUrl : 'the requested URL';
386
+ return [{ type: 'text', 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).` }];
387
+ }
388
+ if (typeof value.pendingUrl === 'string' && value.pendingUrl.length > 0) {
389
+ return [{ type: 'text', text: `Tab ${value.tabId} shows ${shows} and is navigating to ${value.pendingUrl}` }];
390
+ }
391
+ return [{ type: 'text', text: `Tab ${value.tabId} now shows ${shows}` }];
392
+ },
393
+ },
394
+ isConcurrencySafe: () => false,
395
+ presentCall: args => ({ card: 'generic', title: `Open ${args.url}`, kind: 'other' }),
396
+ async execute(args, exec) {
397
+ const params = { url: args.url };
398
+ if (args.tabId !== undefined)
399
+ params.tabId = args.tabId;
400
+ const raw = await controller.execute('nav', params, exec.signal);
401
+ // The declared schema is closed, so the fields describing a navigation that did
402
+ // not arrive have to be carried over on purpose. The old shape check
403
+ // (`siteUnreachable === true`) also missed the object the extension really
404
+ // sends, so an unreachable target came back as a plain success.
405
+ const out = { tabId: raw.tabId };
406
+ if (typeof raw.url === 'string')
407
+ out.url = raw.url;
408
+ if (typeof raw.title === 'string')
409
+ out.title = raw.title;
410
+ if (typeof raw.pendingUrl === 'string')
411
+ out.pendingUrl = raw.pendingUrl;
412
+ if (raw.loadPending === true)
413
+ out.loadPending = true;
414
+ const unreachable = raw.siteUnreachable === true
415
+ || (typeof raw.siteUnreachable === 'object' && raw.siteUnreachable !== null);
416
+ if (unreachable)
417
+ out.siteUnreachable = true;
418
+ return out;
419
+ },
420
+ }));
421
+ ctx.tools.register(defineTool({
422
+ name: 'browser_read',
423
+ 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.',
424
+ parameters: {
425
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
426
+ mode: { type: 'string', description: '"text" (default) for visible text, "html" for the whole document.' },
427
+ offset: { type: 'number', description: `Character index the window starts at; default 0. Use the "call again with offset=" value the previous result reported.` },
428
+ limit: { type: 'number', description: `Characters to return; default and cap ${READ_WINDOW_MAX_CHARS}.` },
429
+ },
430
+ output: {
431
+ schema: {
432
+ type: 'object',
433
+ additionalProperties: false,
434
+ properties: {
435
+ tabId: { type: 'number', required: true },
436
+ mode: { type: 'string', required: true },
437
+ title: { type: 'string', required: true },
438
+ url: { type: 'string', required: true },
439
+ content: { type: 'string', required: true },
440
+ truncated: { type: 'boolean', required: true },
441
+ offset: { type: 'number', required: true },
442
+ totalChars: { type: 'number', required: true },
443
+ },
444
+ },
445
+ render: (_args, value) => {
446
+ const end = value.offset + value.content.length;
447
+ const head = `${value.title} (${value.url}) — ${value.mode} chars ${value.offset}-${end} of ${value.totalChars}`
448
+ + (value.truncated ? `; call again with offset=${end} to continue` : '');
449
+ return [{ type: 'text', text: `${head}\n\n${value.content}` }];
450
+ },
451
+ },
452
+ presentCall: () => ({ card: 'generic', title: 'Read browser page', kind: 'other' }),
453
+ async execute(args, exec) {
454
+ const mode = args.mode === 'html' ? 'html' : 'text';
455
+ const offset = Math.max(0, Math.floor(Number(args.offset ?? 0)) || 0);
456
+ const limit = Math.min(READ_WINDOW_MAX_CHARS, Math.max(1, Math.floor(Number(args.limit ?? READ_WINDOW_MAX_CHARS)) || READ_WINDOW_MAX_CHARS));
457
+ const raw = await controller.execute('content', args.tabId === undefined ? { mode } : { mode, tabId: args.tabId }, exec.signal);
458
+ const full = raw.content ?? '';
459
+ const content = full.slice(offset, offset + limit);
460
+ return {
461
+ tabId: raw.tabId,
462
+ mode,
463
+ title: raw.title ?? '',
464
+ url: raw.url ?? '',
465
+ content,
466
+ truncated: offset + content.length < full.length,
467
+ offset,
468
+ totalChars: full.length,
469
+ };
470
+ },
471
+ }));
472
+ ctx.tools.register(defineTool({
473
+ name: 'browser_snapshot',
474
+ 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.',
475
+ parameters: {
476
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
477
+ limit: { type: 'number', description: 'Max elements returned; defaults to 120, capped at 200.' },
478
+ mode: { type: 'string', 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.' },
479
+ },
480
+ output: {
481
+ schema: {
482
+ type: 'object',
483
+ additionalProperties: false,
484
+ properties: {
485
+ tabId: { type: 'number', required: true },
486
+ title: { type: 'string', required: true },
487
+ url: { type: 'string', required: true },
488
+ mode: { type: 'string' },
489
+ axFallback: { type: 'string' },
490
+ items: {
491
+ type: 'array',
492
+ required: true,
493
+ items: {
494
+ type: 'object',
495
+ additionalProperties: false,
496
+ properties: {
497
+ ref: { type: 'string', required: true },
498
+ tag: { type: 'string', required: true },
499
+ role: { type: 'string' },
500
+ name: { type: 'string' },
501
+ href: { type: 'string' },
502
+ },
503
+ },
504
+ },
505
+ },
506
+ },
507
+ render: (_args, value) => {
508
+ const head = `${value.items.length} interactive elements on ${value.title}`
509
+ + ` (mode ${value.mode ?? 'dom'}); click or fill them by ref:`
510
+ + (value.axFallback === undefined ? '' : `\n(mode ax fell back to dom: ${value.axFallback})`);
511
+ const lines = value.items.map(item => {
512
+ const name = item.name === undefined ? '' : ` "${flattenOneLine(item.name, 80)}"`;
513
+ const href = item.href === undefined ? '' : ` -> ${flattenOneLine(item.href, 100)}`;
514
+ return `[${item.ref}] ${item.role ?? item.tag}${name}${href}`;
515
+ });
516
+ return [{ type: 'text', text: clampRendered([head, ...lines].join('\n'), SNAPSHOT_RENDER_MAX_CHARS) }];
517
+ },
518
+ },
519
+ presentCall: () => ({ card: 'generic', title: 'Snapshot browser page', kind: 'other' }),
520
+ async execute(args, exec) {
521
+ const limit = Math.min(200, Math.max(1, args.limit ?? 120));
522
+ const params = { limit };
523
+ if (typeof args.tabId === 'number')
524
+ params.tabId = args.tabId;
525
+ if (typeof args.mode === 'string')
526
+ params.mode = args.mode;
527
+ const raw = await controller.execute('snapshot', params, exec.signal);
528
+ // 版本错位必须说出来:v1.0.10 及更早的扩展没有 AX 快照(1.0.11 才加的),
529
+ // 它会照单收下 mode:"ax" 然后回一份 DOM 结果,既不带 mode 也不带 axFallback
530
+ // (本机实测:扩展 1.0.10,两次请求的响应完全一样)。那时模型只看到「(mode dom)」,
531
+ // 读不出「我要的 ax 被旧扩展静默降级了」,会把标签名当成可访问名。
532
+ // 按「请求的模式 vs 回报的模式」比对,不一致就自己写 axFallback——扩展不用改,老扩展立刻受益。
533
+ const requestedAx = args.mode === 'ax';
534
+ const gotAx = raw.mode === 'ax' || raw.ax !== undefined;
535
+ const axFallback = typeof raw.axFallback === 'string' && raw.axFallback.length > 0
536
+ ? raw.axFallback
537
+ : (requestedAx && !gotAx
538
+ ? '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'
539
+ : undefined);
540
+ return {
541
+ tabId: raw.tabId,
542
+ title: raw.title ?? '',
543
+ url: raw.url ?? '',
544
+ ...(raw.mode === undefined ? {} : { mode: raw.mode }),
545
+ ...(axFallback === undefined ? {} : { axFallback }),
546
+ items: (raw.items ?? []).map(item => ({
547
+ ref: item.ref,
548
+ tag: item.tag,
549
+ ...(item.role === undefined ? {} : { role: item.role }),
550
+ ...(item.name === undefined ? {} : { name: item.name }),
551
+ ...(item.href === undefined ? {} : { href: item.href }),
552
+ })),
553
+ };
554
+ },
555
+ }));
556
+ ctx.tools.register(defineTool({
557
+ name: 'browser_click',
558
+ description: 'Click a page element with real mouse events; target it by snapshot ref or CSS selector.',
559
+ parameters: {
560
+ ref: { type: 'string', description: 'Element ref from browser_snapshot (e.g. "e3"); wins over selector.' },
561
+ selector: { type: 'string', description: 'CSS selector; ignored when ref is given.' },
562
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
563
+ doubleClick: { type: 'boolean', description: 'Send a double click instead.' },
564
+ },
565
+ output: {
566
+ schema: { type: 'object', additionalProperties: true },
567
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
568
+ },
569
+ presentCall: args => ({ card: 'generic', title: `Click ${args.ref ?? args.selector ?? ''}`, kind: 'other' }),
570
+ async execute(args, exec) {
571
+ const params = { selector: targetSelector(args) };
572
+ if (args.tabId !== undefined)
573
+ params.tabId = args.tabId;
574
+ if (args.doubleClick !== undefined)
575
+ params.doubleClick = args.doubleClick;
576
+ return await controller.execute('click', params, exec.signal);
577
+ },
578
+ }));
579
+ ctx.tools.register(defineTool({
580
+ name: 'browser_type',
581
+ description: 'Fill an input/textarea/select/contentEditable (React-compatible events); optionally press Enter afterwards.',
582
+ parameters: {
583
+ value: { type: 'string', required: true, description: 'Text to put into the element.' },
584
+ ref: { type: 'string', description: 'Element ref from browser_snapshot; wins over selector.' },
585
+ selector: { type: 'string', description: 'CSS selector; ignored when ref is given.' },
586
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
587
+ submit: { type: 'boolean', description: 'Press Enter after filling.' },
588
+ },
589
+ output: {
590
+ schema: { type: 'object', additionalProperties: true },
591
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
592
+ },
593
+ presentCall: args => ({ card: 'generic', title: `Type into ${args.ref ?? args.selector ?? 'element'}`, kind: 'other' }),
594
+ async execute(args, exec) {
595
+ const params = { selector: targetSelector(args), value: args.value };
596
+ if (args.tabId !== undefined)
597
+ params.tabId = args.tabId;
598
+ const filled = await controller.execute('input', params, exec.signal);
599
+ if (args.submit === true) {
600
+ await controller.execute('press', args.tabId === undefined ? { key: 'Enter' } : { key: 'Enter', tabId: args.tabId }, exec.signal);
601
+ }
602
+ return filled;
603
+ },
604
+ }));
605
+ ctx.tools.register(defineTool({
606
+ name: 'browser_press',
607
+ 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".',
608
+ parameters: {
609
+ key: { type: 'string', required: true, description: 'Named key, a single character, or "Modifier+Key" (alt, ctrl/control, meta/command, shift). Unknown names now fail loudly instead of doing nothing.' },
610
+ modifiers: { type: 'array', items: { type: 'string' }, description: 'Alternative to the "Ctrl+A" spelling: ["ctrl", "shift"]. Shift alone still lets the character be typed; ctrl/meta suppress insertion.' },
611
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
612
+ },
613
+ output: {
614
+ schema: { type: 'object', additionalProperties: true },
615
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
616
+ },
617
+ presentCall: args => ({ card: 'generic', title: `Press ${args.key}`, kind: 'other' }),
618
+ async execute(args, exec) {
619
+ const params = { key: args.key };
620
+ if (Array.isArray(args.modifiers))
621
+ params.modifiers = args.modifiers;
622
+ if (args.tabId !== undefined)
623
+ params.tabId = args.tabId;
624
+ return await controller.execute('press', params, exec.signal);
625
+ },
626
+ }));
627
+ ctx.tools.register(defineTool({
628
+ name: 'browser_scroll',
629
+ description: 'Scroll the page viewport by a delta and report the resulting position.',
630
+ parameters: {
631
+ x: { type: 'number', description: 'Horizontal delta in pixels; defaults to 0.' },
632
+ y: { type: 'number', description: 'Vertical delta in pixels; positive scrolls down.' },
633
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
634
+ },
635
+ output: {
636
+ schema: { type: 'object', additionalProperties: true },
637
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
638
+ },
639
+ presentCall: () => ({ card: 'generic', title: 'Scroll browser page', kind: 'other' }),
640
+ async execute(args, exec) {
641
+ const params = { x: args.x ?? 0, y: args.y ?? 0 };
642
+ if (args.tabId !== undefined)
643
+ params.tabId = args.tabId;
644
+ return await controller.execute('scroll', params, exec.signal);
645
+ },
646
+ }));
647
+ /** Page-side ceiling of `cmdWait`; the bridge budget adds slack on top. */
648
+ const WAIT_MAX_MS = 120_000;
649
+ ctx.tools.register(defineTool({
650
+ name: 'browser_wait',
651
+ 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.',
652
+ parameters: {
653
+ selector: { type: 'string', description: 'CSS selector that must exist in the document.' },
654
+ text: { type: 'string', description: 'Literal substring that must appear in document.body.innerText.' },
655
+ fn: { type: 'string', description: 'JS predicate evaluated in the page: an expression ("window.x === 3") or a function body ("() => !!document.querySelector(\'.row\')").' },
656
+ timeoutMs: { type: 'number', description: `Give up after this many milliseconds; defaults to 15000, capped at ${WAIT_MAX_MS}.` },
657
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
658
+ },
659
+ output: {
660
+ schema: { type: 'object', additionalProperties: true },
661
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
662
+ },
663
+ presentCall: args => ({ card: 'generic', title: `Wait for ${args.selector ?? args.text ?? 'page condition'}`, kind: 'other' }),
664
+ async execute(args, exec) {
665
+ const given = ['selector', 'text', 'fn'].filter(k => args[k] !== undefined && String(args[k]).length > 0);
666
+ if (given.length === 0)
667
+ throw new Error('browser_wait needs exactly one of selector, text, fn — otherwise there is nothing to wait for');
668
+ if (given.length > 1)
669
+ throw new Error(`browser_wait takes one condition, got ${given.join(' + ')} — pick the one you actually mean`);
670
+ const timeoutMs = Math.min(WAIT_MAX_MS, Math.max(100, Number(args.timeoutMs ?? 15_000)));
671
+ const params = { [given[0]]: args[given[0]], timeoutMs };
672
+ if (args.tabId !== undefined)
673
+ params.tabId = args.tabId;
674
+ // Slack so the page-side timeout always reports before the bridge gives up.
675
+ return await controller.execute('wait', params, exec.signal, timeoutMs + 10_000);
676
+ },
677
+ }));
678
+ ctx.tools.register(defineTool({
679
+ name: 'browser_find',
680
+ 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.',
681
+ parameters: {
682
+ selector: { type: 'string', required: true, description: 'CSS selector to query.' },
683
+ limit: { type: 'number', description: 'Maximum items to describe; defaults to 10, capped at 50 (count is always the full total).' },
684
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
685
+ },
686
+ output: {
687
+ schema: { type: 'object', additionalProperties: true },
688
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
689
+ },
690
+ presentCall: args => ({ card: 'generic', title: `Find ${args.selector}`, kind: 'other' }),
691
+ async execute(args, exec) {
692
+ const params = { selector: args.selector };
693
+ if (args.limit !== undefined)
694
+ params.limit = args.limit;
695
+ if (args.tabId !== undefined)
696
+ params.tabId = args.tabId;
697
+ return await controller.execute('find', params, exec.signal);
698
+ },
699
+ }));
700
+ ctx.tools.register(defineTool({
701
+ name: 'browser_hold',
702
+ 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).',
703
+ parameters: {
704
+ action: { type: 'string', enum: ['on', 'off', 'status'], required: true, description: '"on" shows the overlay, "off" removes it (and clears the abort state), "status" reports what is actually on the page.' },
705
+ note: { type: 'string', description: 'on: body text — say what you are doing (e.g. "正在把「局域网」档改成放行"). The title cannot be set.' },
706
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
707
+ },
708
+ output: {
709
+ schema: { type: 'object', additionalProperties: true },
710
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
711
+ },
712
+ presentCall: args => ({ card: 'generic', title: `Overlay ${args.action}`, kind: 'other' }),
713
+ async execute(args, exec) {
714
+ const action = String(args.action);
715
+ if (!['on', 'off', 'status'].includes(action))
716
+ throw new Error('browser_hold action must be on, off or status');
717
+ const params = { action };
718
+ if (args.action === 'on' && args.note !== undefined)
719
+ params.note = args.note;
720
+ if (args.tabId !== undefined)
721
+ params.tabId = args.tabId;
722
+ return await controller.execute('hold', params, exec.signal);
723
+ },
724
+ }));
725
+ ctx.tools.register(defineTool({
726
+ name: 'browser_cookies',
727
+ description: 'Read, set or remove cookies of a tab (CDP Network.*, no new permission). Cookie values are credentials — a get returns them in full and they land in the model-visible text, so filter by domain and do not point this at a page whose cookies you do not actually need. set/remove need an anchor: url, or domain (with optional path).',
728
+ parameters: {
729
+ action: { type: 'string', enum: ['get', 'set', 'remove'], required: true, description: '"get" lists cookies visible to the tab (domain filter optional); "set" writes one; "remove" deletes one and reports whether it is still visible afterwards.' },
730
+ name: { type: 'string', description: 'set/remove: cookie name (required).' },
731
+ value: { type: 'string', description: 'set: cookie value (required; pass an empty string to clear the content).' },
732
+ domain: { type: 'string', description: 'get: only cookies whose domain contains this substring. set/remove: anchor — the cookie domain.' },
733
+ url: { type: 'string', description: 'set/remove: alternative anchor — Chrome derives origin/secure defaults from it.' },
734
+ path: { type: 'string', description: 'set/remove: cookie path.' },
735
+ secure: { type: 'boolean', description: 'set: https-only cookie.' },
736
+ httpOnly: { type: 'boolean', description: 'set: hide the cookie from page scripts.' },
737
+ sameSite: { type: 'string', description: 'set: Strict | Lax | None.' },
738
+ expires: { type: 'number', description: 'set: Unix seconds; omit for a session cookie.' },
739
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
740
+ },
741
+ output: {
742
+ schema: { type: 'object', additionalProperties: true },
743
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
744
+ },
745
+ presentCall: args => ({ card: 'generic', title: `Cookies ${args.action}`, kind: 'other' }),
746
+ async execute(args, exec) {
747
+ // 三条命令各写一个字面量调用点:verify_surface 对账的是
748
+ // `controller.execute('命令名')` 的字面量,动态拼名字会让
749
+ // 「扩展实现了但 agent 拿不到」那条断言瞎掉。
750
+ const command = args.action === 'get' ? 'cookies.get'
751
+ : args.action === 'set' ? 'cookies.set'
752
+ : args.action === 'remove' ? 'cookies.remove'
753
+ : null;
754
+ if (command === null)
755
+ throw new Error('browser_cookies action must be get, set or remove');
756
+ const params = {};
757
+ if (args.name !== undefined)
758
+ params.name = args.name;
759
+ if (args.value !== undefined)
760
+ params.value = args.value;
761
+ if (args.domain !== undefined)
762
+ params.domain = args.domain;
763
+ if (args.url !== undefined)
764
+ params.url = args.url;
765
+ if (args.path !== undefined)
766
+ params.path = args.path;
767
+ if (args.secure !== undefined)
768
+ params.secure = args.secure;
769
+ if (args.httpOnly !== undefined)
770
+ params.httpOnly = args.httpOnly;
771
+ if (args.sameSite !== undefined)
772
+ params.sameSite = args.sameSite;
773
+ if (args.expires !== undefined)
774
+ params.expires = args.expires;
775
+ if (args.tabId !== undefined)
776
+ params.tabId = args.tabId;
777
+ if (command === 'cookies.get')
778
+ return await controller.execute('cookies.get', params, exec.signal);
779
+ if (command === 'cookies.set')
780
+ return await controller.execute('cookies.set', params, exec.signal);
781
+ return await controller.execute('cookies.remove', params, exec.signal);
782
+ },
783
+ }));
784
+ ctx.tools.register(defineTool({
785
+ name: 'browser_dialog',
786
+ 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.',
787
+ parameters: {
788
+ policy: { type: 'string', enum: ['accept', 'dismiss', 'manual'], description: 'Set this policy; omit to only read the current one.' },
789
+ },
790
+ output: {
791
+ schema: { type: 'object', additionalProperties: true },
792
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
793
+ },
794
+ presentCall: args => ({ card: 'generic', title: args.policy ? `Dialog policy → ${args.policy}` : 'Read dialog policy', kind: 'other' }),
795
+ async execute(args, exec) {
796
+ const params = {};
797
+ if (args.policy !== undefined)
798
+ params.policy = args.policy;
799
+ return await controller.execute('dialog', params, exec.signal);
800
+ },
801
+ }));
802
+ ctx.tools.register(defineTool({
803
+ name: 'browser_info',
804
+ 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.',
805
+ parameters: {},
806
+ output: {
807
+ schema: { type: 'object', additionalProperties: true },
808
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
809
+ },
810
+ presentCall: () => ({ card: 'generic', title: 'Browser identity', kind: 'other' }),
811
+ async execute(_args, exec) {
812
+ return await controller.execute('browser.info', {}, exec.signal);
813
+ },
814
+ }));
815
+ ctx.tools.register(defineTool({
816
+ name: 'browser_tabs',
817
+ 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.',
818
+ parameters: {
819
+ action: { type: 'string', required: true, description: 'One of: list, open, close, activate, group, ungroup.' },
820
+ url: { type: 'string', description: 'URL for the open action.' },
821
+ tabId: { type: 'number', description: 'Target tab for close/activate/group.' },
822
+ tabIds: { type: 'array', items: { type: 'number' }, 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.' },
823
+ active: { type: 'boolean', description: 'Whether a newly opened tab becomes active; defaults to true.' },
824
+ group: { type: 'boolean', description: 'For open: put the new tab in the DSH tab group. Defaults to true; pass false to keep it loose.' },
825
+ windowId: { type: 'number', description: 'For ungroup: only dissolve that window’s DSH group; omit to clear every window.' },
826
+ },
827
+ output: {
828
+ schema: { type: 'object', additionalProperties: true },
829
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
830
+ },
831
+ presentCall: args => ({ card: 'generic', title: `Browser tabs: ${args.action}`, kind: 'other' }),
832
+ async execute(args, exec) {
833
+ switch (args.action) {
834
+ case 'list':
835
+ return await controller.execute('tabs.list', {}, exec.signal);
836
+ case 'open': {
837
+ if (typeof args.url !== 'string' || args.url.length === 0)
838
+ throw new Error('open requires url');
839
+ const params = { url: args.url };
840
+ if (args.active !== undefined)
841
+ params.active = args.active;
842
+ if (args.group !== undefined)
843
+ params.group = args.group;
844
+ return await controller.execute('tabs.open', params, exec.signal);
845
+ }
846
+ case 'close': {
847
+ const ids = closeTabIds(args);
848
+ // One id keeps the single-tab command; several go out as ONE command
849
+ // (`tabs.closeMany`) instead of N round-trips that can die in between.
850
+ return ids.length === 1
851
+ ? await controller.execute('tabs.close', { tabId: ids[0] }, exec.signal)
852
+ : await controller.execute('tabs.closeMany', { tabIds: ids }, exec.signal);
853
+ }
854
+ case 'activate':
855
+ return await controller.execute('tabs.activate', { tabId: requireTabId(args, 'activate') }, exec.signal);
856
+ case 'group':
857
+ return await controller.execute('tabs.group', { tabId: requireTabId(args, 'group') }, exec.signal);
858
+ case 'ungroup': {
859
+ const params = {};
860
+ if (typeof args.windowId === 'number')
861
+ params.windowId = args.windowId;
862
+ return await controller.execute('tabs.ungroup', params, exec.signal);
863
+ }
864
+ default:
865
+ throw new Error(`unknown tabs action: ${String(args.action)} (use list|open|close|activate|group|ungroup)`);
866
+ }
867
+ },
868
+ }));
869
+ ctx.tools.register(defineTool({
870
+ name: 'browser_evaluate',
871
+ description: 'Run JavaScript in the page and get the JSON result back as a string. Prefer read-only inspection.',
872
+ parameters: {
873
+ expression: { type: 'string', required: true, description: 'JavaScript expression or statement sequence; awaited like a promise body.' },
874
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
875
+ },
876
+ output: {
877
+ schema: {
878
+ type: 'object',
879
+ additionalProperties: false,
880
+ properties: {
881
+ tabId: { type: 'number', required: true },
882
+ json: { type: 'string', required: true },
883
+ },
884
+ },
885
+ render: (_args, value) => [{ type: 'text', text: clampRendered(value.json) }],
886
+ },
887
+ presentCall: () => ({ card: 'generic', title: 'Evaluate in page', kind: 'other' }),
888
+ async execute(args, exec) {
889
+ const raw = await controller.execute('eval', args.tabId === undefined ? { expression: args.expression } : { expression: args.expression, tabId: args.tabId }, exec.signal);
890
+ let json;
891
+ try {
892
+ json = JSON.stringify(raw.value) ?? String(raw.value);
893
+ }
894
+ catch {
895
+ json = String(raw.value);
896
+ }
897
+ return { tabId: raw.tabId, json };
898
+ },
899
+ }));
900
+ ctx.tools.register(defineTool({
901
+ name: 'browser_screenshot',
902
+ description: 'Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path.',
903
+ parameters: {
904
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
905
+ fullPage: { type: 'boolean', description: 'Capture beyond the viewport.' },
906
+ format: { type: 'string', description: '"png" (default) or "jpeg".' },
907
+ },
908
+ output: {
909
+ schema: {
910
+ type: 'object',
911
+ additionalProperties: false,
912
+ properties: {
913
+ file: { type: 'string', required: true },
914
+ bytes: { type: 'number', required: true },
915
+ tabId: { type: 'number', required: true },
916
+ title: { type: 'string' },
917
+ url: { type: 'string' },
918
+ },
919
+ },
920
+ render: (_args, value) => [{ type: 'text', text: `Saved ${value.file} (${value.bytes} bytes)` }],
921
+ },
922
+ presentCall: () => ({ card: 'generic', title: 'Browser screenshot', kind: 'other' }),
923
+ async execute(args, exec) {
924
+ const format = args.format === 'jpeg' ? 'jpeg' : 'png';
925
+ const params = { format };
926
+ if (args.tabId !== undefined)
927
+ params.tabId = args.tabId;
928
+ if (args.fullPage !== undefined)
929
+ params.fullPage = args.fullPage;
930
+ const payload = await controller.execute('screenshot', params, exec.signal);
931
+ return saveScreenshot(controller, payload);
932
+ },
933
+ }));
934
+ ctx.tools.register(defineTool({
935
+ name: 'browser_cleanup',
936
+ description: 'Delete generated screenshots and agent scratch files (__-prefixed temp scripts/artifacts) from their top-level directories.',
937
+ parameters: {},
938
+ output: {
939
+ schema: {
940
+ type: 'object',
941
+ additionalProperties: false,
942
+ properties: {
943
+ shotsRemoved: { type: 'number', required: true },
944
+ scratchRemoved: { type: 'array', required: true, items: { type: 'string' } },
945
+ },
946
+ },
947
+ render: (_args, value) => [{
948
+ type: 'text',
949
+ text: `Cleaned ${value.shotsRemoved} screenshot(s) and ${value.scratchRemoved.length} scratch file(s)`,
950
+ }],
951
+ },
952
+ presentCall: () => ({ card: 'generic', title: 'Clean up browser artifacts', kind: 'other' }),
953
+ async execute() {
954
+ const result = await controller.cleanup();
955
+ return { shotsRemoved: result.shotsRemoved, scratchRemoved: Array.from(result.scratchRemoved) };
956
+ },
957
+ }));
958
+ // v1.0.7: console + network capture, PDF export, device emulation.
959
+ ctx.tools.register(defineTool({
960
+ name: 'browser_console_log',
961
+ 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".',
962
+ parameters: {
963
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
964
+ levels: { type: 'array', items: { type: 'string' }, description: 'Filter to one or more of: log, info, warn, error, debug.' },
965
+ pattern: { type: 'string', description: 'Regex (case-insensitive) matched against the formatted text.' },
966
+ limit: { type: 'number', description: 'Maximum entries to return; default 100, capped at 500.' },
967
+ clear: { type: 'boolean', description: 'Empty the buffer after reading.' },
968
+ },
969
+ output: {
970
+ schema: {
971
+ type: 'object',
972
+ additionalProperties: false,
973
+ properties: {
974
+ tabId: { type: 'number', required: true },
975
+ count: { type: 'number', required: true },
976
+ total: { type: 'number', required: true },
977
+ entries: {
978
+ type: 'array',
979
+ required: true,
980
+ items: {
981
+ type: 'object',
982
+ additionalProperties: false,
983
+ properties: {
984
+ level: { type: 'string', required: true },
985
+ text: { type: 'string', required: true },
986
+ time: { type: 'string' },
987
+ location: { type: 'string' },
988
+ },
989
+ },
990
+ },
991
+ },
992
+ },
993
+ render: (_args, value) => {
994
+ const head = `Tab ${value.tabId}: ${value.count} of ${value.total} console entries`
995
+ + ` (times UTC)${value.count < value.total ? ` — showing the newest ${value.count}; raise limit to see more` : ''}:`;
996
+ const lines = value.entries.map((entry, index) => `${index + 1}. [${entry.time ?? '?'}] ${entry.level}${entry.location === undefined ? '' : ` @${entry.location}`} — ${entry.text}`);
997
+ return [{ type: 'text', text: clampRendered([head, ...lines].join('\n'), LOG_RENDER_MAX_CHARS) }];
998
+ },
999
+ },
1000
+ presentCall: () => ({ card: 'generic', title: 'Read browser console', kind: 'other' }),
1001
+ async execute(args, exec) {
1002
+ const params = {};
1003
+ if (args.tabId !== undefined)
1004
+ params.tabId = args.tabId;
1005
+ if (Array.isArray(args.levels))
1006
+ params.levels = args.levels;
1007
+ if (typeof args.pattern === 'string')
1008
+ params.pattern = args.pattern;
1009
+ if (typeof args.limit === 'number')
1010
+ params.limit = args.limit;
1011
+ if (args.clear === true)
1012
+ params.clear = true;
1013
+ const raw = await controller.execute('console.log', params, exec.signal);
1014
+ return {
1015
+ tabId: raw.tabId,
1016
+ count: raw.count,
1017
+ total: raw.total,
1018
+ entries: (raw.entries ?? []).map(entry => ({
1019
+ level: entry.level ?? 'log',
1020
+ text: flattenOneLine(entry.text ?? '', 400),
1021
+ ...(entry.t === undefined ? {} : { time: formatClock(entry.t) }),
1022
+ ...(entry.location === undefined ? {} : { location: entry.location }),
1023
+ })),
1024
+ };
1025
+ },
1026
+ }));
1027
+ ctx.tools.register(defineTool({
1028
+ name: 'browser_network_log',
1029
+ 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.',
1030
+ parameters: {
1031
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1032
+ includeStatic: { type: 'boolean', description: 'Include images / fonts / stylesheets / scripts. Default false.' },
1033
+ methodPattern: { type: 'string', description: 'Regex (case-insensitive) matched against the HTTP method.' },
1034
+ urlPattern: { type: 'string', description: 'Regex (case-insensitive) matched against the URL.' },
1035
+ status: { type: 'string', description: 'One of: 2xx, 3xx, 4xx, 5xx, failed, pending.' },
1036
+ limit: { type: 'number', description: 'Maximum entries to return; default 200, capped at 1000.' },
1037
+ clear: { type: 'boolean', description: 'Empty the buffer after reading.' },
1038
+ },
1039
+ output: {
1040
+ schema: {
1041
+ type: 'object',
1042
+ additionalProperties: false,
1043
+ properties: {
1044
+ tabId: { type: 'number', required: true },
1045
+ count: { type: 'number', required: true },
1046
+ total: { type: 'number', required: true },
1047
+ requests: {
1048
+ type: 'array',
1049
+ required: true,
1050
+ items: {
1051
+ type: 'object',
1052
+ additionalProperties: false,
1053
+ properties: {
1054
+ method: { type: 'string', required: true },
1055
+ url: { type: 'string', required: true },
1056
+ resourceType: { type: 'string' },
1057
+ status: { type: 'number' },
1058
+ statusText: { type: 'string' },
1059
+ mimeType: { type: 'string' },
1060
+ bytes: { type: 'number' },
1061
+ time: { type: 'string' },
1062
+ finished: { type: 'boolean' },
1063
+ failed: { type: 'boolean' },
1064
+ canceled: { type: 'boolean' },
1065
+ errorText: { type: 'string' },
1066
+ },
1067
+ },
1068
+ },
1069
+ },
1070
+ },
1071
+ render: (_args, value) => {
1072
+ const head = `Tab ${value.tabId}: ${value.count} of ${value.total} network requests`
1073
+ + ` (times UTC)${value.count < value.total ? ` — showing the newest ${value.count}; raise limit or filter by urlPattern/status` : ''}:`;
1074
+ const lines = value.requests.map((row, index) => {
1075
+ const outcome = row.failed === true
1076
+ ? `FAILED ${row.errorText ?? ''}`.trim()
1077
+ : row.status === undefined ? 'PENDING' : `${row.status} ${row.statusText ?? ''}`.trim();
1078
+ const size = row.bytes === undefined ? '' : ` ${formatBytes(row.bytes)}`;
1079
+ const type = row.resourceType === undefined ? '' : ` ${row.resourceType}`;
1080
+ return `${index + 1}. [${row.time ?? '?'}] ${row.method} ${outcome}${type}${size} ${row.url}`;
1081
+ });
1082
+ return [{ type: 'text', text: clampRendered([head, ...lines].join('\n'), LOG_RENDER_MAX_CHARS) }];
1083
+ },
1084
+ },
1085
+ presentCall: () => ({ card: 'generic', title: 'Read browser network log', kind: 'other' }),
1086
+ async execute(args, exec) {
1087
+ const params = {};
1088
+ if (args.tabId !== undefined)
1089
+ params.tabId = args.tabId;
1090
+ if (args.includeStatic === true)
1091
+ params.includeStatic = true;
1092
+ if (typeof args.methodPattern === 'string')
1093
+ params.methodPattern = args.methodPattern;
1094
+ if (typeof args.urlPattern === 'string')
1095
+ params.urlPattern = args.urlPattern;
1096
+ if (typeof args.status === 'string')
1097
+ params.status = args.status;
1098
+ if (typeof args.limit === 'number')
1099
+ params.limit = args.limit;
1100
+ if (args.clear === true)
1101
+ params.clear = true;
1102
+ const raw = await controller.execute('network.log', params, exec.signal);
1103
+ return {
1104
+ tabId: raw.tabId,
1105
+ count: raw.count,
1106
+ total: raw.total,
1107
+ requests: (raw.requests ?? []).map(row => networkRow(row)),
1108
+ };
1109
+ },
1110
+ }));
1111
+ ctx.tools.register(defineTool({
1112
+ name: 'browser_network_clear',
1113
+ description: 'Empty the per-tab network capture buffer without returning the rows.',
1114
+ parameters: {
1115
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1116
+ },
1117
+ output: {
1118
+ schema: {
1119
+ type: 'object',
1120
+ additionalProperties: false,
1121
+ properties: {
1122
+ tabId: { type: 'number', required: true },
1123
+ cleared: { type: 'boolean', required: true },
1124
+ },
1125
+ },
1126
+ render: (_args, value) => [{ type: 'text', text: `Tab ${value.tabId}: network log cleared` }],
1127
+ },
1128
+ presentCall: () => ({ card: 'generic', title: 'Clear browser network log', kind: 'other' }),
1129
+ async execute(args, exec) {
1130
+ const params = {};
1131
+ if (args.tabId !== undefined)
1132
+ params.tabId = args.tabId;
1133
+ return await controller.execute('network.clear', params, exec.signal);
1134
+ },
1135
+ }));
1136
+ ctx.tools.register(defineTool({
1137
+ name: 'browser_pdf',
1138
+ 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.',
1139
+ parameters: {
1140
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1141
+ path: { type: 'string', description: 'Absolute path. Omit to save under the configured shotsDir with a timestamped name.' },
1142
+ landscape: { type: 'boolean', description: 'Use landscape orientation.' },
1143
+ printBackground: { type: 'boolean', description: 'Render CSS backgrounds. Default true.' },
1144
+ paperWidth: { type: 'number', description: 'Paper width in inches.' },
1145
+ paperHeight: { type: 'number', description: 'Paper height in inches.' },
1146
+ scale: { type: 'number', description: 'Page scale (0.1–2.0).' },
1147
+ pageRanges: { type: 'string', description: 'Sub-range, e.g. "1-3" or "1,4-6".' },
1148
+ },
1149
+ output: {
1150
+ schema: {
1151
+ type: 'object',
1152
+ additionalProperties: false,
1153
+ properties: {
1154
+ file: { type: 'string', required: true },
1155
+ bytes: { type: 'number', required: true },
1156
+ tabId: { type: 'number', required: true },
1157
+ },
1158
+ },
1159
+ render: (_args, value) => [{
1160
+ type: 'text',
1161
+ text: `PDF written to ${value.file} (${(value.bytes / 1024).toFixed(1)} KB)`,
1162
+ }],
1163
+ },
1164
+ presentCall: args => ({ card: 'generic', title: `Save PDF${args.path ? ' → ' + args.path : ''}`, kind: 'other' }),
1165
+ async execute(args, exec) {
1166
+ const params = {};
1167
+ if (args.tabId !== undefined)
1168
+ params.tabId = args.tabId;
1169
+ if (args.landscape === true)
1170
+ params.landscape = true;
1171
+ if (args.printBackground === false)
1172
+ params.printBackground = false;
1173
+ if (typeof args.paperWidth === 'number')
1174
+ params.paperWidth = args.paperWidth;
1175
+ if (typeof args.paperHeight === 'number')
1176
+ params.paperHeight = args.paperHeight;
1177
+ if (typeof args.scale === 'number')
1178
+ params.scale = args.scale;
1179
+ if (typeof args.pageRanges === 'string')
1180
+ params.pageRanges = args.pageRanges;
1181
+ const payload = await controller.execute('pdf', params, exec.signal);
1182
+ return await savePdf(controller, payload, typeof args.path === 'string' ? args.path : undefined);
1183
+ },
1184
+ }));
1185
+ ctx.tools.register(defineTool({
1186
+ name: 'browser_emulate',
1187
+ 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.',
1188
+ parameters: {
1189
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1190
+ device: { type: 'string', description: 'Preset: desktop | mobile-iphone-13 | mobile-pixel-7 | tablet-ipad | reset. Or pass custom width/height below.' },
1191
+ width: { type: 'number', description: 'Custom viewport width in CSS px.' },
1192
+ height: { type: 'number', description: 'Custom viewport height in CSS px.' },
1193
+ deviceScaleFactor: { type: 'number', description: 'Custom DPR (1 = standard, 2 = retina, 3 = super-retina).' },
1194
+ isMobile: { type: 'boolean', description: 'Pass as mobile to the page (affects responsive meta).' },
1195
+ hasTouch: { type: 'boolean', description: 'Enable touch event dispatch.' },
1196
+ userAgent: { type: 'string', description: 'Custom User-Agent string. Empty string clears.' },
1197
+ },
1198
+ output: {
1199
+ schema: {
1200
+ type: 'object',
1201
+ additionalProperties: false,
1202
+ properties: {
1203
+ tabId: { type: 'number', required: true },
1204
+ reset: { type: 'boolean' },
1205
+ width: { type: 'number' },
1206
+ height: { type: 'number' },
1207
+ deviceScaleFactor: { type: 'number' },
1208
+ isMobile: { type: 'boolean' },
1209
+ hasTouch: { type: 'boolean' },
1210
+ userAgent: { type: 'string' },
1211
+ },
1212
+ },
1213
+ render: (_args, value) => {
1214
+ if (value.reset)
1215
+ return [{ type: 'text', text: `Tab ${value.tabId}: emulation reset to default` }];
1216
+ // 覆盖生效的是哪个 UA 必须写出来:模型只看这句,看不见 value 里的字段。
1217
+ const ua = value.userAgent === undefined ? '' : ` UA="${flattenOneLine(value.userAgent, 80)}"`;
1218
+ return [{
1219
+ type: 'text',
1220
+ text: `Tab ${value.tabId}: ${value.width || '?'}×${value.height || '?'} DPR=${value.deviceScaleFactor ?? '?'} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}`,
1221
+ }];
1222
+ },
1223
+ },
1224
+ presentCall: args => ({ card: 'generic', title: `Emulate${args.device ? ' ' + args.device : ' device'}`, kind: 'other' }),
1225
+ async execute(args, exec) {
1226
+ const params = {};
1227
+ if (args.tabId !== undefined)
1228
+ params.tabId = args.tabId;
1229
+ if (typeof args.device === 'string')
1230
+ params.device = args.device;
1231
+ if (typeof args.width === 'number')
1232
+ params.width = args.width;
1233
+ if (typeof args.height === 'number')
1234
+ params.height = args.height;
1235
+ if (typeof args.deviceScaleFactor === 'number')
1236
+ params.deviceScaleFactor = args.deviceScaleFactor;
1237
+ if (typeof args.isMobile === 'boolean')
1238
+ params.isMobile = args.isMobile;
1239
+ if (typeof args.hasTouch === 'boolean')
1240
+ params.hasTouch = args.hasTouch;
1241
+ if (typeof args.userAgent === 'string')
1242
+ params.userAgent = args.userAgent;
1243
+ const raw = await controller.execute('emulate', params, exec.signal);
1244
+ // 扩展在无覆盖时回的是显式 `userAgent: null`(background.js:719),而宿主的
1245
+ // 校验器对 null **会下钻判类型**(只跳过 undefined)。原样透传时
1246
+ // `device:"desktop"` 或自定义宽高的每次调用都会抛 ToolOutputError。
1247
+ return {
1248
+ tabId: raw.tabId,
1249
+ ...(raw.reset === true ? { reset: true } : {}),
1250
+ ...(typeof raw.width === 'number' ? { width: raw.width } : {}),
1251
+ ...(typeof raw.height === 'number' ? { height: raw.height } : {}),
1252
+ ...(typeof raw.deviceScaleFactor === 'number' ? { deviceScaleFactor: raw.deviceScaleFactor } : {}),
1253
+ ...(typeof raw.isMobile === 'boolean' ? { isMobile: raw.isMobile } : {}),
1254
+ ...(typeof raw.hasTouch === 'boolean' ? { hasTouch: raw.hasTouch } : {}),
1255
+ ...(typeof raw.userAgent === 'string' && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {}),
1256
+ };
1257
+ },
1258
+ }));
1259
+ }
1260
+ /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
1261
+ export function apply(ctx, config) {
1262
+ const resolved = config;
1263
+ if (resolved.enabled && resolved.token.trim().length === 0) {
1264
+ throw new Error('browser-bridge: token must be a non-empty string when enabled');
1265
+ }
1266
+ const controller = new BridgeController(line => ctx.logger.info(line));
1267
+ let current = () => resolved;
1268
+ // Settings wiring, feature-detected. `sctx.settings.register` exists on
1269
+ // dsh-settings up to 0.1.2-rc.1; 0.1.7-rc.2 and 0.2.x export `SettingsForms`
1270
+ // instead, whose surface is configure/describe/update/replace/mutate — calling
1271
+ // `register` there throws a TypeError in the middle of this callback and used to
1272
+ // skip the reconcile/watch below (issue #8).
1273
+ ctx.inject(['settings'], (sctx) => {
1274
+ const settings = sctx.settings;
1275
+ /** Converge without a namespace: the profile config stays authoritative. */
1276
+ const withoutNamespace = (why) => {
1277
+ current = () => resolved;
1278
+ ctx.logger.info(`browser-bridge: settings integration skipped (${why})`);
1279
+ // A failed start is recorded on the controller so every tool call reports it,
1280
+ // instead of throwing out of a callback the host never awaits.
1281
+ void controller.reconcile(current());
1282
+ };
1283
+ const register = settings.register;
1284
+ if (typeof register !== 'function') {
1285
+ // dsh-settings 0.1.7+ / 0.2.x: the host projects this plugin's own `Config`
1286
+ // schema into an auto settings page, and editing that page re-applies the
1287
+ // entry, so the `config` handed to apply() is already the live value.
1288
+ const configure = settings.configure;
1289
+ if (typeof configure === 'function') {
1290
+ try {
1291
+ ctx.effect(() => configure({ auto: true }), 'browser-bridge: settings presentation');
1292
+ }
1293
+ catch (error) {
1294
+ ctx.logger.info(`browser-bridge: settings presentation skipped: ${errorMessage(error)}`);
1295
+ }
1296
+ }
1297
+ withoutNamespace('no settings.register on this dsh-settings build');
1298
+ return;
1299
+ }
1300
+ let scope;
1301
+ try {
1302
+ scope = register(BROWSER_BRIDGE_SETTINGS_NAMESPACE, Config, {
1303
+ base: config,
1304
+ validate: (value) => {
1305
+ if (value.enabled && (value.token ?? '').trim().length === 0) {
1306
+ throw new Error('browser-bridge: token must be a non-empty string when enabled');
1307
+ }
1308
+ },
1309
+ });
1310
+ }
1311
+ catch (error) {
1312
+ ctx.logger.info(`browser-bridge: settings namespace failed: ${errorMessage(error)}`);
1313
+ withoutNamespace('register threw');
1314
+ return;
1315
+ }
1316
+ current = () => scope.get();
1317
+ ctx.effect(() => () => {
1318
+ // Mirror `isUnloading` from dsh-settings (private): the fiber's own
1319
+ // unload path runs the disposer too, and there re-applying the
1320
+ // composition entry and firing `onChange` would re-register routes
1321
+ // against a fiber whose resources are being released.
1322
+ if (ctx.fiber.state === 4 /* FiberState.DISPOSED */ || ctx.fiber.state === 5 /* FiberState.UNLOADING */)
1323
+ return;
1324
+ current = () => resolved;
1325
+ void controller.reconcile(current());
1326
+ }, 'browser-bridge: settings cleanup');
1327
+ void controller.reconcile(current());
1328
+ scope.watch(() => {
1329
+ if (ctx.fiber.state === 4 /* FiberState.DISPOSED */ || ctx.fiber.state === 5 /* FiberState.UNLOADING */)
1330
+ return;
1331
+ void controller.reconcile(current());
1332
+ });
1333
+ });
1334
+ // Activation converges loudly so a bad port fails the plugin at load;
1335
+ // later settings commits degrade to recorded errors instead.
1336
+ controller.reconcile(resolved, { throwOnError: resolved.enabled }).catch((error) => {
1337
+ throw new Error(`browser-bridge: ${errorMessage(error)}`);
1338
+ });
1339
+ applyBrowserTools(ctx, controller);
1340
+ ctx.effect(() => () => {
1341
+ void controller.stop();
1342
+ }, 'browser-bridge: server lifecycle');
1343
+ }