@angelky7/dsh-browser-control 1.7.6 → 1.7.8

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.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) |
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–v1.7.8 do not touch the extension — skip this if v1.7.2 is already loaded) |
68
+ | [dsh-browser-control-plugin-v1.7.8.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.8/dsh-browser-control-plugin-v1.7.8.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.3"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.8"
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"
package/README.md CHANGED
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
64
64
 
65
65
  | 文件 | 说明 |
66
66
  |---|---|
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) |
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.8 都没有动扩展,装过 v1.7.2 的不用重装) |
68
+ | [dsh-browser-control-plugin-v1.7.8.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.8/dsh-browser-control-plugin-v1.7.8.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.3"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.8"
108
108
 
109
109
  # 本地目录调试(注意:必须显式 file: 前缀)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
@@ -195,7 +195,9 @@ config:
195
195
 
196
196
  被拦的命令**不会发到浏览器**(零副作用),错误码 `blocked_by_policy`,文案写明改哪个字段。
197
197
  注意 `readOnly` 单独打开**不等于**拿掉了任意脚本能力:`browser_evaluate` 能做其它工具能做的所有事,
198
- 所以「能不能执行脚本」是单独一项。这两个开关只管模型那条工具面,HTTP 面(带 token)不受影响。
198
+ 所以「能不能执行脚本」是单独一项。`browser_wait` 带 `fn` 参数时同样在页面里执行任意 JS,
199
+ 因此按脚本调用处理(`allowScript: false` 拒;`readOnly: true` 时也拒——只放行 `selector` / `text` 两种纯查询)。
200
+ 这两个开关对工具面与 HTTP `/api/command` 一视同仁:HTTP 面的写命令同样会收到 `blocked_by_policy`(403)。
199
201
 
200
202
  ### agent 开的标签长什么样(自动归进 DSH 分组)
201
203
 
@@ -240,7 +242,7 @@ curl -X POST http://127.0.0.1:9777/api/command \
240
242
  |---|---|
241
243
  | `browser_navigate` | 导航到 URL |
242
244
  | `browser_read` | 读取页面文本/HTML,正文直接给模型(`offset`/`limit` 窗口,长文分页) |
243
- | `browser_snapshot` | 页面快照 → 逐条列出 ref 的交互树(`mode:"ax"` 走 Chrome 无障碍树,拿 role + 可访问名) |
245
+ | `browser_snapshot` | 页面快照 → 逐条列出 ref 的交互树(`mode:"ax"` 走 Chrome 无障碍树,拿 role + 可访问名);v1.7.7 起支持 `offset` **分页**:页头写明「第几到第几 / 页面总数 / 下一页 offset」,超出扩展 200 条窗口的部分如实说明用 find/read 补齐 |
244
246
  | `browser_click` | 点击元素(by ref / selector) |
245
247
  | `browser_type` | 在输入框填入文本 |
246
248
  | `browser_press` | 模拟键盘按键(回车、Tab、方向键,以及 "Ctrl+A" / "Shift+ArrowRight" 这类组合键;一次按键只插入一次) |
package/lib/index.js CHANGED
@@ -136,7 +136,7 @@ const HINTS = {
136
136
  extension_offline: "浏览器扩展没连上:让用户打开装了 DSH Browser Control 的那个浏览器(扩展图标应为绿色),确认端口与 Token 两边一致后再试。",
137
137
  command_timeout: "扩展超时未回应:先别重试。用 browser_find / browser_snapshot 看页面当前状态,判断动作是否其实已经生效。",
138
138
  link_lost: "命令在途时链路断了:动作可能已经落到页面上。先用只读工具确认页面状态,再决定是否重做。",
139
- cancelled: "调用被取消:按需重新发起,不必当作错误处理。",
139
+ cancelled: "调用被取消:effectState=not_started 时命令确定没发出去,可以按需重新发起;unknown 时动作可能已经执行,先确认页面状态再决定,不要直接重做。",
140
140
  not_configured: "插件还没读到配置:确认 dsh 已加载本插件(dsh plugin list)后再试。",
141
141
  held_by_user: "这是用户在页面上的接管浮层里按下的「中止操作」,不是故障:命令一条都没有发出。先与用户确认;他同意继续之后调用 browser_hold action=off 解除,然后再继续原来的步骤。不要重试,也不要换成别的工具绕过它。",
142
142
  blocked_by_policy: "这是本机策略拦下的,不是故障:命令没有发给浏览器(零副作用)。要放开改 profile 的 cordis.patch.yml(browser-bridge 的 readOnly / allowScript)并重启 dsh,或换成本机允许的只读工具;不要原地重试。",
@@ -190,14 +190,17 @@ var BridgeError = class extends Error {
190
190
  /**
191
191
  * 把任意底层抛出的错误归类成 BridgeError。已经分类过的原样返回,
192
192
  * 保证多次包裹不会把 code 洗掉。
193
+ * @param params - 这次调用的参数。只有 `wait` 需要它:`fn` 是在页面里执行任意 JS,
194
+ * 超时/掉线时与写命令同档报 `unknown`(谓词跑过就不能断言页面没被动过);
195
+ * 不带 `fn` 的 wait 仍是只读。
193
196
  */
194
- function classify(command, error) {
197
+ function classify(command, error, params) {
195
198
  if (error instanceof BridgeError) return error;
196
199
  const raw = error instanceof Error ? error.message : String(error);
197
200
  const locked = raw.startsWith("browser-locked-by-user:") ? "locked_by_user" : raw.startsWith("browser-lock-unreadable:") ? "lock_unreadable" : raw.startsWith("browser-hold-aborted:") ? "held_by_user" : void 0;
198
201
  const markerLength = locked === "locked_by_user" ? 23 : locked === "lock_unreadable" ? 24 : locked === "held_by_user" ? 21 : 0;
199
202
  const message = locked ? raw.slice(markerLength).trim() : raw;
200
- const unknownIsPossible = isWriteCommand(command);
203
+ const unknownIsPossible = isWriteCommand(command) || command === "wait" && typeof params?.fn === "string" && params.fn.trim().length > 0;
201
204
  let code;
202
205
  if (locked !== void 0) code = locked;
203
206
  else if (/no browser extension connected/.test(message)) code = "extension_offline";
@@ -207,7 +210,8 @@ function classify(command, error) {
207
210
  else if (/尚未加载配置|not configured yet/.test(message)) code = "not_configured";
208
211
  else if (/未启用|not enabled|桥接启动失败|listen EADDR|settings integration/.test(message)) code = "bridge_disabled";
209
212
  else code = "command_failed";
210
- return new BridgeError(command, code, code === "command_timeout" || code === "link_lost" ? unknownIsPossible ? "unknown" : "not_started" : code === "command_failed" ? unknownIsPossible ? "unknown" : "not_started" : "not_started", message);
213
+ const effectState = code === "command_timeout" || code === "link_lost" ? unknownIsPossible ? "unknown" : "not_started" : code === "command_failed" ? unknownIsPossible ? "unknown" : "not_started" : code === "cancelled" ? /cancelled before send/.test(message) ? "not_started" : unknownIsPossible ? "unknown" : "not_started" : "not_started";
214
+ return new BridgeError(command, code, effectState, message);
211
215
  }
212
216
  //#endregion
213
217
  //#region lib/types/server.js
@@ -286,7 +290,12 @@ var BridgeServer = class {
286
290
  connectedAt;
287
291
  pending = /* @__PURE__ */ new Map();
288
292
  lastError;
289
- continuationRemainder = "";
293
+ /**
294
+ * Payloads of the message being reassembled. **Buffers, not strings**: decoding
295
+ * each fragment with `toString('utf8')` corrupts any multi-byte character that
296
+ * straddles a fragment boundary. Decode once, at FIN.
297
+ */
298
+ continuationChunks = [];
290
299
  continuationOpen = false;
291
300
  constructor(options) {
292
301
  this.options = options;
@@ -391,7 +400,7 @@ var BridgeServer = class {
391
400
  if (signal !== void 0) {
392
401
  entry.onAbort = () => {
393
402
  if (!this.pending.delete(id)) return;
394
- entry.reject(/* @__PURE__ */ new Error(`browser command cancelled: ${command}`));
403
+ entry.reject(/* @__PURE__ */ new Error(`browser command cancelled after send: ${command}`));
395
404
  };
396
405
  signal.addEventListener("abort", entry.onAbort, { once: true });
397
406
  }
@@ -406,7 +415,7 @@ var BridgeServer = class {
406
415
  try {
407
416
  return await result;
408
417
  } catch (error) {
409
- throw classify(command, error);
418
+ throw classify(command, error, params);
410
419
  } finally {
411
420
  clearTimeout(entry.timer);
412
421
  if (entry.onAbort !== void 0 && signal !== void 0) signal.removeEventListener("abort", entry.onAbort);
@@ -544,20 +553,29 @@ var BridgeServer = class {
544
553
  });
545
554
  return;
546
555
  }
547
- if (typeof parsed.command !== "string" || parsed.command.length === 0) {
556
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
557
+ this.respondJson(res, 400, {
558
+ ok: false,
559
+ error: "body must be {\"command\": \"...\", \"params\": {...}}"
560
+ });
561
+ return;
562
+ }
563
+ const body = parsed;
564
+ if (typeof body.command !== "string" || body.command.length === 0) {
548
565
  this.respondJson(res, 400, {
549
566
  ok: false,
550
567
  error: "body must be {\"command\": \"...\", \"params\": {...}}"
551
568
  });
552
569
  return;
553
570
  }
554
- const timeoutMs = typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : void 0;
555
- this.execute(parsed.command, parsed.params ?? {}, timeoutMs === void 0 ? {} : { timeoutMs }).then((result) => this.respondJson(res, 200, {
571
+ const timeoutMs = typeof body.timeoutMs === "number" ? body.timeoutMs : void 0;
572
+ const options = timeoutMs === void 0 ? {} : { timeoutMs };
573
+ (this.options.httpCommand ?? ((command, params, opts) => this.execute(command, params, opts)))(body.command, body.params ?? {}, options).then((result) => this.respondJson(res, 200, {
556
574
  ok: true,
557
575
  result
558
576
  }), (error) => {
559
577
  if (error instanceof BridgeError) {
560
- const status = error.code === "extension_offline" || error.code === "bridge_disabled" || error.code === "not_configured" ? 503 : error.code === "command_timeout" || error.code === "link_lost" ? 504 : 502;
578
+ const status = error.code === "extension_offline" || error.code === "bridge_disabled" || error.code === "not_configured" ? 503 : error.code === "command_timeout" || error.code === "link_lost" ? 504 : error.code === "blocked_by_policy" ? 403 : 502;
561
579
  this.respondJson(res, status, error.toJSON());
562
580
  return;
563
581
  }
@@ -610,11 +628,15 @@ Sec-WebSocket-Accept: ${accept}\r\n\r
610
628
  this.clientSocket = socket;
611
629
  this.hello = void 0;
612
630
  this.connectedAt = /* @__PURE__ */ new Date();
613
- this.continuationRemainder = "";
631
+ this.continuationChunks = [];
614
632
  this.continuationOpen = false;
615
633
  this.log(`extension connected from ${socket.remoteAddress}:${socket.remotePort}`);
616
634
  const reader = new FrameReader();
617
635
  socket.on("data", (chunk) => {
636
+ if (this.clientSocket !== socket) {
637
+ socket.destroy();
638
+ return;
639
+ }
618
640
  reader.push(chunk);
619
641
  try {
620
642
  reader.drain((frame) => this.handleFrame(frame));
@@ -637,7 +659,7 @@ Sec-WebSocket-Accept: ${accept}\r\n\r
637
659
  switch (frame.opcode) {
638
660
  case 1:
639
661
  case 0:
640
- this.handleText(frame.payload.toString("utf8"), frame.fin, frame.opcode);
662
+ this.handleText(frame.payload, frame.fin, frame.opcode);
641
663
  return;
642
664
  case 8:
643
665
  this.closeClientSocket(1e3, "");
@@ -648,14 +670,14 @@ Sec-WebSocket-Accept: ${accept}\r\n\r
648
670
  default: return;
649
671
  }
650
672
  }
651
- handleText(text, fin, opcode) {
673
+ handleText(payload, fin, opcode) {
652
674
  if (opcode === 0 && !this.continuationOpen) return;
653
- if (opcode === 1) this.continuationRemainder = "";
675
+ if (opcode === 1) this.continuationChunks = [];
654
676
  this.continuationOpen = opcode === 0 || !fin;
655
- this.continuationRemainder += text;
677
+ this.continuationChunks.push(payload);
656
678
  if (!fin) return;
657
- const full = this.continuationRemainder;
658
- this.continuationRemainder = "";
679
+ const full = Buffer.concat(this.continuationChunks).toString("utf8");
680
+ this.continuationChunks = [];
659
681
  this.continuationOpen = false;
660
682
  let msg;
661
683
  try {
@@ -889,9 +911,22 @@ const READ_ONLY_ALLOWED = /* @__PURE__ */ new Set([
889
911
  /**
890
912
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
891
913
  * 也可以点,但别在我页面里跑任意 JS」——`browser_evaluate` 能绕开其他所有工具做的事。
914
+ *
915
+ * **按命令名判不够**:`wait` 的 `fn` 参数同样是在页面里执行任意 JS(扩展侧用
916
+ * `(${fn})()` 求出谓词),只按名字看它会从只读白名单和脚本开关两道门缝里同时漏过去。
917
+ * 所以真正判「是不是脚本」的是下面 `isScriptCall()`:命令名 + 参数一起看,
918
+ * `wait(selector)` / `wait(text)` 仍按只读处理,`wait({fn})` 与 `eval` 同档。
892
919
  */
893
920
  const SCRIPT_COMMANDS = /* @__PURE__ */ new Set(["eval"]);
894
921
  /**
922
+ * 这次调用会不会在页面里执行任意 JS。判据是「这次实际要做的动作」,不是命令名:
923
+ * `wait` 只有带 `fn` 时才升级为脚本调用(`selector` / `text` 是纯查询)。
924
+ */
925
+ function isScriptCall(command, params) {
926
+ if (SCRIPT_COMMANDS.has(command)) return true;
927
+ return command === "wait" && typeof params?.fn === "string" && params.fn.trim().length > 0;
928
+ }
929
+ /**
895
930
  * 不经过扩展、直接在插件进程里改动本机文件的命令名。
896
931
  *
897
932
  * `browser_cleanup` 走 `BridgeController.cleanup()`,**不经过 `execute()`**,所以它天然
@@ -922,7 +957,9 @@ const CONFIG_HINT = "开关在 profile 的 cordis.patch.yml 里(browser-bridge
922
957
  * @returns 拒绝时写给 agent 的整句理由;放行时 `undefined`。
923
958
  */
924
959
  function policyRefusal(command, flags, params) {
925
- if (!flags.allowScript && SCRIPT_COMMANDS.has(command)) return `在页面里执行任意脚本已被关闭(allowScript: false):命令 ${command} 被拒绝,没有向浏览器发出任何操作。要读页面内容请改用 browser_read / browser_snapshot / browser_find;确实需要执行脚本时,${CONFIG_HINT}之后再说。`;
960
+ const scriptCall = isScriptCall(command, params);
961
+ if (!flags.allowScript && scriptCall) return `在页面里执行任意脚本已被关闭(allowScript: false):${command === "wait" ? "命令 wait 的 fn 参数(会在页面里执行任意 JS)被拒绝" : `命令 ${command} 被拒绝`},没有向浏览器发出任何操作。要等页面就绪请改用 wait 的 selector / text(只读),读页面内容用 browser_read / browser_snapshot / browser_find;确实需要执行脚本时,${CONFIG_HINT}之后再说。`;
962
+ if (flags.readOnly && command === "wait" && scriptCall) return `只读模式已开启(readOnly: true):命令 wait 的 fn 参数会在页面里执行任意 JS(可能改变页面状态),已被拒绝,没有向浏览器发出任何操作。要等页面就绪请改用 wait 的 selector / text(只读);要放开请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
926
963
  if (flags.readOnly && LOCAL_DESTRUCTIVE_COMMANDS.has(command)) return `只读模式已开启(readOnly: true):命令 ${command} 会删除本机保存的截图与临时文件,删除不可逆,所以已被拒绝,没有任何文件被删除。只读档的含义是「什么都不要改」,清理属于改动;要释放磁盘请由用户自己关掉 readOnly,${CONFIG_HINT}。不要原地重试本命令。`;
927
964
  const isReadOnlyAction = command === "hold" && params?.action === "status";
928
965
  if (flags.readOnly && PAGE_MUTATING_COMMANDS.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会往页面里插入一层接管浮层(改页面 DOM),已被拒绝,没有向浏览器发出任何操作。浮层是给用户看的提示层,不是安全边界;只读档里连这种「只是提示」的改动也一并拒绝。要开浮层请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
@@ -1393,7 +1430,8 @@ var BridgeController = class {
1393
1430
  * Journal appends are queued and never block the command path.
1394
1431
  */
1395
1432
  recordAudit(input) {
1396
- this.journal?.append(this.audit.record(input));
1433
+ const entry = this.audit.record(input);
1434
+ this.journal?.append(entry);
1397
1435
  }
1398
1436
  /**
1399
1437
  * Current policy flags. Tools that compose several commands (browser_drag
@@ -1488,7 +1526,8 @@ var BridgeController = class {
1488
1526
  port: config.port,
1489
1527
  token: config.token,
1490
1528
  shotsDir,
1491
- log: this.log
1529
+ log: this.log,
1530
+ httpCommand: (command, params, options) => this.execute(command, params, new AbortController().signal, options.timeoutMs)
1492
1531
  });
1493
1532
  try {
1494
1533
  await server.start();
@@ -1560,18 +1599,35 @@ var BridgeController = class {
1560
1599
  * to work; when it is gone we forget it and let the browser's active tab
1561
1600
  * answer, exactly like before this feature existed.
1562
1601
  */
1563
- async isolateParams(command, params, sessionId, signal) {
1564
- if (typeof params.tabId === "number" || NO_TAB_DEFAULT_COMMANDS.has(command)) return params;
1602
+ /**
1603
+ * The calling session's working tab, after the same liveness probe isolateParams
1604
+ * uses. Tools that compose several commands (browser_drag) need the id *before*
1605
+ * issuing anything, so they must not fall back to the browser's active tab on
1606
+ * their own — that would silently bypass session isolation.
1607
+ */
1608
+ async sessionTab(signal) {
1609
+ const sessionId = TOOL_SESSION.getStore();
1610
+ if (sessionId === void 0) return void 0;
1611
+ return await this.liveRecordedTab(sessionId, signal);
1612
+ }
1613
+ /** Recorded working tab if it is still open; a gone tab is forgotten here. */
1614
+ async liveRecordedTab(sessionId, signal) {
1565
1615
  const recorded = this.sessions.workingTab(sessionId);
1566
- if (recorded === void 0) return params;
1616
+ if (recorded === void 0) return void 0;
1567
1617
  const server = this.server;
1568
1618
  if (server !== void 0) try {
1569
1619
  const listing = await server.execute("tabs.list", {}, { signal });
1570
1620
  if (!(Array.isArray(listing?.tabs) && listing.tabs.some((tab) => tab?.id === recorded))) {
1571
1621
  this.sessions.release(sessionId);
1572
- return params;
1622
+ return;
1573
1623
  }
1574
1624
  } catch {}
1625
+ return recorded;
1626
+ }
1627
+ async isolateParams(command, params, sessionId, signal) {
1628
+ if (typeof params.tabId === "number" || NO_TAB_DEFAULT_COMMANDS.has(command)) return params;
1629
+ const recorded = await this.liveRecordedTab(sessionId, signal);
1630
+ if (recorded === void 0) return params;
1575
1631
  return {
1576
1632
  ...params,
1577
1633
  tabId: recorded
@@ -2032,9 +2088,13 @@ function applyBrowserTools(ctx, controller) {
2032
2088
  type: "number",
2033
2089
  description: "Target tab; defaults to the active tab."
2034
2090
  },
2091
+ offset: {
2092
+ type: "number",
2093
+ description: "Skip this many items into the inventory (paging); default 0. Refs are valid for THIS call only — take the next page in the same breath if you need stable refs."
2094
+ },
2035
2095
  limit: {
2036
2096
  type: "number",
2037
- description: "Max elements returned; defaults to 120, capped at 200."
2097
+ description: "Page size: max items returned by this call; defaults to 120. The extension supplies up to a 200-item window per call."
2038
2098
  },
2039
2099
  mode: {
2040
2100
  type: "string",
@@ -2060,6 +2120,17 @@ function applyBrowserTools(ctx, controller) {
2060
2120
  },
2061
2121
  mode: { type: "string" },
2062
2122
  axFallback: { type: "string" },
2123
+ offset: {
2124
+ type: "number",
2125
+ required: true
2126
+ },
2127
+ total: { type: "number" },
2128
+ returned: { type: "number" },
2129
+ nextOffset: { type: "number" },
2130
+ hasMore: {
2131
+ type: "boolean",
2132
+ required: true
2133
+ },
2063
2134
  items: {
2064
2135
  type: "array",
2065
2136
  required: true,
@@ -2084,9 +2155,13 @@ function applyBrowserTools(ctx, controller) {
2084
2155
  }
2085
2156
  },
2086
2157
  render: (_args, value) => {
2158
+ const shown = value.items.length;
2159
+ const start = value.offset + 1;
2160
+ const end = value.offset + shown;
2161
+ const totalText = value.total === void 0 ? `${value.returned ?? shown} available` : `${value.total} on the page`;
2087
2162
  return [{
2088
2163
  type: "text",
2089
- text: clampRendered([`${value.items.length} interactive elements on ${value.title} (mode ${value.mode ?? "dom"}); click or fill them by ref:` + (value.axFallback === void 0 ? "" : `\n(mode ax fell back to dom: ${value.axFallback})`), ...value.items.map((item) => {
2164
+ text: clampRendered([`${shown === 0 ? `no items at offset ${value.offset}` : `items ${start}–${end} of ${totalText}`}${value.total !== void 0 && value.returned !== void 0 && value.total > value.returned ? ` (this extension returns at most ${value.returned}-item windows — use browser_find/browser_read to reach the rest)` : ""}${value.nextOffset === void 0 ? "" : `; next page: offset ${value.nextOffset}`} (mode ${value.mode ?? "dom"}); click or fill them by ref:` + (value.axFallback === void 0 ? "" : `\n(mode ax fell back to dom: ${value.axFallback})`), ...value.items.map((item) => {
2090
2165
  const name = item.name === void 0 ? "" : ` "${flattenOneLine(item.name, 80)}"`;
2091
2166
  const href = item.href === void 0 ? "" : ` -> ${flattenOneLine(item.href, 100)}`;
2092
2167
  return `[${item.ref}] ${item.role ?? item.tag}${name}${href}`;
@@ -2100,20 +2175,33 @@ function applyBrowserTools(ctx, controller) {
2100
2175
  kind: "other"
2101
2176
  }),
2102
2177
  async execute(args, exec) {
2103
- const params = { limit: Math.min(200, Math.max(1, args.limit ?? 120)) };
2178
+ const offset = Math.max(0, Math.trunc(typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 0));
2179
+ const limit = Math.min(200, Math.max(1, typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.trunc(args.limit) : 120));
2180
+ const params = { limit: 200 };
2104
2181
  if (typeof args.tabId === "number") params.tabId = args.tabId;
2105
2182
  if (typeof args.mode === "string") params.mode = args.mode;
2106
2183
  const raw = await controller.execute("snapshot", params, exec.signal);
2107
2184
  const requestedAx = args.mode === "ax";
2108
2185
  const gotAx = raw.mode === "ax" || raw.ax !== void 0;
2109
2186
  const axFallback = typeof raw.axFallback === "string" && raw.axFallback.length > 0 ? raw.axFallback : requestedAx && !gotAx ? "this extension ignored mode:\"ax\" and returned CSS-DOM matches (AX mode needs DSH Browser Control extension v1.0.11 or newer) — the names below are tag names, not accessible names" : void 0;
2187
+ const window = Array.isArray(raw.items) ? raw.items : [];
2188
+ const returned = window.length;
2189
+ const reportedTotal = typeof raw.total === "number" && Number.isFinite(raw.total) ? Math.trunc(raw.total) : void 0;
2190
+ const total = reportedTotal === void 0 ? void 0 : Math.max(returned, reportedTotal);
2191
+ const page = window.slice(offset, offset + limit);
2192
+ const hasMore = offset + page.length < returned;
2110
2193
  return {
2111
2194
  tabId: raw.tabId,
2112
2195
  title: raw.title ?? "",
2113
2196
  url: raw.url ?? "",
2114
2197
  ...raw.mode === void 0 ? {} : { mode: raw.mode },
2115
2198
  ...axFallback === void 0 ? {} : { axFallback },
2116
- items: (raw.items ?? []).map((item) => ({
2199
+ offset,
2200
+ ...total === void 0 ? {} : { total },
2201
+ returned,
2202
+ hasMore,
2203
+ ...hasMore ? { nextOffset: offset + page.length } : {},
2204
+ items: page.map((item) => ({
2117
2205
  ref: item.ref,
2118
2206
  tag: item.tag,
2119
2207
  ...item.role === void 0 ? {} : { role: item.role },
@@ -3635,16 +3723,26 @@ async function resolveDragPoint(ctrl, tabId, target, signal) {
3635
3723
  }, signal))?.nodeId ?? 0;
3636
3724
  if (nodeId === 0) throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
3637
3725
  await cdpCall(ctrl, tabId, "DOM.scrollIntoViewIfNeeded", { nodeId }, signal);
3726
+ return await measureDragNode(ctrl, tabId, nodeId, selector, signal);
3727
+ }
3728
+ /**
3729
+ * Read a node's **current** viewport geometry without scrolling. Needed after the
3730
+ * *other* endpoint's scrollIntoViewIfNeeded: that scroll can move the first
3731
+ * endpoint (and this one), so coordinates measured before it are stale. DOM
3732
+ * geometry is viewport-relative; dispatching the old numbers would drag from
3733
+ * wherever the element used to be.
3734
+ */
3735
+ async function measureDragNode(ctrl, tabId, nodeId, label, signal) {
3638
3736
  let box;
3639
3737
  try {
3640
3738
  box = await cdpCall(ctrl, tabId, "DOM.getBoxModel", { nodeId }, signal);
3641
3739
  } catch (error) {
3642
- throw new Error(`cannot measure ${JSON.stringify(selector)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
3740
+ throw new Error(`cannot measure ${JSON.stringify(label)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
3643
3741
  }
3644
3742
  const q = box?.model?.content;
3645
- if (!Array.isArray(q) || q.length < 8) throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(selector)}`);
3743
+ if (!Array.isArray(q) || q.length < 8) throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(label)}`);
3646
3744
  return {
3647
- selector,
3745
+ selector: label,
3648
3746
  x: (q[0] + q[2] + q[4] + q[6]) / 4,
3649
3747
  y: (q[1] + q[3] + q[5] + q[7]) / 4,
3650
3748
  width: typeof box?.model?.width === "number" ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]),
@@ -3652,6 +3750,19 @@ async function resolveDragPoint(ctrl, tabId, target, signal) {
3652
3750
  nodeId
3653
3751
  };
3654
3752
  }
3753
+ /** CSS viewport size via Page.getLayoutMetrics (no page JS, works with allowScript off). */
3754
+ async function viewportSize(ctrl, tabId, signal) {
3755
+ try {
3756
+ const metrics = await cdpCall(ctrl, tabId, "Page.getLayoutMetrics", {}, signal);
3757
+ const viewport = metrics?.cssVisualViewport ?? metrics?.cssLayoutViewport;
3758
+ const width = Number(viewport?.clientWidth);
3759
+ const height = Number(viewport?.clientHeight);
3760
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) return {
3761
+ width,
3762
+ height
3763
+ };
3764
+ } catch {}
3765
+ }
3655
3766
  /** Whether the source declares the native HTML5 `draggable` attribute. */
3656
3767
  async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
3657
3768
  const attrs = await cdpCall(ctrl, tabId, "DOM.getAttributes", { nodeId }, signal);
@@ -3827,6 +3938,7 @@ function registerDragTool(ctx, controller) {
3827
3938
  controller.requireExtensionCommand("cdp", "1.7.2");
3828
3939
  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");
3829
3940
  let tabId = args.tabId;
3941
+ if (tabId === void 0) tabId = await controller.sessionTab(exec.signal);
3830
3942
  if (tabId === void 0) {
3831
3943
  const listing = await controller.execute("tabs.list", {}, exec.signal);
3832
3944
  if (typeof listing?.activeTabId !== "number") throw new Error("could not resolve the active tab — pass tabId explicitly");
@@ -3839,56 +3951,92 @@ function registerDragTool(ctx, controller) {
3839
3951
  if (mode === "auto" && source?.nodeId !== void 0 && await sourceIsDraggable(controller, tabId, source.nodeId, exec.signal)) resolvedMode = "html5";
3840
3952
  if (resolvedMode === "mouse") {
3841
3953
  const target = await resolveDragPoint(controller, tabId, to, exec.signal);
3954
+ let sourcePoint = source;
3955
+ let targetPoint = target;
3956
+ if (source?.nodeId !== void 0 && target.nodeId !== void 0) {
3957
+ sourcePoint = await measureDragNode(controller, tabId, source.nodeId, String(from.selector), exec.signal);
3958
+ targetPoint = await measureDragNode(controller, tabId, target.nodeId, String(to.selector), exec.signal);
3959
+ }
3960
+ const viewport = await viewportSize(controller, tabId, exec.signal);
3961
+ if (viewport !== void 0) {
3962
+ const describe = (p) => `${p.selector !== void 0 ? JSON.stringify(p.selector) : "point"} at (${Math.round(p.x)},${Math.round(p.y)})`;
3963
+ const outside = (p) => p.x < 0 || p.y < 0 || p.x > viewport.width || p.y > viewport.height;
3964
+ if (outside(sourcePoint) || outside(targetPoint)) throw new Error(`drag endpoints are not both visible in the viewport (${describe(sourcePoint)} → ${describe(targetPoint)}, viewport ${Math.round(viewport.width)}×${Math.round(viewport.height)}) — scroll so both fit, then retry; mode:"html5" takes selectors and needs no viewport coordinates`);
3965
+ }
3842
3966
  const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === "number" ? args.steps : 12)));
3843
- const sx = source.x, sy = source.y, tx = target.x, ty = target.y;
3967
+ const sx = sourcePoint.x, sy = sourcePoint.y, tx = targetPoint.x, ty = targetPoint.y;
3844
3968
  const dispatch = async (params) => {
3845
3969
  await cdpCall(controller, tabId, "Input.dispatchMouseEvent", params, exec.signal);
3846
3970
  };
3847
- await dispatch({
3848
- type: "mouseMoved",
3849
- x: sx,
3850
- y: sy,
3851
- button: "none",
3852
- buttons: 0
3853
- });
3854
- await dispatch({
3855
- type: "mousePressed",
3856
- x: sx,
3857
- y: sy,
3858
- button: "left",
3859
- buttons: 1,
3860
- clickCount: 1
3861
- });
3862
- for (let i = 1; i <= steps; i++) {
3863
- const f = i / steps;
3971
+ let pressedAt;
3972
+ try {
3864
3973
  await dispatch({
3865
3974
  type: "mouseMoved",
3866
- x: sx + (tx - sx) * f,
3867
- y: sy + (ty - sy) * f,
3975
+ x: sx,
3976
+ y: sy,
3977
+ button: "none",
3978
+ buttons: 0
3979
+ });
3980
+ await dispatch({
3981
+ type: "mousePressed",
3982
+ x: sx,
3983
+ y: sy,
3868
3984
  button: "left",
3869
- buttons: 1
3985
+ buttons: 1,
3986
+ clickCount: 1
3870
3987
  });
3988
+ pressedAt = {
3989
+ x: sx,
3990
+ y: sy
3991
+ };
3992
+ for (let i = 1; i <= steps; i++) {
3993
+ const f = i / steps;
3994
+ await dispatch({
3995
+ type: "mouseMoved",
3996
+ x: sx + (tx - sx) * f,
3997
+ y: sy + (ty - sy) * f,
3998
+ button: "left",
3999
+ buttons: 1
4000
+ });
4001
+ pressedAt = {
4002
+ x: sx + (tx - sx) * f,
4003
+ y: sy + (ty - sy) * f
4004
+ };
4005
+ }
4006
+ await dispatch({
4007
+ type: "mouseReleased",
4008
+ x: tx,
4009
+ y: ty,
4010
+ button: "left",
4011
+ buttons: 0,
4012
+ clickCount: 1
4013
+ });
4014
+ pressedAt = void 0;
4015
+ await dispatch({
4016
+ type: "mouseMoved",
4017
+ x: tx,
4018
+ y: ty,
4019
+ button: "none",
4020
+ buttons: 0
4021
+ });
4022
+ } catch (error) {
4023
+ if (pressedAt !== void 0) try {
4024
+ await cdpCall(controller, tabId, "Input.dispatchMouseEvent", {
4025
+ type: "mouseReleased",
4026
+ x: pressedAt.x,
4027
+ y: pressedAt.y,
4028
+ button: "left",
4029
+ buttons: 0,
4030
+ clickCount: 1
4031
+ }, new AbortController().signal);
4032
+ } catch {}
4033
+ throw error;
3871
4034
  }
3872
- await dispatch({
3873
- type: "mouseReleased",
3874
- x: tx,
3875
- y: ty,
3876
- button: "left",
3877
- buttons: 0,
3878
- clickCount: 1
3879
- });
3880
- await dispatch({
3881
- type: "mouseMoved",
3882
- x: tx,
3883
- y: ty,
3884
- button: "none",
3885
- buttons: 0
3886
- });
3887
4035
  return {
3888
4036
  tabId,
3889
4037
  mode: "mouse",
3890
- from: roundDragPoint(source),
3891
- to: roundDragPoint(target),
4038
+ from: roundDragPoint(sourcePoint),
4039
+ to: roundDragPoint(targetPoint),
3892
4040
  steps,
3893
4041
  dispatched: steps + 4,
3894
4042
  elapsedMs: Date.now() - started
@@ -60,5 +60,8 @@ export declare class BridgeError extends Error {
60
60
  /**
61
61
  * 把任意底层抛出的错误归类成 BridgeError。已经分类过的原样返回,
62
62
  * 保证多次包裹不会把 code 洗掉。
63
+ * @param params - 这次调用的参数。只有 `wait` 需要它:`fn` 是在页面里执行任意 JS,
64
+ * 超时/掉线时与写命令同档报 `unknown`(谓词跑过就不能断言页面没被动过);
65
+ * 不带 `fn` 的 wait 仍是只读。
63
66
  */
64
- export declare function classify(command: string, error: unknown): BridgeError;
67
+ export declare function classify(command: string, error: unknown, params?: Record<string, unknown>): BridgeError;
@@ -69,7 +69,7 @@ const HINTS = {
69
69
  extension_offline: '浏览器扩展没连上:让用户打开装了 DSH Browser Control 的那个浏览器(扩展图标应为绿色),确认端口与 Token 两边一致后再试。',
70
70
  command_timeout: '扩展超时未回应:先别重试。用 browser_find / browser_snapshot 看页面当前状态,判断动作是否其实已经生效。',
71
71
  link_lost: '命令在途时链路断了:动作可能已经落到页面上。先用只读工具确认页面状态,再决定是否重做。',
72
- cancelled: '调用被取消:按需重新发起,不必当作错误处理。',
72
+ cancelled: '调用被取消:effectState=not_started 时命令确定没发出去,可以按需重新发起;unknown 时动作可能已经执行,先确认页面状态再决定,不要直接重做。',
73
73
  not_configured: '插件还没读到配置:确认 dsh 已加载本插件(dsh plugin list)后再试。',
74
74
  held_by_user: '这是用户在页面上的接管浮层里按下的「中止操作」,不是故障:命令一条都没有发出。先与用户确认;他同意继续之后调用 browser_hold action=off 解除,然后再继续原来的步骤。不要重试,也不要换成别的工具绕过它。',
75
75
  blocked_by_policy: '这是本机策略拦下的,不是故障:命令没有发给浏览器(零副作用)。要放开改 profile 的 cordis.patch.yml(browser-bridge 的 readOnly / allowScript)并重启 dsh,或换成本机允许的只读工具;不要原地重试。',
@@ -126,8 +126,11 @@ export class BridgeError extends Error {
126
126
  /**
127
127
  * 把任意底层抛出的错误归类成 BridgeError。已经分类过的原样返回,
128
128
  * 保证多次包裹不会把 code 洗掉。
129
+ * @param params - 这次调用的参数。只有 `wait` 需要它:`fn` 是在页面里执行任意 JS,
130
+ * 超时/掉线时与写命令同档报 `unknown`(谓词跑过就不能断言页面没被动过);
131
+ * 不带 `fn` 的 wait 仍是只读。
129
132
  */
130
- export function classify(command, error) {
133
+ export function classify(command, error, params) {
131
134
  if (error instanceof BridgeError)
132
135
  return error;
133
136
  const raw = error instanceof Error ? error.message : String(error);
@@ -144,7 +147,9 @@ export function classify(command, error) {
144
147
  : 0;
145
148
  const message = locked ? raw.slice(markerLength).trim() : raw;
146
149
  // 写操作一旦超时或掉线,结果就是未知的;读操作永远可以安全重试。
147
- const unknownIsPossible = isWriteCommand(command);
150
+ // wait(fn) 例外:谓词是任意 JS,按写命令同档处理(见函数顶部 @param params)。
151
+ const unknownIsPossible = isWriteCommand(command)
152
+ || (command === 'wait' && typeof params?.fn === 'string' && params.fn.trim().length > 0);
148
153
  let code;
149
154
  if (locked !== undefined)
150
155
  code = locked;
@@ -166,6 +171,10 @@ export function classify(command, error) {
166
171
  ? (unknownIsPossible ? 'unknown' : 'not_started')
167
172
  : code === 'command_failed'
168
173
  ? (unknownIsPossible ? 'unknown' : 'not_started')
169
- : 'not_started';
174
+ // 取消分两段:写命令在 socket.write 之后才被取消时,帧已经出去了,结果未知;
175
+ // 「before send」的那一条确定没发,仍是 not_started。读命令没有页面副作用。
176
+ : code === 'cancelled'
177
+ ? (/cancelled before send/.test(message) ? 'not_started' : (unknownIsPossible ? 'unknown' : 'not_started'))
178
+ : 'not_started';
170
179
  return new BridgeError(command, code, effectState, message);
171
180
  }
@@ -183,7 +183,10 @@ class BridgeController {
183
183
  * Journal appends are queued and never block the command path.
184
184
  */
185
185
  recordAudit(input) {
186
- this.journal?.append(this.audit.record(input));
186
+ // 先记账再落盘:`this.journal?.append(this.audit.record(input))` 会让可选链
187
+ // 短路掉 audit.record —— journal 没开(默认就是关的)时内存审计整本丢失。
188
+ const entry = this.audit.record(input);
189
+ this.journal?.append(entry);
187
190
  }
188
191
  /**
189
192
  * Current policy flags. Tools that compose several commands (browser_drag
@@ -281,7 +284,15 @@ class BridgeController {
281
284
  this.lastError = undefined;
282
285
  return;
283
286
  }
284
- const server = new BridgeServer({ port: config.port, token: config.token, shotsDir, log: this.log });
287
+ const server = new BridgeServer({
288
+ port: config.port,
289
+ token: config.token,
290
+ shotsDir,
291
+ log: this.log,
292
+ // HTTP /api/command 与工具走同一条闸门与账本:裸 server.execute() 会绕开
293
+ // 只读档/allowScript,而且不在审计里留下痕迹(默认 token 还是公开的那一个)。
294
+ httpCommand: (command, params, options) => this.execute(command, params, new AbortController().signal, options.timeoutMs),
295
+ });
285
296
  try {
286
297
  await server.start();
287
298
  }
@@ -349,12 +360,23 @@ class BridgeController {
349
360
  * to work; when it is gone we forget it and let the browser's active tab
350
361
  * answer, exactly like before this feature existed.
351
362
  */
352
- async isolateParams(command, params, sessionId, signal) {
353
- if (typeof params.tabId === 'number' || NO_TAB_DEFAULT_COMMANDS.has(command))
354
- return params;
363
+ /**
364
+ * The calling session's working tab, after the same liveness probe isolateParams
365
+ * uses. Tools that compose several commands (browser_drag) need the id *before*
366
+ * issuing anything, so they must not fall back to the browser's active tab on
367
+ * their own — that would silently bypass session isolation.
368
+ */
369
+ async sessionTab(signal) {
370
+ const sessionId = TOOL_SESSION.getStore();
371
+ if (sessionId === undefined)
372
+ return undefined;
373
+ return await this.liveRecordedTab(sessionId, signal);
374
+ }
375
+ /** Recorded working tab if it is still open; a gone tab is forgotten here. */
376
+ async liveRecordedTab(sessionId, signal) {
355
377
  const recorded = this.sessions.workingTab(sessionId);
356
378
  if (recorded === undefined)
357
- return params;
379
+ return undefined;
358
380
  const server = this.server;
359
381
  if (server !== undefined) {
360
382
  try {
@@ -362,11 +384,19 @@ class BridgeController {
362
384
  const live = Array.isArray(listing?.tabs) && listing.tabs.some((tab) => tab?.id === recorded);
363
385
  if (!live) {
364
386
  this.sessions.release(sessionId);
365
- return params;
387
+ return undefined;
366
388
  }
367
389
  }
368
390
  catch { /* 列不出来就按旧行为走活动标签页 */ }
369
391
  }
392
+ return recorded;
393
+ }
394
+ async isolateParams(command, params, sessionId, signal) {
395
+ if (typeof params.tabId === 'number' || NO_TAB_DEFAULT_COMMANDS.has(command))
396
+ return params;
397
+ const recorded = await this.liveRecordedTab(sessionId, signal);
398
+ if (recorded === undefined)
399
+ return params;
370
400
  return { ...params, tabId: recorded };
371
401
  }
372
402
  /** Record the call and adopt the tab it actually touched (params or result). */
@@ -809,7 +839,8 @@ function applyBrowserTools(ctx, controller) {
809
839
  description: 'Inventory the page\'s interactive elements with stable refs; pass a ref to browser_click/browser_type afterwards. mode:"ax" numbers them by Chrome\'s accessibility tree (role + accessible name), which survives a DOM redesign; the default mode matches CSS selectors.',
810
840
  parameters: {
811
841
  tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
812
- limit: { type: 'number', description: 'Max elements returned; defaults to 120, capped at 200.' },
842
+ offset: { type: 'number', description: 'Skip this many items into the inventory (paging); default 0. Refs are valid for THIS call only — take the next page in the same breath if you need stable refs.' },
843
+ limit: { type: 'number', description: 'Page size: max items returned by this call; defaults to 120. The extension supplies up to a 200-item window per call.' },
813
844
  mode: { type: 'string', description: '"ax" for the accessibility tree, "dom" (default) for the selector list. "ax" falls back to "dom" with axFallback set if the page gives no a11y tree.' },
814
845
  },
815
846
  output: {
@@ -822,6 +853,11 @@ function applyBrowserTools(ctx, controller) {
822
853
  url: { type: 'string', required: true },
823
854
  mode: { type: 'string' },
824
855
  axFallback: { type: 'string' },
856
+ offset: { type: 'number', required: true },
857
+ total: { type: 'number' },
858
+ returned: { type: 'number' },
859
+ nextOffset: { type: 'number' },
860
+ hasMore: { type: 'boolean', required: true },
825
861
  items: {
826
862
  type: 'array',
827
863
  required: true,
@@ -840,7 +876,16 @@ function applyBrowserTools(ctx, controller) {
840
876
  },
841
877
  },
842
878
  render: (_args, value) => {
843
- const head = `${value.items.length} interactive elements on ${value.title}`
879
+ const shown = value.items.length;
880
+ const start = value.offset + 1;
881
+ const end = value.offset + shown;
882
+ const totalText = value.total === undefined ? `${value.returned ?? shown} available` : `${value.total} on the page`;
883
+ const range = shown === 0 ? `no items at offset ${value.offset}` : `items ${start}–${end} of ${totalText}`;
884
+ const capped = value.total !== undefined && value.returned !== undefined && value.total > value.returned
885
+ ? ` (this extension returns at most ${value.returned}-item windows — use browser_find/browser_read to reach the rest)`
886
+ : '';
887
+ const next = value.nextOffset === undefined ? '' : `; next page: offset ${value.nextOffset}`;
888
+ const head = `${range}${capped}${next}`
844
889
  + ` (mode ${value.mode ?? 'dom'}); click or fill them by ref:`
845
890
  + (value.axFallback === undefined ? '' : `\n(mode ax fell back to dom: ${value.axFallback})`);
846
891
  const lines = value.items.map(item => {
@@ -853,8 +898,12 @@ function applyBrowserTools(ctx, controller) {
853
898
  },
854
899
  presentCall: () => ({ card: 'generic', title: 'Snapshot browser page', kind: 'other' }),
855
900
  async execute(args, exec) {
856
- const limit = Math.min(200, Math.max(1, args.limit ?? 120));
857
- const params = { limit };
901
+ const offset = Math.max(0, Math.trunc(typeof args.offset === 'number' && Number.isFinite(args.offset) ? args.offset : 0));
902
+ const limit = Math.min(200, Math.max(1, typeof args.limit === 'number' && Number.isFinite(args.limit) ? Math.trunc(args.limit) : 120));
903
+ // Ask for the largest window the extension offers (200) and page in the
904
+ // plugin: older surfaces never learned an offset parameter, and they do
905
+ // report `total`, so paging stays plugin-side (v1.7.7).
906
+ const params = { limit: 200 };
858
907
  if (typeof args.tabId === 'number')
859
908
  params.tabId = args.tabId;
860
909
  if (typeof args.mode === 'string')
@@ -872,13 +921,24 @@ function applyBrowserTools(ctx, controller) {
872
921
  : (requestedAx && !gotAx
873
922
  ? 'this extension ignored mode:"ax" and returned CSS-DOM matches (AX mode needs DSH Browser Control extension v1.0.11 or newer) — the names below are tag names, not accessible names'
874
923
  : undefined);
875
- return {
924
+ const window = Array.isArray(raw.items) ? raw.items : [];
925
+ const returned = window.length;
926
+ const reportedTotal = typeof raw.total === 'number' && Number.isFinite(raw.total) ? Math.trunc(raw.total) : undefined;
927
+ const total = reportedTotal === undefined ? undefined : Math.max(returned, reportedTotal);
928
+ const page = window.slice(offset, offset + limit);
929
+ const hasMore = offset + page.length < returned;
930
+ const receipt = {
876
931
  tabId: raw.tabId,
877
932
  title: raw.title ?? '',
878
933
  url: raw.url ?? '',
879
934
  ...(raw.mode === undefined ? {} : { mode: raw.mode }),
880
935
  ...(axFallback === undefined ? {} : { axFallback }),
881
- items: (raw.items ?? []).map(item => ({
936
+ offset,
937
+ ...(total === undefined ? {} : { total }),
938
+ returned,
939
+ hasMore,
940
+ ...(hasMore ? { nextOffset: offset + page.length } : {}),
941
+ items: page.map(item => ({
882
942
  ref: item.ref,
883
943
  tag: item.tag,
884
944
  ...(item.role === undefined ? {} : { role: item.role }),
@@ -886,6 +946,7 @@ function applyBrowserTools(ctx, controller) {
886
946
  ...(item.href === undefined ? {} : { href: item.href }),
887
947
  })),
888
948
  };
949
+ return receipt;
889
950
  },
890
951
  }));
891
952
  ctx.tools.register(defineTool({
@@ -1905,21 +1966,44 @@ async function resolveDragPoint(ctrl, tabId, target, signal) {
1905
1966
  if (nodeId === 0)
1906
1967
  throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
1907
1968
  await cdpCall(ctrl, tabId, 'DOM.scrollIntoViewIfNeeded', { nodeId }, signal);
1969
+ return await measureDragNode(ctrl, tabId, nodeId, selector, signal);
1970
+ }
1971
+ /**
1972
+ * Read a node's **current** viewport geometry without scrolling. Needed after the
1973
+ * *other* endpoint's scrollIntoViewIfNeeded: that scroll can move the first
1974
+ * endpoint (and this one), so coordinates measured before it are stale. DOM
1975
+ * geometry is viewport-relative; dispatching the old numbers would drag from
1976
+ * wherever the element used to be.
1977
+ */
1978
+ async function measureDragNode(ctrl, tabId, nodeId, label, signal) {
1908
1979
  let box;
1909
1980
  try {
1910
1981
  box = await cdpCall(ctrl, tabId, 'DOM.getBoxModel', { nodeId }, signal);
1911
1982
  }
1912
1983
  catch (error) {
1913
- throw new Error(`cannot measure ${JSON.stringify(selector)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
1984
+ throw new Error(`cannot measure ${JSON.stringify(label)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
1914
1985
  }
1915
1986
  const q = box?.model?.content;
1916
1987
  if (!Array.isArray(q) || q.length < 8)
1917
- throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(selector)}`);
1988
+ throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(label)}`);
1918
1989
  const x = (q[0] + q[2] + q[4] + q[6]) / 4;
1919
1990
  const y = (q[1] + q[3] + q[5] + q[7]) / 4;
1920
1991
  const width = typeof box?.model?.width === 'number' ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]);
1921
1992
  const height = typeof box?.model?.height === 'number' ? box.model.height : Math.hypot(q[4] - q[2], q[5] - q[3]);
1922
- return { selector, x, y, width, height, nodeId };
1993
+ return { selector: label, x, y, width, height, nodeId };
1994
+ }
1995
+ /** CSS viewport size via Page.getLayoutMetrics (no page JS, works with allowScript off). */
1996
+ async function viewportSize(ctrl, tabId, signal) {
1997
+ try {
1998
+ const metrics = await cdpCall(ctrl, tabId, 'Page.getLayoutMetrics', {}, signal);
1999
+ const viewport = metrics?.cssVisualViewport ?? metrics?.cssLayoutViewport;
2000
+ const width = Number(viewport?.clientWidth);
2001
+ const height = Number(viewport?.clientHeight);
2002
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0)
2003
+ return { width, height };
2004
+ }
2005
+ catch { /* 量不出视口就跳过边界检查,不让一条查询失败挡掉拖拽本身 */ }
2006
+ return undefined;
1923
2007
  }
1924
2008
  /** Whether the source declares the native HTML5 `draggable` attribute. */
1925
2009
  async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
@@ -2067,6 +2151,8 @@ function registerDragTool(ctx, controller) {
2067
2151
  if (controller.policy.readOnly)
2068
2152
  throw new Error('browser_drag changes page state and read-only mode is on — turn readOnly off in the browser-bridge config to drag');
2069
2153
  let tabId = args.tabId;
2154
+ if (tabId === undefined)
2155
+ tabId = await controller.sessionTab(exec.signal);
2070
2156
  if (tabId === undefined) {
2071
2157
  const listing = await controller.execute('tabs.list', {}, exec.signal);
2072
2158
  if (typeof listing?.activeTabId !== 'number')
@@ -2083,24 +2169,59 @@ function registerDragTool(ctx, controller) {
2083
2169
  }
2084
2170
  if (resolvedMode === 'mouse') {
2085
2171
  const target = await resolveDragPoint(controller, tabId, to, exec.signal);
2172
+ // 目标端点的 scrollIntoViewIfNeeded 会把页面滚走,源端点先前的视口坐标就失效了;
2173
+ // 两端都是选择器时按当前几何重测一次(getBoxModel 不滚动,不会再次互相干扰)。
2174
+ // 若滚完后两端不在同一视口里,宁可拒绝,也不盲发一组越界坐标去拖错东西。
2175
+ let sourcePoint = source;
2176
+ let targetPoint = target;
2177
+ if (source?.nodeId !== undefined && target.nodeId !== undefined) {
2178
+ sourcePoint = await measureDragNode(controller, tabId, source.nodeId, String(from.selector), exec.signal);
2179
+ targetPoint = await measureDragNode(controller, tabId, target.nodeId, String(to.selector), exec.signal);
2180
+ }
2181
+ const viewport = await viewportSize(controller, tabId, exec.signal);
2182
+ if (viewport !== undefined) {
2183
+ const describe = (p) => `${p.selector !== undefined ? JSON.stringify(p.selector) : 'point'} at (${Math.round(p.x)},${Math.round(p.y)})`;
2184
+ const outside = (p) => p.x < 0 || p.y < 0 || p.x > viewport.width || p.y > viewport.height;
2185
+ if (outside(sourcePoint) || outside(targetPoint)) {
2186
+ throw new Error(`drag endpoints are not both visible in the viewport (${describe(sourcePoint)} → ${describe(targetPoint)}, viewport ${Math.round(viewport.width)}×${Math.round(viewport.height)}) — scroll so both fit, then retry; mode:"html5" takes selectors and needs no viewport coordinates`);
2187
+ }
2188
+ }
2086
2189
  const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === 'number' ? args.steps : 12)));
2087
- const sx = source.x, sy = source.y, tx = target.x, ty = target.y;
2190
+ const sx = sourcePoint.x, sy = sourcePoint.y, tx = targetPoint.x, ty = targetPoint.y;
2088
2191
  const dispatch = async (params) => {
2089
2192
  await cdpCall(controller, tabId, 'Input.dispatchMouseEvent', params, exec.signal);
2090
2193
  };
2091
- await dispatch({ type: 'mouseMoved', x: sx, y: sy, button: 'none', buttons: 0 });
2092
- await dispatch({ type: 'mousePressed', x: sx, y: sy, button: 'left', buttons: 1, clickCount: 1 });
2093
- for (let i = 1; i <= steps; i++) {
2094
- const f = i / steps;
2095
- await dispatch({ type: 'mouseMoved', x: sx + (tx - sx) * f, y: sy + (ty - sy) * f, button: 'left', buttons: 1 });
2194
+ // 按住之后任何一步失败(取消、超时、CDP 报错)都必须尽力抬起:否则页面会
2195
+ // 停在「鼠标左键按着」的状态,用户下一次点击会被当成拖拽的延续。
2196
+ let pressedAt;
2197
+ try {
2198
+ await dispatch({ type: 'mouseMoved', x: sx, y: sy, button: 'none', buttons: 0 });
2199
+ await dispatch({ type: 'mousePressed', x: sx, y: sy, button: 'left', buttons: 1, clickCount: 1 });
2200
+ pressedAt = { x: sx, y: sy };
2201
+ for (let i = 1; i <= steps; i++) {
2202
+ const f = i / steps;
2203
+ await dispatch({ type: 'mouseMoved', x: sx + (tx - sx) * f, y: sy + (ty - sy) * f, button: 'left', buttons: 1 });
2204
+ pressedAt = { x: sx + (tx - sx) * f, y: sy + (ty - sy) * f };
2205
+ }
2206
+ await dispatch({ type: 'mouseReleased', x: tx, y: ty, button: 'left', buttons: 0, clickCount: 1 });
2207
+ pressedAt = undefined;
2208
+ await dispatch({ type: 'mouseMoved', x: tx, y: ty, button: 'none', buttons: 0 });
2209
+ }
2210
+ catch (error) {
2211
+ if (pressedAt !== undefined) {
2212
+ // 独立信号:调用方的 signal 可能已经 abort,清扫仍要发出去。
2213
+ try {
2214
+ await cdpCall(controller, tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: pressedAt.x, y: pressedAt.y, button: 'left', buttons: 0, clickCount: 1 }, new AbortController().signal);
2215
+ }
2216
+ catch { /* 清扫失败不能盖掉原始错误 */ }
2217
+ }
2218
+ throw error;
2096
2219
  }
2097
- await dispatch({ type: 'mouseReleased', x: tx, y: ty, button: 'left', buttons: 0, clickCount: 1 });
2098
- await dispatch({ type: 'mouseMoved', x: tx, y: ty, button: 'none', buttons: 0 });
2099
2220
  const receipt = {
2100
2221
  tabId,
2101
2222
  mode: 'mouse',
2102
- from: roundDragPoint(source),
2103
- to: roundDragPoint(target),
2223
+ from: roundDragPoint(sourcePoint),
2224
+ to: roundDragPoint(targetPoint),
2104
2225
  steps,
2105
2226
  dispatched: steps + 4,
2106
2227
  elapsedMs: Date.now() - started,
@@ -21,6 +21,11 @@ export declare const READ_ONLY_ALLOWED: ReadonlySet<string>;
21
21
  /**
22
22
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
23
23
  * 也可以点,但别在我页面里跑任意 JS」——`browser_evaluate` 能绕开其他所有工具做的事。
24
+ *
25
+ * **按命令名判不够**:`wait` 的 `fn` 参数同样是在页面里执行任意 JS(扩展侧用
26
+ * `(${fn})()` 求出谓词),只按名字看它会从只读白名单和脚本开关两道门缝里同时漏过去。
27
+ * 所以真正判「是不是脚本」的是下面 `isScriptCall()`:命令名 + 参数一起看,
28
+ * `wait(selector)` / `wait(text)` 仍按只读处理,`wait({fn})` 与 `eval` 同档。
24
29
  */
25
30
  export declare const SCRIPT_COMMANDS: ReadonlySet<string>;
26
31
  /**
@@ -35,8 +35,22 @@ export const READ_ONLY_ALLOWED = new Set([
35
35
  /**
36
36
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
37
37
  * 也可以点,但别在我页面里跑任意 JS」——`browser_evaluate` 能绕开其他所有工具做的事。
38
+ *
39
+ * **按命令名判不够**:`wait` 的 `fn` 参数同样是在页面里执行任意 JS(扩展侧用
40
+ * `(${fn})()` 求出谓词),只按名字看它会从只读白名单和脚本开关两道门缝里同时漏过去。
41
+ * 所以真正判「是不是脚本」的是下面 `isScriptCall()`:命令名 + 参数一起看,
42
+ * `wait(selector)` / `wait(text)` 仍按只读处理,`wait({fn})` 与 `eval` 同档。
38
43
  */
39
44
  export const SCRIPT_COMMANDS = new Set(['eval']);
45
+ /**
46
+ * 这次调用会不会在页面里执行任意 JS。判据是「这次实际要做的动作」,不是命令名:
47
+ * `wait` 只有带 `fn` 时才升级为脚本调用(`selector` / `text` 是纯查询)。
48
+ */
49
+ function isScriptCall(command, params) {
50
+ if (SCRIPT_COMMANDS.has(command))
51
+ return true;
52
+ return command === 'wait' && typeof params?.fn === 'string' && params.fn.trim().length > 0;
53
+ }
40
54
  /**
41
55
  * 不经过扩展、直接在插件进程里改动本机文件的命令名。
42
56
  *
@@ -69,9 +83,19 @@ const CONFIG_HINT = '开关在 profile 的 cordis.patch.yml 里(browser-bridge
69
83
  * @returns 拒绝时写给 agent 的整句理由;放行时 `undefined`。
70
84
  */
71
85
  export function policyRefusal(command, flags, params) {
72
- if (!flags.allowScript && SCRIPT_COMMANDS.has(command)) {
73
- return `在页面里执行任意脚本已被关闭(allowScript: false):命令 ${command} 被拒绝,没有向浏览器发出任何操作。`
74
- + `要读页面内容请改用 browser_read / browser_snapshot / browser_find;确实需要执行脚本时,${CONFIG_HINT}之后再说。`;
86
+ const scriptCall = isScriptCall(command, params);
87
+ if (!flags.allowScript && scriptCall) {
88
+ const what = command === 'wait'
89
+ ? '命令 wait 的 fn 参数(会在页面里执行任意 JS)被拒绝'
90
+ : `命令 ${command} 被拒绝`;
91
+ return `在页面里执行任意脚本已被关闭(allowScript: false):${what},没有向浏览器发出任何操作。`
92
+ + `要等页面就绪请改用 wait 的 selector / text(只读),读页面内容用 browser_read / browser_snapshot / browser_find;确实需要执行脚本时,${CONFIG_HINT}之后再说。`;
93
+ }
94
+ // wait 白名单是按命令名放的,但带 fn 的那一种和 eval 同档:任意 JS 可以改页面,
95
+ // 只读档不能因为「它叫 wait」就放行。不带 fn 的 wait 仍走下面的白名单。
96
+ if (flags.readOnly && command === 'wait' && scriptCall) {
97
+ return `只读模式已开启(readOnly: true):命令 wait 的 fn 参数会在页面里执行任意 JS(可能改变页面状态),已被拒绝,没有向浏览器发出任何操作。`
98
+ + `要等页面就绪请改用 wait 的 selector / text(只读);要放开请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
75
99
  }
76
100
  if (flags.readOnly && LOCAL_DESTRUCTIVE_COMMANDS.has(command)) {
77
101
  return `只读模式已开启(readOnly: true):命令 ${command} 会删除本机保存的截图与临时文件,删除不可逆,所以已被拒绝,没有任何文件被删除。`
@@ -90,6 +90,12 @@ export interface BridgeServerOptions {
90
90
  readonly scratchDir?: string;
91
91
  /** Optional line logger for lifecycle diagnostics. */
92
92
  readonly log?: (line: string) => void;
93
+ /**
94
+ * Executor for the HTTP command face. The plugin passes its controller so
95
+ * `/api/command` goes through the same policy gate and audit trail as tools;
96
+ * without it (bare BridgeServer users, tests) the face stays raw.
97
+ */
98
+ readonly httpCommand?: (command: string, params: Record<string, unknown>, options: BridgeExecuteOptions) => Promise<unknown>;
93
99
  }
94
100
  /**
95
101
  * Clear the screenshots directory and delete agent scratch files. Only the
@@ -116,7 +122,12 @@ export declare class BridgeServer {
116
122
  private connectedAt;
117
123
  private readonly pending;
118
124
  private lastError;
119
- private continuationRemainder;
125
+ /**
126
+ * Payloads of the message being reassembled. **Buffers, not strings**: decoding
127
+ * each fragment with `toString('utf8')` corrupts any multi-byte character that
128
+ * straddles a fragment boundary. Decode once, at FIN.
129
+ */
130
+ private continuationChunks;
120
131
  private continuationOpen;
121
132
  constructor(options: BridgeServerOptions);
122
133
  /** Current link state, also served verbatim as `/api/status`. */
@@ -87,7 +87,12 @@ export class BridgeServer {
87
87
  connectedAt;
88
88
  pending = new Map();
89
89
  lastError;
90
- continuationRemainder = '';
90
+ /**
91
+ * Payloads of the message being reassembled. **Buffers, not strings**: decoding
92
+ * each fragment with `toString('utf8')` corrupts any multi-byte character that
93
+ * straddles a fragment boundary. Decode once, at FIN.
94
+ */
95
+ continuationChunks = [];
91
96
  continuationOpen = false;
92
97
  constructor(options) {
93
98
  this.options = options;
@@ -202,7 +207,10 @@ export class BridgeServer {
202
207
  entry.onAbort = () => {
203
208
  if (!this.pending.delete(id))
204
209
  return;
205
- entry.reject(new Error(`browser command cancelled: ${command}`));
210
+ // 监听器只可能在 socket.write 之后触发(execute 从注册到写出是同步的、中间
211
+ // 没有 await),所以这里必须说 after send:写命令的结果是未知,而不是
212
+ // 「取消 = 什么都没发生」。before send 的那条检查在函数开头,措辞不同。
213
+ entry.reject(new Error(`browser command cancelled after send: ${command}`));
206
214
  };
207
215
  signal.addEventListener('abort', entry.onAbort, { once: true });
208
216
  }
@@ -215,8 +223,9 @@ export class BridgeServer {
215
223
  catch (error) {
216
224
  // One choke point: everything above this line that can fail is a link or
217
225
  // extension failure, and the caller needs to know whether the page may
218
- // already have been changed. See src/errors.ts.
219
- throw classify(command, error);
226
+ // already have been changed. See src/errors.ts. `params` matters for wait(fn):
227
+ // 谓词是任意 JS,超时/取消时按写命令报 unknown。
228
+ throw classify(command, error, params);
220
229
  }
221
230
  finally {
222
231
  clearTimeout(entry.timer);
@@ -359,19 +368,33 @@ export class BridgeServer {
359
368
  this.respondJson(res, 400, { ok: false, error: `invalid JSON body: ${errorMessage(error)}` });
360
369
  return;
361
370
  }
362
- if (typeof parsed.command !== 'string' || parsed.command.length === 0) {
371
+ // `null` parses fine and is NOT an object: without this guard the property
372
+ // reads below throw a TypeError inside an event callback (uncaught).
373
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
374
+ this.respondJson(res, 400, { ok: false, error: 'body must be {"command": "...", "params": {...}}' });
375
+ return;
376
+ }
377
+ const body = parsed;
378
+ if (typeof body.command !== 'string' || body.command.length === 0) {
363
379
  this.respondJson(res, 400, { ok: false, error: 'body must be {"command": "...", "params": {...}}' });
364
380
  return;
365
381
  }
366
- const timeoutMs = typeof parsed.timeoutMs === 'number' ? parsed.timeoutMs : undefined;
367
- this.execute(parsed.command, (parsed.params ?? {}), timeoutMs === undefined ? {} : { timeoutMs }).then((result) => this.respondJson(res, 200, { ok: true, result }), (error) => {
382
+ const timeoutMs = typeof body.timeoutMs === 'number' ? body.timeoutMs : undefined;
383
+ const options = timeoutMs === undefined ? {} : { timeoutMs };
384
+ // The plugin's controller carries the read-only / script gates and the audit
385
+ // trail; the raw execute() below is only the fallback for bare users.
386
+ const run = this.options.httpCommand ?? ((command, params, opts) => this.execute(command, params, opts));
387
+ run(body.command, (body.params ?? {}), options).then((result) => this.respondJson(res, 200, { ok: true, result }), (error) => {
368
388
  if (error instanceof BridgeError) {
369
- // 503 = 现在根本没法工作;504 = 超时且结果未知;502 = 扩展报了具体失败
389
+ // 503 = 现在根本没法工作;504 = 超时且结果未知;403 = 本机策略拦下(零副作用);
390
+ // 502 = 扩展报了具体失败
370
391
  const status = error.code === 'extension_offline' || error.code === 'bridge_disabled' || error.code === 'not_configured'
371
392
  ? 503
372
393
  : error.code === 'command_timeout' || error.code === 'link_lost'
373
394
  ? 504
374
- : 502;
395
+ : error.code === 'blocked_by_policy'
396
+ ? 403
397
+ : 502;
375
398
  this.respondJson(res, status, error.toJSON());
376
399
  return;
377
400
  }
@@ -425,11 +448,19 @@ export class BridgeServer {
425
448
  this.clientSocket = socket;
426
449
  this.hello = undefined;
427
450
  this.connectedAt = new Date();
428
- this.continuationRemainder = '';
451
+ this.continuationChunks = [];
429
452
  this.continuationOpen = false;
430
453
  this.log(`extension connected from ${socket.remoteAddress}:${socket.remotePort}`);
431
454
  const reader = new FrameReader();
432
455
  socket.on('data', (chunk) => {
456
+ // A replaced connection can still deliver buffered frames. Without this
457
+ // guard its pong/frames would be applied to the NEW link, and its close
458
+ // event could tear the new socket down (the close handler already checks,
459
+ // the data handler did not).
460
+ if (this.clientSocket !== socket) {
461
+ socket.destroy();
462
+ return;
463
+ }
433
464
  reader.push(chunk);
434
465
  try {
435
466
  reader.drain((frame) => this.handleFrame(frame));
@@ -454,7 +485,7 @@ export class BridgeServer {
454
485
  switch (frame.opcode) {
455
486
  case 0x1:
456
487
  case 0x0:
457
- this.handleText(frame.payload.toString('utf8'), frame.fin, frame.opcode);
488
+ this.handleText(frame.payload, frame.fin, frame.opcode);
458
489
  return;
459
490
  case 0x8:
460
491
  this.closeClientSocket(1000, '');
@@ -466,17 +497,19 @@ export class BridgeServer {
466
497
  return; // pong or binary: nothing to do
467
498
  }
468
499
  }
469
- handleText(text, fin, opcode) {
500
+ handleText(payload, fin, opcode) {
470
501
  if (opcode === 0x0 && !this.continuationOpen)
471
502
  return; // stray continuation
472
503
  if (opcode === 0x1)
473
- this.continuationRemainder = '';
504
+ this.continuationChunks = [];
474
505
  this.continuationOpen = opcode === 0x0 || !fin;
475
- this.continuationRemainder += text;
506
+ this.continuationChunks.push(payload);
476
507
  if (!fin)
477
508
  return;
478
- const full = this.continuationRemainder;
479
- this.continuationRemainder = '';
509
+ // Decode only the complete message: per-fragment toString('utf8') splits
510
+ // multi-byte characters at fragment boundaries and corrupts the JSON.
511
+ const full = Buffer.concat(this.continuationChunks).toString('utf8');
512
+ this.continuationChunks = [];
480
513
  this.continuationOpen = false;
481
514
  let msg;
482
515
  try {
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.6",
4
+ "version": "1.7.8",
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 && node verify/verify_drag.mjs && node verify/verify_audit.mjs && node verify/verify_journal.mjs && node verify/verify_sessions.mjs && node verify/verify_compat_matrix.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_server_hardening.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_audit.mjs && node verify/verify_journal.mjs && node verify/verify_sessions.mjs && node verify/verify_snapshot_page.mjs && node verify/verify_compat_matrix.mjs"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public",