@angelky7/dsh-browser-control 1.3.0 → 1.5.0
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 +5 -4
- package/README.md +5 -4
- package/lib/index.js +125 -8
- package/lib/types/index.js +102 -9
- package/llms.txt +1 -1
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -64,8 +64,8 @@ In one line: **for "use MY browser" tasks (logged-in Bilibili, Zhihu, admin pane
|
|
|
64
64
|
|
|
65
65
|
| File | Purpose |
|
|
66
66
|
|---|---|
|
|
67
|
-
| [DSH-Browser-Control-v1.
|
|
68
|
-
| [dsh-browser-control-plugin-v1.
|
|
67
|
+
| [DSH-Browser-Control-v1.5.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.5.0/DSH-Browser-Control-v1.5.0.zip) | Chrome extension (unzip and load; this release adds the archive command — reinstall required) |
|
|
68
|
+
| [dsh-browser-control-plugin-v1.5.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.5.0/dsh-browser-control-plugin-v1.5.0.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.
|
|
107
|
+
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.5.0"
|
|
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"
|
|
@@ -263,11 +263,12 @@ curl -X POST http://127.0.0.1:9777/api/command \
|
|
|
263
263
|
| `browser_network_log` | Captured HTTP requests, listed with method/status/URL (v1.0.7+) |
|
|
264
264
|
| `browser_network_clear` | Clear the network log buffer on its own (v1.0.7+) |
|
|
265
265
|
| `browser_pdf` | Export the current page as PDF (v1.0.7+) |
|
|
266
|
+
| `browser_archive` | Save the whole page as a single-file MHTML (text/images/styles inline; a greppable offline evidence snapshot — embedded scripts do not run, v1.5.0+) |
|
|
266
267
|
| `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+) |
|
|
267
268
|
| `browser_cleanup` | Clean up temp files |
|
|
268
269
|
| `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
|
|
269
270
|
|
|
270
|
-
|
|
271
|
+
23 tools in total. `browser_wait`, `browser_find`, `browser_dialog` and `browser_info` existed inside the extension up to v1.0.8 but were never registered as tools, so the agent could not reach them; `browser_network_clear` existed in code and was simply missing from this table. `verify/verify_surface.mjs` now reconciles the extension command table, the commands the plugin calls, this table and the built bundle, so any drift fails `npm run verify`.
|
|
271
272
|
|
|
272
273
|
## Architecture
|
|
273
274
|
|
package/README.md
CHANGED
|
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
|
|
|
64
64
|
|
|
65
65
|
| 文件 | 说明 |
|
|
66
66
|
|---|---|
|
|
67
|
-
| [DSH-Browser-Control-v1.
|
|
68
|
-
| [dsh-browser-control-plugin-v1.
|
|
67
|
+
| [DSH-Browser-Control-v1.5.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.5.0/DSH-Browser-Control-v1.5.0.zip) | Chrome 扩展(解压后加载;这一版扩展新增 archive 存档命令,**必须重装**) |
|
|
68
|
+
| [dsh-browser-control-plugin-v1.5.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.5.0/dsh-browser-control-plugin-v1.5.0.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.
|
|
107
|
+
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.5.0"
|
|
108
108
|
|
|
109
109
|
# 本地目录调试(注意:必须显式 file: 前缀)
|
|
110
110
|
dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
|
|
@@ -257,11 +257,12 @@ curl -X POST http://127.0.0.1:9777/api/command \
|
|
|
257
257
|
| `browser_network_log` | 抓取 HTTP 请求/响应并逐条给出方法/状态/URL(v1.0.7+) |
|
|
258
258
|
| `browser_network_clear` | 单独清空网络日志缓冲(v1.0.7+) |
|
|
259
259
|
| `browser_pdf` | 当前页导出 PDF(v1.0.7+) |
|
|
260
|
+
| `browser_archive` | 整页存成单文件 MHTML(正文/图片/样式全内联,可当文本 grep 的离线证据;是快照,内嵌脚本不会跑,v1.5.0+) |
|
|
260
261
|
| `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7+) |
|
|
261
262
|
| `browser_cleanup` | 清理临时文件 |
|
|
262
263
|
| `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
|
|
263
264
|
|
|
264
|
-
共
|
|
265
|
+
共 23 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
|
|
265
266
|
|
|
266
267
|
## 架构
|
|
267
268
|
|
package/lib/index.js
CHANGED
|
@@ -942,6 +942,18 @@ function policyRefusal(command, flags, params) {
|
|
|
942
942
|
/** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
|
|
943
943
|
const NAV_MAX_MS = 12e4;
|
|
944
944
|
/**
|
|
945
|
+
* Same tier table as the extension's NAV_TIERS (extension/background.js). Both
|
|
946
|
+
* literals must stay in lockstep — verify_nav_waituntil.mjs extracts the
|
|
947
|
+
* extension's by regex and asserts the two agree (cross-side literals are wire
|
|
948
|
+
* protocol: a value only one side knows is a lie on the other).
|
|
949
|
+
*/
|
|
950
|
+
const NAV_TIERS = {
|
|
951
|
+
none: 0,
|
|
952
|
+
commit: 4e3,
|
|
953
|
+
domcontentloaded: 4e3,
|
|
954
|
+
load: 15e3
|
|
955
|
+
};
|
|
956
|
+
/**
|
|
945
957
|
* Extra time handed to the bridge on top of the navigation budget. Without it a
|
|
946
958
|
* caller-requested budget longer than the bridge default (60s) would be beaten by
|
|
947
959
|
* the bridge, and the model would get `command_timeout` (effect unknown) instead
|
|
@@ -1226,6 +1238,30 @@ async function savePdf(controller, payload, requestedPath) {
|
|
|
1226
1238
|
...payload.tabUrl === void 0 ? {} : { url: payload.tabUrl }
|
|
1227
1239
|
};
|
|
1228
1240
|
}
|
|
1241
|
+
/** Write one MHTML archive payload to `path` (absolute or under `controller.shotsDir`).
|
|
1242
|
+
* Mirrors savePdf's contract; MHTML is text, so it is written verbatim as UTF-8. */
|
|
1243
|
+
async function saveMhtml(controller, payload, requestedPath) {
|
|
1244
|
+
const dir = controller.shotsDir;
|
|
1245
|
+
if (dir === void 0) throw new Error("browser-bridge is not configured yet");
|
|
1246
|
+
let file;
|
|
1247
|
+
if (requestedPath && path.isAbsolute(requestedPath)) {
|
|
1248
|
+
file = requestedPath;
|
|
1249
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1250
|
+
} else {
|
|
1251
|
+
await mkdir(dir, { recursive: true });
|
|
1252
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1253
|
+
const random = Math.random().toString(36).slice(2, 6);
|
|
1254
|
+
const name = requestedPath ? path.basename(requestedPath) : `${stamp}-${random}.mhtml`;
|
|
1255
|
+
file = path.join(dir, name);
|
|
1256
|
+
}
|
|
1257
|
+
const buffer = Buffer.from(payload.mhtml, "utf8");
|
|
1258
|
+
await writeFile(file, buffer);
|
|
1259
|
+
return {
|
|
1260
|
+
file,
|
|
1261
|
+
bytes: buffer.length,
|
|
1262
|
+
tabId: payload.tabId
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1229
1265
|
/** Register every `browser_*` tool; each is a thin adapter over one extension command. */
|
|
1230
1266
|
function applyBrowserTools(ctx, controller) {
|
|
1231
1267
|
ctx.systemPrompt.section({
|
|
@@ -1246,9 +1282,13 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1246
1282
|
type: "number",
|
|
1247
1283
|
description: "Target tab; defaults to the active tab."
|
|
1248
1284
|
},
|
|
1285
|
+
waitUntil: {
|
|
1286
|
+
type: "string",
|
|
1287
|
+
description: "When the navigation counts as arrived: \"load\" (default) waits for the page load event (15s budget), \"domcontentloaded\" returns once the DOM is parsed (async components may still be mounting — follow with browser_wait if an element is missing), \"commit\" once the new document's address lands, \"none\" returns immediately with no arrival check at all."
|
|
1288
|
+
},
|
|
1249
1289
|
timeoutMs: {
|
|
1250
1290
|
type: "number",
|
|
1251
|
-
description: `How long to wait for the page load to settle: defaults to 15000, capped at ${NAV_MAX_MS}.
|
|
1291
|
+
description: `How long to wait for the page load to settle: defaults to the waitUntil tier (load 15000, commit/domcontentloaded 4000), capped at ${NAV_MAX_MS}. An explicit value always wins over the tier.`
|
|
1252
1292
|
}
|
|
1253
1293
|
},
|
|
1254
1294
|
output: {
|
|
@@ -1264,7 +1304,10 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1264
1304
|
title: { type: "string" },
|
|
1265
1305
|
pendingUrl: { type: "string" },
|
|
1266
1306
|
loadPending: { type: "boolean" },
|
|
1267
|
-
siteUnreachable: { type: "boolean" }
|
|
1307
|
+
siteUnreachable: { type: "boolean" },
|
|
1308
|
+
waitUntil: { type: "string" },
|
|
1309
|
+
waitedMs: { type: "number" },
|
|
1310
|
+
readyState: { type: "string" }
|
|
1268
1311
|
}
|
|
1269
1312
|
},
|
|
1270
1313
|
render: (_args, value) => {
|
|
@@ -1298,17 +1341,26 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1298
1341
|
kind: "other"
|
|
1299
1342
|
}),
|
|
1300
1343
|
async execute(args, exec) {
|
|
1301
|
-
const
|
|
1344
|
+
const waitUntil = args.waitUntil === void 0 ? "load" : String(args.waitUntil);
|
|
1345
|
+
if (!(waitUntil in NAV_TIERS)) throw new Error(`browser_navigate: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
|
|
1346
|
+
const params = {
|
|
1347
|
+
url: args.url,
|
|
1348
|
+
waitUntil
|
|
1349
|
+
};
|
|
1302
1350
|
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1351
|
+
let budgetMs = NAV_TIERS[waitUntil];
|
|
1352
|
+
if (typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)) budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
|
|
1353
|
+
if (waitUntil !== "none") params.timeoutMs = budgetMs;
|
|
1354
|
+
const raw = await controller.execute("nav", params, exec.signal, waitUntil === "none" ? void 0 : budgetMs + BRIDGE_MARGIN_MS);
|
|
1306
1355
|
const out = { tabId: raw.tabId };
|
|
1307
1356
|
if (typeof raw.url === "string") out.url = raw.url;
|
|
1308
1357
|
if (typeof raw.title === "string") out.title = raw.title;
|
|
1309
1358
|
if (typeof raw.pendingUrl === "string") out.pendingUrl = raw.pendingUrl;
|
|
1310
1359
|
if (raw.loadPending === true) out.loadPending = true;
|
|
1311
1360
|
if (raw.siteUnreachable === true || typeof raw.siteUnreachable === "object" && raw.siteUnreachable !== null) out.siteUnreachable = true;
|
|
1361
|
+
if (typeof raw.waitUntil === "string") out.waitUntil = raw.waitUntil;
|
|
1362
|
+
if (typeof raw.waitedMs === "number") out.waitedMs = raw.waitedMs;
|
|
1363
|
+
if (typeof raw.readyState === "string" && raw.readyState.length > 0) out.readyState = raw.readyState;
|
|
1312
1364
|
return out;
|
|
1313
1365
|
}
|
|
1314
1366
|
}));
|
|
@@ -2011,6 +2063,14 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2011
2063
|
type: "boolean",
|
|
2012
2064
|
description: "For open: put the new tab in the DSH tab group. Defaults to true; pass false to keep it loose."
|
|
2013
2065
|
},
|
|
2066
|
+
waitUntil: {
|
|
2067
|
+
type: "string",
|
|
2068
|
+
description: "For open: when the new tab counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none)."
|
|
2069
|
+
},
|
|
2070
|
+
timeoutMs: {
|
|
2071
|
+
type: "number",
|
|
2072
|
+
description: "For open: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000)."
|
|
2073
|
+
},
|
|
2014
2074
|
windowId: {
|
|
2015
2075
|
type: "number",
|
|
2016
2076
|
description: "For ungroup: only dissolve that window’s DSH group; omit to clear every window."
|
|
@@ -2036,10 +2096,19 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2036
2096
|
case "list": return await controller.execute("tabs.list", {}, exec.signal);
|
|
2037
2097
|
case "open": {
|
|
2038
2098
|
if (typeof args.url !== "string" || args.url.length === 0) throw new Error("open requires url");
|
|
2039
|
-
const
|
|
2099
|
+
const waitUntil = args.waitUntil === void 0 ? "load" : String(args.waitUntil);
|
|
2100
|
+
if (!(waitUntil in NAV_TIERS)) throw new Error(`browser_tabs open: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
|
|
2101
|
+
const params = {
|
|
2102
|
+
url: args.url,
|
|
2103
|
+
waitUntil
|
|
2104
|
+
};
|
|
2040
2105
|
if (args.active !== void 0) params.active = args.active;
|
|
2041
2106
|
if (args.group !== void 0) params.group = args.group;
|
|
2042
|
-
return await controller.execute("tabs.open", params, exec.signal);
|
|
2107
|
+
if (waitUntil === "none") return await controller.execute("tabs.open", params, exec.signal);
|
|
2108
|
+
let budgetMs = NAV_TIERS[waitUntil];
|
|
2109
|
+
if (typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)) budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
|
|
2110
|
+
params.timeoutMs = budgetMs;
|
|
2111
|
+
return await controller.execute("tabs.open", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
2043
2112
|
}
|
|
2044
2113
|
case "close": {
|
|
2045
2114
|
const ids = closeTabIds(args);
|
|
@@ -2545,6 +2614,54 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2545
2614
|
return await savePdf(controller, await controller.execute("pdf", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
|
|
2546
2615
|
}
|
|
2547
2616
|
}));
|
|
2617
|
+
ctx.tools.register(defineTool({
|
|
2618
|
+
name: "browser_archive",
|
|
2619
|
+
description: "Save the whole page as a single-file MHTML archive (text, images and styles inline; no network needed to reopen it). Returns the absolute path. The archive is a snapshot of when it was taken — scripts inside do not run and later page changes are not reflected; read it back with file tools or grep it like text.",
|
|
2620
|
+
parameters: {
|
|
2621
|
+
tabId: {
|
|
2622
|
+
type: "number",
|
|
2623
|
+
description: "Target tab; defaults to the active tab."
|
|
2624
|
+
},
|
|
2625
|
+
path: {
|
|
2626
|
+
type: "string",
|
|
2627
|
+
description: "Absolute path. Omit to save under the configured shotsDir with a timestamped .mhtml name."
|
|
2628
|
+
}
|
|
2629
|
+
},
|
|
2630
|
+
output: {
|
|
2631
|
+
schema: {
|
|
2632
|
+
type: "object",
|
|
2633
|
+
additionalProperties: false,
|
|
2634
|
+
properties: {
|
|
2635
|
+
file: {
|
|
2636
|
+
type: "string",
|
|
2637
|
+
required: true
|
|
2638
|
+
},
|
|
2639
|
+
bytes: {
|
|
2640
|
+
type: "number",
|
|
2641
|
+
required: true
|
|
2642
|
+
},
|
|
2643
|
+
tabId: {
|
|
2644
|
+
type: "number",
|
|
2645
|
+
required: true
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
},
|
|
2649
|
+
render: (_args, value) => [{
|
|
2650
|
+
type: "text",
|
|
2651
|
+
text: `MHTML archive written to ${value.file} (${(value.bytes / 1024).toFixed(1)} KB)`
|
|
2652
|
+
}]
|
|
2653
|
+
},
|
|
2654
|
+
presentCall: (args) => ({
|
|
2655
|
+
card: "generic",
|
|
2656
|
+
title: `Save page archive${args.path ? " → " + args.path : ""}`,
|
|
2657
|
+
kind: "other"
|
|
2658
|
+
}),
|
|
2659
|
+
async execute(args, exec) {
|
|
2660
|
+
const params = {};
|
|
2661
|
+
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
2662
|
+
return await saveMhtml(controller, await controller.execute("archive", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
|
|
2663
|
+
}
|
|
2664
|
+
}));
|
|
2548
2665
|
ctx.tools.register(defineTool({
|
|
2549
2666
|
name: "browser_emulate",
|
|
2550
2667
|
description: "Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing. `device:\"reset\"` restores the user's actual viewport. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.",
|
package/lib/types/index.js
CHANGED
|
@@ -26,6 +26,13 @@ import { BridgeError, classify } from "./errors.js";
|
|
|
26
26
|
import { policyRefusal } from "./policy.js";
|
|
27
27
|
/** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
|
|
28
28
|
const NAV_MAX_MS = 120_000;
|
|
29
|
+
/**
|
|
30
|
+
* Same tier table as the extension's NAV_TIERS (extension/background.js). Both
|
|
31
|
+
* literals must stay in lockstep — verify_nav_waituntil.mjs extracts the
|
|
32
|
+
* extension's by regex and asserts the two agree (cross-side literals are wire
|
|
33
|
+
* protocol: a value only one side knows is a lie on the other).
|
|
34
|
+
*/
|
|
35
|
+
const NAV_TIERS = { none: 0, commit: 4_000, domcontentloaded: 4_000, load: 15_000 };
|
|
29
36
|
/**
|
|
30
37
|
* Extra time handed to the bridge on top of the navigation budget. Without it a
|
|
31
38
|
* caller-requested budget longer than the bridge default (60s) would be beaten by
|
|
@@ -344,6 +351,28 @@ async function savePdf(controller, payload, requestedPath) {
|
|
|
344
351
|
...(payload.tabUrl === undefined ? {} : { url: payload.tabUrl }),
|
|
345
352
|
};
|
|
346
353
|
}
|
|
354
|
+
/** Write one MHTML archive payload to `path` (absolute or under `controller.shotsDir`).
|
|
355
|
+
* Mirrors savePdf's contract; MHTML is text, so it is written verbatim as UTF-8. */
|
|
356
|
+
async function saveMhtml(controller, payload, requestedPath) {
|
|
357
|
+
const dir = controller.shotsDir;
|
|
358
|
+
if (dir === undefined)
|
|
359
|
+
throw new Error('browser-bridge is not configured yet');
|
|
360
|
+
let file;
|
|
361
|
+
if (requestedPath && path.isAbsolute(requestedPath)) {
|
|
362
|
+
file = requestedPath;
|
|
363
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
await mkdir(dir, { recursive: true });
|
|
367
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
368
|
+
const random = Math.random().toString(36).slice(2, 6);
|
|
369
|
+
const name = requestedPath ? path.basename(requestedPath) : `${stamp}-${random}.mhtml`;
|
|
370
|
+
file = path.join(dir, name);
|
|
371
|
+
}
|
|
372
|
+
const buffer = Buffer.from(payload.mhtml, 'utf8');
|
|
373
|
+
await writeFile(file, buffer);
|
|
374
|
+
return { file, bytes: buffer.length, tabId: payload.tabId };
|
|
375
|
+
}
|
|
347
376
|
/** Register every `browser_*` tool; each is a thin adapter over one extension command. */
|
|
348
377
|
function applyBrowserTools(ctx, controller) {
|
|
349
378
|
ctx.systemPrompt.section({
|
|
@@ -367,7 +396,8 @@ function applyBrowserTools(ctx, controller) {
|
|
|
367
396
|
parameters: {
|
|
368
397
|
url: { type: 'string', required: true, description: 'Absolute URL to open in the tab.' },
|
|
369
398
|
tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
|
|
370
|
-
|
|
399
|
+
waitUntil: { type: 'string', description: 'When the navigation counts as arrived: "load" (default) waits for the page load event (15s budget), "domcontentloaded" returns once the DOM is parsed (async components may still be mounting — follow with browser_wait if an element is missing), "commit" once the new document\'s address lands, "none" returns immediately with no arrival check at all.' },
|
|
400
|
+
timeoutMs: { type: 'number', description: `How long to wait for the page load to settle: defaults to the waitUntil tier (load 15000, commit/domcontentloaded 4000), capped at ${NAV_MAX_MS}. An explicit value always wins over the tier.` },
|
|
371
401
|
},
|
|
372
402
|
output: {
|
|
373
403
|
schema: {
|
|
@@ -380,6 +410,9 @@ function applyBrowserTools(ctx, controller) {
|
|
|
380
410
|
pendingUrl: { type: 'string' },
|
|
381
411
|
loadPending: { type: 'boolean' },
|
|
382
412
|
siteUnreachable: { type: 'boolean' },
|
|
413
|
+
waitUntil: { type: 'string' },
|
|
414
|
+
waitedMs: { type: 'number' },
|
|
415
|
+
readyState: { type: 'string' },
|
|
383
416
|
},
|
|
384
417
|
},
|
|
385
418
|
render: (_args, value) => {
|
|
@@ -404,14 +437,23 @@ function applyBrowserTools(ctx, controller) {
|
|
|
404
437
|
isConcurrencySafe: () => false,
|
|
405
438
|
presentCall: args => ({ card: 'generic', title: `Open ${args.url}`, kind: 'other' }),
|
|
406
439
|
async execute(args, exec) {
|
|
407
|
-
const
|
|
440
|
+
const waitUntil = args.waitUntil === undefined ? 'load' : String(args.waitUntil);
|
|
441
|
+
if (!(waitUntil in NAV_TIERS)) {
|
|
442
|
+
throw new Error(`browser_navigate: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
|
|
443
|
+
}
|
|
444
|
+
const params = { url: args.url, waitUntil };
|
|
408
445
|
if (args.tabId !== undefined)
|
|
409
446
|
params.tabId = args.tabId;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
447
|
+
// Explicit timeoutMs always wins over the tier; 'none' waits 0 and sends
|
|
448
|
+
// no budget at all. The bridge gets budget+2s so the extension's own
|
|
449
|
+
// "navigation did not arrive" always reports before the bridge's ceiling.
|
|
450
|
+
let budgetMs = NAV_TIERS[waitUntil];
|
|
451
|
+
if (typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)) {
|
|
452
|
+
budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
|
|
453
|
+
}
|
|
454
|
+
if (waitUntil !== 'none')
|
|
455
|
+
params.timeoutMs = budgetMs;
|
|
456
|
+
const raw = await controller.execute('nav', params, exec.signal, waitUntil === 'none' ? undefined : budgetMs + BRIDGE_MARGIN_MS);
|
|
415
457
|
// The declared schema is closed, so the fields describing a navigation that did
|
|
416
458
|
// not arrive have to be carried over on purpose. The old shape check
|
|
417
459
|
// (`siteUnreachable === true`) also missed the object the extension really
|
|
@@ -429,6 +471,12 @@ function applyBrowserTools(ctx, controller) {
|
|
|
429
471
|
|| (typeof raw.siteUnreachable === 'object' && raw.siteUnreachable !== null);
|
|
430
472
|
if (unreachable)
|
|
431
473
|
out.siteUnreachable = true;
|
|
474
|
+
if (typeof raw.waitUntil === 'string')
|
|
475
|
+
out.waitUntil = raw.waitUntil;
|
|
476
|
+
if (typeof raw.waitedMs === 'number')
|
|
477
|
+
out.waitedMs = raw.waitedMs;
|
|
478
|
+
if (typeof raw.readyState === 'string' && raw.readyState.length > 0)
|
|
479
|
+
out.readyState = raw.readyState;
|
|
432
480
|
return out;
|
|
433
481
|
},
|
|
434
482
|
}));
|
|
@@ -836,6 +884,8 @@ function applyBrowserTools(ctx, controller) {
|
|
|
836
884
|
tabIds: { type: 'array', items: { type: 'number' }, description: 'For close: every tab to close in one call. The result reports each id (closed / alreadyGone / failed) instead of stopping at the first failure.' },
|
|
837
885
|
active: { type: 'boolean', description: 'Whether a newly opened tab becomes active; defaults to true.' },
|
|
838
886
|
group: { type: 'boolean', description: 'For open: put the new tab in the DSH tab group. Defaults to true; pass false to keep it loose.' },
|
|
887
|
+
waitUntil: { type: 'string', description: 'For open: when the new tab counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none).' },
|
|
888
|
+
timeoutMs: { type: 'number', description: 'For open: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000).' },
|
|
839
889
|
windowId: { type: 'number', description: 'For ungroup: only dissolve that window’s DSH group; omit to clear every window.' },
|
|
840
890
|
},
|
|
841
891
|
output: {
|
|
@@ -850,12 +900,24 @@ function applyBrowserTools(ctx, controller) {
|
|
|
850
900
|
case 'open': {
|
|
851
901
|
if (typeof args.url !== 'string' || args.url.length === 0)
|
|
852
902
|
throw new Error('open requires url');
|
|
853
|
-
const
|
|
903
|
+
const waitUntil = args.waitUntil === undefined ? 'load' : String(args.waitUntil);
|
|
904
|
+
if (!(waitUntil in NAV_TIERS)) {
|
|
905
|
+
throw new Error(`browser_tabs open: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
|
|
906
|
+
}
|
|
907
|
+
const params = { url: args.url, waitUntil };
|
|
854
908
|
if (args.active !== undefined)
|
|
855
909
|
params.active = args.active;
|
|
856
910
|
if (args.group !== undefined)
|
|
857
911
|
params.group = args.group;
|
|
858
|
-
|
|
912
|
+
if (waitUntil === 'none') {
|
|
913
|
+
return await controller.execute('tabs.open', params, exec.signal);
|
|
914
|
+
}
|
|
915
|
+
let budgetMs = NAV_TIERS[waitUntil];
|
|
916
|
+
if (typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)) {
|
|
917
|
+
budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
|
|
918
|
+
}
|
|
919
|
+
params.timeoutMs = budgetMs;
|
|
920
|
+
return await controller.execute('tabs.open', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
859
921
|
}
|
|
860
922
|
case 'close': {
|
|
861
923
|
const ids = closeTabIds(args);
|
|
@@ -1211,6 +1273,37 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1211
1273
|
return await savePdf(controller, payload, typeof args.path === 'string' ? args.path : undefined);
|
|
1212
1274
|
},
|
|
1213
1275
|
}));
|
|
1276
|
+
ctx.tools.register(defineTool({
|
|
1277
|
+
name: 'browser_archive',
|
|
1278
|
+
description: 'Save the whole page as a single-file MHTML archive (text, images and styles inline; no network needed to reopen it). Returns the absolute path. The archive is a snapshot of when it was taken — scripts inside do not run and later page changes are not reflected; read it back with file tools or grep it like text.',
|
|
1279
|
+
parameters: {
|
|
1280
|
+
tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
|
|
1281
|
+
path: { type: 'string', description: 'Absolute path. Omit to save under the configured shotsDir with a timestamped .mhtml name.' },
|
|
1282
|
+
},
|
|
1283
|
+
output: {
|
|
1284
|
+
schema: {
|
|
1285
|
+
type: 'object',
|
|
1286
|
+
additionalProperties: false,
|
|
1287
|
+
properties: {
|
|
1288
|
+
file: { type: 'string', required: true },
|
|
1289
|
+
bytes: { type: 'number', required: true },
|
|
1290
|
+
tabId: { type: 'number', required: true },
|
|
1291
|
+
},
|
|
1292
|
+
},
|
|
1293
|
+
render: (_args, value) => [{
|
|
1294
|
+
type: 'text',
|
|
1295
|
+
text: `MHTML archive written to ${value.file} (${(value.bytes / 1024).toFixed(1)} KB)`,
|
|
1296
|
+
}],
|
|
1297
|
+
},
|
|
1298
|
+
presentCall: args => ({ card: 'generic', title: `Save page archive${args.path ? ' → ' + args.path : ''}`, kind: 'other' }),
|
|
1299
|
+
async execute(args, exec) {
|
|
1300
|
+
const params = {};
|
|
1301
|
+
if (args.tabId !== undefined)
|
|
1302
|
+
params.tabId = args.tabId;
|
|
1303
|
+
const payload = await controller.execute('archive', params, exec.signal);
|
|
1304
|
+
return await saveMhtml(controller, payload, typeof args.path === 'string' ? args.path : undefined);
|
|
1305
|
+
},
|
|
1306
|
+
}));
|
|
1214
1307
|
ctx.tools.register(defineTool({
|
|
1215
1308
|
name: 'browser_emulate',
|
|
1216
1309
|
description: 'Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing. `device:"reset"` restores the user\'s actual viewport. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.',
|
package/llms.txt
CHANGED
|
@@ -35,7 +35,7 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
|
|
|
35
35
|
|
|
36
36
|
## 关键文件
|
|
37
37
|
|
|
38
|
-
- `src/index.ts` -
|
|
38
|
+
- `src/index.ts` - 23 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
|
|
39
39
|
- `src/policy.ts` - 三张互不相同的表:只读白名单、脚本命令、插件本地破坏性命令(`browser_cleanup` 不经过 `execute()`,必须自己站闸门)
|
|
40
40
|
- `src/server.ts` - WebSocket 服务 + HTTP 面(鉴权、来源校验、状态页)
|
|
41
41
|
- `src/ws.ts` - RFC 6455 帧编解码
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@angelky7/dsh-browser-control",
|
|
3
3
|
"description": "Local WebSocket bridge between dsh and the DSH Browser Control browser extension, with model-facing browser_* tools and a Settings-managed on/off switch",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
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"
|
|
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"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public",
|