dsh-builtin-browser 0.4.0 → 0.4.2

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.
Files changed (44) hide show
  1. package/README.en.md +68 -30
  2. package/README.md +66 -30
  3. package/client.js +60 -6
  4. package/cordis.patch.yml +21 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +12 -4
  6. package/docs/architecture.md +12 -2
  7. package/docs/tool-reference.md +17 -17
  8. package/docs/user-guide.md +13 -4
  9. package/docs/why-browser.md +2 -2
  10. package/lib/browser/types.d.ts +25 -1
  11. package/lib/browser-electron/bridge-connection.d.ts +31 -5
  12. package/lib/browser-electron/bridge-connection.js +143 -21
  13. package/lib/browser-electron/entry.d.ts +1 -1
  14. package/lib/browser-electron/entry.js +61 -35
  15. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  16. package/lib/browser-electron/ephemeral-profile.js +146 -0
  17. package/lib/browser-electron/history-store.d.ts +25 -0
  18. package/lib/browser-electron/history-store.js +51 -4
  19. package/lib/browser-electron/host-main.js +75 -27
  20. package/lib/browser-electron/provider.d.ts +44 -1
  21. package/lib/browser-electron/provider.js +379 -48
  22. package/lib/browser-electron/remote-host.d.ts +52 -3
  23. package/lib/browser-electron/remote-host.js +244 -23
  24. package/lib/browser-electron/settings-route.d.ts +28 -0
  25. package/lib/browser-electron/settings-route.js +28 -0
  26. package/lib/browser-electron/settings-store.d.ts +40 -0
  27. package/lib/browser-electron/settings-store.js +107 -11
  28. package/lib/browser-electron/system-browser.d.ts +71 -3
  29. package/lib/browser-electron/system-browser.js +355 -38
  30. package/lib/tool-browser/index.js +143 -34
  31. package/package.json +5 -3
  32. package/scripts/verify-host-compat.mjs +195 -0
  33. package/src/browser/types.ts +25 -1
  34. package/src/browser-electron/bridge-connection.ts +142 -21
  35. package/src/browser-electron/entry.ts +58 -33
  36. package/src/browser-electron/ephemeral-profile.ts +152 -0
  37. package/src/browser-electron/history-store.ts +51 -4
  38. package/src/browser-electron/host-main.ts +105 -35
  39. package/src/browser-electron/provider.ts +401 -51
  40. package/src/browser-electron/remote-host.ts +261 -36
  41. package/src/browser-electron/settings-route.ts +46 -0
  42. package/src/browser-electron/settings-store.ts +121 -11
  43. package/src/browser-electron/system-browser.ts +342 -21
  44. package/src/tool-browser/index.ts +144 -36
@@ -1,6 +1,6 @@
1
1
  # 工具参考
2
2
 
3
- 全部 34 个 `browser_*` 工具。守卫列:✅ 表示该动作受 `browser_restrict` 白名单约束;只读工具永不拦截。
3
+ 全部 34 个 `browser_*` 工具。守卫列:✅ 表示该动作受 `browser_restrict` 白名单约束;**「– 豁免」表示无论白名单怎么写都不拦截**(豁免集合为 `READ_ONLY_TOOLS`,`src/tool-browser/index.ts` 导出:**只观察的工具** —— `snapshot`/`a11y`/`content`/`scrape`/`screenshot`/`get_value`/`wait`/`challenge`/`list_tabs`/`session`/`history`/`visited` —— 加上 **解除限制 / 从坏状态恢复的工具** `restrict`/`reset_session`/`reset`;`restrict` 在其中,否则限制到空的任务再也解不开);**「–」表示该工具只观察、不做动作**,故没有可拦的东西。两者之外的工具都受白名单约束 —— 例如 `browser_close_tab`、`browser_auth` 都不属于豁免集合(`browser_auth` 的 `restore` 会向任意域写 cookie,是动作,不是观察)。
4
4
 
5
5
  ## 页面与导航
6
6
 
@@ -8,16 +8,16 @@
8
8
  | --- | --- | --- | --- | --- |
9
9
  | `browser_open` | `url`(必填), `newTab?` | 快照(url/title/elements/truncated/challenge) | ✅ | 打开 URL,返回带编号元素的快照;`newTab: true` 在新标签打开 |
10
10
  | `browser_wait` | `timeoutMs?`, `url?`, `selector?` | `{ ready, reason }` | – | 等待页面加载完成(可选期望 URL / CSS 选择器);未就绪不抛错,返回原因 |
11
- | `browser_snapshot` | – | 快照 | – | 交互元素(输入框/按钮/链接)编号清单,供定位与点击;穿透同源 iframe 与 Shadow DOM,iframe 内元素标注 `frame` |
12
- | `browser_a11y` | `includeHidden?`, `maxNodes?`(10-5000,默认 500) | `{ url, title?, count, nodes[], truncated }` | – | 无障碍树:每个交互节点的 `role`/`name`/`value`/`states`/`depth`/`tag`/坐标;优先 Chrome `computedRole`/`computedName`,穿透同源 iframe 与 Shadow DOM;坐标可直接喂 `browser_click`/`browser_type` |
13
- | `browser_content` | `format`(html/markdown/txt/json,必填), `selector?`, `maxChars?`, `timeoutMs?` | `{ content, truncated }` | – | 抓取页面内容;`selector` 限定区域 |
11
+ | `browser_snapshot` | – | 快照 | – | 交互元素(输入框/按钮/链接)编号清单,供定位与点击;穿透同源 iframe 与 Shadow DOM,iframe 内元素标注 `frame`。元素有 `id` 或 `name` 时,每行末尾附**可直接引用**的 `{#id}` / `{[name=x]}`(推导不出来时该段不出现),可原样作为 `browser_click`/`browser_type` 的 `target {by:"css", value:"…"}` |
12
+ | `browser_a11y` | `includeHidden?`, `maxNodes?`(10-5000,**默认 150**), `coords?` | `{ url, title?, count, nodes[], truncated }` | – | 无障碍树:每个交互节点的 `role`/`name`/`value`/`states`/`depth`/`tag`/`selector`,优先 Chrome `computedRole`/`computedName`,穿透同源 iframe 与 Shadow DOM。**默认不返回坐标**(需要像素落点时传 `coords: true`;要按文本驱动目标,用节点的 `name` 配 `browser_click` 的 `target {by:"text"}`)。`states` **省略字面量 `enabled`(`unchecked`、`collapsed` 等仍会打印)** —— 没有 `states=` 就是 enabled;缩进为一层一个空格 |
13
+ | `browser_content` | `format`(html/markdown/txt/json,必填), `selector?`, `maxChars?`, `timeoutMs?` | `{ content, truncated }` | – | 抓取页面内容;`selector` 限定区域。**字符上限按格式**(默认):`html`/`json` **50 000**,`txt`/`markdown` **20 000**,单次调用用 `maxChars` 覆盖;被截断时结果末尾提示如何收窄。`json` 返回 `{"html": "<元素 outerHTML>", "tag": "<标签名>"}` —— 不是空对象(DOM 节点自身没有可枚举属性,`JSON.stringify` 一个元素只会得到 `{}`) |
14
14
  | `browser_challenge` | – | `{ blocked, kind?, reason?, hint? }` | – | 检测人机验证(CAPTCHA/Cloudflare/reCAPTCHA/hCaptcha/Turnstile);阻塞时请用户处理 |
15
15
 
16
16
  ## 页面操作
17
17
 
18
18
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
19
19
  | --- | --- | --- | --- | --- |
20
- | `browser_execute` | `script`(必填), `args?` | `{ ok, value? / exception? }` | ✅ | 在页面执行 JS;脚本以 `return` 开头或作为表达式;`args` 以 `arguments[0..n]` 传入 |
20
+ | `browser_execute` | `script`(必填), `args?` | `{ ok, value? / exception? }` | ✅ | 在页面执行 JS;脚本以 `return` 开头或作为表达式;`args` 以 `arguments[0..n]` 传入。**返回值上限 50 000 字符**,超出时结果里写明"截断于 50 000 / 实际 N 字符"(要完整内容请让表达式返回更小的值)。受设置「Agent 能做什么 → 允许在页面里执行脚本」门控,关闭后一律被拒(`BROWSER_EXECUTE_DISABLED`),且没有任何工具能把它打开 |
21
21
  | `browser_click` | `target?`(css/text/xpath), `x?`, `y?`(target 与坐标二选一) | `{ clicked }` | ✅ | 语义目标点击:按 `target` 定位、滚动到视口中央再点中心;或视口坐标点击(配合截图做视觉定位) |
22
22
  | `browser_type` | `text`(必填), `target?` | `{ typed }` | ✅ | 输入文本;传 `target` 先聚焦该元素(CDP `Input.insertText`) |
23
23
  | `browser_key` | `key`(必填,枚举) | `{ pressed }` | ✅ | 按命名按键:Enter/Tab/Escape/Backspace/Delete/方向键/Home/End/PageUp/PageDown/Space |
@@ -30,41 +30,41 @@
30
30
  | `browser_check` | `target`(必填), `checked?` | `{ checked }` | ✅ | 勾选/取消勾选 checkbox 或 radio(按 target 定位) |
31
31
  | `browser_select` | `target`(必填), `optionValue?`/`optionText?`/`optionIndex?`(三选一) | `{ value, text }` | ✅ | 选中 `<select>` 的某个选项(按 target 定位) |
32
32
  | `browser_clear` | `target`(必填) | `{ cleared }` | ✅ | 清空输入/文本域/contenteditable,或取消勾选 checkbox/radio |
33
- | `browser_get_value` | `target`(必填) | `{ value?, checked?, selectedText? }` | – | 读取元素当前值,用于操作后验证 |
34
- | `browser_scrape` | `item`(必填), `fields`(必填,映射), `timeoutMs?` | `{ count, items[] }` | – | 结构化提取:容器选择器 + 字段映射(如 `{"title": "h3", "url": "a@href"}`);静态 CSS 查询、不执行任意代码、CSP 安全;等待 item 出现(默认 5s) |
33
+ | `browser_get_value` | `target`(必填) | `{ value?, checked?, selectedText? }` | – 豁免 | 读取元素当前值,用于操作后验证 |
34
+ | `browser_scrape` | `item`(必填), `fields`(必填,映射), `timeoutMs?` | `{ count, items[] }` | – 豁免 | 结构化提取:容器选择器 + 字段映射(如 `{"title": "h3", "url": "a@href"}`);静态 CSS 查询、不执行任意代码、CSP 安全;等待 item 出现(默认 5s)。**结果是最紧凑的 JSON**(不缩进),条目数在最前面 |
35
35
 
36
36
  ## 标签与会话
37
37
 
38
38
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
39
39
  | --- | --- | --- | --- | --- |
40
- | `browser_list_tabs` | – | `{ session, tabs[] }` | – | 当前会话的标签列表 |
40
+ | `browser_list_tabs` | – | `{ tabs: [{ id, url, active }] }` | – | 当前会话的标签列表(输出 schema 只有 `tabs`,**没有 `session` 字段**;会话标识请用 `browser_session`) |
41
41
  | `browser_switch_tab` | `tabId`(必填) | `{ switched }` | ✅ | 按 id 切换标签;自托管下同步切换可见视图 |
42
- | `browser_close_tab` | `tabId`(必填) | `{ closed }` | – | 关闭标签;关闭活动标签后激活下一个 |
43
- | `browser_reset` | – | `{ reset }` | ✅ | 关闭本任务所有标签,回到一个空白标签 |
42
+ | `browser_close_tab` | `tabId`(必填) | `{ closed }` | ✅ | 关闭标签;关闭活动标签后激活下一个。**不属于只读豁免集合,受白名单约束**;`tabId` 只在本会话内查找(接受 `tab:<uuid>` 或裸 uuid),陈旧 id 不会关掉别的任务的标签页 |
43
+ | `browser_reset` | – | `{ reset }` | – 豁免 | 关闭本任务所有标签,回到一个空白标签(列入 `READ_ONLY_TOOLS`:限制到空的任务也必须能复位,否则白名单成了陷阱) |
44
44
  | `browser_session` | – | `{ session, tabs[] }` | – | 查看本任务的浏览器会话与标签 |
45
- | `browser_reset_session` | – | `{ reset }` | ✅ | 关闭并重建本任务的浏览器会话(崩溃/卡死后恢复) |
45
+ | `browser_reset_session` | – | `{ reset }` | – 豁免 | 关闭并重建本任务的浏览器会话(崩溃/卡死后恢复;同上,永远可用) |
46
46
 
47
47
  ## 历史与下载
48
48
 
49
49
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
50
50
  | --- | --- | --- | --- | --- |
51
- | `browser_history` | – | `{ entries[] }` | – | 操作日志(最新在后),含 seq/action/ok/params/result/error |
52
- | `browser_visited` | `limit`(默认 30,上限 200), `domain`(主机名包含匹配) | `{ count, entries[] }` | – | **持久化浏览历史**(访问过的页面,最新在前):与 cookie 同址落盘(`$DSH_HOME/dsh-builtin-browser-host/history.jsonl`),关闭浏览器与重启 DSH 后仍在;上限 5000 条或 90 天。重开某条用 `browser_open`;与 `browser_history`(会话内操作日志,随会话消失)是两件事;可在设置里关闭记录 |
51
+ | `browser_history` | `verbose?` | `{ entries[] }` | – | 操作日志(最新在后),含 seq/action/ok/params/result/error。**默认只渲染最近 20 条,且不显示每条的参数**(参数就是你刚发出去的),首行先给总条数,免得把尾部当成全部;要看参数时传 `verbose: true`。返回的完整 `entries[]` 不受条数限制 |
52
+ | `browser_visited` | `limit`(默认 30,上限 200), `domain`(主机名包含匹配), `query`(URL 或标题包含匹配), `session`(按来源会话标签过滤) | `{ count, entries[] }` | – | **持久化浏览历史**(访问过的页面,最新在前):与 cookie 同址落盘(`$DSH_HOME/dsh-builtin-browser-host/history.jsonl`),关闭浏览器与重启 DSH 后仍在;上限 5000 条或 90 天。重开某条用 `browser_open`;与 `browser_history`(会话内操作日志,随会话消失)是两件事;可在设置里关闭记录。渲染时**时间戳精确到分钟**,会话标记**只在切换时打印一次并取 8 位前缀** |
53
53
  | `browser_replay` | `seq`(必填) | `{ replayed }` | ✅ | 按序号回放某一步(navigate/execute/click/type/scroll/key) |
54
- | `browser_download` | `url`(必填), `savePath`(必填) | `{ path }` | ✅ | 带会话 cookie 下载到本地(仅 http(s);`savePath` 必须为绝对路径且位于 `downloadDir` 内——默认系统下载目录,自动识别 `Downloads`/`下载`/`下載` 与 `XDG_DOWNLOAD_DIR`;不覆盖已有文件;上限 256MB,受 CORS 约束;由子进程直接落盘) |
54
+ | `browser_download` | `url`(必填), `savePath`(必填) | `{ path }` | ✅ | 带会话 cookie 下载到本地(仅 http(s);`savePath` 必须为绝对路径且位于 `downloadDir` 内——默认系统下载目录,自动识别 `Downloads`/`下载`/`下載` 与 `XDG_DOWNLOAD_DIR`;不覆盖已有文件;上限 256MB,受 CORS 约束;由子进程直接落盘)。受设置「允许下载文件到磁盘」门控(`BROWSER_DOWNLOAD_DISABLED`) |
55
55
 
56
56
  ## 登录态与安全
57
57
 
58
58
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
59
59
  | --- | --- | --- | --- | --- |
60
- | `browser_auth` | `action`(flush/restore,必填), `cookies?` | `{ cookies[]? / restored? }` | ✅ | 导出/恢复 cookie(自托管可用);flush 返回 cookie 列表,restore 带列表写回 |
61
- | `browser_restrict` | `allowed?` | `{ restrictedTo[] }` | – | 设置动作白名单;空列表解除;未知工具名报错。**软护栏**——模型可自行解除,非安全边界 |
60
+ | `browser_auth` | `action`(flush/restore,必填), `cookies?` | `{ cookies[]? / restored? }` | ✅ | 导出/恢复 cookie —— **三种载体都可用**:自托管走原生会话,桌面侧栏与本机 Chrome/Edge 走 CDP 的 `Network.getCookies` / `Storage.setCookies`;flush 返回 cookie 列表,restore 带列表写回。**flush 只导出当前页所属站点的 cookie**(不是整台机器的共享 cookie 罐),范围按本会话所在页面的 URL 收敛;**范围未知时直接拒绝而不是放宽**(自托管侧栏载体报"cookie 范围未知",CDP 载体报 `BROWSER_AUTH_SCOPE_UNKNOWN`)。`restored` 反映真的提交了多少(无法构成有效 URL 的 cookie 会被丢弃并报出条数)。**受白名单约束**:`restore` 会向任意域写 cookie,是动作而非观察,因此该工具**不在只读豁免集合**里。设置里「允许读取 cookies / 导出登录状态」关闭时,flush 与 restore **都**抛 `BROWSER_AUTH_DISABLED`。导出受设置「凭据」门控(`BROWSER_AUTH_DISABLED`),写入受「允许写入登录状态」门控(`BROWSER_AUTH_WRITE_DISABLED`)—— 两者独立 |
61
+ | `browser_restrict` | `allowed?` | `{ restrictedTo[] }` | – | 设置动作白名单;空列表解除;**只校验名字是否以 `browser_` 开头**(写错的前缀会报错,`browser_typo` 这种拼错但前缀合法的名字会被接受、等同于拦掉该名字,不额外报错);守卫按名字匹配,因此白名单里列什么名字就只放行什么名字。**软护栏**——模型可自行解除,非安全边界 |
62
62
 
63
63
  ## 截图
64
64
 
65
65
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
66
66
  | --- | --- | --- | --- | --- |
67
- | `browser_screenshot` | `fullPage?`, `savePath?`, `format?`(png/jpeg), `quality?`, `maxWidth?`, `maxHeight?` | `{ dataUrl, path? }` | – | 截图;PNG 默认,JPEG 仅自托管原生路径;`maxWidth`/`maxHeight` 等比缩放降低视觉模型开销;`savePath` 落盘供视觉模型读取(与 `browser_download` 同一准入门:必须位于 `downloadDir` 内且不覆盖已有文件) |
67
+ | `browser_screenshot` | `fullPage?`, `savePath?`, `format?`(png/jpeg), `quality?`, `maxWidth?`, `maxHeight?` | `{ dataUrl, path? }` | – | 截图;PNG 默认,**JPEG 在自托管与本机 Chrome/Edge 上都可用**(自托管在原生 `capturePage` 路径编码,本机浏览器经 CDP 声明 `supportsCdpJpeg`;**桌面侧栏**的 Electron CDP JPEG 编码器会挂起,该载体下 JPEG 请求返回 PNG);`maxWidth`/`maxHeight` 等比缩放**三种载体都支持**;`fullPage` 会跳过原生 `capturePage`、三种载体统一走 CDP `captureBeyondViewport`;`savePath` 落盘供视觉模型读取(与 `browser_download` 同一准入门:必须位于 `downloadDir` 内且不覆盖已有文件) |
68
68
 
69
69
  ## 常用组合
70
70
 
@@ -17,10 +17,11 @@ dsh plugin --profile web add dsh-builtin-browser
17
17
  dsh plugin --profile web add <本仓库路径>
18
18
  ```
19
19
 
20
- 安装会链接插件、把 `dsh-builtin-browser` 加入 profile 的 bundle 层,并挂载三行:
20
+ 安装会链接插件、把 `dsh-builtin-browser` 加入 profile 的 bundle 层,并挂载四行(一个惰性的根行 + 三个功能行):
21
21
 
22
22
  | 行 | 子路径 | 角色 |
23
23
  | --- | --- | --- |
24
+ | `dsh-builtin-browser` | 包名本身 | **惰性根行**:不注册任何东西(空 `apply()`),只用来声明包名 —— 宿主的客户端插件扫描靠精确包名读到 `dsh.client`,少了这行设置栏不会出现 |
24
25
  | `browser` | `dsh-builtin-browser/browser` | `ctx.browser` 能力 seam(始终挂载) |
25
26
  | `browser-electron` | `dsh-builtin-browser/browser-electron` | Electron CDP provider |
26
27
  | `tool-browser` | `dsh-builtin-browser/tool-browser` | `browser_*` 模型侧工具 |
@@ -31,10 +32,10 @@ dsh plugin --profile web add <本仓库路径>
31
32
 
32
33
  | 行 | 配置项 | 类型 | 默认 | 说明 |
33
34
  | --- | --- | --- | --- | --- |
34
- | `browser-electron` | `viewHost` | 对象 | 必填 | 宿主提供的 `ElectronBrowserViewHost`(通常 `!!js ctx.get('electronViewHost')`) |
35
+ | `browser-electron` | `viewHost` | 对象 | 可选 | 宿主提供的 `ElectronBrowserViewHost`(通常 `!!js ctx.get('electronViewHost')`)。**不传时插件自己选载体**:桌面端驱动官方侧栏、否则自托管;设置里显式选择的 Chrome/Edge 优先于两者 |
35
36
  | `browser-electron` | `httpOnly` | 布尔 | `true` | 仅允许 HTTP(S) 导航;`file:`/`data:` 等拒绝 |
36
37
  | `browser-electron` | `snapshotMaxElements` | 数字 | `60` | 快照最多收录的交互元素数 |
37
- | `browser-electron` | `contentMaxChars` | 数字 | `100000` | 内容抓取默认字符上限 |
38
+ | `browser-electron` | `contentMaxChars` | 数字 | **不再被读取(已废弃)** | 曾经的"内容抓取默认字符上限"。**现在没有任何代码读它,改它不改变任何行为** —— 上限按格式固定:html 50 000、json 50 000、txt 20 000、markdown 20 000,单次调用用 `maxChars` 覆盖。保留该键只为不让既有配置因未知键而报错 |
38
39
  | `browser-electron` | `downloadDir` | 字符串 | 系统下载目录(自动识别 `Downloads`/`下载`/`下載`,或 `XDG_DOWNLOAD_DIR`) | 限定 `browser_download` 与 `browser_screenshot` 保存路径必须位于该目录内且不覆盖已有文件;默认收敛到系统下载目录,可改沙箱目录 |
39
40
  | `tool-browser` | `timeoutMs` | 数字 | `60000` | 工具协作超时(ms) |
40
41
  | `tool-browser` | `tabTools` | 布尔 | `true` | 是否注册标签管理工具 |
@@ -66,7 +67,7 @@ dsh plugin --profile web add <本仓库路径>
66
67
  - `browser_session` 查看本任务的会话与标签;
67
68
  - `browser_reset_session` 关闭并重建本任务的会话(崩溃或卡死后用它恢复)。
68
69
 
69
- 登录态(cookie)为所有任务共享;可用 `browser_auth` 导出/恢复,重启后不丢。
70
+ 登录态(cookie)为所有任务共享;可用 `browser_auth` 导出/恢复,重启后不丢。注意 **`flush` 只导出当前页所属站点的 cookie**(按本会话所在页面的 URL 收敛),页面 URL 未知时**直接拒绝**而不是扩大范围去读整台机器的 cookie 罐;`restore` 会向任意域写 cookie,所以 `browser_auth` **同样受 `browser_restrict` 白名单约束**,不属于只读豁免。
70
71
 
71
72
  ## FAQ
72
73
 
@@ -84,12 +85,20 @@ DSH Desktop 上②命中即可用(0.1.18+ 插件自带 electron 包;44+ 二进
84
85
  **Q:浏览器窗口不见了?**
85
86
  窗口标题为 `dsh-browser`(自托管)。若子进程崩溃(或宿主 DSH 重启)会自动重启;崩溃前已打开的会话在**下一次调用时自动重建**——仅页面状态丢失,无需手动 `browser_reset_session`。`browser_reset_session` 仍可用于主动重置。
86
87
 
88
+ **Q:设置里的 `contentMaxChars` 改了没反应?**
89
+ 因为它**已经没有任何代码在读** —— 那是内容上限改为按格式固定之后的遗留键(html/json 50 000,txt/markdown 20 000)。上限请用单次调用的 `maxChars` 覆盖。
90
+
91
+ **Q:本机 Chrome / Edge 被关掉之后还能继续用吗?**
92
+ 能。该载体观察进程退出并清掉**已失效的会话映射**,下一次调用会**重新拉起**浏览器并继续工作;重新拉起前会先 kill 旧进程并**最多等 3 秒**它真正退出,且**只认本次启动写下的 `DevToolsActivePort`**(被杀掉的浏览器没机会清理那个文件,照读会连到别的 Chromium 或 `node --inspect` 上)。反过来,页面只是**慢**不会让插件杀掉你的窗口:命令超时只把连接标记为存疑,下一次调用先用 `Browser.getVersion` 探一次再决定是否丢弃,只有 socket 已关闭才重启。
93
+
87
94
  **Q:下载报 CORS 错误?**
88
95
  `browser_download` 在页面上下文内 `fetch`,受同源/CORS 约束;跨域文件请先在同源页面内操作,或直接请求用户提供。仅支持 HTTP(S) URL;`savePath` 必须为绝对路径且位于 `downloadDir` 内(默认系统下载目录,自动识别 `Downloads`/`下载`/`下載`),不覆盖已有文件;`browser_screenshot` 的 `savePath` 受同一套限制。
89
96
 
90
97
  **Q:如何禁止 agent 乱点?**
91
98
  `browser_restrict` 设置白名单(如只允许 `browser_snapshot`/`browser_content`);传空列表解除。注意它是防误操作的**软护栏**,模型可自行解除,不是安全边界。
92
99
 
100
+ 要一个**模型自己解不掉**的限制,用设置页「Agent 能做什么」的三个开关(执行页面脚本 / 下载文件到磁盘 / 写入登录状态):它们写在设置文档里,而没有任何工具能写那个文档,所以模型只能被拒 —— 这与 `browser_restrict` 的区别不是"谁更强",而是"谁能改"。部署级(对所有人生效)还可以用 `tool-browser` 的 `allowedActions` 配置,见 `cordis.patch.yml` 的注释。
101
+
93
102
  ## 故障排查
94
103
 
95
104
  | 现象 | 可能原因 | 处理 |
@@ -32,8 +32,8 @@ LLM agent 需要"上网"。传统做法是给 agent 一个**无头浏览器**(Pl
32
32
 
33
33
  ## 设计取舍
34
34
 
35
- - **登录态共享**:所有任务共享同一份 cookie(符合"用户登录一次,agent 到处可用");需要隔离时用 `browser_auth` 手动导出/恢复。
36
- - **任务级会话隔离**:每个 DSH 会话(任务)拥有独立的浏览器会话(标签页与历史),并发任务互不抢页面;但窗口只有一个,当前活动的任务视图可见。
35
+ - **登录态共享**:所有任务共享同一份 cookie(符合"用户登录一次,agent 到处可用");需要搬运时用 `browser_auth` 手动导出/恢复 —— 它的 `flush` **只导出当前页所属站点的 cookie**(按本会话所在页面的 URL 收敛,页面 URL 未知时拒绝而不是放宽),所以"导出"是逐站点的一步,不是一次性打包整个 cookie 罐。
36
+ - **任务级会话隔离**:每个 DSH 会话(任务)拥有独立的浏览器会话(标签页与历史),并发任务互不抢页面;自托管载体下**每个会话连窗口都各自一份**(窗口按会话 id 分组,窗口标题带任务标识),侧栏与本机浏览器载体则是同一个宿主里切换当前可见视图。
37
37
  - **装好即用**:不依赖桌面外壳。有外壳时嵌入外壳视图;纯 `dsh web` 时插件自托管——自己拉起 Electron 窗口,通过本机 TCP JSON-RPC 驱动。
38
38
  - **边界清晰**:可见视图、浏览器列布局属于宿主外壳;插件只负责 seam、provider 与工具,不含任何 UI。
39
39
  - **人机验证不硬刚**:检测到 Cloudflare/reCAPTCHA/hCaptcha/Turnstile 时停下,请用户在共享窗口人工完成,而不是盲目重试。
@@ -240,6 +240,14 @@ export interface BrowserWaitResult {
240
240
  export interface BrowserScreenshotResult {
241
241
  /** Base64 data URL of the capture (PNG or JPEG per the request). */
242
242
  readonly dataUrl: string;
243
+ /** Pixel size of the image in `dataUrl`, when the capture was measured and scaled.
244
+ *
245
+ * Present when the request carried `maxWidth`/`maxHeight` and the layout metrics were
246
+ * readable: the scaling happens inside CDP through a clip, and without this the only way to
247
+ * learn what came back was to decode the data URL. Undefined means "not measured", which is
248
+ * also what an unscaled capture reports. */
249
+ readonly width?: number;
250
+ readonly height?: number;
243
251
  /** File path the capture was also saved to, when the request asked for it. */
244
252
  readonly path?: string;
245
253
  }
@@ -366,6 +374,14 @@ export interface BrowserA11yNode {
366
374
  readonly depth: number;
367
375
  /** Element tag name (lowercase). */
368
376
  readonly tag: string;
377
+ /**
378
+ * A CSS selector for this node, when one can be derived from its id or name.
379
+ *
380
+ * Empty for the many elements that have neither. Present because a role and a name are not
381
+ * always enough to target a node: `{by: 'text'}` matches an accessible name only loosely,
382
+ * and this is what lets a caller point at the exact element it was shown.
383
+ */
384
+ readonly selector: string;
369
385
  /** Viewport-relative center, for coordinate fallbacks. */
370
386
  readonly x: number;
371
387
  readonly y: number;
@@ -460,7 +476,15 @@ export interface BrowserProvider {
460
476
  /**
461
477
  * Open a new session. The provider mints the session id and prepares its
462
478
  * backing surface (a view, a headless page, and so on). Sessions are isolated from
463
- * each other; a session must not be visible to any other session's caller.
479
+ * each other in what they OWN — tabs, the active tab, history, the view — so one
480
+ * session must not be visible to any other session's caller.
481
+ *
482
+ * That isolation stops at the browser's own state, and is not meant to go further:
483
+ * cookies, logins and site storage are deliberately shared by every session, because
484
+ * every session drives the same browser and "sign in once, use it everywhere" is the
485
+ * point of using a real one. Reads that hand that state to a caller (see
486
+ * `flushAuth`) are scoped to the page in question, which is least exposure rather
487
+ * than a claim that the jar is per-session.
464
488
  * @param label - optional human-readable label (e.g. the calling DSH task
465
489
  * id) the provider may surface on the visible surface (window title) so a
466
490
  * human can tell which task's page is currently shown.
@@ -27,22 +27,44 @@ export interface BridgeEndpoint {
27
27
  /**
28
28
  * A reusable connection to the bridge.
29
29
  *
30
- * Requests are serialised by construction: one socket, one in-flight request at a
31
- * time, answered in order. A broken socket is discarded so the next call dials
32
- * again — a dead connection must never become a dead plugin.
30
+ * Requests may be in flight CONCURRENTLY, so answers are correlated by id rather than by
31
+ * position: `call()` stamps a unique {@link REQUEST_ID_FIELD} on the request and `onData()`
32
+ * resolves the entry that carries the echoed id. A bridge that does not echo it is still
33
+ * supported — its answer is handed to the oldest outstanding request, which is the
34
+ * arrival-order rule the protocol states. Once such an answer has been seen, the connection
35
+ * stops keeping two requests in flight (a second one re-dials), so position can never be the
36
+ * only thing distinguishing two callers' answers. An answer that matches nothing is never
37
+ * handed to whichever request happens to be first: the socket is dropped instead.
38
+ *
39
+ * A broken socket is discarded so the next call dials again — a dead connection must never
40
+ * become a dead plugin.
33
41
  */
34
42
  export declare class BridgeConnection {
35
43
  private readonly endpoint;
36
44
  private socket;
37
45
  private buffer;
46
+ /** Outstanding requests by id. */
47
+ private readonly pending;
48
+ /** Ids of the requests sent on the current socket, oldest first. */
38
49
  private readonly queue;
50
+ private nextId;
51
+ /** True once any reply arrived without an id: this bridge does not echo request ids. */
52
+ private idLessBridge;
53
+ /** True once this connection may only have one request in flight at a time. */
54
+ private serialisable;
39
55
  /**
40
56
  * @param endpoint - the shell's published bridge endpoint.
41
57
  */
42
58
  constructor(endpoint: BridgeEndpoint);
43
59
  /**
44
60
  * Send one request, reusing the socket if it is still healthy.
45
- * @param request - the request body (the token is added here).
61
+ *
62
+ * Requests may overlap: the request is written as soon as it is queued, so two concurrent
63
+ * callers have two requests in flight. Each carries a unique {@link REQUEST_ID_FIELD}, and
64
+ * `onData()` resolves the entry that carries the echoed id — which is what makes
65
+ * correlation independent of arrival order. A bridge that does not echo the field (the
66
+ * shipped shell today) still works by arrival order; see {@link onData}.
67
+ * @param request - the request body (the token and the request id are added here).
46
68
  * @param timeoutMs - how long to wait for the answer before dropping the socket.
47
69
  * @returns the bridge's answer.
48
70
  */
@@ -51,8 +73,12 @@ export declare class BridgeConnection {
51
73
  close(): void;
52
74
  /** The live socket, connecting and authenticating it on first use. */
53
75
  private ensureSocket;
54
- /** Resolve queued requests in order as answers arrive. */
76
+ /** Tear the connection down for an event raised by `source`, unless it is already gone. */
77
+ private socketFailed;
78
+ /** Resolve queued requests by id, or in order when the bridge does not echo one. */
55
79
  private onData;
80
+ /** Forget one request (its answer can no longer be matched). */
81
+ private forget;
56
82
  /** Tear the socket down, failing pending work. */
57
83
  private reset;
58
84
  }
@@ -18,18 +18,48 @@
18
18
  * @module dsh-browser/browser-electron/bridge-connection
19
19
  */
20
20
  import { connect } from 'node:net';
21
+ /**
22
+ * Safety cap on one un-answered reply line. CDP answers from the sidebar (a screenshot's
23
+ * base64 is the big one) cross this socket, so the cap is generous; what it exists for is a
24
+ * peer that never sends a newline — `this.buffer += chunk` grew without bound, unlike the
25
+ * same buffer in remote-host.ts, which caps at 512 MiB.
26
+ */
27
+ const MAX_BUFFER_BYTES = 128 * 1024 * 1024;
28
+ /**
29
+ * Field this client adds to every request so the shell can echo it back and never relies on
30
+ * byte order to pair an answer with its request. Loosely named, and additive on purpose: a
31
+ * bridge that does not know it simply ignores it (the current shipped shell does), and the
32
+ * client then falls back to arrival order, which is what the protocol guaranteed before.
33
+ */
34
+ const REQUEST_ID_FIELD = 'bridgeRequestId';
21
35
  /**
22
36
  * A reusable connection to the bridge.
23
37
  *
24
- * Requests are serialised by construction: one socket, one in-flight request at a
25
- * time, answered in order. A broken socket is discarded so the next call dials
26
- * again — a dead connection must never become a dead plugin.
38
+ * Requests may be in flight CONCURRENTLY, so answers are correlated by id rather than by
39
+ * position: `call()` stamps a unique {@link REQUEST_ID_FIELD} on the request and `onData()`
40
+ * resolves the entry that carries the echoed id. A bridge that does not echo it is still
41
+ * supported — its answer is handed to the oldest outstanding request, which is the
42
+ * arrival-order rule the protocol states. Once such an answer has been seen, the connection
43
+ * stops keeping two requests in flight (a second one re-dials), so position can never be the
44
+ * only thing distinguishing two callers' answers. An answer that matches nothing is never
45
+ * handed to whichever request happens to be first: the socket is dropped instead.
46
+ *
47
+ * A broken socket is discarded so the next call dials again — a dead connection must never
48
+ * become a dead plugin.
27
49
  */
28
50
  export class BridgeConnection {
29
51
  endpoint;
30
52
  socket;
31
53
  buffer = '';
54
+ /** Outstanding requests by id. */
55
+ pending = new Map();
56
+ /** Ids of the requests sent on the current socket, oldest first. */
32
57
  queue = [];
58
+ nextId = 1;
59
+ /** True once any reply arrived without an id: this bridge does not echo request ids. */
60
+ idLessBridge = false;
61
+ /** True once this connection may only have one request in flight at a time. */
62
+ serialisable = false;
33
63
  /**
34
64
  * @param endpoint - the shell's published bridge endpoint.
35
65
  */
@@ -38,33 +68,67 @@ export class BridgeConnection {
38
68
  }
39
69
  /**
40
70
  * Send one request, reusing the socket if it is still healthy.
41
- * @param request - the request body (the token is added here).
71
+ *
72
+ * Requests may overlap: the request is written as soon as it is queued, so two concurrent
73
+ * callers have two requests in flight. Each carries a unique {@link REQUEST_ID_FIELD}, and
74
+ * `onData()` resolves the entry that carries the echoed id — which is what makes
75
+ * correlation independent of arrival order. A bridge that does not echo the field (the
76
+ * shipped shell today) still works by arrival order; see {@link onData}.
77
+ * @param request - the request body (the token and the request id are added here).
42
78
  * @param timeoutMs - how long to wait for the answer before dropping the socket.
43
79
  * @returns the bridge's answer.
44
80
  */
45
81
  async call(request, timeoutMs = 20_000) {
46
- const socket = this.ensureSocket();
82
+ // One re-dial, and only for the case this exists for: a socket that has already carried
83
+ // an id-less answer cannot have a second request in flight, because two outstanding
84
+ // requests would be matched by position — the swap this correlation prevents. It is a
85
+ // straight-line re-dial rather than a retry loop: an earlier revision re-entered call()
86
+ // from inside its own promise executor, which left an orphaned pending entry behind and,
87
+ // when the condition could not clear, spun until the process ran out of heap.
88
+ let socket = this.ensureSocket();
89
+ if (this.serialisable && this.queue.length > 0) {
90
+ this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'));
91
+ socket = this.ensureSocket();
92
+ }
93
+ const id = this.nextId++;
47
94
  return await new Promise((resolve, reject) => {
95
+ this.pending.set(id, {
96
+ resolve: answer => { clearTimeout(timer); resolve(answer); },
97
+ reject: error => { clearTimeout(timer); reject(error); },
98
+ });
99
+ this.queue.push(id);
48
100
  const timer = setTimeout(() => {
49
- const index = this.queue.findIndex(entry => entry.resolve === resolve);
50
- if (index !== -1)
51
- this.queue.splice(index, 1);
52
101
  const error = new Error(`dsh-builtin-browser: bridge timed out after ${timeoutMs}ms`);
53
- this.reset(error);
102
+ // An unanswered request desynchronises the stream for an id-less bridge, and leaves one
103
+ // outstanding for one that echoes ids — either way the next call should start on a
104
+ // fresh socket. Only when THIS request's socket is still the live one: tearing down the
105
+ // REPLACEMENT because an abandoned request timed out would break whatever is using it.
106
+ //
107
+ // This test used to read `this.pending.has(id) && this.socket === socket`, with
108
+ // `forget(id)` on the line above it — and forget() is what removes the entry that `has`
109
+ // was looking for, so the condition was false every single time and the rebuild never
110
+ // ran. A timed-out call therefore left the desynchronised socket in place, and every
111
+ // later call inherited exactly the ambiguity this reset exists to clear. Deciding
112
+ // before forgetting is the whole fix; the `else` still drops the stale entry.
113
+ if (this.socket === socket)
114
+ this.reset(error);
115
+ else
116
+ this.forget(id);
117
+ // reset() already rejected this entry through its wrapper when it ran, so this is
118
+ // either the only rejection or a no-op that guarantees the caller settles.
54
119
  reject(error);
55
120
  }, timeoutMs);
56
- this.queue.push({
57
- resolve: answer => { clearTimeout(timer); resolve(answer); },
58
- reject: error => { clearTimeout(timer); reject(error); },
59
- });
60
121
  try {
61
- socket.write(JSON.stringify({ token: this.endpoint.token, ...request }) + '\n');
122
+ // The id is added LAST so a request body can never shadow it, and the token is
123
+ // added here rather than by the caller.
124
+ socket.write(JSON.stringify({ token: this.endpoint.token, ...request, [REQUEST_ID_FIELD]: id }) + '\n');
62
125
  }
63
126
  catch (error) {
64
127
  clearTimeout(timer);
65
- this.queue.pop();
128
+ this.forget(id);
66
129
  const failure = error instanceof Error ? error : new Error(String(error));
67
- this.reset(failure);
130
+ if (this.socket === socket)
131
+ this.reset(failure);
68
132
  reject(failure);
69
133
  }
70
134
  });
@@ -80,16 +144,33 @@ export class BridgeConnection {
80
144
  const socket = connect({ host: '127.0.0.1', port: this.endpoint.port });
81
145
  socket.setEncoding('utf8');
82
146
  socket.on('data', chunk => this.onData(String(chunk)));
83
- socket.on('error', error => this.reset(error));
84
- socket.on('close', () => this.reset(new Error('dsh-builtin-browser: bridge connection closed')));
147
+ // Both handlers pass the socket they belong to, so a late event from a socket that was
148
+ // already replaced cannot tear down its successor: 'close' still fires for a socket that
149
+ // was removed mid-recycle, and acting on it there would kill a perfectly good connection.
150
+ socket.on('error', error => this.socketFailed(socket, error));
151
+ socket.on('close', () => this.socketFailed(socket, new Error('dsh-builtin-browser: bridge connection closed')));
85
152
  this.buffer = '';
86
153
  this.socket = socket;
87
154
  socket.write(JSON.stringify({ token: this.endpoint.token }) + '\n');
88
155
  return socket;
89
156
  }
90
- /** Resolve queued requests in order as answers arrive. */
157
+ /** Tear the connection down for an event raised by `source`, unless it is already gone. */
158
+ socketFailed(source, error) {
159
+ if (this.socket !== source)
160
+ return;
161
+ this.reset(error);
162
+ }
163
+ /** Resolve queued requests by id, or in order when the bridge does not echo one. */
91
164
  onData(chunk) {
92
165
  this.buffer += chunk;
166
+ // A peer that never sends a newline must not grow this buffer without bound. Failing
167
+ // the connection is the honest outcome: the caller gets an error naming the cap, while
168
+ // the next call dials a fresh socket.
169
+ if (this.buffer.length > MAX_BUFFER_BYTES) {
170
+ this.buffer = '';
171
+ this.reset(new Error(`dsh-builtin-browser: bridge reply exceeded ${String(MAX_BUFFER_BYTES)} bytes`));
172
+ return;
173
+ }
93
174
  let newline = this.buffer.indexOf('\n');
94
175
  while (newline !== -1) {
95
176
  const line = this.buffer.slice(0, newline).trim();
@@ -97,9 +178,28 @@ export class BridgeConnection {
97
178
  newline = this.buffer.indexOf('\n');
98
179
  if (line === '')
99
180
  continue;
100
- const entry = this.queue.shift();
181
+ const echoed = echoOf(line);
182
+ const id = echoed ?? this.queue[0];
183
+ if (id === undefined) {
184
+ // Nothing is waiting for this answer: the peer is answering something this socket
185
+ // never asked (or answered twice). Drop the socket rather than trust the stream.
186
+ this.reset(new Error('dsh-builtin-browser: bridge answered with no request outstanding'));
187
+ return;
188
+ }
189
+ const index = this.queue.indexOf(id);
190
+ if (index !== -1)
191
+ this.queue.splice(index, 1);
192
+ const entry = this.pending.get(id);
101
193
  if (entry === undefined)
102
194
  continue;
195
+ this.pending.delete(id);
196
+ // Id-less bridges pair by order, so a reply for the WRONG request cannot be told apart
197
+ // from the right one. Remember that this bridge does not echo ids, and serialise every
198
+ // later call so the ambiguity cannot arise again.
199
+ if (echoed === undefined) {
200
+ this.idLessBridge = true;
201
+ this.serialisable = true;
202
+ }
103
203
  try {
104
204
  const answer = JSON.parse(line);
105
205
  // A rejected token means the shell restarted with a new one, so this socket
@@ -117,6 +217,13 @@ export class BridgeConnection {
117
217
  }
118
218
  }
119
219
  }
220
+ /** Forget one request (its answer can no longer be matched). */
221
+ forget(id) {
222
+ this.pending.delete(id);
223
+ const index = this.queue.indexOf(id);
224
+ if (index !== -1)
225
+ this.queue.splice(index, 1);
226
+ }
120
227
  /** Tear the socket down, failing pending work. */
121
228
  reset(error) {
122
229
  if (this.socket !== undefined) {
@@ -126,8 +233,23 @@ export class BridgeConnection {
126
233
  socket.destroy();
127
234
  }
128
235
  this.buffer = '';
129
- const pending = this.queue.splice(0, this.queue.length);
236
+ this.queue.length = 0;
237
+ const pending = [...this.pending.values()];
238
+ this.pending.clear();
130
239
  for (const entry of pending)
131
240
  entry.reject(error);
132
241
  }
133
242
  }
243
+ /** The request id a raw answer line echoes back, when it carries one. */
244
+ function echoOf(line) {
245
+ try {
246
+ const answer = JSON.parse(line);
247
+ const value = answer[REQUEST_ID_FIELD];
248
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
249
+ }
250
+ catch {
251
+ // A malformed line cannot be matched by id; the caller consumes it in order and
252
+ // reports the parse failure itself.
253
+ return undefined;
254
+ }
255
+ }
@@ -34,7 +34,7 @@ export interface Config {
34
34
  readonly downloadDir?: string;
35
35
  /** Maximum snapshot elements before truncation. Default 60. */
36
36
  readonly snapshotMaxElements?: number;
37
- /** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
37
+ /** Maximum content characters before truncation when no maxChars is given. No longer read: the cap is per format (html and json 50 000, otherwise 20 000). */
38
38
  readonly contentMaxChars?: number;
39
39
  }
40
40
  export declare const Config: z<Config>;