@angelky7/dsh-browser-control 1.7.7 → 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
 
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)) {
548
557
  this.respondJson(res, 400, {
549
558
  ok: false,
550
559
  error: "body must be {\"command\": \"...\", \"params\": {...}}"
551
560
  });
552
561
  return;
553
562
  }
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, {
563
+ const body = parsed;
564
+ if (typeof body.command !== "string" || body.command.length === 0) {
565
+ this.respondJson(res, 400, {
566
+ ok: false,
567
+ error: "body must be {\"command\": \"...\", \"params\": {...}}"
568
+ });
569
+ return;
570
+ }
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
@@ -3667,16 +3723,26 @@ async function resolveDragPoint(ctrl, tabId, target, signal) {
3667
3723
  }, signal))?.nodeId ?? 0;
3668
3724
  if (nodeId === 0) throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
3669
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) {
3670
3736
  let box;
3671
3737
  try {
3672
3738
  box = await cdpCall(ctrl, tabId, "DOM.getBoxModel", { nodeId }, signal);
3673
3739
  } catch (error) {
3674
- 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)})`);
3675
3741
  }
3676
3742
  const q = box?.model?.content;
3677
- 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)}`);
3678
3744
  return {
3679
- selector,
3745
+ selector: label,
3680
3746
  x: (q[0] + q[2] + q[4] + q[6]) / 4,
3681
3747
  y: (q[1] + q[3] + q[5] + q[7]) / 4,
3682
3748
  width: typeof box?.model?.width === "number" ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]),
@@ -3684,6 +3750,19 @@ async function resolveDragPoint(ctrl, tabId, target, signal) {
3684
3750
  nodeId
3685
3751
  };
3686
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
+ }
3687
3766
  /** Whether the source declares the native HTML5 `draggable` attribute. */
3688
3767
  async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
3689
3768
  const attrs = await cdpCall(ctrl, tabId, "DOM.getAttributes", { nodeId }, signal);
@@ -3859,6 +3938,7 @@ function registerDragTool(ctx, controller) {
3859
3938
  controller.requireExtensionCommand("cdp", "1.7.2");
3860
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");
3861
3940
  let tabId = args.tabId;
3941
+ if (tabId === void 0) tabId = await controller.sessionTab(exec.signal);
3862
3942
  if (tabId === void 0) {
3863
3943
  const listing = await controller.execute("tabs.list", {}, exec.signal);
3864
3944
  if (typeof listing?.activeTabId !== "number") throw new Error("could not resolve the active tab — pass tabId explicitly");
@@ -3871,56 +3951,92 @@ function registerDragTool(ctx, controller) {
3871
3951
  if (mode === "auto" && source?.nodeId !== void 0 && await sourceIsDraggable(controller, tabId, source.nodeId, exec.signal)) resolvedMode = "html5";
3872
3952
  if (resolvedMode === "mouse") {
3873
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
+ }
3874
3966
  const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === "number" ? args.steps : 12)));
3875
- 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;
3876
3968
  const dispatch = async (params) => {
3877
3969
  await cdpCall(controller, tabId, "Input.dispatchMouseEvent", params, exec.signal);
3878
3970
  };
3879
- await dispatch({
3880
- type: "mouseMoved",
3881
- x: sx,
3882
- y: sy,
3883
- button: "none",
3884
- buttons: 0
3885
- });
3886
- await dispatch({
3887
- type: "mousePressed",
3888
- x: sx,
3889
- y: sy,
3890
- button: "left",
3891
- buttons: 1,
3892
- clickCount: 1
3893
- });
3894
- for (let i = 1; i <= steps; i++) {
3895
- const f = i / steps;
3971
+ let pressedAt;
3972
+ try {
3896
3973
  await dispatch({
3897
3974
  type: "mouseMoved",
3898
- x: sx + (tx - sx) * f,
3899
- 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,
3984
+ button: "left",
3985
+ buttons: 1,
3986
+ clickCount: 1
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,
3900
4010
  button: "left",
3901
- buttons: 1
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
3902
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;
3903
4034
  }
3904
- await dispatch({
3905
- type: "mouseReleased",
3906
- x: tx,
3907
- y: ty,
3908
- button: "left",
3909
- buttons: 0,
3910
- clickCount: 1
3911
- });
3912
- await dispatch({
3913
- type: "mouseMoved",
3914
- x: tx,
3915
- y: ty,
3916
- button: "none",
3917
- buttons: 0
3918
- });
3919
4035
  return {
3920
4036
  tabId,
3921
4037
  mode: "mouse",
3922
- from: roundDragPoint(source),
3923
- to: roundDragPoint(target),
4038
+ from: roundDragPoint(sourcePoint),
4039
+ to: roundDragPoint(targetPoint),
3924
4040
  steps,
3925
4041
  dispatched: steps + 4,
3926
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). */
@@ -1936,21 +1966,44 @@ async function resolveDragPoint(ctrl, tabId, target, signal) {
1936
1966
  if (nodeId === 0)
1937
1967
  throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
1938
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) {
1939
1979
  let box;
1940
1980
  try {
1941
1981
  box = await cdpCall(ctrl, tabId, 'DOM.getBoxModel', { nodeId }, signal);
1942
1982
  }
1943
1983
  catch (error) {
1944
- 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)})`);
1945
1985
  }
1946
1986
  const q = box?.model?.content;
1947
1987
  if (!Array.isArray(q) || q.length < 8)
1948
- 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)}`);
1949
1989
  const x = (q[0] + q[2] + q[4] + q[6]) / 4;
1950
1990
  const y = (q[1] + q[3] + q[5] + q[7]) / 4;
1951
1991
  const width = typeof box?.model?.width === 'number' ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]);
1952
1992
  const height = typeof box?.model?.height === 'number' ? box.model.height : Math.hypot(q[4] - q[2], q[5] - q[3]);
1953
- 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;
1954
2007
  }
1955
2008
  /** Whether the source declares the native HTML5 `draggable` attribute. */
1956
2009
  async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
@@ -2098,6 +2151,8 @@ function registerDragTool(ctx, controller) {
2098
2151
  if (controller.policy.readOnly)
2099
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');
2100
2153
  let tabId = args.tabId;
2154
+ if (tabId === undefined)
2155
+ tabId = await controller.sessionTab(exec.signal);
2101
2156
  if (tabId === undefined) {
2102
2157
  const listing = await controller.execute('tabs.list', {}, exec.signal);
2103
2158
  if (typeof listing?.activeTabId !== 'number')
@@ -2114,24 +2169,59 @@ function registerDragTool(ctx, controller) {
2114
2169
  }
2115
2170
  if (resolvedMode === 'mouse') {
2116
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
+ }
2117
2189
  const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === 'number' ? args.steps : 12)));
2118
- 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;
2119
2191
  const dispatch = async (params) => {
2120
2192
  await cdpCall(controller, tabId, 'Input.dispatchMouseEvent', params, exec.signal);
2121
2193
  };
2122
- await dispatch({ type: 'mouseMoved', x: sx, y: sy, button: 'none', buttons: 0 });
2123
- await dispatch({ type: 'mousePressed', x: sx, y: sy, button: 'left', buttons: 1, clickCount: 1 });
2124
- for (let i = 1; i <= steps; i++) {
2125
- const f = i / steps;
2126
- 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;
2127
2219
  }
2128
- await dispatch({ type: 'mouseReleased', x: tx, y: ty, button: 'left', buttons: 0, clickCount: 1 });
2129
- await dispatch({ type: 'mouseMoved', x: tx, y: ty, button: 'none', buttons: 0 });
2130
2220
  const receipt = {
2131
2221
  tabId,
2132
2222
  mode: 'mouse',
2133
- from: roundDragPoint(source),
2134
- to: roundDragPoint(target),
2223
+ from: roundDragPoint(sourcePoint),
2224
+ to: roundDragPoint(targetPoint),
2135
2225
  steps,
2136
2226
  dispatched: steps + 4,
2137
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.7",
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_snapshot_page.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",