@angelky7/dsh-browser-control 1.7.1 → 1.7.3

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 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.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/DSH-Browser-Control-v1.7.1.zip) | Chrome extension (unzip and load; this release adds the metrics command and emulate throttling — reinstall required) |
68
- | [dsh-browser-control-plugin-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/dsh-browser-control-plugin-v1.7.1.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
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; **the extension no longer changes with feature updates from v1.7.2 on**: v1.7.3 does not touch the extension — skip this if v1.7.2 is already loaded) |
68
+ | [dsh-browser-control-plugin-v1.7.3.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.3/dsh-browser-control-plugin-v1.7.3.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.1"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.3"
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"
@@ -267,10 +267,11 @@ curl -X POST http://127.0.0.1:9777/api/command \
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
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
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+) |
270
+ | `browser_drag` | Drag from/to a selector or viewport point: `auto` synthesizes html5 dragstart→dragover→drop for `draggable="true"` sources (no drop unless accepted), otherwise sends real mouse input (press, interpolated moves, release — sliders/canvas/sortable widgets). **Plugin-side only** over the cdp channel embedded in extension v1.7.2, v1.7.3+ |
270
271
  | `browser_cleanup` | Clean up temp files |
271
272
  | `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
272
273
 
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`.
274
+ 26 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`.
274
275
 
275
276
  ## Architecture
276
277
 
@@ -288,6 +289,7 @@ Chrome browser
288
289
  - Persistent debugger attachment — banner stays visible during control
289
290
  - Listens on 127.0.0.1 only; the command surface (WebSocket and HTTP) shares one token, and no cross-origin access is granted
290
291
  - The wire protocol has its own version, bumped only when the frame contract changes; a mismatch is named on the status page
292
+ - **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
291
293
 
292
294
  ## Verified
293
295
 
@@ -301,6 +303,7 @@ Chrome browser
301
303
  | `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 |
302
304
  | `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 |
303
305
  | `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 |
306
+ | `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 |
304
307
  | **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 |
305
308
  | `tsc --noEmit` + `publint` | ✅ 2026-10-10 |
306
309
 
package/README.md CHANGED
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
64
64
 
65
65
  | 文件 | 说明 |
66
66
  |---|---|
67
- | [DSH-Browser-Control-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/DSH-Browser-Control-v1.7.1.zip) | Chrome 扩展(解压后加载;这一版扩展新增 metrics 命令与 emulate 节流参数,**必须重装**) |
68
- | [dsh-browser-control-plugin-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/dsh-browser-control-plugin-v1.7.1.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
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 扩展(解压后加载;**扩展从 v1.7.2 起不再随功能更新**:v1.7.3 没有动扩展,装过 v1.7.2 的不用重装) |
68
+ | [dsh-browser-control-plugin-v1.7.3.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.3/dsh-browser-control-plugin-v1.7.3.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.1"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.3"
108
108
 
109
109
  # 本地目录调试(注意:必须显式 file: 前缀)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
@@ -260,11 +260,12 @@ curl -X POST http://127.0.0.1:9777/api/command \
260
260
  | `browser_archive` | 整页存成单文件 MHTML(正文/图片/样式全内联,可当文本 grep 的离线证据;是快照,内嵌脚本不会跑,v1.5.0+) |
261
261
  | `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7+);v1.7.1 起还能限速:`network` slow3g/fast3g/offline/reset + `cpuThrottlingRate`(1–20),回执写明生效数值,`device:"reset"` 一并还原 |
262
262
  | `browser_metrics` | 读性能计数器(DOM 节点数 / JS 堆 / 布局与脚本耗时,Performance.getMetrics,只读;让「这页面很重」有数字说话,v1.7.1+) |
263
+ | `browser_drag` | 拖拽:from/to 用选择器或视口坐标;`auto` 对 `draggable="true"` 的源走 html5 事件合成(dragstart→dragover→drop,未接受就不 drop),其余走真实鼠标输入(按下→插值移动→抬起,滑杆/画布/排序组件用这个)。**纯插件实现**(经 v1.7.2 预埋的 cdp 通道),v1.7.3+ |
263
264
  | `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
264
265
  | `browser_cleanup` | 清理临时文件 |
265
266
  | `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
266
267
 
267
- 共 25 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
268
+ 共 26 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
268
269
 
269
270
  ## 架构
270
271
 
@@ -282,6 +283,7 @@ Chrome 浏览器
282
283
  - 持久 debugger 附着——控制期间横幅始终显示
283
284
  - 仅监听 127.0.0.1;命令面(WS 与 HTTP)共用同一个 Token,跨源一律不放行
284
285
  - 协议版本独立于版本号:帧契约变了才升,不匹配时状态页直接写出该升级哪一侧
286
+ - **插件升级对旧扩展零倒退**(铁律:插件 x + 扩展 x-n ≥ 插件 x-n + 扩展 x-n):v1.7.2 起 hello 带 capabilities 精确握手,老扩展走版本映射兜底;缺命令的动作在发送前拦下,给可执行的重装指引而不是裸 unknown command——CDP 层新能力此后只更插件
285
287
 
286
288
  ## 已验证
287
289
 
@@ -295,6 +297,7 @@ Chrome 浏览器
295
297
  | 实测台 `verify_extension_e2e`:真桥 + 真扩展代码 + 真 WebSocket 端到端(帧契约、命令可达、错误分类) | ✅ 2026-10-11,只有 `chrome.*` 是假的;视觉与真实 CDP 行为仍需实机确认 |
296
298
  | 实测台 `verify_cdp_retry`:CDP 会话被抢走后的恢复 / 重放不许把副作用做两遍 / 重试有界(6 场景) | ✅ 2026-10-11,真实 `background.js` + 有状态假 `chrome.debugger`;另用合并预览文件交叉验证过 |
297
299
  | 实测台 `verify_render_contract`:每条工具的真实返回值同时喂给宿主的输出校验器与它自己的 `render`,断言「模型可见文本里有这次调用真正要的数据」 | ✅ 2026-10-12,真桥 + 假扩展 + 宿主 `validateJsonSchemaValue`;这一层此前**零覆盖**,所以两类缺陷活了 12 / 5 个版本 |
300
+ | 实测台 `verify_compat_matrix`:兼容矩阵——从 git 标签物化**真实历史扩展代码**(v1.2.0–v1.7.2),当前插件对每个面跑 28 个探针逐格验收「≥ 当年组合」;新工具报带重装指引的 extension-outdated、绝不许 unknown command 冒充;命令面 append-only(扩展升级对旧插件也纯增益) | ✅ 2026-11-04,6 面 × 186 项;老 hello 无 capabilities ⇒ 版本映射兜底路径的实测,v1.7.2 面实测 capabilities 精确握手 |
298
301
  | **真实宿主 × 真实浏览器**:本机 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` |
299
302
  | `tsc --noEmit` + `publint` | ✅ 2026-10-10 |
300
303
 
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;
@@ -984,6 +987,62 @@ const SNAPSHOT_REF_SELECTOR_PATTERN = /^e\d+$/;
984
987
  * with `offset`, so no single call can put a whole site into the transcript. */
985
988
  const READ_WINDOW_MAX_CHARS = 2e4;
986
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
+ /**
987
1046
  * Owns zero or one live {@link BridgeServer} and restarts it whenever the
988
1047
  * resolved settings change. Reconciles serialize through a promise chain so a
989
1048
  * burst of settings commits cannot interleave stop/start pairs.
@@ -1003,6 +1062,45 @@ var BridgeController = class {
1003
1062
  return this.current === void 0 ? void 0 : path.resolve(this.current.shotsDir);
1004
1063
  }
1005
1064
  /**
1065
+ * Current policy flags. Tools that compose several commands (browser_drag
1066
+ * runs `cdp`/`eval`) read this to refuse with a tool-level message; the
1067
+ * per-command gate in execute() stays as the enforcement backstop.
1068
+ */
1069
+ get policy() {
1070
+ return {
1071
+ readOnly: this.current?.readOnly === true,
1072
+ allowScript: this.current?.allowScript !== false
1073
+ };
1074
+ }
1075
+ /** Version the connected extension reported in its hello frame, if any. */
1076
+ extensionVersion() {
1077
+ return this.server?.status.hello?.version;
1078
+ }
1079
+ /**
1080
+ * Whether the connected extension implements `command`. Exact when the hello
1081
+ * carries a capabilities list (v1.7.2+); otherwise derived from the reported
1082
+ * version via the surface-delta map. No connection ⇒ true: the not-enabled
1083
+ * error from execute is the right failure there, not a capability complaint.
1084
+ */
1085
+ hasExtensionCommand(command) {
1086
+ const hello = this.server?.status.hello;
1087
+ if (hello?.capabilities !== void 0) return hello.capabilities.includes(command);
1088
+ const version = hello?.version;
1089
+ if (version === void 0 || version === "unknown") return true;
1090
+ const entry = EXTENSION_COMMANDS_BY_VERSION.find((e) => e.commands.includes(command));
1091
+ if (entry === void 0) return true;
1092
+ return compareVersions(version, entry.since) >= 0;
1093
+ }
1094
+ /**
1095
+ * Pre-flight capability gate: throw the actionable upgrade error before the
1096
+ * command is sent, so an old extension answers with a what-to-do, never with
1097
+ * a bare "unknown command" the model cannot act on.
1098
+ */
1099
+ requireExtensionCommand(command, since) {
1100
+ if (this.hasExtensionCommand(command)) return;
1101
+ throw extensionOutdatedError(this.extensionVersion(), command, since);
1102
+ }
1103
+ /**
1006
1104
  * Converge the live server onto `config`. With `throwOnError`, an initial
1007
1105
  * start failure rejects (fail-loud activation); later changes record the
1008
1106
  * failure instead, so a bad port cannot tear down an otherwise running session.
@@ -2147,7 +2245,11 @@ function applyBrowserTools(ctx, controller) {
2147
2245
  let budgetMs = NAV_TIERS[waitUntil];
2148
2246
  if (typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)) budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
2149
2247
  params.timeoutMs = budgetMs;
2150
- if (args.action === "back") return await controller.execute("tabs.back", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
2248
+ if (args.action === "back") {
2249
+ controller.requireExtensionCommand("tabs.back", "1.7.0");
2250
+ return await controller.execute("tabs.back", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
2251
+ }
2252
+ controller.requireExtensionCommand("tabs.forward", "1.7.0");
2151
2253
  return await controller.execute("tabs.forward", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
2152
2254
  }
2153
2255
  case "group": return await controller.execute("tabs.group", { tabId: requireTabId(args, "group") }, exec.signal);
@@ -2244,7 +2346,9 @@ function applyBrowserTools(ctx, controller) {
2244
2346
  }),
2245
2347
  async execute(args, exec) {
2246
2348
  switch (args.action) {
2247
- case "list": return await controller.execute("windows.list", {}, exec.signal);
2349
+ case "list":
2350
+ controller.requireExtensionCommand("windows.list", "1.6.0");
2351
+ return await controller.execute("windows.list", {}, exec.signal);
2248
2352
  case "open": {
2249
2353
  const params = {};
2250
2354
  if (args.url !== void 0) params.url = args.url;
@@ -2257,6 +2361,7 @@ function applyBrowserTools(ctx, controller) {
2257
2361
  "height"
2258
2362
  ]) if (typeof args[k] === "number") params[k] = args[k];
2259
2363
  if (args.focused !== void 0) params.focused = args.focused;
2364
+ controller.requireExtensionCommand("windows.open", "1.6.0");
2260
2365
  return await controller.execute("windows.open", params, exec.signal);
2261
2366
  }
2262
2367
  case "update": {
@@ -2271,15 +2376,19 @@ function applyBrowserTools(ctx, controller) {
2271
2376
  if (args.focused !== void 0) params.focused = args.focused;
2272
2377
  if (args.alwaysOnTop !== void 0) params.alwaysOnTop = args.alwaysOnTop;
2273
2378
  if (args.drawAttention === true) params.drawAttention = true;
2379
+ controller.requireExtensionCommand("windows.update", "1.6.0");
2274
2380
  return await controller.execute("windows.update", params, exec.signal);
2275
2381
  }
2276
- case "close": return await controller.execute("windows.close", { windowId: requireWindowId(args, "close") }, exec.signal);
2382
+ case "close":
2383
+ controller.requireExtensionCommand("windows.close", "1.6.0");
2384
+ return await controller.execute("windows.close", { windowId: requireWindowId(args, "close") }, exec.signal);
2277
2385
  case "moveTab": {
2278
2386
  if (args.tabId === void 0 && args.tabIds === void 0) throw new Error("moveTab requires tabId or tabIds");
2279
2387
  const params = { windowId: requireWindowId(args, "moveTab") };
2280
2388
  if (args.tabId !== void 0) params.tabId = args.tabId;
2281
2389
  if (args.tabIds !== void 0) params.tabIds = args.tabIds;
2282
2390
  if (args.index !== void 0) params.index = args.index;
2391
+ controller.requireExtensionCommand("windows.moveTab", "1.6.0");
2283
2392
  return await controller.execute("windows.moveTab", params, exec.signal);
2284
2393
  }
2285
2394
  default: throw new Error(`unknown windows action: ${String(args.action)} (use list|open|update|close|moveTab)`);
@@ -2849,6 +2958,7 @@ function applyBrowserTools(ctx, controller) {
2849
2958
  async execute(args, exec) {
2850
2959
  const params = {};
2851
2960
  if (args.tabId !== void 0) params.tabId = args.tabId;
2961
+ controller.requireExtensionCommand("archive", "1.5.0");
2852
2962
  return await saveMhtml(controller, await controller.execute("archive", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
2853
2963
  }
2854
2964
  }));
@@ -3032,9 +3142,338 @@ function applyBrowserTools(ctx, controller) {
3032
3142
  async execute(args, exec) {
3033
3143
  const params = {};
3034
3144
  if (args.tabId !== void 0) params.tabId = args.tabId;
3145
+ controller.requireExtensionCommand("metrics", "1.7.1");
3035
3146
  return await controller.execute("metrics", params, exec.signal);
3036
3147
  }
3037
3148
  }));
3149
+ registerDragTool(ctx, controller);
3150
+ }
3151
+ /** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
3152
+ async function cdpCall(ctrl, tabId, method, params, signal) {
3153
+ return await ctrl.execute("cdp", {
3154
+ method,
3155
+ params,
3156
+ tabId
3157
+ }, signal);
3158
+ }
3159
+ function dragTargetArgs(selector, x, y, side) {
3160
+ const hasSel = typeof selector === "string" && selector.length > 0;
3161
+ const hasX = typeof x === "number" && Number.isFinite(x);
3162
+ const hasY = typeof y === "number" && Number.isFinite(y);
3163
+ if (hasSel && (hasX || hasY)) throw new Error(`${side}: give either "${side}" (CSS selector) or "${side}X"/"${side}Y" coordinates, not both`);
3164
+ if (!hasSel && !(hasX && hasY)) throw new Error(`${side}: needs a CSS selector ("${side}") or both "${side}X" and "${side}Y" coordinates`);
3165
+ if (hasSel) return { selector };
3166
+ return {
3167
+ x,
3168
+ y
3169
+ };
3170
+ }
3171
+ /**
3172
+ * Resolve a drag endpoint. Selector endpoints become viewport coordinates via
3173
+ * the CDP DOM domain — no page JavaScript, so mouse mode also works with
3174
+ * `allowScript: false`. Top document only; cross-origin iframes are a later
3175
+ * iteration.
3176
+ */
3177
+ async function resolveDragPoint(ctrl, tabId, target, signal) {
3178
+ if (target.selector === void 0) return {
3179
+ x: target.x,
3180
+ y: target.y
3181
+ };
3182
+ const selector = target.selector;
3183
+ const rootId = (await cdpCall(ctrl, tabId, "DOM.getDocument", { depth: 0 }, signal))?.root?.nodeId;
3184
+ if (typeof rootId !== "number") throw new Error("DOM.getDocument returned no root node");
3185
+ const nodeId = (await cdpCall(ctrl, tabId, "DOM.querySelector", {
3186
+ nodeId: rootId,
3187
+ selector
3188
+ }, signal))?.nodeId ?? 0;
3189
+ if (nodeId === 0) throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
3190
+ await cdpCall(ctrl, tabId, "DOM.scrollIntoViewIfNeeded", { nodeId }, signal);
3191
+ let box;
3192
+ try {
3193
+ box = await cdpCall(ctrl, tabId, "DOM.getBoxModel", { nodeId }, signal);
3194
+ } catch (error) {
3195
+ throw new Error(`cannot measure ${JSON.stringify(selector)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
3196
+ }
3197
+ const q = box?.model?.content;
3198
+ if (!Array.isArray(q) || q.length < 8) throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(selector)}`);
3199
+ return {
3200
+ selector,
3201
+ x: (q[0] + q[2] + q[4] + q[6]) / 4,
3202
+ y: (q[1] + q[3] + q[5] + q[7]) / 4,
3203
+ width: typeof box?.model?.width === "number" ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]),
3204
+ height: typeof box?.model?.height === "number" ? box.model.height : Math.hypot(q[4] - q[2], q[5] - q[3]),
3205
+ nodeId
3206
+ };
3207
+ }
3208
+ /** Whether the source declares the native HTML5 `draggable` attribute. */
3209
+ async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
3210
+ const attrs = await cdpCall(ctrl, tabId, "DOM.getAttributes", { nodeId }, signal);
3211
+ const list = Array.isArray(attrs?.attributes) ? attrs.attributes.map(String) : [];
3212
+ const at = list.indexOf("draggable");
3213
+ return at >= 0 && list[at + 1] === "true";
3214
+ }
3215
+ /**
3216
+ * Synthesize a native-order HTML5 drag in the page: dragstart on the source,
3217
+ * dragenter/dragover on the target, drop only when dragover was prevented
3218
+ * (the browser's own rule), dragend on the source, all sharing one DataTransfer.
3219
+ * Untrusted events — pages that gate on `event.isTrusted` will ignore them,
3220
+ * which the tool description states instead of pretending otherwise.
3221
+ */
3222
+ function html5DragExpression(fromSelector, to) {
3223
+ const toExpr = to.selector !== void 0 ? `document.querySelector(${JSON.stringify(to.selector)})` : `document.elementFromPoint(${to.x}, ${to.y})`;
3224
+ return `(() => {
3225
+ const src = document.querySelector(${JSON.stringify(fromSelector)});
3226
+ if (!src) return { error: 'no element matches from selector ' + ${JSON.stringify(fromSelector)} };
3227
+ const dst = ${toExpr};
3228
+ if (!dst) return { error: 'target element not found' };
3229
+ const dt = new DataTransfer();
3230
+ const log = [];
3231
+ const sr = src.getBoundingClientRect();
3232
+ const dr = dst.getBoundingClientRect();
3233
+ const sx = sr.left + sr.width / 2, sy = sr.top + sr.height / 2;
3234
+ const tx = dr.left + dr.width / 2, ty = dr.top + dr.height / 2;
3235
+ const fire = (type, target, cx, cy) => {
3236
+ const ev = new DragEvent(type, { bubbles: true, cancelable: true, composed: true, dataTransfer: dt, clientX: cx, clientY: cy });
3237
+ target.dispatchEvent(ev);
3238
+ log.push({ type, defaultPrevented: ev.defaultPrevented });
3239
+ return ev.defaultPrevented;
3240
+ };
3241
+ const startCancelled = fire('dragstart', src, sx, sy);
3242
+ let dragenterDefaultPrevented = false, overPrevented = false, dropFired = false, dropPrevented = false;
3243
+ if (!startCancelled) {
3244
+ dragenterDefaultPrevented = fire('dragenter', dst, tx, ty);
3245
+ overPrevented = fire('dragover', dst, tx, ty);
3246
+ if (overPrevented) {
3247
+ dropFired = true;
3248
+ dropPrevented = fire('drop', dst, tx, ty);
3249
+ }
3250
+ }
3251
+ fire('dragend', src, sx, sy);
3252
+ return { startCancelled, dragenterDefaultPrevented, overPrevented, dropFired, dropPrevented, events: log.length, log };
3253
+ })()`;
3254
+ }
3255
+ function roundDragPoint(p) {
3256
+ const out = {
3257
+ x: Math.round(p.x * 100) / 100,
3258
+ y: Math.round(p.y * 100) / 100
3259
+ };
3260
+ if (p.selector !== void 0) out.selector = p.selector;
3261
+ if (p.width !== void 0) out.width = Math.round(p.width * 100) / 100;
3262
+ if (p.height !== void 0) out.height = Math.round(p.height * 100) / 100;
3263
+ return out;
3264
+ }
3265
+ function registerDragTool(ctx, controller) {
3266
+ ctx.tools.register(defineTool({
3267
+ name: "browser_drag",
3268
+ description: "Drag a page element or point onto another with real input. mode \"mouse\" sends trusted press→moves→release input via CDP (canvas, sliders, sortable widgets); mode \"html5\" synthesizes dragstart/dragover/drop in the page for [draggable] elements and needs page scripting; \"auto\" (default) picks html5 only when the source declares draggable=\"true\". Endpoints are CSS selectors (top document) or fromX/fromY + toX/toY viewport coordinates. Needs browser extension v1.7.2+ (embedded cdp channel).",
3269
+ parameters: {
3270
+ from: {
3271
+ type: "string",
3272
+ description: "CSS selector of the drag source; use fromX/fromY instead for raw coordinates."
3273
+ },
3274
+ to: {
3275
+ type: "string",
3276
+ description: "CSS selector of the drop target; use toX/toY instead for raw coordinates."
3277
+ },
3278
+ fromX: {
3279
+ type: "number",
3280
+ description: "Viewport x of the drag start point (with fromY)."
3281
+ },
3282
+ fromY: {
3283
+ type: "number",
3284
+ description: "Viewport y of the drag start point (with fromX)."
3285
+ },
3286
+ toX: {
3287
+ type: "number",
3288
+ description: "Viewport x of the drop point (with toY)."
3289
+ },
3290
+ toY: {
3291
+ type: "number",
3292
+ description: "Viewport y of the drop point (with toX)."
3293
+ },
3294
+ tabId: {
3295
+ type: "number",
3296
+ description: "Target tab; defaults to the active tab."
3297
+ },
3298
+ mode: {
3299
+ type: "string",
3300
+ description: "\"auto\" (default), \"mouse\" or \"html5\" — see the tool description."
3301
+ },
3302
+ steps: {
3303
+ type: "number",
3304
+ description: "Intermediate mouse moves in mouse mode, 1–50 (default 12)."
3305
+ }
3306
+ },
3307
+ output: {
3308
+ schema: {
3309
+ type: "object",
3310
+ additionalProperties: false,
3311
+ properties: {
3312
+ tabId: {
3313
+ type: "number",
3314
+ required: true
3315
+ },
3316
+ mode: {
3317
+ type: "string",
3318
+ required: true
3319
+ },
3320
+ from: {
3321
+ type: "object",
3322
+ additionalProperties: false,
3323
+ properties: {
3324
+ selector: { type: "string" },
3325
+ x: { type: "number" },
3326
+ y: { type: "number" },
3327
+ width: { type: "number" },
3328
+ height: { type: "number" }
3329
+ }
3330
+ },
3331
+ to: {
3332
+ type: "object",
3333
+ additionalProperties: false,
3334
+ properties: {
3335
+ selector: { type: "string" },
3336
+ x: { type: "number" },
3337
+ y: { type: "number" },
3338
+ width: { type: "number" },
3339
+ height: { type: "number" }
3340
+ }
3341
+ },
3342
+ steps: { type: "number" },
3343
+ dispatched: { type: "number" },
3344
+ startCancelled: { type: "boolean" },
3345
+ dragenterDefaultPrevented: { type: "boolean" },
3346
+ overPrevented: { type: "boolean" },
3347
+ dropFired: { type: "boolean" },
3348
+ dropPrevented: { type: "boolean" },
3349
+ events: { type: "number" },
3350
+ elapsedMs: {
3351
+ type: "number",
3352
+ required: true
3353
+ }
3354
+ }
3355
+ },
3356
+ render: (_args, value) => {
3357
+ const v = value;
3358
+ const d = (p) => p?.selector !== void 0 ? p.selector : `(${p?.x ?? "?"},${p?.y ?? "?"})`;
3359
+ if (v.mode === "mouse") return [{
3360
+ type: "text",
3361
+ text: `Tab ${v.tabId}: dragged ${d(v.from)} → ${d(v.to)} with mouse input (${v.steps} steps, ${v.dispatched} dispatches, ${v.elapsedMs}ms)`
3362
+ }];
3363
+ const verdict = v.startCancelled === true ? "dragstart was cancelled by the page — nothing dropped" : v.overPrevented === true ? v.dropPrevented === true ? "drop fired and was handled" : "drop fired but no drop handler ran" : "target did not accept the drop (no dragover preventDefault)";
3364
+ return [{
3365
+ type: "text",
3366
+ text: `Tab ${v.tabId}: dragged ${d(v.from)} → ${d(v.to)} with html5 events (${v.events} events, ${v.elapsedMs}ms) — ${verdict}`
3367
+ }];
3368
+ }
3369
+ },
3370
+ presentCall: (args) => ({
3371
+ card: "generic",
3372
+ title: `Drag ${args.from ?? `(${args.fromX},${args.fromY})`} → ${args.to ?? `(${args.toX},${args.toY})`}`,
3373
+ kind: "other"
3374
+ }),
3375
+ async execute(args, exec) {
3376
+ const mode = args.mode === void 0 ? "auto" : args.mode;
3377
+ if (mode !== "auto" && mode !== "mouse" && mode !== "html5") throw new Error(`mode must be "auto", "mouse" or "html5" — got ${JSON.stringify(mode)}`);
3378
+ const from = dragTargetArgs(args.from, args.fromX, args.fromY, "from");
3379
+ const to = dragTargetArgs(args.to, args.toX, args.toY, "to");
3380
+ controller.requireExtensionCommand("cdp", "1.7.2");
3381
+ if (controller.policy.readOnly) throw new Error("browser_drag changes page state and read-only mode is on — turn readOnly off in the browser-bridge config to drag");
3382
+ let tabId = args.tabId;
3383
+ if (tabId === void 0) {
3384
+ const listing = await controller.execute("tabs.list", {}, exec.signal);
3385
+ if (typeof listing?.activeTabId !== "number") throw new Error("could not resolve the active tab — pass tabId explicitly");
3386
+ tabId = listing.activeTabId;
3387
+ }
3388
+ const started = Date.now();
3389
+ let resolvedMode = mode === "auto" ? "mouse" : mode;
3390
+ let source;
3391
+ if (mode !== "html5") source = await resolveDragPoint(controller, tabId, from, exec.signal);
3392
+ if (mode === "auto" && source?.nodeId !== void 0 && await sourceIsDraggable(controller, tabId, source.nodeId, exec.signal)) resolvedMode = "html5";
3393
+ if (resolvedMode === "mouse") {
3394
+ const target = await resolveDragPoint(controller, tabId, to, exec.signal);
3395
+ const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === "number" ? args.steps : 12)));
3396
+ const sx = source.x, sy = source.y, tx = target.x, ty = target.y;
3397
+ const dispatch = async (params) => {
3398
+ await cdpCall(controller, tabId, "Input.dispatchMouseEvent", params, exec.signal);
3399
+ };
3400
+ await dispatch({
3401
+ type: "mouseMoved",
3402
+ x: sx,
3403
+ y: sy,
3404
+ button: "none",
3405
+ buttons: 0
3406
+ });
3407
+ await dispatch({
3408
+ type: "mousePressed",
3409
+ x: sx,
3410
+ y: sy,
3411
+ button: "left",
3412
+ buttons: 1,
3413
+ clickCount: 1
3414
+ });
3415
+ for (let i = 1; i <= steps; i++) {
3416
+ const f = i / steps;
3417
+ await dispatch({
3418
+ type: "mouseMoved",
3419
+ x: sx + (tx - sx) * f,
3420
+ y: sy + (ty - sy) * f,
3421
+ button: "left",
3422
+ buttons: 1
3423
+ });
3424
+ }
3425
+ await dispatch({
3426
+ type: "mouseReleased",
3427
+ x: tx,
3428
+ y: ty,
3429
+ button: "left",
3430
+ buttons: 0,
3431
+ clickCount: 1
3432
+ });
3433
+ await dispatch({
3434
+ type: "mouseMoved",
3435
+ x: tx,
3436
+ y: ty,
3437
+ button: "none",
3438
+ buttons: 0
3439
+ });
3440
+ return {
3441
+ tabId,
3442
+ mode: "mouse",
3443
+ from: roundDragPoint(source),
3444
+ to: roundDragPoint(target),
3445
+ steps,
3446
+ dispatched: steps + 4,
3447
+ elapsedMs: Date.now() - started
3448
+ };
3449
+ }
3450
+ if (from.selector === void 0) throw new Error("html5 mode needs a CSS selector for \"from\" (a point has no draggable element)");
3451
+ if (!controller.policy.allowScript) throw new Error("html5 drag synthesizes DOM events and needs page scripting — allowScript is off; use mode:\"mouse\" or re-enable the script switch");
3452
+ const raw = await controller.execute("eval", {
3453
+ expression: html5DragExpression(from.selector, to),
3454
+ tabId
3455
+ }, exec.signal);
3456
+ const outcome = raw?.value;
3457
+ if (outcome === void 0 || typeof outcome !== "object") throw new Error("html5 drag expression returned no outcome — page may have navigated mid-drag");
3458
+ if (typeof outcome.error === "string") throw new Error(outcome.error);
3459
+ return {
3460
+ tabId: typeof raw?.tabId === "number" ? raw.tabId : tabId,
3461
+ mode: "html5",
3462
+ from: from.selector !== void 0 ? { selector: from.selector } : roundDragPoint(from),
3463
+ to: to.selector !== void 0 ? { selector: to.selector } : {
3464
+ x: to.x,
3465
+ y: to.y
3466
+ },
3467
+ startCancelled: outcome.startCancelled === true,
3468
+ dragenterDefaultPrevented: outcome.dragenterDefaultPrevented === true,
3469
+ overPrevented: outcome.overPrevented === true,
3470
+ dropFired: outcome.dropFired === true,
3471
+ dropPrevented: outcome.dropPrevented === true,
3472
+ events: typeof outcome.events === "number" ? outcome.events : 0,
3473
+ elapsedMs: Date.now() - started
3474
+ };
3475
+ }
3476
+ }));
3038
3477
  }
3039
3478
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
3040
3479
  function apply(ctx, config) {
@@ -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
  // 就是告诉模型「什么都没发生,重试安全」,而那正好是假的。
@@ -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,46 @@ class BridgeController {
80
123
  get shotsDir() {
81
124
  return this.current === undefined ? undefined : path.resolve(this.current.shotsDir);
82
125
  }
126
+ /**
127
+ * Current policy flags. Tools that compose several commands (browser_drag
128
+ * runs `cdp`/`eval`) read this to refuse with a tool-level message; the
129
+ * per-command gate in execute() stays as the enforcement backstop.
130
+ */
131
+ get policy() {
132
+ return { readOnly: this.current?.readOnly === true, allowScript: this.current?.allowScript !== false };
133
+ }
134
+ /** Version the connected extension reported in its hello frame, if any. */
135
+ extensionVersion() {
136
+ return this.server?.status.hello?.version;
137
+ }
138
+ /**
139
+ * Whether the connected extension implements `command`. Exact when the hello
140
+ * carries a capabilities list (v1.7.2+); otherwise derived from the reported
141
+ * version via the surface-delta map. No connection ⇒ true: the not-enabled
142
+ * error from execute is the right failure there, not a capability complaint.
143
+ */
144
+ hasExtensionCommand(command) {
145
+ const hello = this.server?.status.hello;
146
+ if (hello?.capabilities !== undefined)
147
+ return hello.capabilities.includes(command);
148
+ const version = hello?.version;
149
+ if (version === undefined || version === 'unknown')
150
+ return true;
151
+ const entry = EXTENSION_COMMANDS_BY_VERSION.find((e) => e.commands.includes(command));
152
+ if (entry === undefined)
153
+ return true;
154
+ return compareVersions(version, entry.since) >= 0;
155
+ }
156
+ /**
157
+ * Pre-flight capability gate: throw the actionable upgrade error before the
158
+ * command is sent, so an old extension answers with a what-to-do, never with
159
+ * a bare "unknown command" the model cannot act on.
160
+ */
161
+ requireExtensionCommand(command, since) {
162
+ if (this.hasExtensionCommand(command))
163
+ return;
164
+ throw extensionOutdatedError(this.extensionVersion(), command, since);
165
+ }
83
166
  /**
84
167
  * Converge the live server onto `config`. With `throwOnError`, an initial
85
168
  * start failure rejects (fail-loud activation); later changes record the
@@ -964,8 +1047,10 @@ function applyBrowserTools(ctx, controller) {
964
1047
  }
965
1048
  params.timeoutMs = budgetMs;
966
1049
  if (args.action === 'back') {
1050
+ controller.requireExtensionCommand('tabs.back', '1.7.0');
967
1051
  return await controller.execute('tabs.back', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
968
1052
  }
1053
+ controller.requireExtensionCommand('tabs.forward', '1.7.0');
969
1054
  return await controller.execute('tabs.forward', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
970
1055
  }
971
1056
  case 'group':
@@ -1012,6 +1097,7 @@ function applyBrowserTools(ctx, controller) {
1012
1097
  async execute(args, exec) {
1013
1098
  switch (args.action) {
1014
1099
  case 'list':
1100
+ controller.requireExtensionCommand('windows.list', '1.6.0');
1015
1101
  return await controller.execute('windows.list', {}, exec.signal);
1016
1102
  case 'open': {
1017
1103
  const params = {};
@@ -1027,6 +1113,7 @@ function applyBrowserTools(ctx, controller) {
1027
1113
  }
1028
1114
  if (args.focused !== undefined)
1029
1115
  params.focused = args.focused;
1116
+ controller.requireExtensionCommand('windows.open', '1.6.0');
1030
1117
  return await controller.execute('windows.open', params, exec.signal);
1031
1118
  }
1032
1119
  case 'update': {
@@ -1043,9 +1130,11 @@ function applyBrowserTools(ctx, controller) {
1043
1130
  params.alwaysOnTop = args.alwaysOnTop;
1044
1131
  if (args.drawAttention === true)
1045
1132
  params.drawAttention = true;
1133
+ controller.requireExtensionCommand('windows.update', '1.6.0');
1046
1134
  return await controller.execute('windows.update', params, exec.signal);
1047
1135
  }
1048
1136
  case 'close':
1137
+ controller.requireExtensionCommand('windows.close', '1.6.0');
1049
1138
  return await controller.execute('windows.close', { windowId: requireWindowId(args, 'close') }, exec.signal);
1050
1139
  case 'moveTab': {
1051
1140
  if (args.tabId === undefined && args.tabIds === undefined)
@@ -1057,6 +1146,7 @@ function applyBrowserTools(ctx, controller) {
1057
1146
  params.tabIds = args.tabIds;
1058
1147
  if (args.index !== undefined)
1059
1148
  params.index = args.index;
1149
+ controller.requireExtensionCommand('windows.moveTab', '1.6.0');
1060
1150
  return await controller.execute('windows.moveTab', params, exec.signal);
1061
1151
  }
1062
1152
  default:
@@ -1440,6 +1530,7 @@ function applyBrowserTools(ctx, controller) {
1440
1530
  const params = {};
1441
1531
  if (args.tabId !== undefined)
1442
1532
  params.tabId = args.tabId;
1533
+ controller.requireExtensionCommand('archive', '1.5.0');
1443
1534
  const payload = await controller.execute('archive', params, exec.signal);
1444
1535
  return await saveMhtml(controller, payload, typeof args.path === 'string' ? args.path : undefined);
1445
1536
  },
@@ -1596,9 +1687,276 @@ function applyBrowserTools(ctx, controller) {
1596
1687
  const params = {};
1597
1688
  if (args.tabId !== undefined)
1598
1689
  params.tabId = args.tabId;
1690
+ controller.requireExtensionCommand('metrics', '1.7.1');
1599
1691
  return await controller.execute('metrics', params, exec.signal);
1600
1692
  },
1601
1693
  }));
1694
+ registerDragTool(ctx, controller);
1695
+ }
1696
+ /* ------------------- browser_drag: composed plugin-side over the cdp channel ------------------- */
1697
+ /** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
1698
+ async function cdpCall(ctrl, tabId, method, params, signal) {
1699
+ return await ctrl.execute('cdp', { method, params, tabId }, signal);
1700
+ }
1701
+ function dragTargetArgs(selector, x, y, side) {
1702
+ const hasSel = typeof selector === 'string' && selector.length > 0;
1703
+ const hasX = typeof x === 'number' && Number.isFinite(x);
1704
+ const hasY = typeof y === 'number' && Number.isFinite(y);
1705
+ if (hasSel && (hasX || hasY))
1706
+ throw new Error(`${side}: give either "${side}" (CSS selector) or "${side}X"/"${side}Y" coordinates, not both`);
1707
+ if (!hasSel && !(hasX && hasY))
1708
+ throw new Error(`${side}: needs a CSS selector ("${side}") or both "${side}X" and "${side}Y" coordinates`);
1709
+ if (hasSel)
1710
+ return { selector };
1711
+ return { x, y };
1712
+ }
1713
+ /**
1714
+ * Resolve a drag endpoint. Selector endpoints become viewport coordinates via
1715
+ * the CDP DOM domain — no page JavaScript, so mouse mode also works with
1716
+ * `allowScript: false`. Top document only; cross-origin iframes are a later
1717
+ * iteration.
1718
+ */
1719
+ async function resolveDragPoint(ctrl, tabId, target, signal) {
1720
+ if (target.selector === undefined)
1721
+ return { x: target.x, y: target.y };
1722
+ const selector = target.selector;
1723
+ const doc = await cdpCall(ctrl, tabId, 'DOM.getDocument', { depth: 0 }, signal);
1724
+ const rootId = doc?.root?.nodeId;
1725
+ if (typeof rootId !== 'number')
1726
+ throw new Error('DOM.getDocument returned no root node');
1727
+ const found = await cdpCall(ctrl, tabId, 'DOM.querySelector', { nodeId: rootId, selector }, signal);
1728
+ const nodeId = found?.nodeId ?? 0;
1729
+ if (nodeId === 0)
1730
+ throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
1731
+ await cdpCall(ctrl, tabId, 'DOM.scrollIntoViewIfNeeded', { nodeId }, signal);
1732
+ let box;
1733
+ try {
1734
+ box = await cdpCall(ctrl, tabId, 'DOM.getBoxModel', { nodeId }, signal);
1735
+ }
1736
+ catch (error) {
1737
+ throw new Error(`cannot measure ${JSON.stringify(selector)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
1738
+ }
1739
+ const q = box?.model?.content;
1740
+ if (!Array.isArray(q) || q.length < 8)
1741
+ throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(selector)}`);
1742
+ const x = (q[0] + q[2] + q[4] + q[6]) / 4;
1743
+ const y = (q[1] + q[3] + q[5] + q[7]) / 4;
1744
+ const width = typeof box?.model?.width === 'number' ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]);
1745
+ const height = typeof box?.model?.height === 'number' ? box.model.height : Math.hypot(q[4] - q[2], q[5] - q[3]);
1746
+ return { selector, x, y, width, height, nodeId };
1747
+ }
1748
+ /** Whether the source declares the native HTML5 `draggable` attribute. */
1749
+ async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
1750
+ const attrs = await cdpCall(ctrl, tabId, 'DOM.getAttributes', { nodeId }, signal);
1751
+ const list = Array.isArray(attrs?.attributes) ? attrs.attributes.map(String) : [];
1752
+ const at = list.indexOf('draggable');
1753
+ return at >= 0 && list[at + 1] === 'true';
1754
+ }
1755
+ /**
1756
+ * Synthesize a native-order HTML5 drag in the page: dragstart on the source,
1757
+ * dragenter/dragover on the target, drop only when dragover was prevented
1758
+ * (the browser's own rule), dragend on the source, all sharing one DataTransfer.
1759
+ * Untrusted events — pages that gate on `event.isTrusted` will ignore them,
1760
+ * which the tool description states instead of pretending otherwise.
1761
+ */
1762
+ function html5DragExpression(fromSelector, to) {
1763
+ const toExpr = to.selector !== undefined
1764
+ ? `document.querySelector(${JSON.stringify(to.selector)})`
1765
+ : `document.elementFromPoint(${to.x}, ${to.y})`;
1766
+ return `(() => {
1767
+ const src = document.querySelector(${JSON.stringify(fromSelector)});
1768
+ if (!src) return { error: 'no element matches from selector ' + ${JSON.stringify(fromSelector)} };
1769
+ const dst = ${toExpr};
1770
+ if (!dst) return { error: 'target element not found' };
1771
+ const dt = new DataTransfer();
1772
+ const log = [];
1773
+ const sr = src.getBoundingClientRect();
1774
+ const dr = dst.getBoundingClientRect();
1775
+ const sx = sr.left + sr.width / 2, sy = sr.top + sr.height / 2;
1776
+ const tx = dr.left + dr.width / 2, ty = dr.top + dr.height / 2;
1777
+ const fire = (type, target, cx, cy) => {
1778
+ const ev = new DragEvent(type, { bubbles: true, cancelable: true, composed: true, dataTransfer: dt, clientX: cx, clientY: cy });
1779
+ target.dispatchEvent(ev);
1780
+ log.push({ type, defaultPrevented: ev.defaultPrevented });
1781
+ return ev.defaultPrevented;
1782
+ };
1783
+ const startCancelled = fire('dragstart', src, sx, sy);
1784
+ let dragenterDefaultPrevented = false, overPrevented = false, dropFired = false, dropPrevented = false;
1785
+ if (!startCancelled) {
1786
+ dragenterDefaultPrevented = fire('dragenter', dst, tx, ty);
1787
+ overPrevented = fire('dragover', dst, tx, ty);
1788
+ if (overPrevented) {
1789
+ dropFired = true;
1790
+ dropPrevented = fire('drop', dst, tx, ty);
1791
+ }
1792
+ }
1793
+ fire('dragend', src, sx, sy);
1794
+ return { startCancelled, dragenterDefaultPrevented, overPrevented, dropFired, dropPrevented, events: log.length, log };
1795
+ })()`;
1796
+ }
1797
+ function roundDragPoint(p) {
1798
+ const out = {
1799
+ x: Math.round(p.x * 100) / 100,
1800
+ y: Math.round(p.y * 100) / 100,
1801
+ };
1802
+ if (p.selector !== undefined)
1803
+ out.selector = p.selector;
1804
+ if (p.width !== undefined)
1805
+ out.width = Math.round(p.width * 100) / 100;
1806
+ if (p.height !== undefined)
1807
+ out.height = Math.round(p.height * 100) / 100;
1808
+ return out;
1809
+ }
1810
+ function registerDragTool(ctx, controller) {
1811
+ ctx.tools.register(defineTool({
1812
+ name: 'browser_drag',
1813
+ description: 'Drag a page element or point onto another with real input. mode "mouse" sends trusted press→moves→release input via CDP (canvas, sliders, sortable widgets); mode "html5" synthesizes dragstart/dragover/drop in the page for [draggable] elements and needs page scripting; "auto" (default) picks html5 only when the source declares draggable="true". Endpoints are CSS selectors (top document) or fromX/fromY + toX/toY viewport coordinates. Needs browser extension v1.7.2+ (embedded cdp channel).',
1814
+ parameters: {
1815
+ from: { type: 'string', description: 'CSS selector of the drag source; use fromX/fromY instead for raw coordinates.' },
1816
+ to: { type: 'string', description: 'CSS selector of the drop target; use toX/toY instead for raw coordinates.' },
1817
+ fromX: { type: 'number', description: 'Viewport x of the drag start point (with fromY).' },
1818
+ fromY: { type: 'number', description: 'Viewport y of the drag start point (with fromX).' },
1819
+ toX: { type: 'number', description: 'Viewport x of the drop point (with toY).' },
1820
+ toY: { type: 'number', description: 'Viewport y of the drop point (with toX).' },
1821
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1822
+ mode: { type: 'string', description: '"auto" (default), "mouse" or "html5" — see the tool description.' },
1823
+ steps: { type: 'number', description: 'Intermediate mouse moves in mouse mode, 1–50 (default 12).' },
1824
+ },
1825
+ output: {
1826
+ schema: {
1827
+ type: 'object',
1828
+ additionalProperties: false,
1829
+ properties: {
1830
+ tabId: { type: 'number', required: true },
1831
+ mode: { type: 'string', required: true },
1832
+ from: {
1833
+ type: 'object',
1834
+ additionalProperties: false,
1835
+ properties: {
1836
+ selector: { type: 'string' },
1837
+ x: { type: 'number' },
1838
+ y: { type: 'number' },
1839
+ width: { type: 'number' },
1840
+ height: { type: 'number' },
1841
+ },
1842
+ },
1843
+ to: {
1844
+ type: 'object',
1845
+ additionalProperties: false,
1846
+ properties: {
1847
+ selector: { type: 'string' },
1848
+ x: { type: 'number' },
1849
+ y: { type: 'number' },
1850
+ width: { type: 'number' },
1851
+ height: { type: 'number' },
1852
+ },
1853
+ },
1854
+ steps: { type: 'number' },
1855
+ dispatched: { type: 'number' },
1856
+ startCancelled: { type: 'boolean' },
1857
+ dragenterDefaultPrevented: { type: 'boolean' },
1858
+ overPrevented: { type: 'boolean' },
1859
+ dropFired: { type: 'boolean' },
1860
+ dropPrevented: { type: 'boolean' },
1861
+ events: { type: 'number' },
1862
+ elapsedMs: { type: 'number', required: true },
1863
+ },
1864
+ },
1865
+ render: (_args, value) => {
1866
+ const v = value;
1867
+ const d = (p) => p?.selector !== undefined ? p.selector : `(${p?.x ?? '?'},${p?.y ?? '?'})`;
1868
+ if (v.mode === 'mouse') {
1869
+ return [{ type: 'text', text: `Tab ${v.tabId}: dragged ${d(v.from)} → ${d(v.to)} with mouse input (${v.steps} steps, ${v.dispatched} dispatches, ${v.elapsedMs}ms)` }];
1870
+ }
1871
+ const verdict = v.startCancelled === true
1872
+ ? 'dragstart was cancelled by the page — nothing dropped'
1873
+ : v.overPrevented === true
1874
+ ? (v.dropPrevented === true ? 'drop fired and was handled' : 'drop fired but no drop handler ran')
1875
+ : 'target did not accept the drop (no dragover preventDefault)';
1876
+ return [{ type: 'text', text: `Tab ${v.tabId}: dragged ${d(v.from)} → ${d(v.to)} with html5 events (${v.events} events, ${v.elapsedMs}ms) — ${verdict}` }];
1877
+ },
1878
+ },
1879
+ presentCall: args => ({
1880
+ card: 'generic',
1881
+ title: `Drag ${args.from ?? `(${args.fromX},${args.fromY})`} → ${args.to ?? `(${args.toX},${args.toY})`}`,
1882
+ kind: 'other',
1883
+ }),
1884
+ async execute(args, exec) {
1885
+ const mode = args.mode === undefined ? 'auto' : args.mode;
1886
+ if (mode !== 'auto' && mode !== 'mouse' && mode !== 'html5')
1887
+ throw new Error(`mode must be "auto", "mouse" or "html5" — got ${JSON.stringify(mode)}`);
1888
+ const from = dragTargetArgs(args.from, args.fromX, args.fromY, 'from');
1889
+ const to = dragTargetArgs(args.to, args.toX, args.toY, 'to');
1890
+ controller.requireExtensionCommand('cdp', '1.7.2');
1891
+ if (controller.policy.readOnly)
1892
+ throw new Error('browser_drag changes page state and read-only mode is on — turn readOnly off in the browser-bridge config to drag');
1893
+ let tabId = args.tabId;
1894
+ if (tabId === undefined) {
1895
+ const listing = await controller.execute('tabs.list', {}, exec.signal);
1896
+ if (typeof listing?.activeTabId !== 'number')
1897
+ throw new Error('could not resolve the active tab — pass tabId explicitly');
1898
+ tabId = listing.activeTabId;
1899
+ }
1900
+ const started = Date.now();
1901
+ let resolvedMode = mode === 'auto' ? 'mouse' : mode;
1902
+ let source;
1903
+ if (mode !== 'html5')
1904
+ source = await resolveDragPoint(controller, tabId, from, exec.signal);
1905
+ if (mode === 'auto' && source?.nodeId !== undefined && await sourceIsDraggable(controller, tabId, source.nodeId, exec.signal)) {
1906
+ resolvedMode = 'html5';
1907
+ }
1908
+ if (resolvedMode === 'mouse') {
1909
+ const target = await resolveDragPoint(controller, tabId, to, exec.signal);
1910
+ const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === 'number' ? args.steps : 12)));
1911
+ const sx = source.x, sy = source.y, tx = target.x, ty = target.y;
1912
+ const dispatch = async (params) => {
1913
+ await cdpCall(controller, tabId, 'Input.dispatchMouseEvent', params, exec.signal);
1914
+ };
1915
+ await dispatch({ type: 'mouseMoved', x: sx, y: sy, button: 'none', buttons: 0 });
1916
+ await dispatch({ type: 'mousePressed', x: sx, y: sy, button: 'left', buttons: 1, clickCount: 1 });
1917
+ for (let i = 1; i <= steps; i++) {
1918
+ const f = i / steps;
1919
+ await dispatch({ type: 'mouseMoved', x: sx + (tx - sx) * f, y: sy + (ty - sy) * f, button: 'left', buttons: 1 });
1920
+ }
1921
+ await dispatch({ type: 'mouseReleased', x: tx, y: ty, button: 'left', buttons: 0, clickCount: 1 });
1922
+ await dispatch({ type: 'mouseMoved', x: tx, y: ty, button: 'none', buttons: 0 });
1923
+ const receipt = {
1924
+ tabId,
1925
+ mode: 'mouse',
1926
+ from: roundDragPoint(source),
1927
+ to: roundDragPoint(target),
1928
+ steps,
1929
+ dispatched: steps + 4,
1930
+ elapsedMs: Date.now() - started,
1931
+ };
1932
+ return receipt;
1933
+ }
1934
+ if (from.selector === undefined)
1935
+ throw new Error('html5 mode needs a CSS selector for "from" (a point has no draggable element)');
1936
+ if (!controller.policy.allowScript)
1937
+ throw new Error('html5 drag synthesizes DOM events and needs page scripting — allowScript is off; use mode:"mouse" or re-enable the script switch');
1938
+ const raw = await controller.execute('eval', { expression: html5DragExpression(from.selector, to), tabId }, exec.signal);
1939
+ const outcome = raw?.value;
1940
+ if (outcome === undefined || typeof outcome !== 'object')
1941
+ throw new Error('html5 drag expression returned no outcome — page may have navigated mid-drag');
1942
+ if (typeof outcome.error === 'string')
1943
+ throw new Error(outcome.error);
1944
+ const receipt = {
1945
+ tabId: typeof raw?.tabId === 'number' ? raw.tabId : tabId,
1946
+ mode: 'html5',
1947
+ from: from.selector !== undefined ? { selector: from.selector } : roundDragPoint(from),
1948
+ to: to.selector !== undefined ? { selector: to.selector } : { x: to.x, y: to.y },
1949
+ startCancelled: outcome.startCancelled === true,
1950
+ dragenterDefaultPrevented: outcome.dragenterDefaultPrevented === true,
1951
+ overPrevented: outcome.overPrevented === true,
1952
+ dropFired: outcome.dropFired === true,
1953
+ dropPrevented: outcome.dropPrevented === true,
1954
+ events: typeof outcome.events === 'number' ? outcome.events : 0,
1955
+ elapsedMs: Date.now() - started,
1956
+ };
1957
+ return receipt;
1958
+ },
1959
+ }));
1602
1960
  }
1603
1961
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
1604
1962
  export function apply(ctx, config) {
@@ -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 {
@@ -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` - 25 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
45
+ - `src/index.ts` - 26 个 `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.1",
4
+ "version": "1.7.3",
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 && node verify/verify_metrics_throttle.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_drag.mjs && node verify/verify_compat_matrix.mjs"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public",