@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 +3 -3
- package/README.md +6 -4
- package/lib/index.js +182 -66
- package/lib/types/errors.d.ts +4 -1
- package/lib/types/errors.js +13 -4
- package/lib/types/index.js +110 -20
- package/lib/types/policy.d.ts +5 -0
- package/lib/types/policy.js +27 -3
- package/lib/types/server.d.ts +12 -1
- package/lib/types/server.js +49 -16
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -64,8 +64,8 @@ In one line: **for "use MY browser" tasks (logged-in Bilibili, Zhihu, admin pane
|
|
|
64
64
|
|
|
65
65
|
| File | Purpose |
|
|
66
66
|
|---|---|
|
|
67
|
-
| [DSH-Browser-Control-v1.7.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
|
|
68
|
-
| [dsh-browser-control-plugin-v1.7.
|
|
67
|
+
| [DSH-Browser-Control-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/DSH-Browser-Control-v1.7.2.zip) | Chrome extension (unzip and load; **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.
|
|
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
|
|
68
|
-
| [dsh-browser-control-plugin-v1.7.
|
|
67
|
+
| [DSH-Browser-Control-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/DSH-Browser-Control-v1.7.2.zip) | Chrome 扩展(解压后加载;**扩展从 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
555
|
-
|
|
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.
|
|
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
|
|
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(
|
|
673
|
+
handleText(payload, fin, opcode) {
|
|
652
674
|
if (opcode === 0 && !this.continuationOpen) return;
|
|
653
|
-
if (opcode === 1) this.
|
|
675
|
+
if (opcode === 1) this.continuationChunks = [];
|
|
654
676
|
this.continuationOpen = opcode === 0 || !fin;
|
|
655
|
-
this.
|
|
677
|
+
this.continuationChunks.push(payload);
|
|
656
678
|
if (!fin) return;
|
|
657
|
-
const full = this.
|
|
658
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1564
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
3880
|
-
|
|
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
|
|
3899
|
-
y: sy
|
|
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:
|
|
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(
|
|
3923
|
-
to: roundDragPoint(
|
|
4038
|
+
from: roundDragPoint(sourcePoint),
|
|
4039
|
+
to: roundDragPoint(targetPoint),
|
|
3924
4040
|
steps,
|
|
3925
4041
|
dispatched: steps + 4,
|
|
3926
4042
|
elapsedMs: Date.now() - started
|
package/lib/types/errors.d.ts
CHANGED
|
@@ -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;
|
package/lib/types/errors.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/types/index.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
await dispatch({ type: 'mouseMoved', x: sx
|
|
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(
|
|
2134
|
-
to: roundDragPoint(
|
|
2223
|
+
from: roundDragPoint(sourcePoint),
|
|
2224
|
+
to: roundDragPoint(targetPoint),
|
|
2135
2225
|
steps,
|
|
2136
2226
|
dispatched: steps + 4,
|
|
2137
2227
|
elapsedMs: Date.now() - started,
|
package/lib/types/policy.d.ts
CHANGED
|
@@ -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
|
/**
|
package/lib/types/policy.js
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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} 会删除本机保存的截图与临时文件,删除不可逆,所以已被拒绝,没有任何文件被删除。`
|
package/lib/types/server.d.ts
CHANGED
|
@@ -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
|
-
|
|
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`. */
|
package/lib/types/server.js
CHANGED
|
@@ -87,7 +87,12 @@ export class BridgeServer {
|
|
|
87
87
|
connectedAt;
|
|
88
88
|
pending = new Map();
|
|
89
89
|
lastError;
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
367
|
-
|
|
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 = 超时且结果未知;
|
|
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
|
-
:
|
|
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.
|
|
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
|
|
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(
|
|
500
|
+
handleText(payload, fin, opcode) {
|
|
470
501
|
if (opcode === 0x0 && !this.continuationOpen)
|
|
471
502
|
return; // stray continuation
|
|
472
503
|
if (opcode === 0x1)
|
|
473
|
-
this.
|
|
504
|
+
this.continuationChunks = [];
|
|
474
505
|
this.continuationOpen = opcode === 0x0 || !fin;
|
|
475
|
-
this.
|
|
506
|
+
this.continuationChunks.push(payload);
|
|
476
507
|
if (!fin)
|
|
477
508
|
return;
|
|
478
|
-
|
|
479
|
-
|
|
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.
|
|
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",
|