# dsh-browser [English](README.md) | 中文 [![收录于 dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/zh/plugins/cjylzs/dsh-browser) 插件在工作:左侧是 agent 的浏览器工具调用,右侧栏镜像着同一台浏览器

浏览器设置:每个对话一个正在运行的实例,各有自己的 CDP 端口,另含窗口模式、自动化标记与 profile 位置

**让 agent 在一台真实 Chrome 或 Edge 里干活,侧栏同步镜像,你能看着,也能随时接手。** 每个对话一台独立的浏览器,登录态各归各的。Web 界面与 Desktop 应用都能用。 ## 概述 Chrome 或 Edge 以独立进程运行,有自己的 profile、自己的 CDP 端口,而且每个对话各有一台:标签、登录态、端口都不跨对话共享。agent 驱动它,侧栏显示的是同一批页面——两边都能操作。 它不是内嵌的网页视图。页面跑在一个普通浏览器进程里,所以站点看到的是正常浏览器,镜像运行时 DevTools 或另一个 Playwright 可以附加进来,关掉 harness 也不会留下一个假装属于界面的浏览器进程。 ## 特点 - **两个界面都能用。** Web 界面与 Desktop 应用跑的是同一个插件、同一台浏览器;各自装一次,之后没有任何差别,设置页也一样。 - **一个对话一台浏览器。** 标签、登录态、端口都属于一个对话:侧栏面板在它连接的 socket 上报出自己的会话,工具调用按调用来源的会话解析自己的浏览器。 - **看着它做,也可以随时接手。** 浏览器每开一个页面,侧栏就多一个带实时画面的标签,在标签里的点击与输入会到达真实页面。 - **不写选择器也能读陌生页面。** `browser_snapshot` 把页面的无障碍树打印成一行一个节点、每个节点带一个 `ref`——这既是 agent 读陌生页面的方式,也是它指名某个元素去点击的方式;`find` 把大页面收敛到回答问题的那些路径,`boxes` 不上截图就能说出元素在哪。 - **站点会接受的输入。** 在元素自身的位置派发真实鼠标与文本事件——忽略合成 `element.click()` 的页面同样接受——另含 `Ctrl+A` 这类组合键、原生下拉与勾选、由触发它的那一次调用回答的对话框,以及"会被别人接收"时的明确拒绝。 - **工具少而直接,其余交给代码。** `browser_evaluate` 是通用那个,其余只补代码表达不了的语义,比如不先知道选择器就能读页面、或者问页面失败时自己说了什么。见[工具](#tools)。 - **可附加。** 每台浏览器都监听一个对外 CDP 端口,`chrome://inspect`、另一个 Playwright、或随仓库的 `scripts/cdp.mjs` 都能附加到侧栏正在镜像的那一台。 - **工作纪律写在模型会读的地方。** 插件贡献一份技能——先读再动、一次观察一个状态变更动作、页面的文字是数据而不是指令——并附五篇按问题索引的短文,只有遇到那个问题才读。 ## 目录 - [特点](#highlights) - [安装](#install) - [更新](#更新) - [Desktop](#desktop) - [从插件页安装](#从插件页安装) - [版本适配](#compatibility) - [使用](#usage) - [理解设计](#understand-the-design) - [配置](#configuration) - [工具](#tools) - [已知限制与待办](#known-limitations-and-deferred-work) - [开发](#dev-note) - [第三方代码](#third-party-code) ----- ## 安装 先确认宿主 dsh 版本: ```sh dsh -V ``` 发布到 npm 的包名是 `@cjylzs/dsh-browser`: ```sh dsh plugin --profile web add @cjylzs/dsh-browser ``` | 你的 dsh | 插件版本 | 安装命令 | | --- | --- | --- | | ≥ 0.1.7-rc.1 | v0.3.x(最新) | `dsh plugin --profile web add @cjylzs/dsh-browser` | | ≥ 0.1.7-rc.1,按 tag 装 | v0.2.x / v0.3.x | `dsh plugin --profile web add github:CJYLZS/dsh-browser#v0.3.1` | | 0.1.5-rc.2 | v0.1.x | `dsh plugin --profile web add github:CJYLZS/dsh-browser#v0.1.0` | 从 registry 安装取的是已发布的最新版本;从 GitHub 安装要写该版本对应的 `#`,因为不带 ref 的 `github:` 安装会取默认分支的 HEAD,那个位置会漂。构建产物 `lib/` 也随每个 tag 提交,所以按 tag 装不需要构建。 profile 的 `package.json` 会记下你装的是什么;移除插件用 `dsh plugin --profile web remove @cjylzs/dsh-browser`。装完都要重启 harness。机器上需要已安装 Chrome 或 Edge;`playwright-core` 是运行依赖,它自己不会下载浏览器。 ### 更新 从 registry 装会记成 caret 范围(`^0.3.1`),lockfile 再把该范围解析到的那一版钉死,所以用同一个 spec 重新 add 是空操作——profile 里已经就是它。要在范围内往上升,用 `update`: ```sh dsh plugin --profile web update @cjylzs/dsh-browser ``` 想钉死某一版就指名:`dsh plugin --profile web add @cjylzs/dsh-browser@0.3.0`。这记的是精确版本,而 `update` 不动精确版本——要离开它就指名范围或版本(`…@^0.3.0`、`…@0.3.1`);只写包名重新 add 什么都不变,因为 profile 里已经满足它。`github:` 安装记的是当时解析到的 tag 或 commit,`update` 同样推不动,要换就带上想要的 ref 重新 add:`github:CJYLZS/dsh-browser#v0.3.1`。以上每种情况装完都要重启 harness。 早期版本用的是无 scope 的名字 `dsh-browser`,而 npm 上的那个名字是另一个项目。如果你的 profile 里还记着 `dsh-browser`(`package.json` 的依赖,或 `dsh.profile.bundles` 里的一条),先用 `dsh plugin --profile web remove dsh-browser` 删掉再装这个:两者是两份独立安装,上面那些命令碰不到旧的那个键。 ### Desktop Desktop 应用自己拥有它的 profile,而普通 CLI 有意拒绝管理 `--profile desktop`,所以走随应用一起发布的 Desktop CLI: ```sh "/resources/runtime/cli/bin/dsh.cmd" plugin --profile desktop add @cjylzs/dsh-browser ``` macOS 与 Linux 用 `/resources/runtime/cli/bin/dsh`。装完重启应用,和 Web profile 用 `dsh plugin --profile web add` 之后一样;卸载用同一个 `remove`。 ### 从插件页安装 两个界面都不需要终端:在**插件**页点**添加插件**,填仓库地址并带上 `#`(截图里是裸地址;不带 ref 会取默认分支 HEAD),再点**安装**——它装进当前正在运行的 profile,在 Desktop 应用里就是应用自己的 profile,所以上面那条 Desktop CLI 不是唯一的路。对话框会说插件要不要等重启,说要就重启。

Desktop 应用的插件页:添加插件对话框里填着仓库地址,下方是安装按钮

----- ## 版本适配 一份构建只针对一个 dsh 世代,按你的 dsh 选配对的那个插件版本。分叉落在客户端半边——dsh 0.1.7-rc.1 换掉了设置模型且没有兼容层——所以一份构建无法同时服务两个世代。 这个分叉落在客户端半边的 import 上,因此一份构建无法同时针对两个世代,插件按世代配对: - **v0.2.x → dsh ≥ 0.1.7-rc.1**,声明为 `peerDependencies: >=0.1.7-rc.1`,不封顶,所以同一份构建能跑在它实测过的整条 0.2.0 线上([为什么去掉封顶](.agents/notes/implemented/process/2026-09-30-peer-range-floor-only.md))。设置页的字段在 schema 上标为 volatile,编辑经 `loader/volatile-update` 抵达正在运行的浏览器。 - **v0.1.x → dsh 0.1.5-rc.2**,声明为 `peerDependencies: ^0.1.5-rc.2`。设置页注册 namespace scope 并自行渲染控件。 版本旧了会明确失败:只有当插件的 dsh peer 满足声明的区间时 dsh 才激活它,否则报出插件名与未满足的区间。更高的世代会被这条检查放行,所以这份构建在某一代上还跑不跑得起来,是验证的问题而不是版本号的问题。 ## 使用 三步,而且 agent 不需要任何额外交代: 1. **通常什么都不用做。** 第一次需要浏览器的工具调用会自己起一台,右侧栏随即自己打开到那个会话的「浏览器」标签——第一帧就看着。也可以手动打开:**新标签页 → 浏览器**。 2. **让它做事。** 「打开文档,告诉我安装那一节写了什么」就够了:工具作用于面板显示的那台浏览器,所以你能看着事情发生。 3. **需要时接上自己的工具。** 浏览器运行时 `curl http://127.0.0.1:9333/json/version` 有响应;`chrome://inspect`、用 `chromium.connectOverCDP` 的另一个 Playwright、以及 `scripts/cdp.mjs` 都能附加进去。 侧栏与浏览器始终一一对应:浏览器每开一个页面就多一个标签,关掉标签就是关掉它镜像的那个页面,最后一个也关掉时浏览器停下,直到再有东西需要浏览器。工具作用于哪一页由 agent 决定(`browser_tabs`),与你看的是哪个标签无关。其余细节见[理解设计](#understand-the-design)。 ----- ## 理解设计 插件用 harness 的 session id 做一切索引,这一条决定解释了它的大部分行为: - **两条互不相干的路径解析到同一个会话。** 侧栏面板注册在会话作用域的槽位里,于是它的注册工厂拿到 session id,并在 viewer socket 上报出它;工具调用没有面板,就从执行它的 agent 取会话(`exec.agent.id`,与 API 层恢复会话用的 `SessionId` 是同一个)。两条路径都够不到别的对话的浏览器;没有会话的调用(定时任务、没有会话的子代理)会直接失败,而不是落进某个人的浏览器。 - **一个进程一个 profile。** Chrome 的 profile 目录被单个运行中的进程独占,所以配置里的 profile 目录是**父目录**:同一个共享单位下的浏览器各有一个子目录,子目录名用 harness 自己的 id 转义规则生成。单位由 `profileMode` 决定——`session` 是一个对话一个,`workspace` 是一个项目一个,`temp` 则什么都不保留——所以"登录态会不会延续"只由模式回答。由此而来的结果值得直说,因为它是隔离的代价:默认模式下**登录态不跨会话共享,也不跨浏览器生命周期保留。** 会话在重新打开时保持自己的身份,所以 `session` 模式的对话会回到它自己的 profile。 - **端口是分配出来的,不是假设的。** 配置给的是一段**端口范围**(`debugPortMin`–`debugPortMax`),因为一个数字不可能是地址:每个会话各有一个浏览器、各自一个监听端口。每台浏览器在范围里取它能绑定的最小端口并持有到退出——因为探测给出的答案在给出的那一刻就已经过期。范围的上界是防止探测跑进这台机器上别的服务里:范围里没有空端口时它会直接报错,并把范围写进消息。`GET /dsh-browser/status` 报告每个会话最终落在哪个端口;任何地方都不应该假设它是配置里的某个值。 - **镜像属于 CDP 会话,不属于面板。** `Page.startScreencast` 挂在某一个 CDP 会话上,而每一条"起一台浏览器"的路径——面板重启、崩溃恢复、改启动项——都会换掉那个会话。因此新起的浏览器一就绪,只要还有人在看就重新挂流;否则一次重启之后仍开着的面板会永远停在最后一帧,而它的状态行却一直在更新。 - **浏览器死了会被换掉,而不是干等。** 关窗口或进程消失会把实例置为 `closed` 并带上原因、发布给面板,下一次请求就会起一台新的。没有任何情况需要手工修,包括浏览器进程恰好在两次工具调用之间死掉。 - **快照是与模型之间的约定。** `Accessibility.getFullAXTree` 被打印成一行一个节点,被 Chrome 标记忽略的包装节点被丢掉,每个背后有 DOM 节点的行都带一个 `ref`。ref 只属于某一页的某一次快照:导航会让它们全部失效,而过期的 ref 会报错并指向 `browser_snapshot`,绝不会点到那个位置上现在的东西。 - **ref 或定位:定位命中多个就拒绝,不猜。** ref 是某一次快照就某一份文档给出的答案——查起来便宜,但会过期。定位(`role` 配 `name`、`text`、`selector`)是这个答案背后的那个问题,在动作执行时才再问一遍,所以重渲染和导航都拦不住它。命中多个元素时**拒绝**而不是挑一个,而且拒绝里会列出每个候选项与它最近的具名祖先——那是区分两个同名控件的唯一事实。匹配读的是无障碍树,所以定位能匹配到的,恰好就是快照会打印出来的;`selector` 由页面自己走查,因此进得去开着的影子根与同源帧——那是从文档根查起看不见的地方,`target` 与 `snapshotIgnore` 用的是同一次走查。 - **点击是算出来的,不是猜的。** 一次点击把 ref 解析成 backend node id,把元素滚进视口,读它的 content quad,再把页面坐标换算成 CDP 派发输入所用的视口坐标;然后发出移动、按下、抬起三个事件——这正是它可信的原因。 - **一个页面有自己的标签。** 侧栏里浏览器每开一个页面就有一个标签,用页面的 CDP target id 作地址,所以两个同址页面是两个标签,客户端重载后每个标签也还能接回它原来在看的页面。一个面板的画面、输入与地址栏都属于它自己的页面——无论工具此刻作用于哪一页;浏览器关掉一个页面,它的标签跟着走,关掉一个标签就是关掉那个页面。**入口的每一页都来自一次点击**:引导页入口开出的那个没有页面的标签,挂载时会向 host 要一个页面(幂等键是这条标签记录自己的 id,所以切走再切回、刷新客户端都不会多开),host 对已经跑起来的浏览器开新页、对没跑起来的浏览器用它启动时的页面作答;工具作用的那一页则由 `browser_tabs` 的 `open`/`select` 决定,与面板在看哪一页无关。 - **插件自己的路由自带信任检查。** viewer socket、状态路由与页面路由(开页与关页)由本插件提供,而 webserver 的路由路径本身不做任何认证,所以每一条都在应答之前先走 Connection 的拒绝逻辑(`isTrustedApiRequest` 与浏览器认证)。未认证的请求会得到 401。 - **设置是用户覆盖,不是状态。** 设置页写入 harness 的 settings 文档,`cordis.yml` 里的条目始终是它下面的基础层;清空某项即删除覆盖。每个字段都改变浏览器的启动或编码方式,所以一次写入会重启正在运行的浏览器,面板会自行重连。 ----- ## 配置 设置页(设置 → 浏览器)编辑下表字段,并报告**真正在跑的东西**——这与"配置成什么"不是一回事:横幅列出每个会话的浏览器,以及它实际持有的端口、窗口模式与页面数。没有设置服务的部署什么都不注册,组合配置就是全部配置。 | 字段 | 默认 | 说明 | | --- | --- | --- | | `channel` | `chrome` | 驱动哪个已安装的浏览器:`chrome` 或 `msedge`。 | | `executablePath` | 空 | 显式指定浏览器可执行文件,用于 channel 查找不到的安装;设置后覆盖 `channel`。 | | `headless` | `true` | 无窗口运行。无头没有可被遮挡或最小化的东西,是推荐值。 | | `stealth` | `true` | 去掉 Playwright 启动的浏览器所带的两个标记:`navigator.webdriver`,以及无头时写成 `HeadlessChrome/…` 的 UA。 | | `userDataDir` | 空 | 保留型模式解析出的 profile 的父目录。留空用该模式自己的默认位置:`session` 是临时目录下的一份,`workspace` 是 `<项目>/.dsh/browser`。`temp` 完全无视它。必须是绝对路径。 | | `profileMode` | `temp` | 谁共享登录态、以及关掉之后还留不留:`temp` 什么都不保留,`session` 每个对话各一份,`workspace` 同一项目的全部对话共用一份。 | | `debugPortMin` / `debugPortMax` | `9333` / `9400` | 每会话 CDP 端口的分配范围;每台浏览器取其中最小的空闲端口。改动只影响**之后**启动的浏览器,正在跑的浏览器保留它已经持有的端口。 | | `viewportWidth` / `viewportHeight` | `1440` / `900` | 无头模式下的页面尺寸——无头没有真实窗口可取值。 | | `quality` | `70` | 镜像帧的 JPEG 质量。 | | `maxWidth` / `maxHeight` | `1600` / `1200` | 镜像帧的最大边长(设备像素)。 | | `everyNthFrame` | `1` | 每 N 帧镜像一帧。 | | `snapshotNodes` | `300` | 一次 `browser_snapshot` 最多打印多少个无障碍树节点,超出会被截断并注明。 | | `maxInstances` | `4` | 同时允许多少个会话浏览器;到顶时请求报错,而不是驱逐正在被人看的浏览器。 | | `startupUrl` | `about:blank` | 浏览器启动后首先打开的地址。 | | `extraArgs` | `[]` | 追加的浏览器启动参数,接在插件自己的参数之后。 | `stealth` 默认开启,因为这是**测出来的**而不是假设的:带标记时 Google 连续三次搜索全部被拦,去掉后三次全部返回结果——包括用同样方式启动的真实 Edge 窗口,正是它说明决定因素是标记而不是浏览器。这个开关存在,是为了让那次对照可以复现。当真需要标记之外的手段时,`executablePath` 接受用户自备的任意 Chromium 二进制(含加固过的构建);本插件不打包也不下载任何第三方二进制。 ----- ## 工具 | 工具 | 作用 | | --- | --- | | `browser_navigate` | 打开地址;返回最终 URL、标题与标签列表。 | | `browser_snapshot` | 把页面读成角色、名称与 ref(`- button "Sign in" [ref=e2]`);同样返回标签列表。`find` 只回匹配一段文本或 `/正则/` 的节点与到它的路径;`boxes` 给每个元素附上视口坐标;`target` 与 `depth` 取一棵子树到某一层。 | | `browser_click` | 点击快照 ref 指名的元素,或点击调用那一刻才用 `role` + `name`、`text`、`selector` 找到的那个,在它自己的位置派发真实鼠标事件。`button` 选右键或中键,`double` 派发页面读作一次双击的那两组按下抬起;页面说这次点击会被别的元素收下时**默认拒绝**,加 `force` 才照发并回报是谁收下的。原生 `