dsh-builtin-browser 0.4.11 → 0.4.13
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/CHANGELOG.md +115 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/desktop-bridge/plugin-browser-bridge.js +15 -1
- package/docs/development.md +11 -3
- package/docs/user-guide.md +21 -2
- package/lib/browser/types.d.ts +16 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +4 -3
- package/lib/browser-electron/desktop-bridge-host.js +12 -5
- package/lib/browser-electron/entry.js +33 -9
- package/lib/browser-electron/provider.d.ts +7 -2
- package/lib/tool-browser/index.js +7 -7
- package/package.json +6 -4
- package/src/browser/types.ts +17 -0
- package/src/browser-electron/desktop-bridge-host.ts +15 -8
- package/src/browser-electron/entry.ts +33 -9
- package/src/browser-electron/provider.ts +7 -2
- package/src/tool-browser/index.ts +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,121 @@
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
# 第五十轮(2026-10-17,对齐 DSH 0.2.1 的插件契约 + 工具描述瘦身)
|
|
8
|
+
|
|
9
|
+
## 一、版本范围改到 DSH 真正读取的位置
|
|
10
|
+
|
|
11
|
+
DSH 从 0.2.1-alpha.3 起改成:**有 `engines.dsh` 就只看它**,`@deepseek-ai/dsh*` 的 `peerDependencies` 被忽略;没有才回退到 peer 规则(官方指南 `docs/upgrade-guide/v0.2.1-alpha.3/plugin-engines-dsh/`)。
|
|
12
|
+
|
|
13
|
+
本插件原来把范围写在 **`dsh.compatibility.dsh`** —— **那一块没有任何代码读取**(在 `packages/boot` 里搜 `manifest.dsh` 只命中 `dsh.bundle` 与 `dsh.profile.bundles`,`dshReleases` 零匹配)。当时的兼容完全是 peer 回退规则顺带成立的,而且声明下限还与 peers 不一致。
|
|
14
|
+
|
|
15
|
+
现在 `engines.dsh: ">=0.1.1-rc.2 <0.3.0"`,与 peers 逐字相同。用 DSH 自己的 `evaluatePluginCompatibility` 验证:改前改后在 `0.2.1-rc.1` 上都是 compatible,`0.3.0` 与 `0.1.0-rc.8` 两个方向都被 BLOCKED,非字符串取值被明确拒绝。
|
|
16
|
+
|
|
17
|
+
## 二、更新路径改成两步
|
|
18
|
+
|
|
19
|
+
`plugin-install-over-installed`(0.2.1-alpha.2):`installBundle` 对已依赖的包**在跑 pnpm 之前就失败**,报 `already-installed`(代码证据:`plugin-manager/src/index.ts:439`、`:444`)。原文档写的"更新它 = 把依赖更新到新版本"照做会撞上这个失败。已改为明确的 `remove` 然后 `add <版本>`,两版 README 与用户指南都同步。
|
|
20
|
+
|
|
21
|
+
## 三、工具描述瘦身(省 5%)
|
|
22
|
+
|
|
23
|
+
先测量,再动手。34 个工具的**模型可见**开销是 `name + description + parameters` —— 注意**不含 `output.schema`**:DSH 的 `schemaOf()` 只投影前三个字段,所以第一版把 `output` 算进去得出的 79k 字符 / 22k tokens 是**高估**,实际是 20,348 字符 / 约 5,650 tokens。
|
|
24
|
+
|
|
25
|
+
据此重排优先级:参数 9,782 字符(48%)、描述 9,932 字符(49%),平均每个工具 166 tokens —— **已经算紧凑**,所以"精简能省一大截"不成立。
|
|
26
|
+
|
|
27
|
+
只压最长的 6 条描述(restrict / screenshot / auth / click / open / fill / a11y),保留全部操作约束、参数含义与安全警告,删掉解释性从句与重复列举。结果:
|
|
28
|
+
|
|
29
|
+
| | 改前 | 改后 |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| 描述合计 | 9,932 字符 | 8,932 字符 |
|
|
32
|
+
| 模型可见合计 | 20,348 字符 | 19,348 字符 |
|
|
33
|
+
| 估算 token | 约 5,650 | 约 5,374 |
|
|
34
|
+
|
|
35
|
+
**收益 5%,如实记录**:不是数量级改进,但它是确定性的、无风险的部分。
|
|
36
|
+
|
|
37
|
+
## 四、评估后不做的两条
|
|
38
|
+
|
|
39
|
+
- **`presentationMeta`**:查了 `packages/core/tools/src/presentation.ts`,它是把结构化数据存进会话日志、由 `presentResult` 读回来渲染成**特定卡片类型**(`card: 'read'`、`'search'`)。UI 只认已定义的卡片,浏览器元素没有对应类型,加了只会回退到原始文本 —— **收益为零**,不做。
|
|
40
|
+
- **`ToolSchema.deferLoading`**:看似正是为这种情况设计的(Anthropic 的 `defer_loading`)。但 DSH 的语义是"延迟的基线工具仍需一次 tool-addition 才能激活",且在不支持 tool-update 的路由上标志会被**剥掉、全部立即加载**。插件单方面标记它的风险是工具直接不可用,不做。
|
|
41
|
+
|
|
42
|
+
**真正能省一个量级的是 PTC 模式**:prompt 里从 34 个工具变成 `run_code` 一个入口,省掉约 5,000 tokens。但它需要宿主有 `ctx.ptcRuntime` 且 agent preset 切到 `mode: ptc` —— **插件无法单方面启用**,只能写进文档由使用者选择。
|
|
43
|
+
|
|
44
|
+
## 五、验证
|
|
45
|
+
|
|
46
|
+
- `scripts/verify-host-compat.mjs` 在 **0.2.1-rc.1** 上全绿:34 个工具注册、`dsh_browser_session` 与 `dsh_browser_a11y` 实跑成功;
|
|
47
|
+
- DSH 自己的 `evaluatePluginCompatibility` 对改动前后、四个版本边界逐一验证;
|
|
48
|
+
- **测试 290 → 290(289 通过 + 1 条平台门控跳过)**,`tsc` 干净。
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
# 第四十九轮(2026-10-17,DSH 0.2.1 的兼容性新规:版本范围必须写在 `engines.dsh`)
|
|
53
|
+
|
|
54
|
+
DSH 升到 **`0.2.1-rc.1`** 后,兼容性判定规则变了。本插件把版本范围写在一个**没有任何代码读取**的位置,当时的"兼容"是靠回退规则蒙对的。
|
|
55
|
+
|
|
56
|
+
## 一、规则变了什么
|
|
57
|
+
|
|
58
|
+
官方迁移指南:`docs/upgrade-guide/v0.2.1-alpha.3/plugin-engines-dsh/`(英文/中文各一份)。判定实现在 `packages/boot/app-boot/src/plugin-compatibility.ts`。
|
|
59
|
+
|
|
60
|
+
**新规**:**有 `engines.dsh` 就只看它**,`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 的 `peerDependencies` **被忽略**;**没有** `engines.dsh` 才回退到 peer 规则。另有三条附带约束:
|
|
61
|
+
|
|
62
|
+
- `engines.dsh` 里**不接受** `workspace:` 值(只有 peers 接受),写成那样等于声明不兼容;
|
|
63
|
+
- 非对象 `engines` 或非字符串 `engines.dsh` → manifest 对兼容性而言**不可读** → 拒绝该包;
|
|
64
|
+
- `IncompatiblePlugin` / `PluginCompatibility` 的 `peers` 字段**改名为 `requirements`**,键为 `engines.dsh` 或 peer 包名。
|
|
65
|
+
|
|
66
|
+
## 二、本插件的处境
|
|
67
|
+
|
|
68
|
+
范围原来写在 **`dsh.compatibility.dsh`**:`>=0.1.1-rc.1 <0.3.0`。**那一块没有任何代码读取** —— 在 `packages/boot` 里搜 `manifest.dsh` 只命中 `dsh.bundle` 与 `dsh.profile.bundles`,`dshReleases` 零匹配。当时的兼容完全是 peer 回退规则(`@deepseek-ai/dsh-*: >=0.1.1-rc.2 <0.3.0`)顺带成立的。
|
|
69
|
+
|
|
70
|
+
于是这个声明在两个意义上都是虚的:写在没人读的地方,而且它的下限也与 peers 的 `<0.1.1-rc.2` 不一致。
|
|
71
|
+
|
|
72
|
+
## 三、改法
|
|
73
|
+
|
|
74
|
+
`engines` 里加 `dsh: ">=0.1.1-rc.2 <0.3.0"`,**与 peers 逐字相同**。迁移指南第 1 条明确要求"范围必须覆盖所有支持的 release,因为 peers 不再增减它" —— 取与 peers 相同的值,是保证"换规则前后答案不变"最直接的方式。
|
|
75
|
+
|
|
76
|
+
`dsh.compatibility` 块保留但**去掉 `dsh` 字段**,并加 `_comment` 说明它现在只是给人看的兼容性记录、不参与任何判定;`dshReleases` 补记 `0.2.1-rc.1`。留着比删掉好 —— 逐版本的支持记录对人有价值,而且它不会被误认为判定依据了:真正的判据在 `engines.dsh` 里一眼可见。
|
|
77
|
+
|
|
78
|
+
第三条不受影响:插件代码从不读 `IncompatiblePlugin` / `PluginCompatibility`(`src/` 里那三处 `requirements` 是注释里对需求文档的引用,与 API 无关)。
|
|
79
|
+
|
|
80
|
+
## 四、验证:用 DSH 自己的判定函数
|
|
81
|
+
|
|
82
|
+
不靠推断,直接 import `packages/boot/app-boot/lib/types/plugin-compatibility.js` 里的 `evaluatePluginCompatibility`:
|
|
83
|
+
|
|
84
|
+
| manifest | `engines.dsh` | 0.2.1-rc.1 上的判定 |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| 改之前 | 无(靠 peers 回退) | compatible |
|
|
87
|
+
| 改之后 | `>=0.1.1-rc.2 <0.3.0` | compatible |
|
|
88
|
+
|
|
89
|
+
范围真的生效,两个方向都试了:`0.3.0` → BLOCKED、`0.1.0-rc.8` → BLOCKED、`0.2.0-rc.2` → compatible;非字符串的 `engines.dsh` 被明确拒绝(`Plugin manifest engines.dsh must be a string`)。
|
|
90
|
+
|
|
91
|
+
**改前改后都是 compatible,这一点值得说清楚**:这一轮不是修一个"装不上"的故障,而是把一个**恰好成立的默认**换成**显式成立的声明**。区别在于今后 DSH 再收紧规则、或有人照着 `dsh.compatibility` 去改范围时,行为会与声明一致,而不是继续靠巧合。
|
|
92
|
+
|
|
93
|
+
**测试**:285 → 290(新增 5 条:`tests/manifest-compatibility.test.mjs`)。其中一条直接调用 DSH 的判定函数,在 checkout 不存在时跳过(CI 上没有那个 checkout);另一条守住下面那条文档要求。`tsc` 干净。
|
|
94
|
+
|
|
95
|
+
## 五、其余迁移指南逐条核对(结论:一条要改文档,其余无关)
|
|
96
|
+
|
|
97
|
+
0.2.1 前后共有 40 余份迁移指南。按"本插件是否用到被改动的 API/配置"逐条核对,用 grep 确认而非推断:
|
|
98
|
+
|
|
99
|
+
| 指南 | 是否影响 | 依据 |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `plugin-engines-dsh` | **是,已改** | 见上文 |
|
|
102
|
+
| `plugin-install-over-installed` | **是,改文档** | 见下 |
|
|
103
|
+
| `tool-presentation-mode` | 否 | 搜索 `presentAs` / `mode: both` / `DSH_TOOLS_MODE` 零匹配 |
|
|
104
|
+
| `jsonl-persistence-plugin` | 否 | 不使用 `sessionPersistence`,自有一份 history 存储 |
|
|
105
|
+
| `web-listener-trust-config` | 否 | 不注入 `webRuntime`,设置路由走 `ctx.webServer` |
|
|
106
|
+
| `remove-runtime-invariants` | 否 | 不导入 `@deepseek-ai/dsh-invariants` |
|
|
107
|
+
| `working-directory` | 否 | 34 个工具用的 `output` 是返回值 JSON Schema,不是 `output.presentationMeta`(该字段零匹配) |
|
|
108
|
+
| `instruction-home-override` | 否 | 不配置 `agent-instructions` |
|
|
109
|
+
| `subpath-plugin-display-manifest` | 否 | 本包是包根插件,不是子路径插件 |
|
|
110
|
+
| `plugin-option-placement` | 否 | 那是 CLI 参数位置,不影响已安装的包 |
|
|
111
|
+
| `experimental-package-composition` | 否 | 本包的 `dsh.bundle.patch` 是完整 insertion,不是"只启用被移除的行" |
|
|
112
|
+
| `new-session-generations`、`account-*`、`pi-ai-*`、`sdk-*`、`subagent-*`、`python-*`、`desktop-*`、`schedule-*` 等 | 否 | 均不涉及浏览器插件使用的任何接缝 |
|
|
113
|
+
|
|
114
|
+
**`plugin-install-over-installed` 要改的是文档,不是代码**:从 0.2.1-alpha.2 起,`installBundle` 对 profile 已依赖的包**在跑 pnpm 之前就失败**,报 `already-installed`,且不改任何 profile 文件。代码侧证据:`packages/boot/plugin-manager/src/index.ts:439`、`:444` 的 `refused('already-installed', ...)`,以及 `tools.ts:18` 的错误文案本身就写着"to install another version, call remove_bundle first, then install_bundle again"。
|
|
115
|
+
|
|
116
|
+
原来的文档只说"更新它 = 把依赖更新到新版本",**照做会撞上这个失败**。已改为明确的两步(`remove` 然后 `add <版本>`),并写明替代路径(Web 插件管理页的 Update 按钮会自己先移除再安装),README 中英两版都加了这一步的提示。
|
|
117
|
+
|
|
118
|
+
这一条加了一条守护测试:断言文档里 `remove` 出现在 `add` 之前,且 README 两版都带这步 —— 否则下次改文档时很容易又把一步写回去。
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
7
122
|
# 第四十八轮(2026-10-16,实机四载体检验:三次误判、一个真 bug、一个跨载体缺陷)
|
|
8
123
|
|
|
9
124
|
YG 要求逐个检验四种载体(侧栏 / 自托管 / Chrome / Edge)能否正常拉开并操作。检验过程里我犯了一个贯穿始终的错误,也发现了两个真实缺陷。
|
package/README.en.md
CHANGED
|
@@ -27,6 +27,8 @@ In one line: **install the plugin and you have a real browser shared with, and d
|
|
|
27
27
|
|
|
28
28
|
**First, which side are you on** — these are two separate installs; updating one does not update the other, and they host the browser differently.
|
|
29
29
|
|
|
30
|
+
> **Updating an installed plugin takes two steps** (since DSH 0.2.1-alpha.2, installing over an installed package fails outright with `already-installed`): run `dsh plugin --profile <profile> remove dsh-builtin-browser` first, then `add` the target version. See the [user guide](docs/user-guide.md) (Chinese).
|
|
31
|
+
|
|
30
32
|
### DSH Desktop
|
|
31
33
|
|
|
32
34
|
```sh
|
package/README.md
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
**先确认你在哪一端** —— 两端是两份独立安装,**更新其中一端不会带上另一端**,浏览器的承载方式也不同。
|
|
29
29
|
|
|
30
|
+
> **更新已安装的插件是两步**(DSH 0.2.1-alpha.2 起,对已装包再装会直接报 `already-installed`):先 `dsh plugin --profile <profile> remove dsh-builtin-browser`,再 `add` 目标版本。详见[用户指南](docs/user-guide.md#先移除再安装)。
|
|
31
|
+
|
|
30
32
|
### DSH Desktop(桌面端)
|
|
31
33
|
|
|
32
34
|
```sh
|
|
@@ -609,6 +609,20 @@ async function handle(request) { const op = String(request?.op ?? '')
|
|
|
609
609
|
}
|
|
610
610
|
|
|
611
611
|
|
|
612
|
+
/**
|
|
613
|
+
* When the bridge instance currently running was constructed.
|
|
614
|
+
*
|
|
615
|
+
* Recorded once and republished unchanged, because a reader uses it to answer "how long has this
|
|
616
|
+
* bridge been up". Refreshing it on every keepalive made it mean "the last keepalive" instead: a
|
|
617
|
+
* reporter read two values 15 seconds apart, concluded the bridge had restarted, and only noticed
|
|
618
|
+
* otherwise because the port had not changed. Each module evaluation gets its own value, so a
|
|
619
|
+
* genuinely new instance is still reported as such.
|
|
620
|
+
*/
|
|
621
|
+
const instanceStartedAt = (() => {
|
|
622
|
+
const at = new Date().toISOString()
|
|
623
|
+
return () => at
|
|
624
|
+
})()
|
|
625
|
+
|
|
612
626
|
/**
|
|
613
627
|
* Publish the endpoint, and keep it published.
|
|
614
628
|
*
|
|
@@ -624,7 +638,7 @@ function publishEndpoint(port, token) {
|
|
|
624
638
|
port,
|
|
625
639
|
token,
|
|
626
640
|
pid: process.pid,
|
|
627
|
-
startedAt:
|
|
641
|
+
startedAt: instanceStartedAt(),
|
|
628
642
|
updatedAt: new Date().toISOString(),
|
|
629
643
|
}
|
|
630
644
|
try {
|
package/docs/development.md
CHANGED
|
@@ -31,8 +31,16 @@ node scripts/verify-host-compat.mjs
|
|
|
31
31
|
|
|
32
32
|
它用某个 profile 里**真实安装**的 `@deepseek-ai/*` 包组装一个宿主,加载本插件的编译产物、经宿主自己的 `defineTool` 注册工具、再用一个假 provider 真调两个工具(`dsh_browser_session` / `dsh_browser_a11y`)。默认读 `$DSH_HOME/profiles` 下的 profile,可用 `DSH_PROFILE` / `DSH_HOME` 指定。
|
|
33
33
|
|
|
34
|
-
## 关于
|
|
34
|
+
## 关于 DSH 版本范围
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
插件在 **`engines.dsh`** 里声明 `>=0.1.1-rc.2 <0.3.0`。
|
|
37
37
|
|
|
38
|
-
DSH
|
|
38
|
+
位置不是随便选的。DSH 从 0.2.1-alpha.3 之后改成:**有 `engines.dsh` 就只看它,`@deepseek-ai/dsh*` 的 `peerDependencies` 被忽略;没有 `engines.dsh` 才回退到 peer 规则**(`packages/boot/app-boot/src/plugin-compatibility.ts`;官方迁移指南在 `docs/upgrade-guide/v0.2.1-alpha.3/plugin-engines-dsh/`)。
|
|
39
|
+
|
|
40
|
+
本插件 0.4.12 之前把范围写在 `dsh.compatibility.dsh` 里 —— 那个位置**没有任何代码读取**,当时的兼容性实际是靠 peer 回退蒙对的。现在两处一致(`engines.dsh` 与 peers 同范围),所以哪条规则生效都得到同一个答案。
|
|
41
|
+
|
|
42
|
+
这个范围在 **semver 默认语义**下**不匹配**任何带预发布标签的版本(如 `0.2.1-rc.1`)—— 规范如此:预发布只被"同一 major.minor.patch 元组"的比较器允许。
|
|
43
|
+
|
|
44
|
+
DSH 判定时用的是 `includePrerelease: true`,因此这些预发布宿主**实际都在范围内**。若某个外部工具用默认语义得出"不兼容",那是它的语义与 DSH 不同,不是这里的声明写错了。
|
|
45
|
+
|
|
46
|
+
`engines.dsh` 里不接受 `workspace:` 值(只有 peers 接受);写成那样等于声明不兼容。
|
package/docs/user-guide.md
CHANGED
|
@@ -160,7 +160,7 @@ DSH Desktop 上②命中即可用(0.1.18+ 插件自带 electron 包;44+ 二进
|
|
|
160
160
|
|
|
161
161
|
| 组件 | 版本 |
|
|
162
162
|
| --- | --- |
|
|
163
|
-
| DeepSeek Harness(dsh) | `0.2.1-
|
|
163
|
+
| DeepSeek Harness(dsh) | `0.2.1-rc.1`(`engines.dsh` 声明 `>=0.1.1-rc.2 <0.3.0`;更早的 `0.2.1-alpha.1`、`0.2.0-rc.2`、`0.1.x` 亦有实测记录) |
|
|
164
164
|
| Electron | `44.0.0`(推荐 ≥ 40;33.x 存在合成器缺陷) |
|
|
165
165
|
| Node.js | `22.20.0` |
|
|
166
166
|
| 本机 Chrome / Edge(可选载体) | `154.0.8037.58` / `154.0.4258.37` |
|
|
@@ -174,6 +174,23 @@ DSH Desktop 上②命中即可用(0.1.18+ 插件自带 electron 包;44+ 二进
|
|
|
174
174
|
|
|
175
175
|
插件在两种宿主里各有一份安装,更新路径也不同 —— **更新其中一端不会连带更新另一端**。
|
|
176
176
|
|
|
177
|
+
### 先移除,再安装
|
|
178
|
+
|
|
179
|
+
**DSH 0.2.1-alpha.2 起,对已经装着的包再执行一次安装会直接失败**,报 `already-installed`,并且在跑 pnpm 之前就失败、不改任何 profile 文件(官方迁移指南:`docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/`)。这条在启用 HMR 的 profile 上生效,而自带 profile 都启用 HMR。
|
|
180
|
+
|
|
181
|
+
所以更新一个**已安装**的插件是两步:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
dsh plugin --profile web remove dsh-builtin-browser
|
|
185
|
+
dsh plugin --profile web add dsh-builtin-browser@<目标版本>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
移除会停掉该 bundle 的插件并清掉它的包映射,新版本随即重新映射,因此**不需要重启**就能生效(桌面端仍需重启,理由见下)。
|
|
189
|
+
|
|
190
|
+
替代做法:Web 界面的插件管理页对官方插件提供 Update 按钮,它自己会先移除再安装。桌面端尚无可用的新版,按上面的两步来。
|
|
191
|
+
|
|
192
|
+
## 桌面端细节
|
|
193
|
+
|
|
177
194
|
**桌面端(DSH Desktop)**
|
|
178
195
|
|
|
179
196
|
- 插件是桌面端 profile 里的依赖(profile 目录通常是 `$DSH_HOME/profiles/desktop`)。更新它 = 把该 profile 里的依赖更新到新版本,然后**重启 DSH Desktop**,客户端设置栏与工具才会换成新代码。
|
|
@@ -186,12 +203,14 @@ DSH Desktop 上②命中即可用(0.1.18+ 插件自带 electron 包;44+ 二进
|
|
|
186
203
|
- 浏览器内核默认由桌面端自带的 Electron 提供,插件不会再下载一份 Electron;也可以在设置里改用它自己装的 Chrome / Edge。
|
|
187
204
|
- **升级桌面端本身不会带上插件**,按上面的方法单独更新它。
|
|
188
205
|
|
|
206
|
+
## Web 端细节
|
|
207
|
+
|
|
189
208
|
**Web 端(`dsh web`)**
|
|
190
209
|
|
|
191
210
|
- 插件是 web profile 里的依赖(`$DSH_HOME/profiles/web`),更新后**重启 `dsh web`** 生效。
|
|
192
211
|
- Web 端**没有侧栏、也没有 bridge**:共享浏览器由插件自托管拉起。首次安装可能需要 Electron 二进制;若包管理器的构建白名单拦下了它(pnpm v10+ 会拦 `electron` 的 postinstall),执行一次 `npx install-electron` 补上即可。
|
|
193
212
|
- 同样可以在设置里改用本机 Chrome / Edge —— 这是两端**行为一致**的选项。
|
|
194
|
-
- 更新方式与首次安装它时一致(按 npm 包名 `dsh-builtin-browser`、按 GitHub 仓库 `wqty123/dsh-browser`,或本地目录)
|
|
213
|
+
- 更新方式与首次安装它时一致(按 npm 包名 `dsh-builtin-browser`、按 GitHub 仓库 `wqty123/dsh-browser`,或本地目录),但**已安装时先按上面的两步移除再装**。
|
|
195
214
|
|
|
196
215
|
**两端一致的体验**
|
|
197
216
|
|
package/lib/browser/types.d.ts
CHANGED
|
@@ -472,6 +472,22 @@ export interface CarrierPage {
|
|
|
472
472
|
readonly url: string;
|
|
473
473
|
readonly title?: string;
|
|
474
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Why a carrier reported that it can no longer be used.
|
|
477
|
+
*
|
|
478
|
+
* Two different failures reach the same fallback, and saying which one is the difference between a
|
|
479
|
+
* message a user can act on and one that misleads them:
|
|
480
|
+
*
|
|
481
|
+
* - `shell-exited`: the shell process behind the carrier is gone. Its published endpoint stops
|
|
482
|
+
* being refreshed, which is the condition the fallback was originally written for.
|
|
483
|
+
* - `panel-unusable`: the shell is alive and answering, but its sidebar panel cannot provide a page
|
|
484
|
+
* — on a shell that renders that panel as an iframe there is no webContents and no CDP endpoint,
|
|
485
|
+
* so the carrier is undrivable rather than merely unready.
|
|
486
|
+
*
|
|
487
|
+
* The fallback announced the first of these unconditionally, so a `panel-unusable` fallback told
|
|
488
|
+
* the user their desktop shell had exited while it was running normally. Reported as such.
|
|
489
|
+
*/
|
|
490
|
+
export type CarrierLossReason = 'shell-exited' | 'panel-unusable';
|
|
475
491
|
/** Request to open a URL: into the active tab or a new tab. */ export interface BrowserOpenRequest {
|
|
476
492
|
/** The URL to open. */
|
|
477
493
|
readonly url: string;
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
|
|
26
26
|
import type { BrowserUserAction } from './provider.js';
|
|
27
|
-
import type { CarrierPage } from '../browser/types.js';
|
|
27
|
+
import type { CarrierPage, CarrierLossReason } from '../browser/types.js';
|
|
28
28
|
/** Absolute path of the endpoint file the shell writes. */
|
|
29
29
|
export declare function bridgeEndpointPath(): string;
|
|
30
30
|
/**
|
|
@@ -102,14 +102,15 @@ export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
102
102
|
*/
|
|
103
103
|
private constructor();
|
|
104
104
|
/**
|
|
105
|
-
* Called
|
|
105
|
+
* Called when this carrier concludes it cannot serve anything more.
|
|
106
106
|
*
|
|
107
107
|
* Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
|
|
108
108
|
* browser, and a host that tried to replace itself would be reaching outside its own contract.
|
|
109
|
+
* @param reason - which failure this was, so the fallback can say so accurately.
|
|
109
110
|
*/
|
|
110
111
|
private reportLost;
|
|
111
112
|
/** Register the "this carrier is gone" listener (see the interface's `onLost`). */
|
|
112
|
-
onLost(listener: () => void): void;
|
|
113
|
+
onLost(listener: (reason: CarrierLossReason) => void): void;
|
|
113
114
|
/**
|
|
114
115
|
* Find a usable desktop bridge, if this surface has one.
|
|
115
116
|
*
|
|
@@ -118,17 +118,20 @@ export class DesktopBridgeViewHost {
|
|
|
118
118
|
*/
|
|
119
119
|
constructor(endpoint, sessionId) {
|
|
120
120
|
this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39, 39) ? sessionId : randomUUID();
|
|
121
|
-
this
|
|
121
|
+
// The transport reaches this only by concluding the shell stopped refreshing its endpoint, which
|
|
122
|
+
// is `shell-exited`.
|
|
123
|
+
this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost('shell-exited'));
|
|
122
124
|
}
|
|
123
125
|
/**
|
|
124
|
-
* Called
|
|
126
|
+
* Called when this carrier concludes it cannot serve anything more.
|
|
125
127
|
*
|
|
126
128
|
* Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
|
|
127
129
|
* browser, and a host that tried to replace itself would be reaching outside its own contract.
|
|
130
|
+
* @param reason - which failure this was, so the fallback can say so accurately.
|
|
128
131
|
*/
|
|
129
|
-
reportLost() {
|
|
132
|
+
reportLost(reason) {
|
|
130
133
|
try {
|
|
131
|
-
this.lostListener?.();
|
|
134
|
+
this.lostListener?.(reason);
|
|
132
135
|
}
|
|
133
136
|
catch {
|
|
134
137
|
// The listener's failure is not the transport's problem.
|
|
@@ -326,8 +329,12 @@ export class DesktopBridgeViewHost {
|
|
|
326
329
|
// shell whose sidebar panel is an iframe, that is every call forever. Reported as "installing
|
|
327
330
|
// the bridge made it worse than not installing it": the carrier was switched to one that
|
|
328
331
|
// cannot work, with no path back to the one that does.
|
|
332
|
+
//
|
|
333
|
+
// `panel-unusable`, not `shell-exited`: the shell is alive and answered this very request. The
|
|
334
|
+
// fallback's message used to announce an exiting shell unconditionally, which told the user
|
|
335
|
+
// their desktop had crashed when it was running normally.
|
|
329
336
|
if (String(answer.error).includes('produced no page'))
|
|
330
|
-
this.reportLost();
|
|
337
|
+
this.reportLost('panel-unusable');
|
|
331
338
|
throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`);
|
|
332
339
|
}
|
|
333
340
|
const id = Number(answer.id);
|
|
@@ -127,19 +127,29 @@ export function apply(ctx, config) {
|
|
|
127
127
|
*
|
|
128
128
|
* This is the half the startup retries never covered: a bridge that was alive at apply time and
|
|
129
129
|
* exited later left every subsequent call dialling a port nobody owns, because the swap happened
|
|
130
|
-
* once and only in one direction. Reaching here means the
|
|
131
|
-
* startup is no longer a choice — the alternative to falling back is a browser
|
|
132
|
-
* nothing until DSH is restarted.
|
|
130
|
+
* once and only in one direction. Reaching here means the carrier cannot serve anything more, so
|
|
131
|
+
* the choice made at startup is no longer a choice — the alternative to falling back is a browser
|
|
132
|
+
* surface that does nothing until DSH is restarted.
|
|
133
|
+
*
|
|
134
|
+
* Two failures arrive here and the message must distinguish them. The first draft announced an
|
|
135
|
+
* exiting shell unconditionally, so a fallback caused by an undrivable sidebar panel told the
|
|
136
|
+
* user their desktop shell had exited while it was running normally.
|
|
133
137
|
* @param adopted - the carrier that reported the loss, disposed once its replacement is in place.
|
|
138
|
+
* @param reason - which failure this was; see {@link CarrierLossReason}.
|
|
134
139
|
*/
|
|
135
|
-
const restoreSelfHosted = (adopted) => {
|
|
140
|
+
const restoreSelfHosted = (adopted, reason) => {
|
|
136
141
|
try {
|
|
137
142
|
const restored = new RemoteElectronViewHost(defaultHostMainPath());
|
|
138
143
|
unregister();
|
|
139
144
|
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(restored, providerConfig));
|
|
140
|
-
ctx.logger?.warn?.(
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
ctx.logger?.warn?.(reason === 'shell-exited'
|
|
146
|
+
? 'dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
|
|
147
|
+
+ 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
|
|
148
|
+
+ 'open the page again.'
|
|
149
|
+
: 'dsh-builtin-browser: this desktop shell renders the sidebar browser panel in a way that '
|
|
150
|
+
+ 'cannot be driven (no webContents, so no CDP endpoint), so the browser this plugin hosts '
|
|
151
|
+
+ 'itself takes over. The shell is still running and nothing else is affected; the page '
|
|
152
|
+
+ 'opens in this plugin\'s own window. Set browser.channel to another value to silence this.');
|
|
143
153
|
}
|
|
144
154
|
catch (error) {
|
|
145
155
|
// Nothing better is available: the adopted carrier is gone and a provider that throws is
|
|
@@ -147,7 +157,21 @@ export function apply(ctx, config) {
|
|
|
147
157
|
ctx.logger?.warn?.(`dsh-builtin-browser: could not fall back to the self-hosted browser: ${String(error)}`);
|
|
148
158
|
}
|
|
149
159
|
finally {
|
|
150
|
-
|
|
160
|
+
// Disposed on a LATER turn of the event loop, deliberately.
|
|
161
|
+
//
|
|
162
|
+
// Disposing synchronously here closes the bridge socket while the request that triggered this
|
|
163
|
+
// fallback is still settling, and the close rejects every call still waiting on that socket —
|
|
164
|
+
// so the caller received "bridge closed" instead of the reason it actually failed for. The
|
|
165
|
+
// fallback is what the user is supposed to be able to understand; replacing its explanation
|
|
166
|
+
// with one about the connection loses exactly the information this path exists to convey.
|
|
167
|
+
setImmediate(() => {
|
|
168
|
+
try {
|
|
169
|
+
adopted.dispose?.();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// A carrier that cannot be released is not the caller's problem; the swap already happened.
|
|
173
|
+
}
|
|
174
|
+
});
|
|
151
175
|
}
|
|
152
176
|
};
|
|
153
177
|
/**
|
|
@@ -169,7 +193,7 @@ export function apply(ctx, config) {
|
|
|
169
193
|
// A borrowed carrier can lose the process behind it, and this is the only notification that
|
|
170
194
|
// will ever arrive. Registering AFTER the swap succeeded means a listener can never fire
|
|
171
195
|
// against a provider that was never installed.
|
|
172
|
-
host.onLost?.(
|
|
196
|
+
host.onLost?.(reason => { restoreSelfHosted(host, reason); });
|
|
173
197
|
const previous = adoptedCarrier;
|
|
174
198
|
adoptedCarrier = host;
|
|
175
199
|
// Released AFTER the replacement is installed, so the surface is never without a provider.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { type VisitedPage } from './history-store.js';
|
|
10
10
|
import type { BrowserSettings } from './settings-store.js';
|
|
11
11
|
import type { BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentRequest, BrowserContentResult, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserHistoryEntry, BrowserOpenRequest, BrowserProvider, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, ExportedCookie } from '../browser/types.js';
|
|
12
|
-
import type { CarrierPage } from '../browser/types.js';
|
|
12
|
+
import type { CarrierPage, CarrierLossReason } from '../browser/types.js';
|
|
13
13
|
/** Stable provider id registered with `ctx.browser`. */
|
|
14
14
|
export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
|
|
15
15
|
/**
|
|
@@ -157,9 +157,14 @@ export interface ElectronBrowserViewHost {
|
|
|
157
157
|
* startup, so the choice made then is final for the process's life — which is what a user
|
|
158
158
|
* experiences as "the browser stopped working until I restart DSH". Optional: a carrier that
|
|
159
159
|
* owns its own lifetime has nothing to report.
|
|
160
|
+
*
|
|
161
|
+
* The listener receives WHY, because the fallback's message has to match what happened: a shell
|
|
162
|
+
* that exited and a shell whose panel cannot provide a page both reach the same fallback, and
|
|
163
|
+
* announcing "the shell has exited" for the second tells the user their desktop crashed when it
|
|
164
|
+
* did not.
|
|
160
165
|
* @param listener - called once, when the carrier concludes it is gone.
|
|
161
166
|
*/
|
|
162
|
-
onLost?(listener: () => void): void;
|
|
167
|
+
onLost?(listener: (reason: CarrierLossReason) => void): void;
|
|
163
168
|
/**
|
|
164
169
|
* Fold the carrier's presentation away while keeping the page alive.
|
|
165
170
|
*
|
|
@@ -319,7 +319,7 @@ export function apply(ctx, config = {}) {
|
|
|
319
319
|
};
|
|
320
320
|
registerSafely(defineTool({
|
|
321
321
|
name: 'dsh_browser_open',
|
|
322
|
-
description: 'Open a URL in the shared browser window. Opens this task\'s
|
|
322
|
+
description: 'Open a URL in the shared browser window and return the resulting snapshot. Opens this task\'s session on first use; newTab is optional. A fresh session starts on a blank tab: call this with the REAL target URL before reading, because reading that tab returns an empty page instead of waiting. Never treat the bare DSH host (e.g. http://127.0.0.1:3080/) as the target — it needs the authenticated URL printed by `dsh web`, and the bare root is refused.',
|
|
323
323
|
parameters: {
|
|
324
324
|
url: { type: 'string', required: true, description: 'The URL to open (HTTP/HTTPS).' },
|
|
325
325
|
newTab: { type: 'boolean', description: 'Open in a new tab instead of the active one.' },
|
|
@@ -445,7 +445,7 @@ export function apply(ctx, config = {}) {
|
|
|
445
445
|
}));
|
|
446
446
|
registerSafely(defineTool({
|
|
447
447
|
name: 'dsh_browser_a11y',
|
|
448
|
-
description: 'Read the
|
|
448
|
+
description: 'Read the accessibility tree: every interactive node with its role (button/link/textbox/checkbox/…), accessible name, value, and states (enabled/checked/expanded/…); coordinates only with coords: true. Prefer this over dsh_browser_snapshot to understand structure and find the right element — roles and names say WHAT each node is, and the name is what dsh_browser_click matches when targeting by text. Penetrates same-origin iframes and shadow roots.',
|
|
449
449
|
parameters: {
|
|
450
450
|
includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
|
|
451
451
|
maxNodes: { type: 'number', description: 'Maximum nodes (default 150, range 10-5000).' },
|
|
@@ -744,7 +744,7 @@ export function apply(ctx, config = {}) {
|
|
|
744
744
|
}));
|
|
745
745
|
registerSafely(defineTool({
|
|
746
746
|
name: 'dsh_browser_click',
|
|
747
|
-
description: 'Click in the shared browser. Prefer
|
|
747
|
+
description: 'Click in the shared browser. Prefer target {by: css|text|xpath, value, index?}: the element is located from the DOM, scrolled into view, and clicked at its centre — no image input needed, and the only form that works without vision. Alternatively x/y viewport coordinates, meaningful only after a vision model located the element on a dsh_browser_screenshot; refused under the non-visual strategy. Provide exactly one of target or x/y.',
|
|
748
748
|
parameters: {
|
|
749
749
|
target: {
|
|
750
750
|
type: 'object',
|
|
@@ -965,7 +965,7 @@ export function apply(ctx, config = {}) {
|
|
|
965
965
|
}));
|
|
966
966
|
registerSafely(defineTool({
|
|
967
967
|
name: 'dsh_browser_fill',
|
|
968
|
-
description: 'Fill a form in one batch:
|
|
968
|
+
description: 'Fill a form in one batch: each field takes a CSS selector or name/label/placeholder text plus its value (boolean for checkbox/radio; option value or visible text for selects and radio groups). The native setter plus input/change events fire, so React/Vue controlled inputs update. Optionally submit the form. Prefer this over hand-written dsh_browser_execute; per-field failures are reported instead of throwing.',
|
|
969
969
|
parameters: {
|
|
970
970
|
fields: {
|
|
971
971
|
type: 'array',
|
|
@@ -1263,7 +1263,7 @@ export function apply(ctx, config = {}) {
|
|
|
1263
1263
|
}));
|
|
1264
1264
|
registerSafely(defineTool({
|
|
1265
1265
|
name: 'dsh_browser_screenshot',
|
|
1266
|
-
description: '
|
|
1266
|
+
description: 'Screenshot the shared-browser page (PNG default, JPEG optional) for models that can read images: layout, charts, CAPTCHAs, or locating an element before clicking its coordinates. Without image input it gains nothing — dsh_browser_snapshot/a11y/content carry the same page as text, dsh_browser_scrape extracts structured data. Supports fullPage, savePath, JPEG quality, and maxWidth/maxHeight downscaling to cut vision tokens. JPEG needs a carrier whose CDP encoder works (self-hosted, installed Chrome/Edge); the desktop sidebar returns PNG. Downscaling works everywhere.',
|
|
1267
1267
|
parameters: {
|
|
1268
1268
|
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
|
|
1269
1269
|
savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
|
|
@@ -1716,7 +1716,7 @@ export function apply(ctx, config = {}) {
|
|
|
1716
1716
|
}));
|
|
1717
1717
|
registerSafely(defineTool({
|
|
1718
1718
|
name: 'dsh_browser_restrict',
|
|
1719
|
-
description: '
|
|
1719
|
+
description: 'Allow only the listed browser tools; any other dsh_browser_* call is refused. Empty list or omitted lifts it. Never blocked whatever the list says — the observing tools (snapshot/a11y/content/scrape/screenshot/get_value/wait/challenge/list_tabs/session/history/visited) and restrict/reset/reset_session — so a restriction can always be read around and undone. A SOFT guardrail against stray clicks, NOT a security boundary: you can lift it with an empty list.',
|
|
1720
1720
|
parameters: {
|
|
1721
1721
|
allowed: {
|
|
1722
1722
|
type: 'array',
|
|
@@ -1751,7 +1751,7 @@ export function apply(ctx, config = {}) {
|
|
|
1751
1751
|
}));
|
|
1752
1752
|
registerSafely(defineTool({
|
|
1753
1753
|
name: 'dsh_browser_auth',
|
|
1754
|
-
description: 'Export or restore the
|
|
1754
|
+
description: 'Export or restore the session\'s cookies (login state): "flush" returns a JSON cookie list, "restore" takes that list back (e.g. after the host restarted). Works on all carriers. Refused when the credential switch is off. Exported cookies are LIVE CREDENTIALS: never echo them into the conversation or logs; keep the list in a private file.',
|
|
1755
1755
|
parameters: {
|
|
1756
1756
|
action: { type: 'string', required: true, enum: ['flush', 'restore'], description: 'flush = export cookies; restore = import cookies.' },
|
|
1757
1757
|
cookies: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Cookie list to restore (required when action=restore).' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.13",
|
|
4
4
|
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (dsh_browser_auth), CAPTCHA detection, batch form fill (dsh_browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
"url": "https://github.com/wqty123/dsh-browser/issues"
|
|
66
66
|
},
|
|
67
67
|
"engines": {
|
|
68
|
-
"node": ">=22.19"
|
|
68
|
+
"node": ">=22.19",
|
|
69
|
+
"dsh": ">=0.1.1-rc.2 <0.3.0"
|
|
69
70
|
},
|
|
70
71
|
"dependencies": {
|
|
71
72
|
"electron": ">= 30.0.0"
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
]
|
|
98
99
|
},
|
|
99
100
|
"compatibility": {
|
|
100
|
-
"
|
|
101
|
+
"_comment": "Documentation only. DSH decides plugin compatibility from engines.dsh (falling back to @deepseek-ai/dsh* peerDependencies when it is absent); nothing reads this block. Kept because the release-by-release record is useful to a human and costs nothing.",
|
|
101
102
|
"profiles": [
|
|
102
103
|
"web",
|
|
103
104
|
"desktop"
|
|
@@ -108,7 +109,8 @@
|
|
|
108
109
|
"0.1.1-rc.2": "compatible",
|
|
109
110
|
"0.2.0-rc.1": "compatible",
|
|
110
111
|
"0.2.0-rc.2": "compatible",
|
|
111
|
-
"0.2.1-alpha.1": "compatible"
|
|
112
|
+
"0.2.1-alpha.1": "compatible",
|
|
113
|
+
"0.2.1-rc.1": "compatible"
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
}
|
package/src/browser/types.ts
CHANGED
|
@@ -511,6 +511,23 @@ export interface CarrierPage {
|
|
|
511
511
|
readonly title?: string
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Why a carrier reported that it can no longer be used.
|
|
516
|
+
*
|
|
517
|
+
* Two different failures reach the same fallback, and saying which one is the difference between a
|
|
518
|
+
* message a user can act on and one that misleads them:
|
|
519
|
+
*
|
|
520
|
+
* - `shell-exited`: the shell process behind the carrier is gone. Its published endpoint stops
|
|
521
|
+
* being refreshed, which is the condition the fallback was originally written for.
|
|
522
|
+
* - `panel-unusable`: the shell is alive and answering, but its sidebar panel cannot provide a page
|
|
523
|
+
* — on a shell that renders that panel as an iframe there is no webContents and no CDP endpoint,
|
|
524
|
+
* so the carrier is undrivable rather than merely unready.
|
|
525
|
+
*
|
|
526
|
+
* The fallback announced the first of these unconditionally, so a `panel-unusable` fallback told
|
|
527
|
+
* the user their desktop shell had exited while it was running normally. Reported as such.
|
|
528
|
+
*/
|
|
529
|
+
export type CarrierLossReason = 'shell-exited' | 'panel-unusable'
|
|
530
|
+
|
|
514
531
|
/** Request to open a URL: into the active tab or a new tab. */export interface BrowserOpenRequest {
|
|
515
532
|
/** The URL to open. */
|
|
516
533
|
readonly url: string
|
|
@@ -30,7 +30,7 @@ import { join } from 'node:path'
|
|
|
30
30
|
import { randomUUID } from 'node:crypto'
|
|
31
31
|
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
|
|
32
32
|
import type { BrowserUserAction } from './provider.js'
|
|
33
|
-
import type { CarrierPage } from '../browser/types.js'
|
|
33
|
+
import type { CarrierPage, CarrierLossReason } from '../browser/types.js'
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
/** Absolute path of the endpoint file the shell writes. */
|
|
@@ -120,32 +120,35 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
120
120
|
*/
|
|
121
121
|
private userActionHandler: ((action: BrowserUserAction) => void) | undefined
|
|
122
122
|
/** Told when the shell behind this bridge exits; see `onLost`. */
|
|
123
|
-
private lostListener: (() => void) | undefined
|
|
123
|
+
private lostListener: ((reason: CarrierLossReason) => void) | undefined
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
126
|
* @param endpoint - the shell's published bridge endpoint.
|
|
127
127
|
*/
|
|
128
128
|
private constructor(endpoint: BridgeEndpoint, sessionId?: string) {
|
|
129
129
|
this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39,39) ? sessionId : randomUUID()
|
|
130
|
-
this
|
|
130
|
+
// The transport reaches this only by concluding the shell stopped refreshing its endpoint, which
|
|
131
|
+
// is `shell-exited`.
|
|
132
|
+
this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost('shell-exited'))
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
/**
|
|
134
|
-
* Called
|
|
136
|
+
* Called when this carrier concludes it cannot serve anything more.
|
|
135
137
|
*
|
|
136
138
|
* Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
|
|
137
139
|
* browser, and a host that tried to replace itself would be reaching outside its own contract.
|
|
140
|
+
* @param reason - which failure this was, so the fallback can say so accurately.
|
|
138
141
|
*/
|
|
139
|
-
private reportLost(): void {
|
|
142
|
+
private reportLost(reason: CarrierLossReason): void {
|
|
140
143
|
try {
|
|
141
|
-
this.lostListener?.()
|
|
144
|
+
this.lostListener?.(reason)
|
|
142
145
|
} catch {
|
|
143
146
|
// The listener's failure is not the transport's problem.
|
|
144
147
|
}
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
/** Register the "this carrier is gone" listener (see the interface's `onLost`). */
|
|
148
|
-
onLost(listener: () => void): void {
|
|
151
|
+
onLost(listener: (reason: CarrierLossReason) => void): void {
|
|
149
152
|
this.lostListener = listener
|
|
150
153
|
}
|
|
151
154
|
|
|
@@ -335,7 +338,11 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
|
335
338
|
// shell whose sidebar panel is an iframe, that is every call forever. Reported as "installing
|
|
336
339
|
// the bridge made it worse than not installing it": the carrier was switched to one that
|
|
337
340
|
// cannot work, with no path back to the one that does.
|
|
338
|
-
|
|
341
|
+
//
|
|
342
|
+
// `panel-unusable`, not `shell-exited`: the shell is alive and answered this very request. The
|
|
343
|
+
// fallback's message used to announce an exiting shell unconditionally, which told the user
|
|
344
|
+
// their desktop had crashed when it was running normally.
|
|
345
|
+
if (String(answer.error).includes('produced no page')) this.reportLost('panel-unusable')
|
|
339
346
|
throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`)
|
|
340
347
|
}
|
|
341
348
|
const id = Number(answer.id)
|
|
@@ -16,6 +16,7 @@ import z from '@deepseek-ai/schemastery'
|
|
|
16
16
|
import type { BrowserRuntime } from '../browser/runtime.js'
|
|
17
17
|
import { ElectronBrowserProvider } from './provider.js'
|
|
18
18
|
import type { ElectronBrowserViewHost } from './provider.js'
|
|
19
|
+
import type { CarrierLossReason } from '../browser/types.js'
|
|
19
20
|
import { randomUUID } from 'node:crypto'
|
|
20
21
|
import { existsSync, mkdirSync, renameSync } from 'node:fs'
|
|
21
22
|
import { homedir } from 'node:os'
|
|
@@ -165,27 +166,50 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
165
166
|
*
|
|
166
167
|
* This is the half the startup retries never covered: a bridge that was alive at apply time and
|
|
167
168
|
* exited later left every subsequent call dialling a port nobody owns, because the swap happened
|
|
168
|
-
* once and only in one direction. Reaching here means the
|
|
169
|
-
* startup is no longer a choice — the alternative to falling back is a browser
|
|
170
|
-
* nothing until DSH is restarted.
|
|
169
|
+
* once and only in one direction. Reaching here means the carrier cannot serve anything more, so
|
|
170
|
+
* the choice made at startup is no longer a choice — the alternative to falling back is a browser
|
|
171
|
+
* surface that does nothing until DSH is restarted.
|
|
172
|
+
*
|
|
173
|
+
* Two failures arrive here and the message must distinguish them. The first draft announced an
|
|
174
|
+
* exiting shell unconditionally, so a fallback caused by an undrivable sidebar panel told the
|
|
175
|
+
* user their desktop shell had exited while it was running normally.
|
|
171
176
|
* @param adopted - the carrier that reported the loss, disposed once its replacement is in place.
|
|
177
|
+
* @param reason - which failure this was; see {@link CarrierLossReason}.
|
|
172
178
|
*/
|
|
173
|
-
const restoreSelfHosted = (adopted: ElectronBrowserViewHost): void => {
|
|
179
|
+
const restoreSelfHosted = (adopted: ElectronBrowserViewHost, reason: CarrierLossReason): void => {
|
|
174
180
|
try {
|
|
175
181
|
const restored = new RemoteElectronViewHost(defaultHostMainPath())
|
|
176
182
|
unregister()
|
|
177
183
|
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(restored, providerConfig))
|
|
178
184
|
ctx.logger?.warn?.(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
185
|
+
reason === 'shell-exited'
|
|
186
|
+
? 'dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
|
|
187
|
+
+ 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
|
|
188
|
+
+ 'open the page again.'
|
|
189
|
+
: 'dsh-builtin-browser: this desktop shell renders the sidebar browser panel in a way that '
|
|
190
|
+
+ 'cannot be driven (no webContents, so no CDP endpoint), so the browser this plugin hosts '
|
|
191
|
+
+ 'itself takes over. The shell is still running and nothing else is affected; the page '
|
|
192
|
+
+ 'opens in this plugin\'s own window. Set browser.channel to another value to silence this.',
|
|
182
193
|
)
|
|
183
194
|
} catch (error) {
|
|
184
195
|
// Nothing better is available: the adopted carrier is gone and a provider that throws is
|
|
185
196
|
// worse than one that explains.
|
|
186
197
|
ctx.logger?.warn?.(`dsh-builtin-browser: could not fall back to the self-hosted browser: ${String(error)}`)
|
|
187
198
|
} finally {
|
|
188
|
-
|
|
199
|
+
// Disposed on a LATER turn of the event loop, deliberately.
|
|
200
|
+
//
|
|
201
|
+
// Disposing synchronously here closes the bridge socket while the request that triggered this
|
|
202
|
+
// fallback is still settling, and the close rejects every call still waiting on that socket —
|
|
203
|
+
// so the caller received "bridge closed" instead of the reason it actually failed for. The
|
|
204
|
+
// fallback is what the user is supposed to be able to understand; replacing its explanation
|
|
205
|
+
// with one about the connection loses exactly the information this path exists to convey.
|
|
206
|
+
setImmediate(() => {
|
|
207
|
+
try {
|
|
208
|
+
adopted.dispose?.()
|
|
209
|
+
} catch {
|
|
210
|
+
// A carrier that cannot be released is not the caller's problem; the swap already happened.
|
|
211
|
+
}
|
|
212
|
+
})
|
|
189
213
|
}
|
|
190
214
|
}
|
|
191
215
|
|
|
@@ -208,7 +232,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
208
232
|
// A borrowed carrier can lose the process behind it, and this is the only notification that
|
|
209
233
|
// will ever arrive. Registering AFTER the swap succeeded means a listener can never fire
|
|
210
234
|
// against a provider that was never installed.
|
|
211
|
-
host.onLost?.(
|
|
235
|
+
host.onLost?.(reason => { restoreSelfHosted(host, reason) })
|
|
212
236
|
const previous = adoptedCarrier
|
|
213
237
|
adoptedCarrier = host
|
|
214
238
|
// Released AFTER the replacement is installed, so the surface is never without a provider.
|
|
@@ -51,7 +51,7 @@ import type {
|
|
|
51
51
|
ExportedCookie,
|
|
52
52
|
} from '../browser/types.js'
|
|
53
53
|
import { BrowserError } from '../browser/types.js'
|
|
54
|
-
import type { CarrierPage } from '../browser/types.js'
|
|
54
|
+
import type { CarrierPage, CarrierLossReason } from '../browser/types.js'
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
|
|
@@ -255,9 +255,14 @@ export interface ElectronBrowserViewHost {
|
|
|
255
255
|
* startup, so the choice made then is final for the process's life — which is what a user
|
|
256
256
|
* experiences as "the browser stopped working until I restart DSH". Optional: a carrier that
|
|
257
257
|
* owns its own lifetime has nothing to report.
|
|
258
|
+
*
|
|
259
|
+
* The listener receives WHY, because the fallback's message has to match what happened: a shell
|
|
260
|
+
* that exited and a shell whose panel cannot provide a page both reach the same fallback, and
|
|
261
|
+
* announcing "the shell has exited" for the second tells the user their desktop crashed when it
|
|
262
|
+
* did not.
|
|
258
263
|
* @param listener - called once, when the carrier concludes it is gone.
|
|
259
264
|
*/
|
|
260
|
-
onLost?(listener: () => void): void
|
|
265
|
+
onLost?(listener: (reason: CarrierLossReason) => void): void
|
|
261
266
|
/**
|
|
262
267
|
* Fold the carrier's presentation away while keeping the page alive.
|
|
263
268
|
*
|
|
@@ -375,7 +375,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
375
375
|
|
|
376
376
|
registerSafely(defineTool({
|
|
377
377
|
name: 'dsh_browser_open',
|
|
378
|
-
description: 'Open a URL in the shared browser window. Opens this task\'s
|
|
378
|
+
description: 'Open a URL in the shared browser window and return the resulting snapshot. Opens this task\'s session on first use; newTab is optional. A fresh session starts on a blank tab: call this with the REAL target URL before reading, because reading that tab returns an empty page instead of waiting. Never treat the bare DSH host (e.g. http://127.0.0.1:3080/) as the target — it needs the authenticated URL printed by `dsh web`, and the bare root is refused.',
|
|
379
379
|
parameters: {
|
|
380
380
|
url: { type: 'string', required: true, description: 'The URL to open (HTTP/HTTPS).' },
|
|
381
381
|
newTab: { type: 'boolean', description: 'Open in a new tab instead of the active one.' },
|
|
@@ -501,7 +501,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
501
501
|
|
|
502
502
|
registerSafely(defineTool({
|
|
503
503
|
name: 'dsh_browser_a11y',
|
|
504
|
-
description: 'Read the
|
|
504
|
+
description: 'Read the accessibility tree: every interactive node with its role (button/link/textbox/checkbox/…), accessible name, value, and states (enabled/checked/expanded/…); coordinates only with coords: true. Prefer this over dsh_browser_snapshot to understand structure and find the right element — roles and names say WHAT each node is, and the name is what dsh_browser_click matches when targeting by text. Penetrates same-origin iframes and shadow roots.',
|
|
505
505
|
parameters: {
|
|
506
506
|
includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
|
|
507
507
|
maxNodes: { type: 'number', description: 'Maximum nodes (default 150, range 10-5000).' },
|
|
@@ -799,7 +799,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
799
799
|
|
|
800
800
|
registerSafely(defineTool({
|
|
801
801
|
name: 'dsh_browser_click',
|
|
802
|
-
description: 'Click in the shared browser. Prefer
|
|
802
|
+
description: 'Click in the shared browser. Prefer target {by: css|text|xpath, value, index?}: the element is located from the DOM, scrolled into view, and clicked at its centre — no image input needed, and the only form that works without vision. Alternatively x/y viewport coordinates, meaningful only after a vision model located the element on a dsh_browser_screenshot; refused under the non-visual strategy. Provide exactly one of target or x/y.',
|
|
803
803
|
parameters: {
|
|
804
804
|
target: {
|
|
805
805
|
type: 'object',
|
|
@@ -1018,7 +1018,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1018
1018
|
|
|
1019
1019
|
registerSafely(defineTool({
|
|
1020
1020
|
name: 'dsh_browser_fill',
|
|
1021
|
-
description: 'Fill a form in one batch:
|
|
1021
|
+
description: 'Fill a form in one batch: each field takes a CSS selector or name/label/placeholder text plus its value (boolean for checkbox/radio; option value or visible text for selects and radio groups). The native setter plus input/change events fire, so React/Vue controlled inputs update. Optionally submit the form. Prefer this over hand-written dsh_browser_execute; per-field failures are reported instead of throwing.',
|
|
1022
1022
|
parameters: {
|
|
1023
1023
|
fields: {
|
|
1024
1024
|
type: 'array',
|
|
@@ -1315,7 +1315,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1315
1315
|
|
|
1316
1316
|
registerSafely(defineTool({
|
|
1317
1317
|
name: 'dsh_browser_screenshot',
|
|
1318
|
-
description: '
|
|
1318
|
+
description: 'Screenshot the shared-browser page (PNG default, JPEG optional) for models that can read images: layout, charts, CAPTCHAs, or locating an element before clicking its coordinates. Without image input it gains nothing — dsh_browser_snapshot/a11y/content carry the same page as text, dsh_browser_scrape extracts structured data. Supports fullPage, savePath, JPEG quality, and maxWidth/maxHeight downscaling to cut vision tokens. JPEG needs a carrier whose CDP encoder works (self-hosted, installed Chrome/Edge); the desktop sidebar returns PNG. Downscaling works everywhere.',
|
|
1319
1319
|
parameters: {
|
|
1320
1320
|
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
|
|
1321
1321
|
savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
|
|
@@ -1764,7 +1764,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1764
1764
|
|
|
1765
1765
|
registerSafely(defineTool({
|
|
1766
1766
|
name: 'dsh_browser_restrict',
|
|
1767
|
-
description: '
|
|
1767
|
+
description: 'Allow only the listed browser tools; any other dsh_browser_* call is refused. Empty list or omitted lifts it. Never blocked whatever the list says — the observing tools (snapshot/a11y/content/scrape/screenshot/get_value/wait/challenge/list_tabs/session/history/visited) and restrict/reset/reset_session — so a restriction can always be read around and undone. A SOFT guardrail against stray clicks, NOT a security boundary: you can lift it with an empty list.',
|
|
1768
1768
|
parameters: {
|
|
1769
1769
|
allowed: {
|
|
1770
1770
|
type: 'array',
|
|
@@ -1798,7 +1798,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1798
1798
|
|
|
1799
1799
|
registerSafely(defineTool({
|
|
1800
1800
|
name: 'dsh_browser_auth',
|
|
1801
|
-
description: 'Export or restore the
|
|
1801
|
+
description: 'Export or restore the session\'s cookies (login state): "flush" returns a JSON cookie list, "restore" takes that list back (e.g. after the host restarted). Works on all carriers. Refused when the credential switch is off. Exported cookies are LIVE CREDENTIALS: never echo them into the conversation or logs; keep the list in a private file.',
|
|
1802
1802
|
parameters: {
|
|
1803
1803
|
action: { type: 'string', required: true, enum: ['flush', 'restore'], description: 'flush = export cookies; restore = import cookies.' },
|
|
1804
1804
|
cookies: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Cookie list to restore (required when action=restore).' },
|