@angelky7/dsh-browser-control 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +8 -5
- package/README.md +8 -5
- package/lib/index.js +201 -12
- package/lib/types/errors.js +3 -0
- package/lib/types/index.js +170 -5
- package/lib/types/policy.js +3 -0
- package/lib/types/server.d.ts +5 -0
- package/lib/types/server.js +5 -1
- package/llms.txt +8 -1
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -64,8 +64,8 @@ In one line: **for "use MY browser" tasks (logged-in Bilibili, Zhihu, admin pane
|
|
|
64
64
|
|
|
65
65
|
| File | Purpose |
|
|
66
66
|
|---|---|
|
|
67
|
-
| [DSH-Browser-Control-v1.7.
|
|
68
|
-
| [dsh-browser-control-plugin-v1.7.
|
|
67
|
+
| [DSH-Browser-Control-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/DSH-Browser-Control-v1.7.2.zip) | Chrome extension (unzip and load; this release adds the cdp channel and capabilities handshake — **reinstall required, and the last one a feature update will ever ask for**: CDP-level features now ship plugin-side) |
|
|
68
|
+
| [dsh-browser-control-plugin-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/dsh-browser-control-plugin-v1.7.2.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
|
|
69
69
|
|
|
70
70
|
## Install the Chrome extension (30 seconds)
|
|
71
71
|
|
|
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
|
|
|
104
104
|
|
|
105
105
|
```bash
|
|
106
106
|
# Straight from GitHub
|
|
107
|
-
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.
|
|
107
|
+
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.2"
|
|
108
108
|
|
|
109
109
|
# Local checkout for debugging (note: the explicit file: prefix is required)
|
|
110
110
|
dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
|
|
@@ -265,11 +265,12 @@ curl -X POST http://127.0.0.1:9777/api/command \
|
|
|
265
265
|
| `browser_pdf` | Export the current page as PDF (v1.0.7+) |
|
|
266
266
|
| `browser_archive` | Save the whole page as a single-file MHTML (text/images/styles inline; a greppable offline evidence snapshot — embedded scripts do not run, v1.5.0+) |
|
|
267
267
|
| `browser_windows` | Window control: list / open / update state & geometry / close a whole window / move tabs across windows (the only tool that may touch window geometry, v1.6.0+) |
|
|
268
|
-
| `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+) |
|
|
268
|
+
| `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+); v1.7.1+ adds throttling: `network` slow3g/fast3g/offline/reset + `cpuThrottlingRate` (1–20), the receipt states the applied numbers, `device:"reset"` clears them too |
|
|
269
|
+
| `browser_metrics` | Read performance counters (DOM nodes / JS heap / layout & script durations, Performance.getMetrics, read-only; puts numbers behind "this page is heavy", v1.7.1+) |
|
|
269
270
|
| `browser_cleanup` | Clean up temp files |
|
|
270
271
|
| `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
|
|
271
272
|
|
|
272
|
-
|
|
273
|
+
25 tools in total. `browser_wait`, `browser_find`, `browser_dialog` and `browser_info` existed inside the extension up to v1.0.8 but were never registered as tools, so the agent could not reach them; `browser_network_clear` existed in code and was simply missing from this table. `verify/verify_surface.mjs` now reconciles the extension command table, the commands the plugin calls, this table and the built bundle, so any drift fails `npm run verify`.
|
|
273
274
|
|
|
274
275
|
## Architecture
|
|
275
276
|
|
|
@@ -287,6 +288,7 @@ Chrome browser
|
|
|
287
288
|
- Persistent debugger attachment — banner stays visible during control
|
|
288
289
|
- Listens on 127.0.0.1 only; the command surface (WebSocket and HTTP) shares one token, and no cross-origin access is granted
|
|
289
290
|
- The wire protocol has its own version, bumped only when the frame contract changes; a mismatch is named on the status page
|
|
291
|
+
- **Plugin upgrades never regress old extensions** (iron rule: plugin x + extension x-n ≥ plugin x-n + extension x-n): from v1.7.2 the hello frame carries capabilities for an exact handshake, old extensions fall back to the version map, and a missing command is refused pre-flight with an actionable reinstall pointer instead of a raw unknown command — CDP-level features now ship plugin-side
|
|
290
292
|
|
|
291
293
|
## Verified
|
|
292
294
|
|
|
@@ -300,6 +302,7 @@ Chrome browser
|
|
|
300
302
|
| `verify_extension_e2e` harness: real bridge + real extension code + real WebSocket end to end (frame contract, command reachability, error classification) | ✅ 2026-10-11 — only `chrome.*` is faked; visuals and real CDP behaviour still need a live browser |
|
|
301
303
|
| `verify_cdp_retry` harness: session recovery after Chrome steals the attach, replay must not re-run side effects, bounded retries (6 scenarios) | ✅ 2026-10-11, real `background.js` against a stateful fake `chrome.debugger`; cross-checked on a merge-preview build |
|
|
302
304
|
| `verify_render_contract` harness: every tool's real return value goes through the host's own output validator **and** its own `render`, asserting the model-facing text actually contains the data the call asked for | ✅ 2026-10-12, real bridge + fake extension + host `validateJsonSchemaValue`; this layer had **zero coverage** before, which is how two defect classes survived 12 / 5 released versions |
|
|
305
|
+
| `verify_compat_matrix` harness: materializes **real historical extension code from git tags** (v1.2.0–v1.7.1) and runs 27 probes per surface against the current plugin, cell-by-cell asserting “≥ the matched pair from that era”; newer tools must fail with the actionable extension-outdated pointer, never a raw unknown command; append-only command surface (extension upgrades are pure gains for old plugins too) | ✅ 2026-11-04, 5 surfaces × 150 checks; exercises the version-map fallback path real old hellos take |
|
|
303
306
|
| **Real host × real browser**: local dsh `0.2.0-rc.2` mounted this plugin from an isolated `DSH_HOME`, the user's actual Chrome attached to the bridge, and 9 read-only tools ran through the real code path | ✅ 2026-10-12, Chrome 153 + extension 1.0.10: `browser_read` returned 1,341 chars of real page text, `browser_snapshot` returned 14 ref-bearing lines, zero output-schema violations; **this is also the run that exposed an older extension silently ignoring `mode:"ax"`** (local extension was 1.0.10, predating the 1.0.11 AX snapshot) — the plugin now reports `axFallback` explicitly |
|
|
304
307
|
| `tsc --noEmit` + `publint` | ✅ 2026-10-10 |
|
|
305
308
|
|
package/README.md
CHANGED
|
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
|
|
|
64
64
|
|
|
65
65
|
| 文件 | 说明 |
|
|
66
66
|
|---|---|
|
|
67
|
-
| [DSH-Browser-Control-v1.7.
|
|
68
|
-
| [dsh-browser-control-plugin-v1.7.
|
|
67
|
+
| [DSH-Browser-Control-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/DSH-Browser-Control-v1.7.2.zip) | Chrome 扩展(解压后加载;这一版新增 cdp 通道与 capabilities 握手,**必须重装——也是最后一次因功能更新而重装**,此后 CDP 层新能力只更 dsh 插件) |
|
|
68
|
+
| [dsh-browser-control-plugin-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/dsh-browser-control-plugin-v1.7.2.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
|
|
69
69
|
|
|
70
70
|
## 安装 Chrome 扩展(30 秒)
|
|
71
71
|
|
|
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
|
|
|
104
104
|
|
|
105
105
|
```bash
|
|
106
106
|
# 直接从 GitHub 安装
|
|
107
|
-
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.
|
|
107
|
+
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.2"
|
|
108
108
|
|
|
109
109
|
# 本地目录调试(注意:必须显式 file: 前缀)
|
|
110
110
|
dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
|
|
@@ -258,12 +258,13 @@ curl -X POST http://127.0.0.1:9777/api/command \
|
|
|
258
258
|
| `browser_network_clear` | 单独清空网络日志缓冲(v1.0.7+) |
|
|
259
259
|
| `browser_pdf` | 当前页导出 PDF(v1.0.7+) |
|
|
260
260
|
| `browser_archive` | 整页存成单文件 MHTML(正文/图片/样式全内联,可当文本 grep 的离线证据;是快照,内嵌脚本不会跑,v1.5.0+) |
|
|
261
|
-
| `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7
|
|
261
|
+
| `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7+);v1.7.1 起还能限速:`network` slow3g/fast3g/offline/reset + `cpuThrottlingRate`(1–20),回执写明生效数值,`device:"reset"` 一并还原 |
|
|
262
|
+
| `browser_metrics` | 读性能计数器(DOM 节点数 / JS 堆 / 布局与脚本耗时,Performance.getMetrics,只读;让「这页面很重」有数字说话,v1.7.1+) |
|
|
262
263
|
| `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
|
|
263
264
|
| `browser_cleanup` | 清理临时文件 |
|
|
264
265
|
| `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
|
|
265
266
|
|
|
266
|
-
共
|
|
267
|
+
共 25 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
|
|
267
268
|
|
|
268
269
|
## 架构
|
|
269
270
|
|
|
@@ -281,6 +282,7 @@ Chrome 浏览器
|
|
|
281
282
|
- 持久 debugger 附着——控制期间横幅始终显示
|
|
282
283
|
- 仅监听 127.0.0.1;命令面(WS 与 HTTP)共用同一个 Token,跨源一律不放行
|
|
283
284
|
- 协议版本独立于版本号:帧契约变了才升,不匹配时状态页直接写出该升级哪一侧
|
|
285
|
+
- **插件升级对旧扩展零倒退**(铁律:插件 x + 扩展 x-n ≥ 插件 x-n + 扩展 x-n):v1.7.2 起 hello 带 capabilities 精确握手,老扩展走版本映射兜底;缺命令的动作在发送前拦下,给可执行的重装指引而不是裸 unknown command——CDP 层新能力此后只更插件
|
|
284
286
|
|
|
285
287
|
## 已验证
|
|
286
288
|
|
|
@@ -294,6 +296,7 @@ Chrome 浏览器
|
|
|
294
296
|
| 实测台 `verify_extension_e2e`:真桥 + 真扩展代码 + 真 WebSocket 端到端(帧契约、命令可达、错误分类) | ✅ 2026-10-11,只有 `chrome.*` 是假的;视觉与真实 CDP 行为仍需实机确认 |
|
|
295
297
|
| 实测台 `verify_cdp_retry`:CDP 会话被抢走后的恢复 / 重放不许把副作用做两遍 / 重试有界(6 场景) | ✅ 2026-10-11,真实 `background.js` + 有状态假 `chrome.debugger`;另用合并预览文件交叉验证过 |
|
|
296
298
|
| 实测台 `verify_render_contract`:每条工具的真实返回值同时喂给宿主的输出校验器与它自己的 `render`,断言「模型可见文本里有这次调用真正要的数据」 | ✅ 2026-10-12,真桥 + 假扩展 + 宿主 `validateJsonSchemaValue`;这一层此前**零覆盖**,所以两类缺陷活了 12 / 5 个版本 |
|
|
299
|
+
| 实测台 `verify_compat_matrix`:兼容矩阵——从 git 标签物化**真实历史扩展代码**(v1.2.0–v1.7.1),当前插件对每个面跑 27 个探针逐格验收「≥ 当年组合」;新工具报带重装指引的 extension-outdated、绝不许 unknown command 冒充;命令面 append-only(扩展升级对旧插件也纯增益) | ✅ 2026-11-04,5 面 × 150 项;老 hello 无 capabilities ⇒ 版本映射兜底路径的实测 |
|
|
297
300
|
| **真实宿主 × 真实浏览器**:本机 dsh `0.2.0-rc.2` 用隔离 `DSH_HOME` 挂载本插件,用户的真实 Chrome 连上桥,9 条只读工具全部走真实代码路径 | ✅ 2026-10-12,Chrome 153 + 扩展 1.0.10:`browser_read` 交出 1341 字符真实正文、`browser_snapshot` 交出 14 条带 ref 的清单、输出 schema 零违规;**也正是这一跑发现了旧扩展会静默忽略 `mode:"ax"`**(本机扩展是 1.0.10,早于 AX 快照的 1.0.11)——已改成插件侧显式回报 `axFallback` |
|
|
298
301
|
| `tsc --noEmit` + `publint` | ✅ 2026-10-10 |
|
|
299
302
|
|
package/lib/index.js
CHANGED
|
@@ -119,6 +119,7 @@ const WRITE_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
119
119
|
"dialog",
|
|
120
120
|
"tabs.back",
|
|
121
121
|
"tabs.forward",
|
|
122
|
+
"cdp",
|
|
122
123
|
"cleanup",
|
|
123
124
|
"hold",
|
|
124
125
|
"windows.open"
|
|
@@ -663,6 +664,7 @@ Sec-WebSocket-Accept: ${accept}\r\n\r
|
|
|
663
664
|
}
|
|
664
665
|
if (msg.type === "hello") {
|
|
665
666
|
const declared = typeof msg.protocol === "number" ? msg.protocol : 0;
|
|
667
|
+
const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities.filter((c) => typeof c === "string") : void 0;
|
|
666
668
|
this.hello = {
|
|
667
669
|
client: typeof msg.client === "string" ? msg.client : "unknown",
|
|
668
670
|
version: typeof msg.version === "string" ? msg.version : "unknown",
|
|
@@ -671,9 +673,10 @@ Sec-WebSocket-Accept: ${accept}\r\n\r
|
|
|
671
673
|
name: "unknown",
|
|
672
674
|
version: "unknown",
|
|
673
675
|
ua: ""
|
|
674
|
-
}
|
|
676
|
+
},
|
|
677
|
+
...capabilities !== void 0 ? { capabilities } : {}
|
|
675
678
|
};
|
|
676
|
-
this.log(`hello: client=${this.hello.client} v${this.hello.version} protocol=v${declared || "?"} browser=${this.hello.browser.name} ${this.hello.browser.version}`);
|
|
679
|
+
this.log(`hello: client=${this.hello.client} v${this.hello.version} protocol=v${declared || "?"} browser=${this.hello.browser.name} ${this.hello.browser.version}${capabilities ? ` capabilities=${capabilities.length}` : ""}`);
|
|
677
680
|
const note = this.protocolNote;
|
|
678
681
|
if (note !== null) this.log(`protocol mismatch: ${note}`);
|
|
679
682
|
return;
|
|
@@ -878,7 +881,8 @@ const READ_ONLY_ALLOWED = /* @__PURE__ */ new Set([
|
|
|
878
881
|
"console.log",
|
|
879
882
|
"network.log",
|
|
880
883
|
"windows.list",
|
|
881
|
-
"cookies.get"
|
|
884
|
+
"cookies.get",
|
|
885
|
+
"metrics"
|
|
882
886
|
]);
|
|
883
887
|
/**
|
|
884
888
|
* 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
|
|
@@ -983,6 +987,62 @@ const SNAPSHOT_REF_SELECTOR_PATTERN = /^e\d+$/;
|
|
|
983
987
|
* with `offset`, so no single call can put a whole site into the transcript. */
|
|
984
988
|
const READ_WINDOW_MAX_CHARS = 2e4;
|
|
985
989
|
/**
|
|
990
|
+
* Extension command surface, as deltas over the pre-1.5 baseline. Used when a
|
|
991
|
+
* connected extension predates the capability handshake (no `capabilities` in
|
|
992
|
+
* its hello frame): the plugin maps its reported version to the commands it
|
|
993
|
+
* must have. Anything not listed here exists since the baseline and is assumed
|
|
994
|
+
* present; a version newer than every entry is assumed to have everything
|
|
995
|
+
* (forward compatible — an extension never removes commands).
|
|
996
|
+
*/
|
|
997
|
+
const EXTENSION_COMMANDS_BY_VERSION = [
|
|
998
|
+
{
|
|
999
|
+
since: "1.5.0",
|
|
1000
|
+
commands: ["archive"]
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
since: "1.6.0",
|
|
1004
|
+
commands: [
|
|
1005
|
+
"windows.list",
|
|
1006
|
+
"windows.open",
|
|
1007
|
+
"windows.update",
|
|
1008
|
+
"windows.close",
|
|
1009
|
+
"windows.moveTab"
|
|
1010
|
+
]
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
since: "1.7.0",
|
|
1014
|
+
commands: ["tabs.back", "tabs.forward"]
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
since: "1.7.1",
|
|
1018
|
+
commands: ["metrics"]
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
since: "1.7.2",
|
|
1022
|
+
commands: ["cdp"]
|
|
1023
|
+
}
|
|
1024
|
+
];
|
|
1025
|
+
/** Compare two `x.y.z` version strings numerically; unparseable input sorts lowest. */
|
|
1026
|
+
function compareVersions(a, b) {
|
|
1027
|
+
const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)/.exec(v.trim()) ?? []).slice(1).map(Number);
|
|
1028
|
+
const [pa, pb] = [parse(a), parse(b)];
|
|
1029
|
+
if (pa.length === 0 || pb.length === 0) return pa.length === pb.length ? 0 : pa.length === 0 ? -1 : 1;
|
|
1030
|
+
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1031
|
+
return 0;
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* The plugin updated, the extension did not: the invariant is that this combo is
|
|
1035
|
+
* never WORSE than the matched pair from the extension's era — existing tools
|
|
1036
|
+
* behave identically, and tools that need commands the old extension lacks fail
|
|
1037
|
+
* with an actionable upgrade pointer instead of a raw "unknown command".
|
|
1038
|
+
*/
|
|
1039
|
+
function extensionOutdatedError(have, command, since) {
|
|
1040
|
+
const message = "extension-outdated: the connected browser extension" + (have === void 0 ? "" : ` (v${have})`) + ` does not have the "${command}" command this action needs (added in extension v${since}). One-time fix: download DSH-Browser-Control.zip from https://github.com/angelky7/dsh-browser-control/releases/latest, unzip, then chrome://extensions → Developer mode → replace the loaded folder (remove the old one first). That update makes the extension compatible with all future plugin updates — CDP-level features ship plugin-side from v1.7.2 on.`;
|
|
1041
|
+
const error = new Error(message);
|
|
1042
|
+
error.code = "extension_outdated";
|
|
1043
|
+
return error;
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
986
1046
|
* Owns zero or one live {@link BridgeServer} and restarts it whenever the
|
|
987
1047
|
* resolved settings change. Reconciles serialize through a promise chain so a
|
|
988
1048
|
* burst of settings commits cannot interleave stop/start pairs.
|
|
@@ -1001,6 +1061,34 @@ var BridgeController = class {
|
|
|
1001
1061
|
get shotsDir() {
|
|
1002
1062
|
return this.current === void 0 ? void 0 : path.resolve(this.current.shotsDir);
|
|
1003
1063
|
}
|
|
1064
|
+
/** Version the connected extension reported in its hello frame, if any. */
|
|
1065
|
+
extensionVersion() {
|
|
1066
|
+
return this.server?.status.hello?.version;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Whether the connected extension implements `command`. Exact when the hello
|
|
1070
|
+
* carries a capabilities list (v1.7.2+); otherwise derived from the reported
|
|
1071
|
+
* version via the surface-delta map. No connection ⇒ true: the not-enabled
|
|
1072
|
+
* error from execute is the right failure there, not a capability complaint.
|
|
1073
|
+
*/
|
|
1074
|
+
hasExtensionCommand(command) {
|
|
1075
|
+
const hello = this.server?.status.hello;
|
|
1076
|
+
if (hello?.capabilities !== void 0) return hello.capabilities.includes(command);
|
|
1077
|
+
const version = hello?.version;
|
|
1078
|
+
if (version === void 0 || version === "unknown") return true;
|
|
1079
|
+
const entry = EXTENSION_COMMANDS_BY_VERSION.find((e) => e.commands.includes(command));
|
|
1080
|
+
if (entry === void 0) return true;
|
|
1081
|
+
return compareVersions(version, entry.since) >= 0;
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Pre-flight capability gate: throw the actionable upgrade error before the
|
|
1085
|
+
* command is sent, so an old extension answers with a what-to-do, never with
|
|
1086
|
+
* a bare "unknown command" the model cannot act on.
|
|
1087
|
+
*/
|
|
1088
|
+
requireExtensionCommand(command, since) {
|
|
1089
|
+
if (this.hasExtensionCommand(command)) return;
|
|
1090
|
+
throw extensionOutdatedError(this.extensionVersion(), command, since);
|
|
1091
|
+
}
|
|
1004
1092
|
/**
|
|
1005
1093
|
* Converge the live server onto `config`. With `throwOnError`, an initial
|
|
1006
1094
|
* start failure rejects (fail-loud activation); later changes record the
|
|
@@ -2146,7 +2234,11 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2146
2234
|
let budgetMs = NAV_TIERS[waitUntil];
|
|
2147
2235
|
if (typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)) budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
|
|
2148
2236
|
params.timeoutMs = budgetMs;
|
|
2149
|
-
if (args.action === "back")
|
|
2237
|
+
if (args.action === "back") {
|
|
2238
|
+
controller.requireExtensionCommand("tabs.back", "1.7.0");
|
|
2239
|
+
return await controller.execute("tabs.back", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
2240
|
+
}
|
|
2241
|
+
controller.requireExtensionCommand("tabs.forward", "1.7.0");
|
|
2150
2242
|
return await controller.execute("tabs.forward", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
2151
2243
|
}
|
|
2152
2244
|
case "group": return await controller.execute("tabs.group", { tabId: requireTabId(args, "group") }, exec.signal);
|
|
@@ -2243,7 +2335,9 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2243
2335
|
}),
|
|
2244
2336
|
async execute(args, exec) {
|
|
2245
2337
|
switch (args.action) {
|
|
2246
|
-
case "list":
|
|
2338
|
+
case "list":
|
|
2339
|
+
controller.requireExtensionCommand("windows.list", "1.6.0");
|
|
2340
|
+
return await controller.execute("windows.list", {}, exec.signal);
|
|
2247
2341
|
case "open": {
|
|
2248
2342
|
const params = {};
|
|
2249
2343
|
if (args.url !== void 0) params.url = args.url;
|
|
@@ -2256,6 +2350,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2256
2350
|
"height"
|
|
2257
2351
|
]) if (typeof args[k] === "number") params[k] = args[k];
|
|
2258
2352
|
if (args.focused !== void 0) params.focused = args.focused;
|
|
2353
|
+
controller.requireExtensionCommand("windows.open", "1.6.0");
|
|
2259
2354
|
return await controller.execute("windows.open", params, exec.signal);
|
|
2260
2355
|
}
|
|
2261
2356
|
case "update": {
|
|
@@ -2270,15 +2365,19 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2270
2365
|
if (args.focused !== void 0) params.focused = args.focused;
|
|
2271
2366
|
if (args.alwaysOnTop !== void 0) params.alwaysOnTop = args.alwaysOnTop;
|
|
2272
2367
|
if (args.drawAttention === true) params.drawAttention = true;
|
|
2368
|
+
controller.requireExtensionCommand("windows.update", "1.6.0");
|
|
2273
2369
|
return await controller.execute("windows.update", params, exec.signal);
|
|
2274
2370
|
}
|
|
2275
|
-
case "close":
|
|
2371
|
+
case "close":
|
|
2372
|
+
controller.requireExtensionCommand("windows.close", "1.6.0");
|
|
2373
|
+
return await controller.execute("windows.close", { windowId: requireWindowId(args, "close") }, exec.signal);
|
|
2276
2374
|
case "moveTab": {
|
|
2277
2375
|
if (args.tabId === void 0 && args.tabIds === void 0) throw new Error("moveTab requires tabId or tabIds");
|
|
2278
2376
|
const params = { windowId: requireWindowId(args, "moveTab") };
|
|
2279
2377
|
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
2280
2378
|
if (args.tabIds !== void 0) params.tabIds = args.tabIds;
|
|
2281
2379
|
if (args.index !== void 0) params.index = args.index;
|
|
2380
|
+
controller.requireExtensionCommand("windows.moveTab", "1.6.0");
|
|
2282
2381
|
return await controller.execute("windows.moveTab", params, exec.signal);
|
|
2283
2382
|
}
|
|
2284
2383
|
default: throw new Error(`unknown windows action: ${String(args.action)} (use list|open|update|close|moveTab)`);
|
|
@@ -2848,12 +2947,13 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2848
2947
|
async execute(args, exec) {
|
|
2849
2948
|
const params = {};
|
|
2850
2949
|
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
2950
|
+
controller.requireExtensionCommand("archive", "1.5.0");
|
|
2851
2951
|
return await saveMhtml(controller, await controller.execute("archive", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
|
|
2852
2952
|
}
|
|
2853
2953
|
}));
|
|
2854
2954
|
ctx.tools.register(defineTool({
|
|
2855
2955
|
name: "browser_emulate",
|
|
2856
|
-
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.",
|
|
2956
|
+
description: "Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing, and throttle the network or CPU to test slow conditions. `device:\"reset\"` restores the user's actual viewport and clears all throttling. `network` presets (this tool's own numbers): slow3g = 400ms RTT + ~50KB/s both ways, fast3g = 150ms + ~200KB/s down / ~94KB/s up, offline, reset to clear. `cpuThrottlingRate` multiplies CPU cost (1 = off, up to 20). Throttling lives in the debugger session — it is gone when the extension detaches, and device:\"reset\" clears it too. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.",
|
|
2857
2957
|
parameters: {
|
|
2858
2958
|
tabId: {
|
|
2859
2959
|
type: "number",
|
|
@@ -2863,6 +2963,14 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2863
2963
|
type: "string",
|
|
2864
2964
|
description: "Preset: desktop | mobile-iphone-13 | mobile-pixel-7 | tablet-ipad | reset. Or pass custom width/height below."
|
|
2865
2965
|
},
|
|
2966
|
+
network: {
|
|
2967
|
+
type: "string",
|
|
2968
|
+
description: "slow3g | fast3g | offline | reset. The receipt reports the applied latency/throughput in bytes per second."
|
|
2969
|
+
},
|
|
2970
|
+
cpuThrottlingRate: {
|
|
2971
|
+
type: "number",
|
|
2972
|
+
description: "CPU slowdown multiplier: 1 = none (default), 4 = mid-range phone, up to 20."
|
|
2973
|
+
},
|
|
2866
2974
|
width: {
|
|
2867
2975
|
type: "number",
|
|
2868
2976
|
description: "Custom viewport width in CSS px."
|
|
@@ -2903,30 +3011,39 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2903
3011
|
deviceScaleFactor: { type: "number" },
|
|
2904
3012
|
isMobile: { type: "boolean" },
|
|
2905
3013
|
hasTouch: { type: "boolean" },
|
|
2906
|
-
userAgent: { type: "string" }
|
|
3014
|
+
userAgent: { type: "string" },
|
|
3015
|
+
network: { type: "string" },
|
|
3016
|
+
latency: { type: "number" },
|
|
3017
|
+
downloadThroughput: { type: "number" },
|
|
3018
|
+
uploadThroughput: { type: "number" },
|
|
3019
|
+
cpuThrottlingRate: { type: "number" }
|
|
2907
3020
|
}
|
|
2908
3021
|
},
|
|
2909
3022
|
render: (_args, value) => {
|
|
2910
3023
|
if (value.reset) return [{
|
|
2911
3024
|
type: "text",
|
|
2912
|
-
text: `Tab ${value.tabId}: emulation reset to default`
|
|
3025
|
+
text: `Tab ${value.tabId}: emulation reset to default (network unthrottled, CPU rate 1)`
|
|
2913
3026
|
}];
|
|
2914
3027
|
const ua = value.userAgent === void 0 ? "" : ` UA="${flattenOneLine(value.userAgent, 80)}"`;
|
|
3028
|
+
const net = value.network === void 0 ? "" : ` network=${value.network}(${value.latency}ms↓${value.downloadThroughput}B/s↑${value.uploadThroughput}B/s)`;
|
|
3029
|
+
const cpu = value.cpuThrottlingRate === void 0 ? "" : ` cpu×${value.cpuThrottlingRate}`;
|
|
2915
3030
|
return [{
|
|
2916
3031
|
type: "text",
|
|
2917
|
-
text: `Tab ${value.tabId}: ${value.width || "?"}×${value.height || "?"} DPR=${value.deviceScaleFactor ?? "?"} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}`
|
|
3032
|
+
text: `Tab ${value.tabId}: ${value.width || "?"}×${value.height || "?"} DPR=${value.deviceScaleFactor ?? "?"} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}${net}${cpu}`
|
|
2918
3033
|
}];
|
|
2919
3034
|
}
|
|
2920
3035
|
},
|
|
2921
3036
|
presentCall: (args) => ({
|
|
2922
3037
|
card: "generic",
|
|
2923
|
-
title: `Emulate${args.device ? " " + args.device : "
|
|
3038
|
+
title: `Emulate${args.device ? " " + args.device : ""}${args.network ? " " + args.network : ""}${args.cpuThrottlingRate ? " cpu×" + args.cpuThrottlingRate : ""}`,
|
|
2924
3039
|
kind: "other"
|
|
2925
3040
|
}),
|
|
2926
3041
|
async execute(args, exec) {
|
|
2927
3042
|
const params = {};
|
|
2928
3043
|
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
2929
3044
|
if (typeof args.device === "string") params.device = args.device;
|
|
3045
|
+
if (typeof args.network === "string") params.network = args.network;
|
|
3046
|
+
if (typeof args.cpuThrottlingRate === "number") params.cpuThrottlingRate = args.cpuThrottlingRate;
|
|
2930
3047
|
if (typeof args.width === "number") params.width = args.width;
|
|
2931
3048
|
if (typeof args.height === "number") params.height = args.height;
|
|
2932
3049
|
if (typeof args.deviceScaleFactor === "number") params.deviceScaleFactor = args.deviceScaleFactor;
|
|
@@ -2942,10 +3059,82 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2942
3059
|
...typeof raw.deviceScaleFactor === "number" ? { deviceScaleFactor: raw.deviceScaleFactor } : {},
|
|
2943
3060
|
...typeof raw.isMobile === "boolean" ? { isMobile: raw.isMobile } : {},
|
|
2944
3061
|
...typeof raw.hasTouch === "boolean" ? { hasTouch: raw.hasTouch } : {},
|
|
2945
|
-
...typeof raw.userAgent === "string" && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {}
|
|
3062
|
+
...typeof raw.userAgent === "string" && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {},
|
|
3063
|
+
...typeof raw.network === "string" ? { network: raw.network } : {},
|
|
3064
|
+
...typeof raw.latency === "number" ? { latency: raw.latency } : {},
|
|
3065
|
+
...typeof raw.downloadThroughput === "number" ? { downloadThroughput: raw.downloadThroughput } : {},
|
|
3066
|
+
...typeof raw.uploadThroughput === "number" ? { uploadThroughput: raw.uploadThroughput } : {},
|
|
3067
|
+
...typeof raw.cpuThrottlingRate === "number" ? { cpuThrottlingRate: raw.cpuThrottlingRate } : {}
|
|
2946
3068
|
};
|
|
2947
3069
|
}
|
|
2948
3070
|
}));
|
|
3071
|
+
ctx.tools.register(defineTool({
|
|
3072
|
+
name: "browser_metrics",
|
|
3073
|
+
description: "Read the tab's performance counters (Performance.getMetrics): DOM node count, JS heap usage, layout/style recalc counts, script/task durations. Use it to back \"this page is heavy\" with numbers, before and after an action. Read-only.",
|
|
3074
|
+
parameters: { tabId: {
|
|
3075
|
+
type: "number",
|
|
3076
|
+
description: "Target tab; defaults to the active tab."
|
|
3077
|
+
} },
|
|
3078
|
+
output: {
|
|
3079
|
+
schema: {
|
|
3080
|
+
type: "object",
|
|
3081
|
+
additionalProperties: false,
|
|
3082
|
+
properties: {
|
|
3083
|
+
tabId: {
|
|
3084
|
+
type: "number",
|
|
3085
|
+
required: true
|
|
3086
|
+
},
|
|
3087
|
+
metrics: {
|
|
3088
|
+
type: "object",
|
|
3089
|
+
required: true,
|
|
3090
|
+
additionalProperties: false,
|
|
3091
|
+
properties: {
|
|
3092
|
+
Timestamp: { type: "number" },
|
|
3093
|
+
Documents: { type: "number" },
|
|
3094
|
+
Frames: { type: "number" },
|
|
3095
|
+
JSEventListeners: { type: "number" },
|
|
3096
|
+
Nodes: { type: "number" },
|
|
3097
|
+
LayoutCount: { type: "number" },
|
|
3098
|
+
RecalcStyleCount: { type: "number" },
|
|
3099
|
+
LayoutDuration: { type: "number" },
|
|
3100
|
+
RecalcStyleDuration: { type: "number" },
|
|
3101
|
+
ScriptDuration: { type: "number" },
|
|
3102
|
+
TaskDuration: { type: "number" },
|
|
3103
|
+
JSHeapUsedSize: { type: "number" },
|
|
3104
|
+
JSHeapTotalSize: { type: "number" }
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
},
|
|
3109
|
+
render: (_args, value) => {
|
|
3110
|
+
const m = value.metrics ?? {};
|
|
3111
|
+
const parts = [];
|
|
3112
|
+
if (m.Nodes !== void 0) parts.push(`nodes=${m.Nodes}`);
|
|
3113
|
+
if (m.JSHeapUsedSize !== void 0) {
|
|
3114
|
+
const total = m.JSHeapTotalSize !== void 0 ? `/${(m.JSHeapTotalSize / 1048576).toFixed(1)}MB` : "";
|
|
3115
|
+
parts.push(`heap=${(m.JSHeapUsedSize / 1048576).toFixed(1)}${total}`);
|
|
3116
|
+
}
|
|
3117
|
+
if (m.LayoutCount !== void 0) parts.push(`layouts=${m.LayoutCount}`);
|
|
3118
|
+
if (m.ScriptDuration !== void 0) parts.push(`script=${Number(m.ScriptDuration).toFixed(2)}s`);
|
|
3119
|
+
if (m.TaskDuration !== void 0) parts.push(`tasks=${Number(m.TaskDuration).toFixed(2)}s`);
|
|
3120
|
+
return [{
|
|
3121
|
+
type: "text",
|
|
3122
|
+
text: `Tab ${value.tabId}: ${parts.join(" ") || JSON.stringify(value.metrics)}`
|
|
3123
|
+
}];
|
|
3124
|
+
}
|
|
3125
|
+
},
|
|
3126
|
+
presentCall: () => ({
|
|
3127
|
+
card: "generic",
|
|
3128
|
+
title: "Browser metrics",
|
|
3129
|
+
kind: "other"
|
|
3130
|
+
}),
|
|
3131
|
+
async execute(args, exec) {
|
|
3132
|
+
const params = {};
|
|
3133
|
+
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
3134
|
+
controller.requireExtensionCommand("metrics", "1.7.1");
|
|
3135
|
+
return await controller.execute("metrics", params, exec.signal);
|
|
3136
|
+
}
|
|
3137
|
+
}));
|
|
2949
3138
|
}
|
|
2950
3139
|
/** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
|
|
2951
3140
|
function apply(ctx, config) {
|
package/lib/types/errors.js
CHANGED
|
@@ -44,6 +44,9 @@ const WRITE_COMMANDS = new Set([
|
|
|
44
44
|
// 历史导航与 nav 同族:超时/掉线后重试可能**多跳一条**历史(第一次 back 其实
|
|
45
45
|
// 已经落了地),报 unknown 才诚实。
|
|
46
46
|
'tabs.back', 'tabs.forward',
|
|
47
|
+
// CDP 透传(v1.7.2):原始能力等价于「页面内任意 JS」,按写命令分类,
|
|
48
|
+
// 只读档拒、接管中止态拦。它是内部原语,不注册成模型工具。
|
|
49
|
+
'cdp',
|
|
47
50
|
// 这条不是扩展命令,而是插件本地命令 `browser_cleanup`:它逐条 rm,中途失败
|
|
48
51
|
// (Windows 上被占用的截图会 EPERM)时前面的文件已经没了,报 not_started
|
|
49
52
|
// 就是告诉模型「什么都没发生,重试安全」,而那正好是假的。
|
package/lib/types/index.js
CHANGED
|
@@ -61,6 +61,49 @@ const SNAPSHOT_REF_SELECTOR_PATTERN = /^e\d+$/;
|
|
|
61
61
|
/** One `browser_read` window, in characters of page text. Longer pages are paged
|
|
62
62
|
* with `offset`, so no single call can put a whole site into the transcript. */
|
|
63
63
|
const READ_WINDOW_MAX_CHARS = 20_000;
|
|
64
|
+
/**
|
|
65
|
+
* Extension command surface, as deltas over the pre-1.5 baseline. Used when a
|
|
66
|
+
* connected extension predates the capability handshake (no `capabilities` in
|
|
67
|
+
* its hello frame): the plugin maps its reported version to the commands it
|
|
68
|
+
* must have. Anything not listed here exists since the baseline and is assumed
|
|
69
|
+
* present; a version newer than every entry is assumed to have everything
|
|
70
|
+
* (forward compatible — an extension never removes commands).
|
|
71
|
+
*/
|
|
72
|
+
const EXTENSION_COMMANDS_BY_VERSION = [
|
|
73
|
+
{ since: '1.5.0', commands: ['archive'] },
|
|
74
|
+
{ since: '1.6.0', commands: ['windows.list', 'windows.open', 'windows.update', 'windows.close', 'windows.moveTab'] },
|
|
75
|
+
{ since: '1.7.0', commands: ['tabs.back', 'tabs.forward'] },
|
|
76
|
+
{ since: '1.7.1', commands: ['metrics'] },
|
|
77
|
+
{ since: '1.7.2', commands: ['cdp'] },
|
|
78
|
+
];
|
|
79
|
+
/** Compare two `x.y.z` version strings numerically; unparseable input sorts lowest. */
|
|
80
|
+
function compareVersions(a, b) {
|
|
81
|
+
const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)/.exec(v.trim()) ?? []).slice(1).map(Number);
|
|
82
|
+
const [pa, pb] = [parse(a), parse(b)];
|
|
83
|
+
if (pa.length === 0 || pb.length === 0)
|
|
84
|
+
return pa.length === pb.length ? 0 : (pa.length === 0 ? -1 : 1);
|
|
85
|
+
for (let i = 0; i < 3; i++) {
|
|
86
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
87
|
+
return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The plugin updated, the extension did not: the invariant is that this combo is
|
|
93
|
+
* never WORSE than the matched pair from the extension's era — existing tools
|
|
94
|
+
* behave identically, and tools that need commands the old extension lacks fail
|
|
95
|
+
* with an actionable upgrade pointer instead of a raw "unknown command".
|
|
96
|
+
*/
|
|
97
|
+
function extensionOutdatedError(have, command, since) {
|
|
98
|
+
const message = 'extension-outdated: the connected browser extension'
|
|
99
|
+
+ (have === undefined ? '' : ` (v${have})`)
|
|
100
|
+
+ ` does not have the "${command}" command this action needs (added in extension v${since}).`
|
|
101
|
+
+ ' One-time fix: download DSH-Browser-Control.zip from https://github.com/angelky7/dsh-browser-control/releases/latest, unzip, then chrome://extensions → Developer mode → replace the loaded folder (remove the old one first).'
|
|
102
|
+
+ ' That update makes the extension compatible with all future plugin updates — CDP-level features ship plugin-side from v1.7.2 on.';
|
|
103
|
+
const error = new Error(message);
|
|
104
|
+
error.code = 'extension_outdated';
|
|
105
|
+
return error;
|
|
106
|
+
}
|
|
64
107
|
/**
|
|
65
108
|
* Owns zero or one live {@link BridgeServer} and restarts it whenever the
|
|
66
109
|
* resolved settings change. Reconciles serialize through a promise chain so a
|
|
@@ -80,6 +123,38 @@ class BridgeController {
|
|
|
80
123
|
get shotsDir() {
|
|
81
124
|
return this.current === undefined ? undefined : path.resolve(this.current.shotsDir);
|
|
82
125
|
}
|
|
126
|
+
/** Version the connected extension reported in its hello frame, if any. */
|
|
127
|
+
extensionVersion() {
|
|
128
|
+
return this.server?.status.hello?.version;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether the connected extension implements `command`. Exact when the hello
|
|
132
|
+
* carries a capabilities list (v1.7.2+); otherwise derived from the reported
|
|
133
|
+
* version via the surface-delta map. No connection ⇒ true: the not-enabled
|
|
134
|
+
* error from execute is the right failure there, not a capability complaint.
|
|
135
|
+
*/
|
|
136
|
+
hasExtensionCommand(command) {
|
|
137
|
+
const hello = this.server?.status.hello;
|
|
138
|
+
if (hello?.capabilities !== undefined)
|
|
139
|
+
return hello.capabilities.includes(command);
|
|
140
|
+
const version = hello?.version;
|
|
141
|
+
if (version === undefined || version === 'unknown')
|
|
142
|
+
return true;
|
|
143
|
+
const entry = EXTENSION_COMMANDS_BY_VERSION.find((e) => e.commands.includes(command));
|
|
144
|
+
if (entry === undefined)
|
|
145
|
+
return true;
|
|
146
|
+
return compareVersions(version, entry.since) >= 0;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Pre-flight capability gate: throw the actionable upgrade error before the
|
|
150
|
+
* command is sent, so an old extension answers with a what-to-do, never with
|
|
151
|
+
* a bare "unknown command" the model cannot act on.
|
|
152
|
+
*/
|
|
153
|
+
requireExtensionCommand(command, since) {
|
|
154
|
+
if (this.hasExtensionCommand(command))
|
|
155
|
+
return;
|
|
156
|
+
throw extensionOutdatedError(this.extensionVersion(), command, since);
|
|
157
|
+
}
|
|
83
158
|
/**
|
|
84
159
|
* Converge the live server onto `config`. With `throwOnError`, an initial
|
|
85
160
|
* start failure rejects (fail-loud activation); later changes record the
|
|
@@ -964,8 +1039,10 @@ function applyBrowserTools(ctx, controller) {
|
|
|
964
1039
|
}
|
|
965
1040
|
params.timeoutMs = budgetMs;
|
|
966
1041
|
if (args.action === 'back') {
|
|
1042
|
+
controller.requireExtensionCommand('tabs.back', '1.7.0');
|
|
967
1043
|
return await controller.execute('tabs.back', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
968
1044
|
}
|
|
1045
|
+
controller.requireExtensionCommand('tabs.forward', '1.7.0');
|
|
969
1046
|
return await controller.execute('tabs.forward', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
970
1047
|
}
|
|
971
1048
|
case 'group':
|
|
@@ -1012,6 +1089,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1012
1089
|
async execute(args, exec) {
|
|
1013
1090
|
switch (args.action) {
|
|
1014
1091
|
case 'list':
|
|
1092
|
+
controller.requireExtensionCommand('windows.list', '1.6.0');
|
|
1015
1093
|
return await controller.execute('windows.list', {}, exec.signal);
|
|
1016
1094
|
case 'open': {
|
|
1017
1095
|
const params = {};
|
|
@@ -1027,6 +1105,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1027
1105
|
}
|
|
1028
1106
|
if (args.focused !== undefined)
|
|
1029
1107
|
params.focused = args.focused;
|
|
1108
|
+
controller.requireExtensionCommand('windows.open', '1.6.0');
|
|
1030
1109
|
return await controller.execute('windows.open', params, exec.signal);
|
|
1031
1110
|
}
|
|
1032
1111
|
case 'update': {
|
|
@@ -1043,9 +1122,11 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1043
1122
|
params.alwaysOnTop = args.alwaysOnTop;
|
|
1044
1123
|
if (args.drawAttention === true)
|
|
1045
1124
|
params.drawAttention = true;
|
|
1125
|
+
controller.requireExtensionCommand('windows.update', '1.6.0');
|
|
1046
1126
|
return await controller.execute('windows.update', params, exec.signal);
|
|
1047
1127
|
}
|
|
1048
1128
|
case 'close':
|
|
1129
|
+
controller.requireExtensionCommand('windows.close', '1.6.0');
|
|
1049
1130
|
return await controller.execute('windows.close', { windowId: requireWindowId(args, 'close') }, exec.signal);
|
|
1050
1131
|
case 'moveTab': {
|
|
1051
1132
|
if (args.tabId === undefined && args.tabIds === undefined)
|
|
@@ -1057,6 +1138,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1057
1138
|
params.tabIds = args.tabIds;
|
|
1058
1139
|
if (args.index !== undefined)
|
|
1059
1140
|
params.index = args.index;
|
|
1141
|
+
controller.requireExtensionCommand('windows.moveTab', '1.6.0');
|
|
1060
1142
|
return await controller.execute('windows.moveTab', params, exec.signal);
|
|
1061
1143
|
}
|
|
1062
1144
|
default:
|
|
@@ -1440,16 +1522,19 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1440
1522
|
const params = {};
|
|
1441
1523
|
if (args.tabId !== undefined)
|
|
1442
1524
|
params.tabId = args.tabId;
|
|
1525
|
+
controller.requireExtensionCommand('archive', '1.5.0');
|
|
1443
1526
|
const payload = await controller.execute('archive', params, exec.signal);
|
|
1444
1527
|
return await saveMhtml(controller, payload, typeof args.path === 'string' ? args.path : undefined);
|
|
1445
1528
|
},
|
|
1446
1529
|
}));
|
|
1447
1530
|
ctx.tools.register(defineTool({
|
|
1448
1531
|
name: 'browser_emulate',
|
|
1449
|
-
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.',
|
|
1532
|
+
description: 'Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing, and throttle the network or CPU to test slow conditions. `device:"reset"` restores the user\'s actual viewport and clears all throttling. `network` presets (this tool\'s own numbers): slow3g = 400ms RTT + ~50KB/s both ways, fast3g = 150ms + ~200KB/s down / ~94KB/s up, offline, reset to clear. `cpuThrottlingRate` multiplies CPU cost (1 = off, up to 20). Throttling lives in the debugger session — it is gone when the extension detaches, and device:"reset" clears it too. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.',
|
|
1450
1533
|
parameters: {
|
|
1451
1534
|
tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
|
|
1452
1535
|
device: { type: 'string', description: 'Preset: desktop | mobile-iphone-13 | mobile-pixel-7 | tablet-ipad | reset. Or pass custom width/height below.' },
|
|
1536
|
+
network: { type: 'string', description: 'slow3g | fast3g | offline | reset. The receipt reports the applied latency/throughput in bytes per second.' },
|
|
1537
|
+
cpuThrottlingRate: { type: 'number', description: 'CPU slowdown multiplier: 1 = none (default), 4 = mid-range phone, up to 20.' },
|
|
1453
1538
|
width: { type: 'number', description: 'Custom viewport width in CSS px.' },
|
|
1454
1539
|
height: { type: 'number', description: 'Custom viewport height in CSS px.' },
|
|
1455
1540
|
deviceScaleFactor: { type: 'number', description: 'Custom DPR (1 = standard, 2 = retina, 3 = super-retina).' },
|
|
@@ -1470,26 +1555,37 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1470
1555
|
isMobile: { type: 'boolean' },
|
|
1471
1556
|
hasTouch: { type: 'boolean' },
|
|
1472
1557
|
userAgent: { type: 'string' },
|
|
1558
|
+
network: { type: 'string' },
|
|
1559
|
+
latency: { type: 'number' },
|
|
1560
|
+
downloadThroughput: { type: 'number' },
|
|
1561
|
+
uploadThroughput: { type: 'number' },
|
|
1562
|
+
cpuThrottlingRate: { type: 'number' },
|
|
1473
1563
|
},
|
|
1474
1564
|
},
|
|
1475
1565
|
render: (_args, value) => {
|
|
1476
1566
|
if (value.reset)
|
|
1477
|
-
return [{ type: 'text', text: `Tab ${value.tabId}: emulation reset to default` }];
|
|
1478
|
-
// 覆盖生效的是哪个 UA
|
|
1567
|
+
return [{ type: 'text', text: `Tab ${value.tabId}: emulation reset to default (network unthrottled, CPU rate 1)` }];
|
|
1568
|
+
// 覆盖生效的是哪个 UA / 哪档节流必须写出来:模型只看这句,看不见 value 里的字段。
|
|
1479
1569
|
const ua = value.userAgent === undefined ? '' : ` UA="${flattenOneLine(value.userAgent, 80)}"`;
|
|
1570
|
+
const net = value.network === undefined ? '' : ` network=${value.network}(${value.latency}ms↓${value.downloadThroughput}B/s↑${value.uploadThroughput}B/s)`;
|
|
1571
|
+
const cpu = value.cpuThrottlingRate === undefined ? '' : ` cpu×${value.cpuThrottlingRate}`;
|
|
1480
1572
|
return [{
|
|
1481
1573
|
type: 'text',
|
|
1482
|
-
text: `Tab ${value.tabId}: ${value.width || '?'}×${value.height || '?'} DPR=${value.deviceScaleFactor ?? '?'} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}`,
|
|
1574
|
+
text: `Tab ${value.tabId}: ${value.width || '?'}×${value.height || '?'} DPR=${value.deviceScaleFactor ?? '?'} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}${net}${cpu}`,
|
|
1483
1575
|
}];
|
|
1484
1576
|
},
|
|
1485
1577
|
},
|
|
1486
|
-
presentCall: args => ({ card: 'generic', title: `Emulate${args.device ? ' ' + args.device : '
|
|
1578
|
+
presentCall: args => ({ card: 'generic', title: `Emulate${args.device ? ' ' + args.device : ''}${args.network ? ' ' + args.network : ''}${args.cpuThrottlingRate ? ' cpu×' + args.cpuThrottlingRate : ''}`, kind: 'other' }),
|
|
1487
1579
|
async execute(args, exec) {
|
|
1488
1580
|
const params = {};
|
|
1489
1581
|
if (args.tabId !== undefined)
|
|
1490
1582
|
params.tabId = args.tabId;
|
|
1491
1583
|
if (typeof args.device === 'string')
|
|
1492
1584
|
params.device = args.device;
|
|
1585
|
+
if (typeof args.network === 'string')
|
|
1586
|
+
params.network = args.network;
|
|
1587
|
+
if (typeof args.cpuThrottlingRate === 'number')
|
|
1588
|
+
params.cpuThrottlingRate = args.cpuThrottlingRate;
|
|
1493
1589
|
if (typeof args.width === 'number')
|
|
1494
1590
|
params.width = args.width;
|
|
1495
1591
|
if (typeof args.height === 'number')
|
|
@@ -1515,9 +1611,78 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1515
1611
|
...(typeof raw.isMobile === 'boolean' ? { isMobile: raw.isMobile } : {}),
|
|
1516
1612
|
...(typeof raw.hasTouch === 'boolean' ? { hasTouch: raw.hasTouch } : {}),
|
|
1517
1613
|
...(typeof raw.userAgent === 'string' && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {}),
|
|
1614
|
+
...(typeof raw.network === 'string' ? { network: raw.network } : {}),
|
|
1615
|
+
...(typeof raw.latency === 'number' ? { latency: raw.latency } : {}),
|
|
1616
|
+
...(typeof raw.downloadThroughput === 'number' ? { downloadThroughput: raw.downloadThroughput } : {}),
|
|
1617
|
+
...(typeof raw.uploadThroughput === 'number' ? { uploadThroughput: raw.uploadThroughput } : {}),
|
|
1618
|
+
...(typeof raw.cpuThrottlingRate === 'number' ? { cpuThrottlingRate: raw.cpuThrottlingRate } : {}),
|
|
1518
1619
|
};
|
|
1519
1620
|
},
|
|
1520
1621
|
}));
|
|
1622
|
+
// Performance metrics: a pure read of CDP counters — whitelisted under
|
|
1623
|
+
// readOnly, no effect on the page, and the numbers ARE the content.
|
|
1624
|
+
ctx.tools.register(defineTool({
|
|
1625
|
+
name: 'browser_metrics',
|
|
1626
|
+
description: 'Read the tab\'s performance counters (Performance.getMetrics): DOM node count, JS heap usage, layout/style recalc counts, script/task durations. Use it to back "this page is heavy" with numbers, before and after an action. Read-only.',
|
|
1627
|
+
parameters: {
|
|
1628
|
+
tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
|
|
1629
|
+
},
|
|
1630
|
+
output: {
|
|
1631
|
+
schema: {
|
|
1632
|
+
type: 'object',
|
|
1633
|
+
additionalProperties: false,
|
|
1634
|
+
properties: {
|
|
1635
|
+
tabId: { type: 'number', required: true },
|
|
1636
|
+
metrics: {
|
|
1637
|
+
type: 'object',
|
|
1638
|
+
required: true,
|
|
1639
|
+
additionalProperties: false,
|
|
1640
|
+
properties: {
|
|
1641
|
+
Timestamp: { type: 'number' },
|
|
1642
|
+
Documents: { type: 'number' },
|
|
1643
|
+
Frames: { type: 'number' },
|
|
1644
|
+
JSEventListeners: { type: 'number' },
|
|
1645
|
+
Nodes: { type: 'number' },
|
|
1646
|
+
LayoutCount: { type: 'number' },
|
|
1647
|
+
RecalcStyleCount: { type: 'number' },
|
|
1648
|
+
LayoutDuration: { type: 'number' },
|
|
1649
|
+
RecalcStyleDuration: { type: 'number' },
|
|
1650
|
+
ScriptDuration: { type: 'number' },
|
|
1651
|
+
TaskDuration: { type: 'number' },
|
|
1652
|
+
JSHeapUsedSize: { type: 'number' },
|
|
1653
|
+
JSHeapTotalSize: { type: 'number' },
|
|
1654
|
+
},
|
|
1655
|
+
},
|
|
1656
|
+
},
|
|
1657
|
+
},
|
|
1658
|
+
render: (_args, value) => {
|
|
1659
|
+
const m = (value.metrics ?? {});
|
|
1660
|
+
// 数字本身就是产出:关键几项原样进模型可见文本,heap 顺手换算成 MB。
|
|
1661
|
+
const parts = [];
|
|
1662
|
+
if (m.Nodes !== undefined)
|
|
1663
|
+
parts.push(`nodes=${m.Nodes}`);
|
|
1664
|
+
if (m.JSHeapUsedSize !== undefined) {
|
|
1665
|
+
const total = m.JSHeapTotalSize !== undefined ? `/${(m.JSHeapTotalSize / 1_048_576).toFixed(1)}MB` : '';
|
|
1666
|
+
parts.push(`heap=${(m.JSHeapUsedSize / 1_048_576).toFixed(1)}${total}`);
|
|
1667
|
+
}
|
|
1668
|
+
if (m.LayoutCount !== undefined)
|
|
1669
|
+
parts.push(`layouts=${m.LayoutCount}`);
|
|
1670
|
+
if (m.ScriptDuration !== undefined)
|
|
1671
|
+
parts.push(`script=${Number(m.ScriptDuration).toFixed(2)}s`);
|
|
1672
|
+
if (m.TaskDuration !== undefined)
|
|
1673
|
+
parts.push(`tasks=${Number(m.TaskDuration).toFixed(2)}s`);
|
|
1674
|
+
return [{ type: 'text', text: `Tab ${value.tabId}: ${parts.join(' ') || JSON.stringify(value.metrics)}` }];
|
|
1675
|
+
},
|
|
1676
|
+
},
|
|
1677
|
+
presentCall: () => ({ card: 'generic', title: 'Browser metrics', kind: 'other' }),
|
|
1678
|
+
async execute(args, exec) {
|
|
1679
|
+
const params = {};
|
|
1680
|
+
if (args.tabId !== undefined)
|
|
1681
|
+
params.tabId = args.tabId;
|
|
1682
|
+
controller.requireExtensionCommand('metrics', '1.7.1');
|
|
1683
|
+
return await controller.execute('metrics', params, exec.signal);
|
|
1684
|
+
},
|
|
1685
|
+
}));
|
|
1521
1686
|
}
|
|
1522
1687
|
/** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
|
|
1523
1688
|
export function apply(ctx, config) {
|
package/lib/types/policy.js
CHANGED
|
@@ -28,6 +28,9 @@ export const READ_ONLY_ALLOWED = new Set([
|
|
|
28
28
|
'windows.list',
|
|
29
29
|
// 读浏览器自己的状态(cookie 是凭据,但 get 不改任何东西;set/remove 不在此列)
|
|
30
30
|
'cookies.get',
|
|
31
|
+
// 读性能计数器(Performance.getMetrics):纯粹读,重试无损害;节流(emulate
|
|
32
|
+
// 的 network/cpu 参数)改页面加载行为,emulate 整体仍是写命令,不在此列。
|
|
33
|
+
'metrics',
|
|
31
34
|
]);
|
|
32
35
|
/**
|
|
33
36
|
* 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
|
package/lib/types/server.d.ts
CHANGED
|
@@ -42,6 +42,11 @@ export interface BridgeHello {
|
|
|
42
42
|
readonly version: string;
|
|
43
43
|
readonly ua: string;
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* Command names the extension implements (v1.7.2+). Absent when the extension
|
|
47
|
+
* predates the capability handshake — callers fall back to a version map.
|
|
48
|
+
*/
|
|
49
|
+
readonly capabilities?: readonly string[];
|
|
45
50
|
}
|
|
46
51
|
/** Snapshot of the link and in-flight work, served verbatim on `/api/status`. */
|
|
47
52
|
export interface BridgeServerStatus {
|
package/lib/types/server.js
CHANGED
|
@@ -488,13 +488,17 @@ export class BridgeServer {
|
|
|
488
488
|
}
|
|
489
489
|
if (msg.type === 'hello') {
|
|
490
490
|
const declared = typeof msg.protocol === 'number' ? msg.protocol : 0;
|
|
491
|
+
const capabilities = Array.isArray(msg.capabilities)
|
|
492
|
+
? msg.capabilities.filter((c) => typeof c === 'string')
|
|
493
|
+
: undefined;
|
|
491
494
|
this.hello = {
|
|
492
495
|
client: typeof msg.client === 'string' ? msg.client : 'unknown',
|
|
493
496
|
version: typeof msg.version === 'string' ? msg.version : 'unknown',
|
|
494
497
|
protocol: declared,
|
|
495
498
|
browser: (msg.browser ?? { name: 'unknown', version: 'unknown', ua: '' }),
|
|
499
|
+
...(capabilities !== undefined ? { capabilities } : {}),
|
|
496
500
|
};
|
|
497
|
-
this.log(`hello: client=${this.hello.client} v${this.hello.version} protocol=v${declared || '?'} browser=${this.hello.browser.name} ${this.hello.browser.version}`);
|
|
501
|
+
this.log(`hello: client=${this.hello.client} v${this.hello.version} protocol=v${declared || '?'} browser=${this.hello.browser.name} ${this.hello.browser.version}${capabilities ? ` capabilities=${capabilities.length}` : ''}`);
|
|
498
502
|
const note = this.protocolNote;
|
|
499
503
|
if (note !== null)
|
|
500
504
|
this.log(`protocol mismatch: ${note}`);
|
package/llms.txt
CHANGED
|
@@ -33,9 +33,16 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
|
|
|
33
33
|
- `browser_cookies` 的 get 会把 **cookie 值原样**放进模型可见文本(cookie 值 = 凭据,这是这条工具的定义而不是副作用);只读档放行 get、拒 set/remove
|
|
34
34
|
- `browser_hold`(接管浮层)是**软层**:它告诉用户模型在操作这一页并挡住误触,用户双击即可关掉;卡片上按「中止操作」后扩展拒绝 `click`/`input`/`press`/`nav`,但模型可以调 `hold action=off` 解除。它不是安全边界,也不是防逃逸手段——要冻住浏览器用扩展弹窗里的「锁定」
|
|
35
35
|
|
|
36
|
+
## 版本与兼容
|
|
37
|
+
|
|
38
|
+
- **插件升级对旧扩展零倒退**(铁律:插件 x + 扩展 x-n ≥ 插件 x-n + 扩展 x-n,兼容矩阵台机器验证,不是口号)
|
|
39
|
+
- v1.7.2+ 扩展的 hello 帧带 `capabilities`(命令清单),插件精确握手;老扩展的 hello 不带 ⇒ 版本映射兜底
|
|
40
|
+
- 插件比扩展新时,缺命令的动作在发送前拦下:`extension_outdated` + 可执行的重装指引,绝不裸 unknown command
|
|
41
|
+
- 扩展预埋 `cdp` 透传(内部原语,不进模型工具面):CDP 层新能力从此只发插件,扩展装一次长期用
|
|
42
|
+
|
|
36
43
|
## 关键文件
|
|
37
44
|
|
|
38
|
-
- `src/index.ts` -
|
|
45
|
+
- `src/index.ts` - 25 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
|
|
39
46
|
- `src/policy.ts` - 三张互不相同的表:只读白名单、脚本命令、插件本地破坏性命令(`browser_cleanup` 不经过 `execute()`,必须自己站闸门)
|
|
40
47
|
- `src/server.ts` - WebSocket 服务 + HTTP 面(鉴权、来源校验、状态页)
|
|
41
48
|
- `src/ws.ts` - RFC 6455 帧编解码
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@angelky7/dsh-browser-control",
|
|
3
3
|
"description": "Local WebSocket bridge between dsh and the DSH Browser Control browser extension, with model-facing browser_* tools and a Settings-managed on/off switch",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"build": "tsc -p tsconfig.json && tsdown",
|
|
38
38
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
39
39
|
"check": "npm run typecheck && npm run build && publint",
|
|
40
|
-
"verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs && node verify/verify_archive.mjs && node verify/verify_windows.mjs && node verify/verify_history_nav.mjs"
|
|
40
|
+
"verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs && node verify/verify_archive.mjs && node verify/verify_windows.mjs && node verify/verify_history_nav.mjs && node verify/verify_metrics_throttle.mjs && node verify/verify_compat_matrix.mjs"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public",
|