dsh-builtin-browser 0.4.2 → 0.4.6
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 +1753 -0
- package/README.en.md +63 -20
- package/README.md +60 -20
- package/client.js +433 -24
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +425 -163
- package/docs/architecture.md +4 -4
- package/docs/tool-reference.md +3 -3
- package/docs/user-guide.md +50 -2
- package/lib/browser/types.d.ts +9 -2
- package/lib/browser-electron/bridge-connection.d.ts +18 -21
- package/lib/browser-electron/bridge-connection.js +42 -1
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +20 -0
- package/lib/browser-electron/entry.js +110 -7
- package/lib/browser-electron/history-store.d.ts +11 -2
- package/lib/browser-electron/history-store.js +27 -5
- package/lib/browser-electron/host-main.js +11 -1
- package/lib/browser-electron/provider.d.ts +62 -2
- package/lib/browser-electron/provider.js +28 -12
- package/lib/browser-electron/remote-host.js +19 -6
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +6 -6
- package/lib/browser-electron/settings-route.js +48 -8
- package/lib/browser-electron/settings-store.js +57 -16
- package/lib/browser-electron/system-browser.d.ts +31 -9
- package/lib/browser-electron/system-browser.js +116 -27
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +26 -60
- package/package.json +6 -2
- package/src/browser/types.ts +9 -2
- package/src/browser-electron/bridge-connection.ts +37 -1
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +113 -8
- package/src/browser-electron/history-store.ts +32 -7
- package/src/browser-electron/host-main.ts +10 -1
- package/src/browser-electron/provider.ts +91 -18
- package/src/browser-electron/remote-host.ts +19 -6
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +54 -13
- package/src/browser-electron/settings-store.ts +56 -17
- package/src/browser-electron/system-browser.ts +121 -28
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +31 -60
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,1753 @@
|
|
|
1
|
+
# dsh-builtin-browser CHANGELOG
|
|
2
|
+
|
|
3
|
+
本文件记录各轮功能开发与问题修复(由仓库外独立文档迁入,随仓库管理)。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 第四十二轮(2026-10-08,三个"能看见的"缺陷:跨会话读错、空白页堆积、升级登出)
|
|
8
|
+
|
|
9
|
+
0.4.5 发布后,用户连续报了三个现象。**三个都在同一段代码的新实现里**,而它们看着毫不相干——一个是"别的会话读到我这边",一个是"一次开一堆空白页",一个是"升级后所有站点被登出"。前两个实际上是**同一个会话在两种状态下的两种表现**。
|
|
10
|
+
|
|
11
|
+
## 一、面板查找越出本会话(跨会话读错)
|
|
12
|
+
|
|
13
|
+
**症状**:另一个会话调用时读到了**本会话的侧栏**——同一段对话里两次读取给出相反结论(先说"已登录"、后说"未登录"),点 GitHub 登录也没有反应。
|
|
14
|
+
|
|
15
|
+
**根因(实测确认,不是推理)**:客户端半边的 `guestsInPanel` 从本会话的侧栏条**向上走**,找"第一个含 `<webview>` 的祖先"。而**外壳把所有会话的侧栏都挂在同一棵文档树里**(隐藏的也在),所以多走一步就落到了共同根,那里的"第一个 guest"属于**外壳先渲染的那个会话**——顺序随挂载状态漂移,于是同一个会话两次能拿到不同的页面。
|
|
16
|
+
|
|
17
|
+
现场证据:
|
|
18
|
+
|
|
19
|
+
panelGuest(本会话 session-fe0a980a) →
|
|
20
|
+
{"panel":true,"guestId":3,"url":"https://github.com/login?...virtualembryo.ai..."}
|
|
21
|
+
|
|
22
|
+
那个 GitHub 登录页**不是本会话打开的**。本会话因此会去驱动一个不属于它的页面——第二次读取时那个页面已不在,于是"未登录"。
|
|
23
|
+
|
|
24
|
+
**修法**:向上走的循环**一遇到不属于本会话的侧栏条就停**。同级的侧栏条是"已经跨出本会话"的精确标志,且不依赖任何类名;一个会话自己可以有多个,所以判据是**"有没有不是我的"**,不是数量。起点也改回侧栏条自身(而非其父节点),这样 guest 与条同级的布局也能走到。
|
|
25
|
+
|
|
26
|
+
**守护用例**构造了两会话共存的树、邻居持有 guest,断言本会话报告自己没有、且打开面板时**不导航邻居的任何页面**。
|
|
27
|
+
|
|
28
|
+
## 二、等待期间误建页面(一次开一堆空白初始页)
|
|
29
|
+
|
|
30
|
+
**症状**:"一次性打开很多个浏览器初始页面,但只有最后一个是我们需要的。"
|
|
31
|
+
|
|
32
|
+
**根因**:桥在等 guest 附着时每 250ms 轮询一次,**而每轮调的都是 `openPanel`——那是个会创建标签的动作**。20 秒的预算 = 最多 80 次创建,每次一个不带地址的初始页。"最后一个是需要的"也对得上:第一个才是带地址的那次。
|
|
33
|
+
|
|
34
|
+
**修法**:等待改用**只读**形式。`evaluatePanelService` 现在明确区分两种语义——**`null` 是读,字符串才是放置/导航**。只读形式自带 `ok`,因为"你现在有什么"回答"还没有"是合法答案,不是请求失败(否则每一轮都像出错)。
|
|
35
|
+
|
|
36
|
+
**守护用例**:无论等待多久都**恰好一次**放置,且每一轮调的都是 `api.panelGuest(`、从不调 `api.openPanel(`。匹配的是**调用**而非名字,因为每个表达式都带 `typeof api.openPanel !== 'function'` 的能力检查。
|
|
37
|
+
|
|
38
|
+
## 三、升级把浏览器登出(profile 路径变更的尾巴)
|
|
39
|
+
|
|
40
|
+
**症状**(由 @EmyiaC 在 issue #21 复测后报告):0.4.1 → 0.4.5 升级后,浏览器**登出所有站点**。他建议写进 release notes。
|
|
41
|
+
|
|
42
|
+
**根因**:早先修过一个路径不一致——从快捷方式启动的桌面端(环境里没有 `DSH_HOME`)把浏览器 profile 放在 `<homedir>/dsh-builtin-browser-host/`,而 settings 与 history 在 `<homedir>/.dsh/dsh-builtin-browser-host/`。**登录态就在 profile 里**,路径一改,旧目录连同登录态被丢下。
|
|
43
|
+
|
|
44
|
+
**修法**:**不写那行 release note,而是把代价去掉。** apply 时若旧根存在、当前根不存在,则把旧根**移**过去。两条规则兜住风险:
|
|
45
|
+
|
|
46
|
+
- **只在目标缺席时移动**——正在用的 profile 永不被覆盖;
|
|
47
|
+
- **任何失败都退回空 profile**(跨设备 rename、权限、被别的进程占用),此时旧目录**原样保留**而不是移一半。Windows 的 rename 需要目标父目录存在,所以先建。
|
|
48
|
+
|
|
49
|
+
**守护用例四条**:移动、不覆盖、无可迁移、两侧同路径。旧根做成**带默认值的参数**,这让移动**可以在不碰真实 home 的情况下被测**。
|
|
50
|
+
|
|
51
|
+
**对已升级的人(包括报告者)为时已晚**——他们的登出发生在修复发布之前。它有效的是"下一次升级的人不必再付这笔代价"。
|
|
52
|
+
|
|
53
|
+
## 四、这一轮的自我更正
|
|
54
|
+
|
|
55
|
+
**第一个缺陷是我两条命令之前刚引入的**:同一轮里我先修了"向外读错",又在修"等待误建"时把轮询从创建改成只读,**中间那版一度让"标签数没增加"被误判成"侧栏没挂载"**——而 `openTabIn` 对同类页面是**去重**的,所以那个判据会把"已经持有该页面"读成"未挂载",于是"打开过的地址反而打不开"。判据改为**"从 0 到 0"**才是真信号,那条守护用例在写出来的当次就抓到了同批的一个 `created` 恒真错误。
|
|
56
|
+
|
|
57
|
+
**发布**:0.4.6。`npm` 与仓库同步——**没发出去的修复等于没有修复**。
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# 第四十一轮(2026-10-06,桌面端:会话串扰的真正根因,以及一次架构级改正)
|
|
62
|
+
|
|
63
|
+
用户报的症状只有一句:**"还是其他窗口的侧栏"**。前几轮都在 owner 台账上加检查,这一轮做的第一件事是**不采信台账的立论,把它实测一遍**。
|
|
64
|
+
|
|
65
|
+
## 一、owner 台账的立论是错的
|
|
66
|
+
|
|
67
|
+
上一轮写下并自称"实测过"的前提:
|
|
68
|
+
|
|
69
|
+
> 每个 DSH 会话是自己的插件进程 …… 两个会话时桌面有两个 host 进程。
|
|
70
|
+
|
|
71
|
+
这一轮把进程全量枚举:
|
|
72
|
+
|
|
73
|
+
| pid | 命令行摘要 | 真实身份 |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| 47648 | `DeepSeek Harness.exe` | Electron 主进程 |
|
|
76
|
+
| 27136 | `--expose-internals …dsh-desktop-host/lib/index.js <app> <profile> …` | **唯一**的 DSH 宿主进程 |
|
|
77
|
+
| 44348 / 7648 / 39192 | `dsh-subprocess-local/runner.js -- powershell.exe …` | **工具执行的沙箱 runner**,随每次命令起落 |
|
|
78
|
+
|
|
79
|
+
宿主进程的命令行里**没有任何会话标识**。上一轮看到的"第二个 host 进程"是**工具 runner**,不是会话进程。前提塌了,建在它上面的台账也就没有意义。
|
|
80
|
+
|
|
81
|
+
**而且这不只是理论问题**:`entry.ts` 的 `readSessionId` 取 `ctx.get('agents').list()[0].id`。打包版 `dsh-desktop-host/lib/index.js` 自己用 `agents.list()` 只是为了判断"有没有活跃任务"(L144、L167)——它是**单例**进程,这个列表装着**所有**会话。`[0]` 是哪一个是完全不受控的,所以那个 `owner` 完全可能是**另一个会话的 id**。用户看到的"还是其他窗口的侧栏",这就是来源。
|
|
82
|
+
|
|
83
|
+
## 二、真正的机制:创建面板的通路只看屏幕
|
|
84
|
+
|
|
85
|
+
DSH 的 `browser.new`(`Ctrl+T`,或启动页的「浏览器」卡片)最终落到:
|
|
86
|
+
|
|
87
|
+
ui-sidebar-browser/src/client/index.ts:59-63 openTabFromTarget('browser', target)
|
|
88
|
+
ui-sidebar-right/src/client/service.ts:534 → this.require()
|
|
89
|
+
ui-sidebar-right/src/client/service.ts:727 const sessionId = this.mounted.getSnapshot()
|
|
90
|
+
|
|
91
|
+
`mounted` 是**外壳此刻显示的那个会话**。所以任何"模拟按键 / 点卡片 / 往地址栏打字"的创建方式,**结构上只能建到人此刻在看的会话**——"在 A 里发指令、切到 B、页面落在 B",是这条通路的定义,不是偶发。
|
|
92
|
+
|
|
93
|
+
## 三、正规入口本来就在
|
|
94
|
+
|
|
95
|
+
ui-sidebar-right/src/client/service.ts:358 openTabIn(sessionId, kind, options)
|
|
96
|
+
打包运行版实测存在:dsh-client-ui-sidebar-right/lib/client.js:6418
|
|
97
|
+
|
|
98
|
+
它按**指定会话**导航,**完全不读 `mounted`**,而且"reaches any session's store by id … on screen or not"(同文件 L1-35 的设计说明)。这一轮把它变成唯一的创建通路。
|
|
99
|
+
|
|
100
|
+
## 四、改了什么
|
|
101
|
+
|
|
102
|
+
**1. 渲染侧:一个按会话工作的面板服务(新增)**
|
|
103
|
+
|
|
104
|
+
`client.js` 发布 `globalThis.__dshBuiltinBrowser`:
|
|
105
|
+
|
|
106
|
+
openPanel(sessionId, url) 有页面就报 guest id;没有就 openTabIn 再等它出现
|
|
107
|
+
panelGuest(sessionId) 只读地报当前 guest
|
|
108
|
+
|
|
109
|
+
面板按会话 id 在 React fiber 上找(`[class*=_tabStrip]` 上的 `sessionId`),**只在那个子树里**读 `<webview>` 的 `getWebContentsId()`。找不到该会话的侧栏就**具名拒绝**,不去够别人的。
|
|
110
|
+
|
|
111
|
+
**2. 桥:创建通路换成一次求值**
|
|
112
|
+
|
|
113
|
+
ensureSidebar → evaluatePanelService(shell, owner, url)
|
|
114
|
+
→ shell 里执行 __dshBuiltinBrowser.openPanel(sessionId, url)
|
|
115
|
+
→ 拿到该会话自己的 guest,CDP 驱动
|
|
116
|
+
|
|
117
|
+
**没有点击、没有按键、没有 DOM 查询决定页面去哪**。
|
|
118
|
+
|
|
119
|
+
**3. 宿主:owner 从"进程"搬到"视图"(架构改正)**
|
|
120
|
+
|
|
121
|
+
一个插件进程服务**所有**会话,所以 owner 放在 host 上只能是一个会话的 id 施加给全部。现在:
|
|
122
|
+
|
|
123
|
+
ElectronBrowserViewHost.createView(owner?) ← 新增参数,provider 传 label
|
|
124
|
+
DesktopBridgeViewHost.views: Map<viewId, { guest?, owner }>
|
|
125
|
+
releasePage(owner?) ← 具名释放,不再关掉别人的页面
|
|
126
|
+
|
|
127
|
+
`showView` / `releasePage` / `orphaned` 全部随之带上归属。
|
|
128
|
+
|
|
129
|
+
**4. 删除**
|
|
130
|
+
|
|
131
|
+
上一轮为"串扰"写的补偿代码整体移除,共 **743 行**:`legacyEnsureSidebar`(Ctrl+T + 点卡片 + 往地址栏打字,517 行)、`ensureTabs`(只数不建的 84 行)、`shellPrepareSidebar`(108 行)、`sidebarState`、宿主侧的 `sidebarOwnership` + `SIDEBAR_OWNERSHIP_PROBE`。桥从 1357 行回到 614 行。
|
|
132
|
+
|
|
133
|
+
删除而非改名:`legacyEnsureSidebar` 留着就是一个**能手工调用、且必然开错地方**的入口。
|
|
134
|
+
|
|
135
|
+
## 五、验证
|
|
136
|
+
|
|
137
|
+
`tests/` 从 222 条到 **230 条,229 通过、0 失败、1 跳过**(平台门控)。`tsc` 干净,两份产物 `node --check` 干净。
|
|
138
|
+
|
|
139
|
+
新写的守护用例直接盯住这次的机制:
|
|
140
|
+
|
|
141
|
+
| 用例 | 断言 |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `opening a panel names the calling conversation and never the on-screen one` | 调 `openTabIn` 且**从不**调 `openTab` |
|
|
144
|
+
| `the panel lookup never leaves the calling conversation subtree` | 同一文档里两个会话的侧栏,A 拿不到 B 的 guest |
|
|
145
|
+
| `a conversation the shell has not mounted is refused by name, not driven` | 拒绝,**零次**导航 |
|
|
146
|
+
| `nothing in the bridge clicks or types into the shell any more` | 全文件无 `card.click()`、无 `Input.dispatchKeyEvent` |
|
|
147
|
+
| `two conversations asking in turn are each answered with their own page` | 路由按会话 id,不靠仲裁 |
|
|
148
|
+
| `every view of one conversation drives that conversation's one page` | 该载体的语义是"一个会话一个可见页面" |
|
|
149
|
+
|
|
150
|
+
**违反上一轮假设的用例被改写成守护新机制,而不是删掉**——旧用例记录的是旧通路,那正是现在要防止回归的东西。
|
|
151
|
+
|
|
152
|
+
## 六、实机验证:机制成立,并且发现了两条只在真机上才看得见的事实
|
|
153
|
+
|
|
154
|
+
`tsc` 与 230 条测试都是离线的。之后用一条直连桥的探针(`probe-panel.mjs`,只走 socket 协议、不经插件)在**运行中的外壳**上实测,得到三件事。
|
|
155
|
+
|
|
156
|
+
**① 两条假设都被证实,不需要重启。**
|
|
157
|
+
|
|
158
|
+
- `exec.agent.id` **就是**渲染侧用来标记侧栏的那个 sessionId:探针用本会话 id 调用时,渲染侧的服务**找到了该会话自己的 store**,并在其中成功放置了面板。
|
|
159
|
+
- 插件 client 半边**已经注入到 `sidebarRight`**:它返回的是本文件自己写的裁决,而不是 "the shell's sidebar service is not available"。
|
|
160
|
+
- 另外发现:`client.js` 的改动**不必重启外壳**即生效(探针在未重启的进程里就吃到了新代码)。
|
|
161
|
+
|
|
162
|
+
**② shell 窗口的 debugger 通道不可靠,guest 的可靠 —— 而这不是本轮的改动导致的。**
|
|
163
|
+
|
|
164
|
+
分层实测:同一个 `op: 'cdp'` 对 guest(页面)秒回,对 shell 窗口(`dsh-app://app/`)则**反复挂起数分钟**,连 `Browser.getVersion` 这种不碰页面 JS 的命令也一样。所以面板通路**不再走 CDP**:shell 只是一个宿主 UI,本轮要它做的只是一次同步求值,改用 `webContents.executeJavaScript` —— 不经 debugger、没有会失效的 attach、没有会丢的回包。`evaluateInPanel`(`showTab`/`closeSidebarBrowser`/`collapseSidebar` 用)一并改掉。**guest 驱动仍用 CDP**,因为驱动一个页面确实需要协议。
|
|
165
|
+
|
|
166
|
+
顺带修了一个会让桥永久失声的缺陷:`stop()` 不 detach debugger,而 attach 属于 `webContents`、不属于模块;热重载(见下)换掉模块后,新桥看到 `isAttached() === true`,于是跳过自己的 attach,把命令发进一个已经死掉的会话。现在 `start()` 里先把残留的 attach 全部丢掉 —— 刚启动的桥没有任何自己的 attach,所以此刻还挂着的必然是遗留。
|
|
167
|
+
|
|
168
|
+
**③ 外壳只渲染"当前显示会话"的侧栏 —— 这是 DSH 的行为,不是插件的。**
|
|
169
|
+
|
|
170
|
+
实测数据:DOM 里只有一个侧栏容器,它的 owner 是**另一个会话**;而本会话的面板被成功放置(公开方法 `tabsIn` 证明 tab 真的进了本会话的 store),只是**没有 DOM**,因此读不回 guest。
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
wanted : session-fe0a980a-…
|
|
174
|
+
strips : 1
|
|
175
|
+
owners : ["session-279557d6-…"] ← 只有屏幕上那个会话有 DOM
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**这条数据同时是最强的正确性证据**:渲染侧看到了**别的**会话的侧栏,没有去动它,而是**具名拒绝**并回报自己找到了什么。**开错地方这件事在结构上已经不可能发生。**
|
|
179
|
+
|
|
180
|
+
由此得到一条必须写进 README 的行为:**在会话 A 里发出的请求会落在 A 的侧栏;A 若不是屏幕上显示的那个,面板会照建立,但页面要等 A 被显示时才附上**。所以"我在 A 问、随后切到 B,A 的页面仍然只在 A"成立;而"切走之后立刻从 A 里读回页面"做不到 —— 那是外壳不渲染后台会话侧栏的结果。
|
|
181
|
+
|
|
182
|
+
**④ 会话内成功验收。**
|
|
183
|
+
|
|
184
|
+
用户切回本会话后,同一条真实链路走通:
|
|
185
|
+
|
|
186
|
+
browser_open https://example.com/ → URL: https://example.com/
|
|
187
|
+
Title: Example Domain
|
|
188
|
+
[1] link: Learn more
|
|
189
|
+
browser_content (markdown) → 页面正文(多语言示例文本)
|
|
190
|
+
|
|
191
|
+
工具层 → provider → 宿主 → 桥 → 渲染侧按会话 id 打开面板 → guest 附着 → CDP 读回,**整条链路成立**,页面落在**发起会话自己的侧栏**里。
|
|
192
|
+
|
|
193
|
+
而更有价值的是它与前一次的对照:用户切走时,同一次调用得到的是"面板已放置,但页面只能在被显示时附着";用户切回后,同一次调用成功。**两次的差别只有屏幕上是哪个会话,落点没有变**——这正是本轮要建立的性质。
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
# 第四十轮(2026-10-05,桌面端:侧栏多标签、设置保存,以及一次收敛失败的自查)
|
|
198
|
+
|
|
199
|
+
用户报的三个症状:**设置面板全部点不动**、**第二次打开浏览器报"认不出"**、**不断新建浏览器入口**。
|
|
200
|
+
|
|
201
|
+
这一轮的特殊之处是:**十三处修复里有四处是我自己在同一次会话中引入的**。因此后半段的方法从"改代码"转向"建立能发现我自己错误的检查",最后才收敛。
|
|
202
|
+
|
|
203
|
+
## 一、设置面板点不动 —— 三次猜错,第四次是实测
|
|
204
|
+
|
|
205
|
+
**症状**:桌面端任何开关改了都不生效,退出再进仍是旧值。
|
|
206
|
+
|
|
207
|
+
**根因**:`sameOrigin` 用 `Origin` 与 `Host` 比对,而外壳从 Electron 自己的 `dsh-app://` 提供 UI、插件路由在 `127.0.0.1:<port>` —— 两者必然不等,每笔写入被 403 拒掉,而面板不显示这个错误。
|
|
208
|
+
|
|
209
|
+
**三次猜错**:先后假设请求头是 `dsh-app://app`、是 `null`、是缺 `Sec-Fetch-Site`。**全部落空。**
|
|
210
|
+
|
|
211
|
+
**实测纠正**:加一段临时诊断记录真实请求头,答案是**三个头全空**:
|
|
212
|
+
|
|
213
|
+
origin="" sec-fetch-site="" referer="" host="127.0.0.1:19387"
|
|
214
|
+
|
|
215
|
+
Chromium **不对离开自定义协议的请求附加跨源元数据**。判据改为测"手写客户端不会带的东西":完整的 `user-agent` + `accept-language`。curl 两样都没有、仍写不进去;而伪造它们意味着能直读磁盘上的设置文件 —— 那从来不是真正的边界。
|
|
216
|
+
|
|
217
|
+
**实测**:`POST /dsh-builtin-browser/settings` → **200**,`channel` 从 `bundled` 变为 `sidebar`,文件 mtime 更新。**此修复不需要重启**(插件是 link: 的,lib 每次进程启动重读)。
|
|
218
|
+
|
|
219
|
+
## 二、第二次打开:宿主的真实调用形态从未被测过
|
|
220
|
+
|
|
221
|
+
**实测出的宿主机制(两步)**:
|
|
222
|
+
|
|
223
|
+
建「浏览器」标签(Ctrl+T 或标签栏 "+") -> 标签存在,但【没有】webview
|
|
224
|
+
在该标签里导航 -> webview 才出现
|
|
225
|
+
|
|
226
|
+
而这轮读宿主工具层才发现:**会话的第一次 `browser_open` 到达桥时【没有 url】**,因为打开 view 的命令是 `documentStamp`(走 `Runtime.evaluate`),`Page.navigate` 在它之后。桥在无 url 时只做一件事 —— 探"恢复上次页面"按钮。于是第一次打开实际是**赌侧栏里恰好有一个可恢复的页面**;而我自己早先的验证之所以通过,是因为我总是**直接带着 url** 调用 `ensureSidebar`,那是宿主第二次调用的形态。
|
|
227
|
+
|
|
228
|
+
## 三、十三处修复
|
|
229
|
+
|
|
230
|
+
| # | 问题 | 后果 |
|
|
231
|
+
|---|---|---|
|
|
232
|
+
| 1 | `ensureSidebar` 复用分支无视 owner 台账 | 会话 B 被直接还给 A 的页面 |
|
|
233
|
+
| 2 | `list` 返回所有 guest | 能看到别人的页面 |
|
|
234
|
+
| 3 | `showTab` 不验 owner | 能把侧栏切到别人的页面上 |
|
|
235
|
+
| 4 | `closeSidebarBrowser` 不验 owner | **能关掉别人的页面** |
|
|
236
|
+
| 5 | `ensureSidebar` 返回 `created[0]` 而非新建的 guest | 导航被施加到别人的标签;调用方报失败 |
|
|
237
|
+
| 6 | `+` 控件没做可见性过滤(页面有两个,一个隐藏) | 点到隐藏元素,什么都不发生 |
|
|
238
|
+
| 7 | `guestFor` 从不传 `newTab` | 第二个 view 的重担落到最脆的 `ensureTabs` 上 |
|
|
239
|
+
| 8 | `releasePage` / `showView` / `closeSidebarBrowser` 调用点缺 owner(3 处) | **全部静默失效**:什么都没关、什么都没切 |
|
|
240
|
+
| 9 | 无 url 时只探"恢复"、不建页面 | 首次打开靠运气 |
|
|
241
|
+
| 10 | `newTab` 与无 url 两条路都点卡片 | 一次调用建两个页面 |
|
|
242
|
+
| 11 | 有地址栏时仍点卡片 | 又建一个多余页面 |
|
|
243
|
+
| 12 | 普通路径用**前缀**匹配启动器卡片 | 命中标签栏上名为「浏览器」的标签,只切标签不建页面 |
|
|
244
|
+
| 13 | **`String(await sendCdp(...))?.result?.value`** | 探测函数永远返回 `undefined` → 永远走"点卡片" → **"不断新建"的真正根源** |
|
|
245
|
+
|
|
246
|
+
写表之后又找到三处,共同点是**都来自"回到早期实测数据重新读"**,而不是继续读代码:
|
|
247
|
+
|
|
248
|
+
| # | 问题 | 后果 |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| 14 | `newTab` 激活的是"名字以「浏览器」开头的最新标签" | 而 Ctrl+T 建的那个叫「开始」(实测的标签栏内容)。于是它激活了一个【旧】标签,地址栏因此可见,下一步便断定"已有页面"并跳过点卡片 —— 新建的引导页标签永远停在引导页 |
|
|
251
|
+
| 15 | 无 url 时等满 12 秒(24 轮 × 500ms) | 而 guest 只在【导航】后出现,这次调用不导航 —— 每个会话的第一次 `browser_open` 都白等 12 秒。不是边缘情况:宿主的首个调用(`documentStamp`)本来就无 url |
|
|
252
|
+
| 16 | `ensureTabs` 点 `+` 与卡片试图建标签(循环 30 次) | 它建出的标签**永远不会产生 guest**(同上:它不导航),于是等满 18 秒后失败,**并把那个空的「开始」标签留在侧栏里** —— 那正是"不断新建浏览器入口"的另一半:里面什么都没有的入口 |
|
|
253
|
+
|
|
254
|
+
第 13 处是这一轮最重要的发现:它**通过了 `node --check`、所有权测试、边界测试,以及我自己三次逐行阅读**。发现它靠的不是读代码,而是一个**数点击次数**的 harness —— 这一整个会话的失败模式不是"答错了",是**"答对了太多次"**。
|
|
255
|
+
|
|
256
|
+
## 四、为了发现自己的错误而建的检查
|
|
257
|
+
|
|
258
|
+
后半段的工作重心从修 bug 转为建立检查,每条都对应一次**实际犯过的错**,而不是预想的错:
|
|
259
|
+
|
|
260
|
+
- `bridge-parses` —— 我第四次把反引号写进模板字符串,整个文件不再解析。改用 Node 自己的解析器,外加一条只扫 `expression: \` 到 `})()\` 之间的定点检查(通用扫描器误报十几处,已废弃)。
|
|
261
|
+
- `bridge-ownership` —— 5 条,用**进程内加载真实桥文件**(改写 electron import 为 stub)驱动,不必部署。
|
|
262
|
+
- `bridge-boundary` —— 4 条,检查"宿主发的 op 桥都有"与"桥设门的 op 调用时都带 owner"。这一条抓出了第 8 处的三处静默失效。
|
|
263
|
+
- `bridge-card-clicks` —— 4 条,**数点击次数**。抓到第 13 处。
|
|
264
|
+
- `bridge-reply-shape` —— 2 条,扫描"响应被先 String 再取值"这一形状,并验证过它在旧代码上会报出恰好 1 处违规。
|
|
265
|
+
- `acceptance-script` —— 3 条,证明验收脚本**能失败**。永远不会失败的检查器是最坏的浪费。
|
|
266
|
+
- `session-invariants` —— 7 条,把这一轮建立的全部不变量从代码里重新推导一遍。
|
|
267
|
+
|
|
268
|
+
## 五、收敛判定
|
|
269
|
+
|
|
270
|
+
十轮静态自查之后,结论由**不变量**给出而非"看起来对了":
|
|
271
|
+
|
|
272
|
+
七条不变量全部成立 → 静态层面没有更多可提取的事实,剩下的未知只有运行中的实例
|
|
273
|
+
|
|
274
|
+
因此重启前的最后一件工作是 `tools/accept-desktop.mjs`:**八项检查,一条命令**,脚本自身已用三个假桥端点验证过"正确时通过、错误时失败"。
|
|
275
|
+
|
|
276
|
+
**一个没有采纳的方向(记录以免重复)**:曾尝试在进程内模拟完整流程 —— 让 harness 模拟外壳的行为
|
|
277
|
+
(Ctrl+T 产生「开始」标签、点卡片把它变成「浏览器」标签、导航后才产生 guest),从而不必重启就能验证。
|
|
278
|
+
实测发现:桥发出的第一个页面脚本就是 `shellPrepareSidebar`,而它自己负责找地址栏、点卡片、发 Ctrl+T,
|
|
279
|
+
调用方必须精确回答它返回的每个 verdict(`FOCUSED` / `CLICKED_LAUNCHER` / `SENT_CTRL_T` / `NO_ADDRESS_BAR`)。
|
|
280
|
+
这意味着模拟器要携带"桥的页面脚本长什么样"这一知识,而**每次改桥都会让它悄悄失效** —— 一个过期的模拟器
|
|
281
|
+
比没有更糟:它会报告流程正常而真实外壳行为不同。因此保留现有两层(分支级 harness + 真机 10 项验收),
|
|
282
|
+
不做中间层。
|
|
283
|
+
## 六、教训
|
|
284
|
+
|
|
285
|
+
**十三处里有四处是我自己引入的。** 后半段每次改动的期望收益都在下降,而"再读一遍代码"已经证明抓不到第 13 处那类错误。真正有效的是:**把问题从"这对不对"换成"这发生了多少次"。**
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
# 第三十八轮(2026-10-05,桌面端:设置面板、自动打开、多标签、issue #25)
|
|
292
|
+
|
|
293
|
+
一轮端到端的桌面端故障排查。**全部结论都来自运行中的实例**(探针日志、boot manifest、combo 响应、真实请求头),而不是读代码推断 —— 这一点很关键,因为本轮**三次静态推断都是错的**,最后都是实测纠正的。
|
|
294
|
+
|
|
295
|
+
## 一、设置面板在桌面端根本存不进去(所有开关都点不动)
|
|
296
|
+
|
|
297
|
+
**症状**:桌面端设置面板里任何开关点了都"没反应",退出再进仍是旧值。web 端正常。
|
|
298
|
+
|
|
299
|
+
**根因**:sameOrigin 用 Origin 与 Host 比对,而桌面外壳从 Electron 自己的 dsh-app:// 协议提供 UI、插件路由在 127.0.0.1:<port> —— 两者**必然不等**,每笔写入都被 403 拒掉,而面板不显示这个错误。
|
|
300
|
+
|
|
301
|
+
**三次猜错**:先后假设请求头是 dsh-app://app(加白名单)、是 null(加 referer 兜底)、是缺 Sec-Fetch-Site(调整顺序)。**全部落空。**
|
|
302
|
+
|
|
303
|
+
**实测纠正**:加了一段临时诊断记录真实请求头,答案是**三个头全空**:
|
|
304
|
+
|
|
305
|
+
origin="" sec-fetch-site="" referer="" host="127.0.0.1:19387"
|
|
306
|
+
|
|
307
|
+
Chromium **不对离开自定义协议的请求附加跨源元数据**。判据因此改为测"手写客户端不会带的东西":完整的 user-agent + accept-language。curl 两样都没有、仍写不进去;而伪造它们意味着能直读磁盘上的设置文件 —— 那从来不是真正的边界。
|
|
308
|
+
|
|
309
|
+
**实测**:POST /dsh-builtin-browser/settings → **200**,channel 从 bundled 变为 sidebar,文件 mtime 更新。
|
|
310
|
+
|
|
311
|
+
## 二、同一会话只能有一个浏览器页面
|
|
312
|
+
|
|
313
|
+
**实测出的宿主机制(两步)**:
|
|
314
|
+
|
|
315
|
+
建「浏览器」标签(Ctrl+T 或标签栏 "+") -> 标签存在,但【没有】webview
|
|
316
|
+
在该标签里导航 -> webview 才出现
|
|
317
|
+
|
|
318
|
+
桥一直假设"建标签 = 有 webview",于是第二个标签永远等不到 guest。browser_open 之所以一直正常,是因为**它导航了** —— 那个不对称就是全部原因。
|
|
319
|
+
|
|
320
|
+
**修法**:ensureSidebar 新增 newTab,发**真实 Ctrl+T**(宿主自己注册的 browser.new;合成 DOM 事件到不了快捷键表,必须走 CDP Input 域)→ 激活最新标签 → 走原有地址栏路线导航它。
|
|
321
|
+
|
|
322
|
+
**实测**:一个会话、两个页面、全自动、只开两个,各自可操作(标题 / 正文 / 执行 JS / 截图)。
|
|
323
|
+
|
|
324
|
+
## 三、issue #25(隐藏 input 被当成地址栏)
|
|
325
|
+
|
|
326
|
+
报告者的诊断准确:**页面同时存在隐藏的 URL 输入框**,而桥取"最后一个匹配项"可能命中它,提交后得到 TYPED_ONLY、guest 从不创建。三处地址栏查找统一改为**可见性过滤 + 精确 placeholder 优先**。
|
|
327
|
+
|
|
328
|
+
## 四、自动打开(不再需要人工先开面板)
|
|
329
|
+
|
|
330
|
+
- 启动器卡片的正则**匹配不上真实文本** —— 卡片内容是「浏览器浏览网页Ctrl+T」,而规则要求整串等于「浏览器」。
|
|
331
|
+
- 随后改前缀匹配,**又**会命中标签栏上名为「浏览器」的标签(点它只是切标签)。最终判据:同时含标题与副标题、且长度受限。
|
|
332
|
+
- 调用方在"已请求打开"后不等待面板构建,立即找地址栏必然失败。改为轮询。
|
|
333
|
+
|
|
334
|
+
## 五、本轮引入并修掉的两个回归(如实记录)
|
|
335
|
+
|
|
336
|
+
- **ensureTabs 的每轮重试里放了一次点击** —— 而点击本身就是创建标签,于是 30 轮 = 十几个浏览器。改为**每次调用最多点一次**,循环只等待与重读 DOM。
|
|
337
|
+
- **用 PowerShell 改源码引入 BOM**(第二次)与**中文乱码**。源码编辑一律改用 Node 或编辑器工具。
|
|
338
|
+
|
|
339
|
+
## 六、两端差异清单(结论:只有装载层)
|
|
340
|
+
|
|
341
|
+
服务侧两端加载**同一个包**(都 link: 到本仓库),工具集不可能不同;桥的 7 个 op 与插件调用面**完全对齐**;dsh-client-modules 两端 md5 相同。**唯一差异在 package.json 的两个缺陷**(BOM 让 JSON.parse 失败、三个 inject 包缺 peer),已修。
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
# 第三十七轮(2026-10-04,架构重构 + 一份外部审查的落实)
|
|
348
|
+
|
|
349
|
+
**未 bump 版本** —— 这些改动随下一次发布生效。
|
|
350
|
+
|
|
351
|
+
一份独立审查外加一次架构评审,共同得出一个结论:**这个仓库最需要改的不是"大",是同一份事实在四处手抄**。两个已发布的 bug 都出自这个机制 —— `browser_a11y` 的 `selector` 与 `browser_screenshot` 的 `width`/`height`,都是 provider 算了、schema 声明了、而工具层的**手写白名单**把它丢了。两次全套测试都是绿的,因为**没有一个测试真的跑过一个工具**。
|
|
352
|
+
|
|
353
|
+
## 一、字段集:一处声明,两端同源
|
|
354
|
+
|
|
355
|
+
- 新增 `src/tool-browser/element-fields.ts`:元素与节点的字段表、由它导出的 item schema、以及 `projectElement`/`projectNode` 投影。schema 与投影从此同源,"漏一个静默丢数据、多一个整包被拒"在结构上不再可能。
|
|
356
|
+
- 类型上踩过一个坑并如实记下:`defineTool` 校验的是自己的 `ValueSchemaSpec`,`Record<string, unknown>` 太宽、`as const` 也救不了索引签名。最终**字面量承担类型、测试守护一致性**。
|
|
357
|
+
- `browser_snapshot` 的 schema 也切过来了 —— 之前的重构只做了 `browser_open` 那一半。
|
|
358
|
+
- `BrowserA11yNode.value` 的契约由 `string | null` 改为 `string?`:provider 产出 `null` 表示"不适用",而投影会丢掉它,两者说的是同一件事。
|
|
359
|
+
- 新增 `tests/element-fields.test.mjs`(7 条):schema ⟷ 表**双向相等**、投影携带全部声明字段、投影**丢弃未声明字段**、required 一致,以及**遍历全部 34 个注册工具**断言"schema 要求的字段工具确实产出"。
|
|
360
|
+
|
|
361
|
+
## 二、页面脚本:安全网从 4/16 变成全覆盖
|
|
362
|
+
|
|
363
|
+
- 原 `script-syntax.test.mjs` 靠"驱动某个行为"来捕获脚本,**最多覆盖 16 段里的 4 段** —— 虚拟光标、下载、bridge 的脚本从未被解析过。
|
|
364
|
+
- 新增 `tests/page-scripts-parse.test.mjs`:直接扫描源码,覆盖 provider、`host-main` 的工具栏与下载、`virtual-cursor`、以及 sidebar bridge 的脚本。
|
|
365
|
+
- 写它时一度以为发现了语法错误,实际是**抽取器**没做模板转义解码:源码里 `/recaptcha\\/api|.../i` 的 `\\/` 在模板求值时变成 `\/`,保留双反斜杠会让正则提前结束、把后面的内容读成 flags。这条教训写进了测试注释。
|
|
366
|
+
|
|
367
|
+
## 三、自托管载体的两个真 bug
|
|
368
|
+
|
|
369
|
+
- **`materializeOnce` 的"失败即重置"是死代码**:存进去的是**派生 promise**,而 catch 里比较的是**原始 promise**,永远为假。于是子进程活着但不应答这类失败(不是 `HostGoneError`,`withRecovery` 不兜)会**永久毒死该句柄**,注释承诺的"重试"不可达。
|
|
370
|
+
- **`fail()` 误清 `kill()` 的 500ms 兜底刀**:socket 死 ≠ 子进程死,而刀存在的唯一理由正是子进程不理会 socket 关闭。清掉它意味着 dispose 后进程/窗口泄漏、继续占着 profile。现在只在观察到的退出与释放路径上清。
|
|
371
|
+
|
|
372
|
+
## 四、seam 不再说谎
|
|
373
|
+
|
|
374
|
+
- 四个能力从**结构性强转**提升为接口声明:handle 的 `download`/`flushAuth`/`restoreAuth`/`capture`,以及宿主的 `notifyUserActionError`。强转对编译器什么都不说,照着接口实现的新宿主会得到静默 no-op。
|
|
375
|
+
- 顺带把 issue #16 的现场收进契约:那次未绑定调用让 `this` 为 undefined,宿主第一条语句抛错、异常逃出 async catch 变成 unhandled rejection,**整个 DSH 进程退出**。
|
|
376
|
+
- `capture` 的契约里补上尺寸 —— 那是 `browser_screenshot` 的 schema 声明过的,而 seam 从来只有 `{base64, mime}`。
|
|
377
|
+
- 删掉 8 个只为满足接口而存在的空方法,以及 2 个全仓无调用点的宿主级 `focus()`。接口成员全是 `?` 可选,provider 全部 `?.()` 调用,这是纯减法。
|
|
378
|
+
|
|
379
|
+
## 五、截图尺寸:native 路径终于也有
|
|
380
|
+
|
|
381
|
+
审查指出 `2a8e52e`("a scaled screenshot reports its size")**只修了 CDP 载体那一半**:schema 与 execute 都做了,但**原生 `capturePage` 路径没把尺寸传回**,而 `RemoteView.capture` 的契约只有 `{base64, mime}` —— 于是**默认的自托管载体上 `width`/`height` 永远缺席**。现在 child 侧在下采样之后记录尺寸并回报,provider 的 `capture()` 类型、`screenshot()` 返回类型与 native 分支全部接通。
|
|
382
|
+
|
|
383
|
+
## 六、设置与存储
|
|
384
|
+
|
|
385
|
+
- **`section()` 不再对"整节手误"fail-open**:`"actions": false`、`null`、一个字符串都会被当成"缺席",于是每个 gate 取**放行的默认值** —— 与它自己"不可读的开关必须解析为 OFF"的教义相反。现在区分"缺席"(取默认)与"存在但非对象"(拒绝)。
|
|
386
|
+
- **`history.maxEntries`/`maxAgeDays` 不再是死旋钮**:它们被解析、被持久化,却从不传给 `HistoryStore`,`entry.ts` 也不传。改开关有效、改数字无效。现在读取器把当前值传给 `updateLimits()`。
|
|
387
|
+
|
|
388
|
+
## 七、系统浏览器载体
|
|
389
|
+
|
|
390
|
+
- **`'exited'` 的报错不再把任意退出码都解释成"移交给了占着 profile 的实例"** —— 那句解释只对 code 0 成立,而对崩溃码它会把人引向一个不存在的残留进程。
|
|
391
|
+
- **轮询同时看 `signalCode`**:被信号杀死的浏览器从不设置 `exitCode`,只查它是让一次秒死拖满 30 秒、最后报成"did not expose CDP"。
|
|
392
|
+
- **端口探活的 `fetch` 加上 `AbortSignal`**:环回请求挂住会越过启动 deadline,因为循环只在迭代之间复查。
|
|
393
|
+
|
|
394
|
+
## 八、RPC 词汇表
|
|
395
|
+
|
|
396
|
+
- 新增 `src/browser-electron/rpc-ops.ts`:子端 switch 与父端发送的 op 名从此有了一份清单,并有测试**读两侧源码**断言一致。这类"改一处要记得改另一处"的缺口已经咬过一次 —— `noteStartFailure` 里那个恒为真的去重比较,让一个专门让静默失败可见的日志行**从未写过**。
|
|
397
|
+
- 该测试当场抓到清单里两个我凭印象写下的 op(`release`/`collapse`)子端并不处理。
|
|
398
|
+
|
|
399
|
+
## 九、这一轮自己犯的错(如实记下)
|
|
400
|
+
|
|
401
|
+
- 用脚本把 4 处"成对清空"替换成 `forgetBrowserState()` 时,**把方法自己体内的两行也替换了**,于是它调用自己 → 无限递归 → 两个测试挂起。编译器看不见(递归是合法的),是测试跑不动才暴露的。
|
|
402
|
+
- CHANGELOG 的插入脚本把内容放在模板字符串里,而内容含有反引号(`\`browser_a11y\``),导致脚本自身语法错误。
|
|
403
|
+
- 这两条都属于同一个模式:**大段自动替换时要确认替换范围,写完立刻跑一遍**。
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
# 第三十六轮(2026-10-04,一轮外部审计的落实)
|
|
408
|
+
|
|
409
|
+
**随 0.4.2 发布**(第三十二~三十六轮合并)。一份外部审计给了 8 条带行号与证据链的指控,逐条用代码核实
|
|
410
|
+
后分三批修完。其中 **3 条我核实后不同意**,另有 1 条定性有误,都写在第五节 —— 审计的价值在于它逼着人
|
|
411
|
+
去看代码,不在于它的每一条结论都对。
|
|
412
|
+
|
|
413
|
+
### 一、存储与设置:读不透的东西不该等于"全部放开"
|
|
414
|
+
|
|
415
|
+
- **设置读取从 fail-open 改成 fail-closed**:文件**存在但读不懂**(JSON 损坏、写到一半、权限读不到)时,
|
|
416
|
+
四个门控开关(`credentials.allowRead` 与三个动作开关)一律按**关**;其余设置取默认值。"文件不存在"
|
|
417
|
+
(首次运行)仍按默认值 —— 这两件事不是同一个陈述,而原先都落到同一份全开的默认值上。
|
|
418
|
+
- **门控字段的类型错误按 OFF**:`bool(value, fallback)` 原先只认真布尔,其余一律回落 fallback(对这些
|
|
419
|
+
开关就是 `true`),于是 `"false"` 字符串、被截断的 `null`、数字 `1` 全都读成**开**。新增 `gate()`
|
|
420
|
+
区分"缺失"(取默认)与"存在但不对"(拒绝)。
|
|
421
|
+
- **`history.jsonl` 的整文件重写改用原子写**:原先 `writeFileSync` 先截断,进程被 TerminateProcess
|
|
422
|
+
(而 Windows 上浏览器宿主正是这么被杀的)就留下半截文件;而读取端丢弃的是**半截尾部**,撕裂点之后
|
|
423
|
+
的内容全丢。现在复用设置那套"写临时文件 + rename"的提交点。
|
|
424
|
+
- **`contentMaxChars` 重新生效**:它曾是 `?? 100_000` 的单一上限,按格式分档(html/json 50k、其余 20k)
|
|
425
|
+
之后**赋值却无人读取**,连注释都写着 "No longer read"。配置项静默失效比没有这个旋钮更糟 —— 那是一个
|
|
426
|
+
操作者的决定悄悄落空。现在"不设"取分档默认、"设了"就是操作者的意思,而调用方的 `maxChars` 仍然
|
|
427
|
+
优先级最高。
|
|
428
|
+
|
|
429
|
+
### 二、生命周期:先请它退出,再杀它
|
|
430
|
+
|
|
431
|
+
`kill()` 的顺序反了。旧注释声称子进程的退出路径 "closes its window and flushes the profile",所以抢先
|
|
432
|
+
杀它才是风险 —— 这句**两半都错**:那条路径走的是 `app.exit(0)`,跳过 before-quit/will-quit 且**不**
|
|
433
|
+
冲刷 Chromium 的 profile 写入器;而 Windows 上 `child.kill()` 就是 TerminateProcess,子进程连一个
|
|
434
|
+
handler 都来不及跑。于是它"保护"的那条优雅路径从未开始,而 profile 在**每一次** dispose 都没落盘 ——
|
|
435
|
+
刚登录过的会话尤其如此。
|
|
436
|
+
|
|
437
|
+
现在先关 socket(那才是子进程真正处理的信号,它的 close 监听器去 quit),给一段 grace 期,仍活着才强杀。
|
|
438
|
+
定时器**故意不 unref**:父进程得活到能执行升级动作,否则"兜底"会随进程一起消失;它在 `fail()` 里被清掉,
|
|
439
|
+
所以一次普通的退出不会让事件循环白等一个已经没有目标的 kill。子进程侧也不再 `app.exit(0)`,改成
|
|
440
|
+
`app.quit()` 加五秒强制退出兜底。
|
|
441
|
+
|
|
442
|
+
### 三、可用性:看得见、等得到,却点不到
|
|
443
|
+
|
|
444
|
+
快照与 `waitFor` 会进 shadow root 与同源 iframe,而 click/type/setValue/fill 的目标解析只看顶层
|
|
445
|
+
`document`。模型刚拿到引用的元素因此回答 "not found" —— 读起来像页面过期,实际是定位缺口。两个解析
|
|
446
|
+
脚本现在都收集页面的全部根(与快照同一套形状),CSS 分支仍先对顶层 `document` 解析一次,好让"选择器
|
|
447
|
+
写错了"继续被当作参数错误而不是"还没出现"。脚本是字符串,编译器看不到,所以新增了一个守卫:驱动
|
|
448
|
+
provider 捕获它发出的每个脚本并逐个编译。
|
|
449
|
+
|
|
450
|
+
Windows 上补了 `%LOCALAPPDATA%` 的用户级安装位置(Chrome 的安装器在无法写 Program Files 时就装在那里,
|
|
451
|
+
而这是非管理员安装的常见情形),并把 "exited immediately (exit code 0)" 的文案从"路径不是可运行浏览器"
|
|
452
|
+
改成它真正的含义:浏览器把请求交给了已占用该 profile 的实例然后退出。
|
|
453
|
+
|
|
454
|
+
### 四、两处会静默破坏他人工作的地方
|
|
455
|
+
|
|
456
|
+
- `tools/install-web-plugin.mjs` 会把 registry 版本号直接写进 profile 的依赖,**覆盖 `link:` 钉版** ——
|
|
457
|
+
那等于把开发者的工作树悄悄换成发布版,而这恰好是"我要求装一个发布版"时最不该发生的事。现在拒绝并
|
|
458
|
+
指明要改哪个文件。
|
|
459
|
+
- adopt 失败的回滚路径没有 dispose 那个半成品 host(它可能已持有 socket、监听器或子进程)。现在先
|
|
460
|
+
dispose,再注册自托管的兜底。另外侧栏桥的发现从"启动时一次"改成有限重试: `dsh web` 常在外壳之前
|
|
461
|
+
启动,桥一秒后才起来就永远不会被 adopt。
|
|
462
|
+
|
|
463
|
+
### 五、核实后不同意的三条
|
|
464
|
+
|
|
465
|
+
- **"`type()` 不聚焦目标视图"**:`type()` 在页面里执行 `el.focus()`,而它用的 `Input.insertText` 正是
|
|
466
|
+
插入到**页面焦点元素**;需要**视图级**聚焦的是 `key()`(`Input.dispatchKeyEvent`),而它**已经**调了
|
|
467
|
+
`focusView`。两者需求不同,不是漏改。
|
|
468
|
+
- **"缺 `window-all-closed` / `before-quit`"**:grep 为 0 不等于行为欠缺 —— Electron 的默认行为本来就是
|
|
469
|
+
"所有窗口关闭即退出"。
|
|
470
|
+
- **"需要 `requestSingleInstanceLock` 修孤儿"**:那会**破坏**正常重启(旧实例还在时新实例被拒),而孤儿
|
|
471
|
+
本身会自愈 —— 父进程一死 OS 立刻关掉 socket,子进程据此退出。
|
|
472
|
+
- 另有报告把 `contentMaxChars` 说成"schema 里承诺却没人读的参数":schema 里的 `maxChars` **是**接上的,
|
|
473
|
+
死的是**插件配置字段**。
|
|
474
|
+
|
|
475
|
+
### 六、未做的一条(明确搁置)
|
|
476
|
+
|
|
477
|
+
桌面侧栏桥的 TOCTOU(`guestFor()` 先查后建)与 `releasePage` 按**标题**关标签页(同名标签会被误关)属实,
|
|
478
|
+
但桥当前在本机不可用(两个补丁器互相覆盖 `main.js`),改它的 API 无法验证,反而增加风险。搁置,不做。
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
# 第三十五轮(2026-10-03,DSH 0.2.1 适配)
|
|
482
|
+
|
|
483
|
+
**当时仍未 bump 版本,最终随 `0.4.2` 一起发布**(与第三十二轮同)。宿主升到 **0.2.1-alpha.1**(相对 `0.2.0-rc.2`
|
|
484
|
+
有 **266 个提交**),所以把插件依赖的宿主 API 面逐项重核了一遍 —— 版本范围写在 `package.json` 里
|
|
485
|
+
只是**声明**,不是证据。
|
|
486
|
+
|
|
487
|
+
> 第三十三轮(系统浏览器载体的崩溃恢复)与第三十四轮(操作者级动作开关)的明细**当时**只在 README 的
|
|
488
|
+
> 更新记录表里。两节此后都已回填进本文件 —— 第三十三轮在下方,第三十四轮紧随其后。
|
|
489
|
+
|
|
490
|
+
### 一、核对结果:形状全部未变
|
|
491
|
+
|
|
492
|
+
- **`cordis`**(vendor 4.0.5-alpha.1):`Context` 与 `Service` 都在;插件用的 `ctx.effect`(含 generator
|
|
493
|
+
形式)、`ctx.get(name)`、`ctx.on` 语义未变。
|
|
494
|
+
- **`@deepseek-ai/dsh-tools`**:`defineTool` 仍在,`DefineToolOptions` 的字段逐个比对 —— `name` /
|
|
495
|
+
`description` / `parameters` / `output.schema` / `output.render` / `timeoutMs` /
|
|
496
|
+
`isConcurrencySafe` / `execute` 与插件传入的写法**完全一致**;**参数规格的语义也没变**
|
|
497
|
+
("per-property parameter schema compiled to an implicit open object root")。
|
|
498
|
+
- **`@deepseek-ai/dsh-system-prompt`**:`ctx.systemPrompt.section(spec)` 签名未变。
|
|
499
|
+
- **`@deepseek-ai/dsh-llm`**:`HarnessError` 仍是可继承的错误类。
|
|
500
|
+
- **`@deepseek-ai/schemastery`**(vendor 3.18.5-alpha.1):默认导出仍是 schema 构造器。
|
|
501
|
+
- **客户端侧**:`ctx.slots.inject('settings.section', …)` 与 `ctx.slots.register({ name, id, order, label,
|
|
502
|
+
locale, inject }, Component)` 的形状,与 DSH 0.2.1 自带的客户端插件(`ui-agent-preset`、
|
|
503
|
+
`ui-settings-account`)**逐字一致**;`ctx.locale.register(NS, { zh, en })` 与 `ctx.locale.bind(NS)` 同理。
|
|
504
|
+
- `ctx.browser` **是插件自己提供的服务**(`dsh-builtin-browser/browser` 那一行),不依赖宿主 —— 这一点
|
|
505
|
+
值得写下来,因为 DSH 0.2.1 新增了自己的 `ctx.browserUse` 具名槽,两者并存、互不干扰。
|
|
506
|
+
|
|
507
|
+
### 二、真机验证:新增 `scripts/verify-host-compat.mjs`
|
|
508
|
+
|
|
509
|
+
把这次的一次性核对沉淀成可复跑的探针:它用某个 profile 里**真实安装**的 `@deepseek-ai/*` 包组装一个
|
|
510
|
+
宿主(profile 的 `package.json` 作为解析根,因此 link 依赖与运行时一致),加载插件的**编译产物**,
|
|
511
|
+
经宿主自己的 `defineTool` 注册工具,再用一个假 provider 真调 `browser_session` 与 `browser_a11y`。
|
|
512
|
+
|
|
513
|
+
在 `0.2.1-alpha.1` 上实测:**全部通过** —— 34 个工具注册成功、每个工具定义都带合法的 JSON Schema 与
|
|
514
|
+
输出契约、`browser_session` 返回会话、`browser_a11y` 渲染出 `{#id}` 引用选择器。它不覆盖浏览器真正
|
|
515
|
+
启动(这里没有 Electron),也不覆盖面板在真实会话里渲染。
|
|
516
|
+
|
|
517
|
+
无法解析到 profile 时,探针明确报告"跑不了"并**以 0 退出**:探不到的宿主不等于"不兼容",为此让 CI
|
|
518
|
+
变红是在报告错误的东西。
|
|
519
|
+
|
|
520
|
+
### 三、两处声明层面的处理
|
|
521
|
+
|
|
522
|
+
- `dsh.compatibility.dshReleases` 记入 **`0.2.1-alpha.1` = compatible**(这是实测记录,精确版本白名单)。
|
|
523
|
+
- **没有**给 `peerDependencies` / `compatibility.dsh` 的范围打补丁。原因值得记下:范围
|
|
524
|
+
`>=0.1.1-rc.2 <0.3.0` 在 **semver 默认语义**下**不匹配任何**带预发布标签的版本 —— 规范如此,
|
|
525
|
+
预发布只被"同一 major.minor.patch 元组"的比较器允许,因此 `0.2.1-alpha.1` 需要一条
|
|
526
|
+
`>=0.2.1-alpha.1` 这样的分支才认。而 DSH 自己判定插件兼容性用的是 `includePrerelease: true`
|
|
527
|
+
(`packages/boot/app-boot/src/plugin-compatibility.ts:77`),在这些宿主上**本来就通过**。加那种分支是
|
|
528
|
+
"按元组打补丁":DSH 每出一条新的预发布线就要再加一段,还会让人以为范围已经覆盖所有预发布。
|
|
529
|
+
实测确认 `0.2.1-alpha.1` 能在这个宿主上加载运行,所以声明保持原样,把语义写进 README 说明。
|
|
530
|
+
|
|
531
|
+
---
|
|
532
|
+
# 第三十二轮(2026-10-03,输出成本审计与一次独立验证)
|
|
533
|
+
|
|
534
|
+
**当时仍未 bump 版本,最终随 `0.4.2` 一起发布**。前一半来自一轮"每次调用都在付的输出成本"审计
|
|
535
|
+
(按实测字符数量化),后一半来自一次独立验证——**验证者把修复改回旧代码后测试仍然全绿**,因此
|
|
536
|
+
下面多数条目同时补上了能真正失败的覆盖。
|
|
537
|
+
|
|
538
|
+
### 一、每次调用的输出成本
|
|
539
|
+
|
|
540
|
+
- **`browser_a11y`**:默认节点数 **500 → 150**(真实页面一律顶到上限,实测 38 953 字符 = 一次调用
|
|
541
|
+
10k~13k token;要更多请显式传 `maxNodes`);缩进 **两个空格 → 一个空格**每层(占实测输出的
|
|
542
|
+
19.7%);`states=[…]` **省略字面量 `enabled`**(`unchecked`、`collapsed` 等仍会打印)——500 个节点里有 405 个状态本来就是 `enabled`,那 22% 的
|
|
543
|
+
输出零信息量,现在**没有 `states=` 就是 enabled**;结果里每个节点带 `{#id}` / `{[name=x]}` 引用
|
|
544
|
+
选择器(见下条);结尾那句重复工具描述的提示改为短句 `(no coords; pass coords: true)`。
|
|
545
|
+
- **`browser_snapshot` 与 `browser_a11y` 现在给出可引用的选择器**:页面脚本一直为每个元素算了稳定
|
|
546
|
+
选择器,工具层却把它丢掉,而三个定位工具只接受 css/text/xpath —— 模型看到 `[7] button: Submit`
|
|
547
|
+
却无从引用,只能猜文本或退回截图坐标。现在元素有 `id` 或 `name` 时打印 `{#id}` / `{[name=x]}`,
|
|
548
|
+
并**在每个输出 schema 里声明该字段**(`additionalProperties: false` 下,字段不在 schema 里会让
|
|
549
|
+
整个结果在校验时被拒 —— 这就是它第一次真实调用时暴露出来的方式)。
|
|
550
|
+
- **`browser_content` 按格式设上限**:原先是一个 100 000 字符的平值(HTML 25k~33k token,而同样的
|
|
551
|
+
数字对纯文本根本不构成上限) → 改为 **html 50 000、json 50 000,其余 20 000**,单次调用
|
|
552
|
+
`maxChars` 可覆盖;截断提示同时说明如何收窄(光写 `(truncated)` 只会换来一次更贵的盲目重试)。
|
|
553
|
+
**`json` 不再返回 `{}`**:对 DOM 节点做 `JSON.stringify` 什么也序列化不出来(属性都在原型链上),
|
|
554
|
+
每个元素都得到同一个空对象,调用方读成"这页没有结构化数据"再换选择器重试一轮 → 现在返回
|
|
555
|
+
`{"html": …, "tag": …}`。
|
|
556
|
+
- **`browser_scrape` 结果改紧凑 JSON**:两空格缩进对模型没有信息量,却占实测 39 559 字符里的一成
|
|
557
|
+
以上;条目数仍在最前面。
|
|
558
|
+
- **`browser_history` 有了便宜用法**:它把调用方自己刚发出的参数原样回显(200 条操作实测 18 162
|
|
559
|
+
字符,且随会话增长)→ 现在默认只展示**最近 20 条**且不含参数,**首行先给总条数**免得把尾部当成
|
|
560
|
+
全部,要细节时传 `verbose: true`(该参数此前**声明了却没人读**,提示也跟着只在有用时才出现)。
|
|
561
|
+
- **`browser_execute` 的返回值上限 50 000 字符**:它原先完全没有上限,`document.body.outerHTML`
|
|
562
|
+
实测 157 350 字符(一次调用 39k~52k token);截断时写明"截断于 50 000 / 实际 N 字符",避免调用方
|
|
563
|
+
把截断值当成真实长度。
|
|
564
|
+
- **`browser_visited` 每行不再背一个会话 UUID**:30 条实测 5 319 字符里有 1 140 是那个几乎不变的
|
|
565
|
+
36 位 id → 只在**会话变化时**打印一次并取 8 位前缀,时间戳去掉秒与时区(精确到分钟)。
|
|
566
|
+
|
|
567
|
+
### 二、一次独立验证(它把修复改回去,测试还是绿的)
|
|
568
|
+
|
|
569
|
+
- **`browser_visited` 曾经输出空行**:会话标记那次改动把 map 回调的 `return` 一起删掉了,
|
|
570
|
+
`join('\n')` 只产出换行——URL、标题、时间戳全没了,而且没有任何测试会发现。`return` 已补回。
|
|
571
|
+
- **"连接卡住"必须能被判定**:只在 `!client.isAlive()` 时丢弃客户端的设计有个洞——浏览器只是
|
|
572
|
+
**不再回答**时 socket 仍然是 OPEN 的,于是客户端被永久缓存,`ensureClient()` 认为它可用,之后每次
|
|
573
|
+
调用都各自等满超时直到重启 DSH(这正是原始报告描述的形态)。现在的判定是"**没有应答**"
|
|
574
|
+
(超时、socket 已死)与"socket 已关闭"都丢弃,而**CDP 协议错误仍然保留连接**,因为那时浏览器答了。
|
|
575
|
+
独立验证者用一个"完成握手之后什么都不回"的假 CDP 服务器复现了这一条。
|
|
576
|
+
- 同一次验证还证明:改用例之前,H1~H3 的任何一条被改回旧代码,**测试都还是绿的**——所以这一轮
|
|
577
|
+
的测试是重新做的,而不是"相信已经覆盖"。
|
|
578
|
+
|
|
579
|
+
### 三、其余修复
|
|
580
|
+
|
|
581
|
+
- **设置写入改为原子替换**:写同目录的**唯一临时文件**再 `rename` 覆盖(rename 是提交点,读者只会
|
|
582
|
+
看到旧文档或新文档,不会是半份);`writeFileSync` 直接写目标会**原地截断**,崩溃/磁盘满/杀毒锁
|
|
583
|
+
都能留下半个 JSON,而读者把解析失败按默认值处理 —— 那会静默把 `credentials.allowRead`、
|
|
584
|
+
`cookies.persist`、`browser.channel` 一起翻回默认。临时文件带 pid + 随机后缀,两个写者不会互相
|
|
585
|
+
覆盖;失败时只清理临时文件,目标文件保持原样,并且**不再把新值发布为缓存**(否则下次读取会静默
|
|
586
|
+
回退成旧值)。
|
|
587
|
+
- **设置缓存先返回、再读文件**:`get()` 原先先读完整个设置文件、再比较 mtime,于是缓存什么都没省下
|
|
588
|
+
—— 每次调用都在事件循环上同步读一遍整个文件然后丢掉。现在只有一次 `stat`,mtime 变了才读。
|
|
589
|
+
- **浏览历史不再每次导航重写整个文件**:prune 的判据写反了(问的是"文件是否超过上限",而一旦到达
|
|
590
|
+
上限它就永远为真),于是 5001 条之后**每次导航都重写 648 KB 的整个文件**(实测 17.5~20.6ms 一次,
|
|
591
|
+
9.9ms 一次导航,28.6% 的导航在付这个成本)。现在按重写后推进的水位线判断,成本按"余量"付一次而
|
|
592
|
+
不是每次;另加一个"距上次重写追加了多少条"的计数,让"无事可做"的那条路径退化成一次比较而不是
|
|
593
|
+
读 + 解析整个文件。
|
|
594
|
+
- **`browser_content` 的页内脚本曾经整体语法错误**:一个裸赋值夹在 `else if` 与 `else` 之间,IIFE
|
|
595
|
+
直接抛 `SyntaxError`,**四种格式全坏**;套件里没人调用 `content()`,`tsc` 也看不进模板字符串。
|
|
596
|
+
`else if (fmt === 'json')` 分支已恢复,并且页内脚本现在会从编译产物里抽出来在 `vm` 里真跑四种
|
|
597
|
+
格式。
|
|
598
|
+
- 其他:`click`/`type`/`key` 派发到 `present()` 屏障**返回的那个 view**,view 换了就明确失败
|
|
599
|
+
(`BROWSER_VIEW_CHANGED`)而不是把为 A 页算出的坐标发给 B 页;`fill` 的 `kind` 默认为 null(不做
|
|
600
|
+
过滤),文本类接受 `TEXTAREA` 与 contenteditable;截图的 MIME 跟随实际请求的格式(JPEG 曾被说成
|
|
601
|
+
PNG);`savePath` 的大小写折叠只在 Windows 上做、等于 `downloadDir` 自身时拒绝;`target.index`
|
|
602
|
+
两层都校验;`browser_wait` 钳到工具预算以内(超预算的参数原先会让整个调用中断,连文档承诺的
|
|
603
|
+
`{ready:false}` 判词都拿不到);`browser_fill` 超过 200 个字段直接拒绝并建议分批;
|
|
604
|
+
`bridge-connection` 真正按请求 id 关联应答(此前发的 id 没被回传,超时请求的迟到应答会交给队列里
|
|
605
|
+
的下一个调用 —— 那是"点击报成功、payload 是别人的"这类静默错误);`tools/install-web-plugin.mjs`
|
|
606
|
+
在**先确认修得回来**之前不再动 profile。
|
|
607
|
+
|
|
608
|
+
**测试**:132 → **134**。
|
|
609
|
+
|
|
610
|
+
# 第三十三轮(2026-10-03,系统浏览器载体:崩溃之后真的能用)
|
|
611
|
+
|
|
612
|
+
**当时仍未 bump 版本**:`package.json` 与 tag 当时都还是 `0.4.1`,以下内容最终随 `0.4.2` 发布。
|
|
613
|
+
|
|
614
|
+
0.4.1 修的是"进程死了要重连",这一轮修的是**重连之后能不能用**,以及**不要把慢页面当成死浏览器**。
|
|
615
|
+
|
|
616
|
+
### 一、重连之后确实可用(H1~H3 的收尾)
|
|
617
|
+
|
|
618
|
+
- **一个命令失败不再拆掉整条连接**:CDP 协议错误意味着浏览器**答了并拒绝**这一条命令,连接是健康的。
|
|
619
|
+
以前 `sendCommand` 把 `client.send` 包在同一个 `try` 里,于是"人类关了标签页(`session not found`)、
|
|
620
|
+
导航把执行上下文换掉、选择器没解析成功"这类普通失败都会关掉浏览器级 WebSocket。
|
|
621
|
+
- **重启后清掉的是会话映射,不只是缓存里的连接与子进程**:退出监听原先只清 `client`/`child`,
|
|
622
|
+
`views`/`sessions` 留着,于是新的浏览器收到的每条命令都带着**上一个进程发放的 session id** ——
|
|
623
|
+
这正是上报的"重启之后每条调用都失败、永不恢复"的成因。另外,主动 `kill` 旧进程再 spawn 的那条路
|
|
624
|
+
会在旧 child 的 `exit` 触发前就改写 `this.child`,那个监听器因此**提前返回、什么也没清**:现在
|
|
625
|
+
放弃一个 child 时直接清客户端与两个映射表。
|
|
626
|
+
- **同一份 `--user-data-dir` 不会再拉起第二个浏览器**:旧子进程先被 kill,spawn 失败(如 `.cmd` 在
|
|
627
|
+
Windows 上被拒)**同步抛出并说明原因**,而不是逃出 `start()`。
|
|
628
|
+
- **回归测试不再恒为绿**:上一版那份测试用的桩是 `.cmd`,Node 拒绝 spawn(EINVAL,同步)——
|
|
629
|
+
三个断言恰好都能被一次 spawn 失败满足,CI 一直是绿的而退出处理一次都没跑。换成真能说 CDP 的
|
|
630
|
+
夹具后,测试走完"写端口文件 → 连接 → 发放 session → 浏览器死亡 → 新端口新 session 的替代进程上线"
|
|
631
|
+
全链路,断言是正向的。
|
|
632
|
+
|
|
633
|
+
### 二、慢页面不是死浏览器
|
|
634
|
+
|
|
635
|
+
以错误**文本**判断连接死活,两个方向都会错:外层调用常常只有 5s(等待轮询)或 15s(滚动/定位),
|
|
636
|
+
而内层 `CdpClient` 是写死的 30s,所以外层先失败、内层计时器 15~25 秒后才在这个 catch 里触发 ——
|
|
637
|
+
**一次慢页面就杀掉浏览器**,人打开的标签与填到一半的表单一起消失(比它要修的"连接被丢弃"更糟)。
|
|
638
|
+
状态现在是三态:
|
|
639
|
+
|
|
640
|
+
- **socket 已关闭** → 丢弃(浏览器确实没了);
|
|
641
|
+
- **有应答但拒绝** → 保留(一条命令失败,连接健康);
|
|
642
|
+
- **没有应答** → 标记为**存疑**,下一次调用先用 `Browser.getVersion` 探一次,答得上就继续用。
|
|
643
|
+
|
|
644
|
+
另外,失败的 catch 原先关的是 `this.client` **字段**(在判过本地变量之后),并发下这个字段可能已经是
|
|
645
|
+
别的调用刚发布的健康连接 —— 现在只动**本次调用实际用的那个客户端**。
|
|
646
|
+
|
|
647
|
+
### 三、启动链路的两个竞态
|
|
648
|
+
|
|
649
|
+
- **kill 之后要等它真的死**:`kill()` 只负责发信号,Windows 上是异步的 `TerminateProcess`,进程可能
|
|
650
|
+
还占着这个 profile 的单例锁 —— 立刻 spawn 会让新进程把命令行交给旧实例然后自己退出,而那个退出
|
|
651
|
+
被报成"检查这个路径是不是可运行的浏览器",**把代码自己制造的竞态怪到用户安装上**。现在**最多等
|
|
652
|
+
3 秒**它的退出事件,拒绝死的进程也不会挂住调用。
|
|
653
|
+
- **只认本次启动写下的端口**:`DevToolsActivePort` 从不清除也从不判新旧,而**被杀掉的浏览器没有机会
|
|
654
|
+
清理它**,于是第一次重启之后那份文件描述的一直是**上一个**浏览器;如果那个端口现在属于别的
|
|
655
|
+
Chromium、或者一个 `node --inspect`,宿主会连上去、发布一个不属于自己的客户端,`dispose()` 永远
|
|
656
|
+
杀不掉它。现在 spawn 前先删掉该文件,且**只接受写入时间晚于本次 spawn 的**那一份。
|
|
657
|
+
|
|
658
|
+
### 四、cookie 导出的作用域(两次修复才真正做到)
|
|
659
|
+
|
|
660
|
+
- 第一次的写法**无效**:它给 `Storage.getCookies` 传 `urls`,而那个方法只认 `browserContextId`,
|
|
661
|
+
浏览器照旧返回整个 jar,注释却声称做了过滤(`urls` 属于 `Network.getCookies`);更糟的是**页面 URL
|
|
662
|
+
取不到时会退化成不过滤的读**,错误路径反而要得比正常路径更多。现在 CDP 走 `Network.getCookies`,
|
|
663
|
+
自托管侧栏按 view 自己的 URL 过滤,**两侧在范围未知时一律拒绝**,而不是放宽。
|
|
664
|
+
- **`browser_auth` 不再是只读工具**:它在 `flush` 时读、在 `restore` 时**向任意域写** cookie
|
|
665
|
+
(没有域白名单,正是会话固定攻击的原语),所以它和别的动作一样受 `browser_restrict` 白名单管辖;
|
|
666
|
+
只豁免 `flush` 挡不住 `restore`。
|
|
667
|
+
- **恢复的条数反映真的提交了多少**:`Storage.setCookies` 没有返回值,`result.cookies` 永远是
|
|
668
|
+
`undefined`,计数恒为 0 —— 工具报"Restored 0 cookies."并带成功状态,cookie 其实已经写进去了,
|
|
669
|
+
读起来像失败。同时,无法构成有效 URL 的 cookie 会被**过滤掉并报出丢弃条数**,不再让一条坏数据
|
|
670
|
+
使整批失败。
|
|
671
|
+
- 自托管侧的 `cookies.get` / `cookies.set`(后者每条 cookie 一次)与下载的 `Runtime.evaluate`
|
|
672
|
+
都套上了同一套 `COMMAND_TIMEOUT_MS`:这些无上限的 await 会让串行操作队列永久停在那一条上——
|
|
673
|
+
窗口活着、进程活着、之后每个 `browser_*` 都超时,直到重启 DSH。这一轮补齐了先前提交声称做完
|
|
674
|
+
却只做了一半的三条。
|
|
675
|
+
|
|
676
|
+
**测试**:134/134(与 0.4.1 的 132 相比,新增的是"浏览器真的能被替换"那份夹具与重启用例)。
|
|
677
|
+
|
|
678
|
+
# 第三十四轮(2026-10-03,操作者级动作开关)
|
|
679
|
+
|
|
680
|
+
**当时仍未 bump 版本,最终随 `0.4.2` 一起发布。**
|
|
681
|
+
|
|
682
|
+
> 本节按 README 的更新记录表回填,并逐条对照 `src/` 核实。它当时只进了 README 那张表,没有进本文件 ——
|
|
683
|
+
> CHANGELOG 是"每轮都记"的地方,缺的这一段现在补上,不假装它一直在。
|
|
684
|
+
|
|
685
|
+
`browser_restrict` 是**模型自己**的软护栏:它由模型设定,也由模型解除。所以"别让 agent 在页面里执行
|
|
686
|
+
脚本""别让它往磁盘写文件"这类要求,此前**没有任何东西能满足** —— 模型可以把自己的白名单撤掉。这一轮
|
|
687
|
+
补上真正由**操作者**持有的那一层。
|
|
688
|
+
|
|
689
|
+
### 一、设置页新增「Agent 能做什么」
|
|
690
|
+
|
|
691
|
+
三个开关,与设置文档的 `actions` 段一一对应(`src/browser-electron/settings-store.ts`):
|
|
692
|
+
|
|
693
|
+
- **执行页面脚本** → `actions.allowExecute`,关闭即拒绝 `browser_execute`(`BROWSER_EXECUTE_DISABLED`);
|
|
694
|
+
- **下载文件到磁盘** → `actions.allowDownload`,关闭即拒绝 `browser_download`(`BROWSER_DOWNLOAD_DISABLED`);
|
|
695
|
+
- **写入登录状态** → `actions.allowCredentialWrite`,关闭即拒绝 `browser_auth` 的 `restore`(`BROWSER_AUTH_WRITE_DISABLED`)。
|
|
696
|
+
|
|
697
|
+
三个都默认**开**(即出厂行为)。门控在 `ElectronBrowserProvider.assertActionAllowed()` 里,并且**每次
|
|
698
|
+
调用现读**设置文档,所以改完即时生效、不需要重启插件。
|
|
699
|
+
|
|
700
|
+
### 二、与 `browser_restrict` 的区别是"谁能改",不是"谁更强"
|
|
701
|
+
|
|
702
|
+
- `browser_restrict`:模型可设定、也可解除;防误操作,不是安全边界。
|
|
703
|
+
- 这三个开关:写在**设置文档**里,而 `browser_*` 工具**没有一个会写它** —— 模型只能被拒,不能不认。
|
|
704
|
+
- 部署级还有第三层:`tool-browser` 行的 `allowedActions` 配置(见 `cordis.patch.yml` 的注释),对该
|
|
705
|
+
部署里的每个任务生效;任务仍可用 `browser_restrict` 在其中再收窄。
|
|
706
|
+
|
|
707
|
+
### 三、顺带修正一处读/写混淆
|
|
708
|
+
|
|
709
|
+
恢复登录态(`browser_auth` 的 `restore`)原先查的是**读**开关(`credentials.allowRead`),报错还说
|
|
710
|
+
"reading cookies is switched off"。两个后果都不成立:允许读取的操作者**没有任何开关能拒绝写入**,
|
|
711
|
+
而关掉读取的人会连带失去 `restore`,理由还是错的。现在 `restore` 归 `actions.allowCredentialWrite`,
|
|
712
|
+
`flush` 归 `credentials.allowRead`,读与写可以分别设置。
|
|
713
|
+
|
|
714
|
+
**测试**:148 → **157**(此数字取自 README 的更新记录表;本节回填时未重跑当年的用例)。
|
|
715
|
+
|
|
716
|
+
## 0.4.1 发布(2026-10-02)
|
|
717
|
+
|
|
718
|
+
**修复 issue #21:系统浏览器载体(chrome/edge)进程死亡后不重连、不重启,所有 `browser_*` 调用挂起 30 秒,**
|
|
719
|
+
**只能重启 DSH。**
|
|
720
|
+
|
|
721
|
+
启动阶段的失败上一轮已经处理(加了 `error` 监听、轮询里检查退出码),但**启动成功之后**的死亡完全没有
|
|
722
|
+
观察:没有 `exit` 监听,`this.client` 赋值后从不清空,于是 `ensureClient()` **永远返回那条已断开的连接**,
|
|
723
|
+
每条命令各自等满自己的超时。自托管载体一直有崩溃自动重启,这一条没有。
|
|
724
|
+
|
|
725
|
+
按报告里的两种失败形态分别处理:
|
|
726
|
+
|
|
727
|
+
- **进程死亡** → 子进程现在**全程**被观察,退出即清空缓存的 client 与 child,下一次调用会**重新拉起**浏览器;
|
|
728
|
+
- **socket 死亡而进程存活**(浏览器关掉了调试端点、连接被丢弃)→ 使用缓存的 client 前先查其存活状态;
|
|
729
|
+
- **进程与 socket 都在、但命令不再回答** —— 报告者看到的第二种形态:Edge 的 `/json/version` 返回 200、
|
|
730
|
+
`/json/list` 正常列出标签,而 socket 上再也不回来任何东西。**这种状态没有任何存活检查能发现**,所以改为:
|
|
731
|
+
**命令失败即丢弃 client**,下一次调用重新开始。
|
|
732
|
+
|
|
733
|
+
此外,**在已关闭的 socket 上发命令会立刻失败并说明原因**,而不是等满预算再报一条指向不到真正原因的"超时"——
|
|
734
|
+
报告者指出,原来那条超时让人以为插件坏了,排查成本极高。
|
|
735
|
+
|
|
736
|
+
新增两条回归测试(用一个**立即退出**的桩浏览器):断言命令**及时**失败且错误不误导,以及第二次调用**不会**
|
|
737
|
+
挂在陈旧 client 上。
|
|
738
|
+
|
|
739
|
+
**测试**:130 → **132**。
|
|
740
|
+
|
|
741
|
+
## 0.4.0 发布(2026-10-02)
|
|
742
|
+
|
|
743
|
+
第二十七~三十一轮合并发布。这一版的主题是**把"限制"分成真限制与实现限制**:两轮独立
|
|
744
|
+
代码审查发现,README 里写着的一批"已知限制"其实不是载体做不到,而是实现没走那条路 ——
|
|
745
|
+
同时审出并修掉了若干会崩进程、会静默做错事、会永久挂住的缺陷。
|
|
746
|
+
|
|
747
|
+
**能力放开**(原本被过度保守地限制)
|
|
748
|
+
|
|
749
|
+
- `browser_auth` **三种载体都可用**:以前只有自托管能导出/恢复 cookie,因为实现直接调用
|
|
750
|
+
只有它才有的原生方法;但 cookie 本来就在 CDP 里(`Storage.getCookies` / `Storage.setCookies`),
|
|
751
|
+
而三种载体全都走 CDP。现在有原生方法时优先用它,否则走 CDP;两侧换算处理了 domain+path
|
|
752
|
+
与 URL 的差异、前导点写法、秒与毫秒,并**丢弃无法构成有效 URL 的 cookie** 而不是导出个恢复
|
|
753
|
+
不了的东西。
|
|
754
|
+
- **本机 Chrome / Edge 支持 JPEG**:JPEG 原本对所有 CDP 路径禁用,理由是"CDP JPEG 在
|
|
755
|
+
Electron 上挂起"——对 Electron 成立,对真浏览器无关。view 现在通过 `supportsCdpJpeg` 声明自
|
|
756
|
+
己能否编码,只有本机浏览器声明;侧栏保持 PNG(在那里这句话仍然成立)。
|
|
757
|
+
- **降采样三种载体都可用**:工具描述一直承诺 `maxWidth`/`maxHeight`,却只在原生路径实现。
|
|
758
|
+
CDP 路径改用 `clip.scale`(先读 `Page.getLayoutMetrics` 取文档尺寸,读不到就**不缩放地照常
|
|
759
|
+
截图**)。
|
|
760
|
+
|
|
761
|
+
**修复**(由两轮独立审查发现,其中多条被审查者亲手复现)
|
|
762
|
+
|
|
763
|
+
- **会崩宿主**:`spawn` 出来的浏览器子进程**没有 `error` 监听**,而启动失败(ENOENT、无执行
|
|
764
|
+
权限、二进制损坏)是**异步事件** —— 无监听即 unhandled,Node 会**把整个 DSH 进程带走**;
|
|
765
|
+
现在改为让该次命令失败并说明原因。
|
|
766
|
+
- **取消被吞**:`navigate` 只在开头检查一次 signal,而 `settleDocument` 遇到 abort 是静默
|
|
767
|
+
return,于是被取消的导航**全程无异常** —— 向调用方报成功、记入历史、还把这一页写进持久
|
|
768
|
+
浏览历史。
|
|
769
|
+
- **设置写失败静默回退**:写失败后仍把新值发布为缓存并用当前时间戳,读者拿它和文件 mtime
|
|
770
|
+
比对 → 下次读取重新读盘、**静默回退成旧值**;而且 HTTP 路由无论存没存都回 `ok:true`。现在
|
|
771
|
+
失败即清空缓存并抛错,路由随之返回 400。
|
|
772
|
+
- **凭据开关是死的**:`credentials.allowRead` 只存在于设置存储与面板,**没有任何代码读它** ——
|
|
773
|
+
用户关掉"允许 agent 读取 cookies"后 `browser_auth` 照常导出全部 cookie。两条 auth 路径现在
|
|
774
|
+
真的受它门控。
|
|
775
|
+
- **只读工具被白名单拦**:工具描述与 README 都承诺只读工具永不被拦,实际只豁免
|
|
776
|
+
`browser_restrict` 自己,过窄的白名单会把 agent 的 `browser_history`、`browser_reset_session`、
|
|
777
|
+
`browser_auth` 一起锁死。现在有显式的只读集合。
|
|
778
|
+
- **`browser_restrict` 跨任务锁死**:限制写在每 apply 一份的状态上,却与按任务分键的会话映射
|
|
779
|
+
并列,一个任务的限制会锁死所有任务。现在按任务分键,配置级的单独放。
|
|
780
|
+
- **`browser_fill` 漏 `value` 会反向取消勾选 / 清空文本,还报成功** —— 现在报错并指明字段。
|
|
781
|
+
- **跨会话操作标签**:`locateTab` 在本会话找不到就遍历所有会话,陈旧 id 能关掉**别的任务或
|
|
782
|
+
人类**的标签页还报成功。现在只查本会话。
|
|
783
|
+
- **挂死**:`CdpClient.whenReady()` 等待无界的 promise、`focus()` 是全文件唯一没有预算的 await ——
|
|
784
|
+
两者都能让一次工具调用永不返回。
|
|
785
|
+
- **泄漏**:`dispose()` 落在启动轮询期间会让已拉起的浏览器无人可杀;`browser_wait` 每轮泄漏一个
|
|
786
|
+
abort 监听器(30s 等待约 150 个);每个无 agent 的 default 会话各注册一个 exit 监听器且从不
|
|
787
|
+
摘除。
|
|
788
|
+
- **报错指向错误的方向**:三种不同的失败(子进程退出/被释放/真超时)共用一条"30 秒内没暴露
|
|
789
|
+
CDP",而且**半秒就报出来**;scrape 的外层超时必然先于页内到期,页内已判明的"no elements
|
|
790
|
+
matched"永远传不回来。
|
|
791
|
+
- **明确选择被侧栏顶掉**:选了 chrome/edge 仍被桌面侧栏覆盖,这同时让"你选的浏览器没装"那条
|
|
792
|
+
说明永远无法出现。
|
|
793
|
+
- 以及:`el` 别名导致 `kind` 定向打到错误元素、历史首条被 BOM 吞掉、`browser_auth` 空列表静默
|
|
794
|
+
"恢复 0 个"、`fill.kind` 页内从不读取、`focus()` 注释与 Windows 实际行为不符、若干死代码,
|
|
795
|
+
和一条 flaky 测试(30ms 窗口配 40ms 等待,约 11 次失败 1 次)。
|
|
796
|
+
|
|
797
|
+
**测试**:100 → **130**(新增独立审查要求的回归覆盖,含"只读工具不被白名单拦"这条此前零覆盖
|
|
798
|
+
的承诺)。
|
|
799
|
+
|
|
800
|
+
- **第二十七轮 · 释放后的宿主必须保持释放**:`ensureClient` 未检查 `disposed`,于是释放后再调用会**重新拉起一个没人管的浏览器**;`start()` 轮询端口时也未检查,若 `dispose()` 恰好落在启动过程中,进程被 kill 而循环仍会连上它,留下悬空连接。两处都修,并补测试断言"已释放的宿主报告不可用、命令失败、不产生任何进程"。
|
|
801
|
+
- **第二十八轮 · 两条"限制"其实是实现限制**(由文档审查引出):`browser_auth` 原本只走自托管才有的原生方法,但 **cookie 本来就在 CDP 里**且三种载体全走 CDP → 现在有原生方法时优先用它、否则走 `Storage.getCookies`/`setCookies`,**三种载体全部可用**;JPEG 被对所有 CDP 路径禁用虽然对 Electron 成立,但**对本机 Chrome/Edge 无关** → 新增 `supportsCdpJpeg` 能力声明,只有本机浏览器声明;降采样在描述里承诺了却只在原生路径实现 → CDP 路径改用 `clip.scale`。文档同时补齐两条从未写过的载体差异。
|
|
802
|
+
- **第二十九轮 · 独立代码审查**(另派审查者通读全树并亲手复现):
|
|
803
|
+
- **会崩宿主**:`spawn` 出的子进程**没有 `error` 监听**,而启动失败(ENOENT / 无执行权限 / 二进制损坏)是**异步事件** —— 无监听即 unhandled,Node 会**带走整个 DSH 进程**;现在改为让该次命令失败并说明原因;
|
|
804
|
+
- **资源泄漏**:`dispose()` 落在 `start()` 的 250ms 轮询期间时,已拉起的浏览器**无人可杀**;轮询现在会在退出前 kill 它;
|
|
805
|
+
- **错误信息误导**:三种不同失败(子进程退出 / 被释放 / 真超时)共用一条"30 秒内没暴露 CDP",而且**半秒就报出来**;现在各自说明真实原因;
|
|
806
|
+
- **抵消修复的优先级矛盾**:**明确选择 `chrome`/`edge` 时仍被桌面侧栏顶掉**(发现流程从不检查 channel),这同时让"你选的浏览器没装"那条说明**永远无法出现**;现在明确选择会跳过侧栏发现;
|
|
807
|
+
- **永不返回**:`CdpClient.whenReady()` 等待的 promise 无界,连接被中间层丢弃时那次工具调用永不返回 → 已加界;
|
|
808
|
+
- **事实错误**:`focus()` 用 `kill('SIGCONT')`,在 Windows 上是空操作,而注释承诺"把窗口前置" → 注释改为实情;
|
|
809
|
+
- **死代码**:删除不可达的 `brave` 分支。
|
|
810
|
+
|
|
811
|
+
## 0.3.0 发布(2026-10-01)
|
|
812
|
+
|
|
813
|
+
第二十一~二十六轮合并发布。要点:
|
|
814
|
+
|
|
815
|
+
- **桌面端改由官方侧栏承载**:agent 操作的页面就是人看到的那个页面,不再多开一个窗口(需要 `desktop-bridge/install.mjs`;桌面端升级后重跑一次);
|
|
816
|
+
- **可选用本机 Chrome / Edge**:以独立 profile 启动,不碰用户日常的窗口、书签与登录状态,登录态按 `cookies.persist` 决定是否保留;
|
|
817
|
+
- **需求表 v2 全部落地**:视觉策略真正生效、快照对无视觉模型更友好、沙箱边界明示、生命周期开关接线、多会话标签隔离、历史可按关键词与来源会话检索、光标带操作气泡;
|
|
818
|
+
- **性能**:每条命令 49.1ms → 0.6ms(长连接复用 + 去掉逐次存活检查);
|
|
819
|
+
- **修复**:issue #16(错误上报路径致命)、CVE-2026-84961(undici),以及 6 个实测发现的 bug(逐字动画文本被拆散、重启后首次调用必失败、三个设置项是死的、释放会关掉别人的标签、设置文件带 BOM 时全部设置被丢弃)。
|
|
820
|
+
|
|
821
|
+
> 升级提示:桌面端需重跑一次 `node desktop-bridge/install.mjs`;Web 端无此步骤。详见 README「更新方式(两端不同)」。
|
|
822
|
+
|
|
823
|
+
---
|
|
824
|
+
|
|
825
|
+
## 第一轮:安全与健壮性修复
|
|
826
|
+
|
|
827
|
+
- **日期**:2026-08-18
|
|
828
|
+
- **对象仓库**:`wqty123/dsh-browser`(DeepSeek Harness 共享浏览器插件,版本 0.1.15)
|
|
829
|
+
- **状态**:修复已完成并通过验证,已作为**单独一次提交**保存在本地 git,**未推送**。
|
|
830
|
+
|
|
831
|
+
---
|
|
832
|
+
|
|
833
|
+
## 一、改了什么
|
|
834
|
+
|
|
835
|
+
共修改 **30 个文件**:`src/` 15 个(源码)+ `lib/` 15 个(随仓库发布的构建产物,已重新编译同步)。
|
|
836
|
+
|
|
837
|
+
| 文件 | 改动内容 |
|
|
838
|
+
| --- | --- |
|
|
839
|
+
| `src/browser-electron/remote-host.ts` | RPC 认证(随机 token 握手 + 单连接强制);下载自动创建目标目录 |
|
|
840
|
+
| `src/browser-electron/host-main.ts` | 首条消息回传 token(hello);窗口 resize 同步视图;弹窗接管;下载流式限流 + Content-Length 提前拒绝 |
|
|
841
|
+
| `src/browser-electron/provider.ts` | 下载准入(仅 http/s、绝对路径、`downloadDir` 限定);CDP 超时后打断(terminateExecution / stopLoading);click/type 超时与松键恢复;closeTab 激活下一个标签;快照 selector 用 `CSS.escape`;replay 保留 number/boolean 参数 |
|
|
842
|
+
| `src/browser-electron/entry.ts` | 透传新增的 `downloadDir` 配置 |
|
|
843
|
+
| `src/browser/types.ts` | 修正准入注释(与实际行为一致) |
|
|
844
|
+
| `src/tool-browser/index.ts` | 会话/白名单状态改为每 context 作用域;会话绑定 agent 生命周期自动关闭;`browser_auth` 凭证敏感提示;`browser_history` 对输入文本脱敏;5 个变更型工具改为串行标记 |
|
|
845
|
+
| `src/browser/runtime.ts`、`src/index.ts` | 相对导入后缀 `.ts` → `.js` |
|
|
846
|
+
| `src/types/electron-shim.d.ts` | 补充 `setWindowOpenHandler` / `loadURL` 类型 |
|
|
847
|
+
| `tsconfig.json` | 移除 `allowImportingTsExtensions` / `rewriteRelativeImportExtensions`(修 .d.ts 产物) |
|
|
848
|
+
| `lib/**` | 全部按新源码重新构建 |
|
|
849
|
+
| `README.md`、`README.en.md`、`docs/architecture.md`、`docs/tool-reference.md`、`docs/user-guide.md` | 修正过时表述(测试脚本、版本号),补充下载准入、`downloadDir`、弹窗行为、RPC 认证说明 |
|
|
850
|
+
|
|
851
|
+
---
|
|
852
|
+
|
|
853
|
+
## 二、每个问题修了什么
|
|
854
|
+
|
|
855
|
+
### 🔴 安全问题
|
|
856
|
+
|
|
857
|
+
**1. 自托管 RPC 无认证——本机任意进程可接管浏览器(高)**
|
|
858
|
+
- 问题:父进程在 loopback 临时端口起 TCP 服务,协议无 token、无身份校验。本机任意进程可抢先伪冒子进程(读到全部命令,包括注入页面的 JS、下载内容、cookie),或在子进程已连接后再连一个 socket 劫持回复(id 从 1 递增可猜),造成页面任意 JS、cookie 窃取、任意文件写入。
|
|
859
|
+
- 修复:每次 spawn 生成随机 token(`--rpc-token` 传入);子进程首条消息必须回传该 token(`hello`),认证前命令一律排队不发;服务端只接受**一个**连接,其余直接断开。
|
|
860
|
+
- 涉及:`remote-host.ts`、`host-main.ts`。
|
|
861
|
+
|
|
862
|
+
**2. `browser_download` 任意文件写入 + 无 URL 准入 + 先全量缓冲再限流(高)**
|
|
863
|
+
- 问题:`savePath` 完全由 agent 控制可覆盖任意文件;URL 不校验协议(可打本地服务);整个 body 先拉进内存,之后才检查 256MB 上限,超大文件直接 OOM。
|
|
864
|
+
- 修复:URL 仅允许 http(s);`savePath` 必须为绝对路径;新增 `downloadDir` 配置,设置后保存路径必须位于该目录内(防 `..` 逃逸);下载改为流式读取,按 `Content-Length` 提前拒绝、边读边限流;自动创建目标目录。
|
|
865
|
+
- 涉及:`provider.ts`、`host-main.ts`、`remote-host.ts`、`entry.ts`。
|
|
866
|
+
|
|
867
|
+
**3. `browser_auth flush` 将全部会话 cookie(含 HttpOnly)明文返回给模型(中)**
|
|
868
|
+
- 问题:导出 cookie 是实时登录凭证,模型可能回显进对话或写入日志。
|
|
869
|
+
- 修复:工具描述与输出结果均明确标注"LIVE CREDENTIALS——请存私有文件、勿回显、勿入日志"。
|
|
870
|
+
- 涉及:`tool-browser/index.ts`。
|
|
871
|
+
|
|
872
|
+
### 🟠 健壮性问题
|
|
873
|
+
|
|
874
|
+
**4. CDP 调用超时后底层命令仍挂起 → 会话永久卡死(高)**
|
|
875
|
+
- 问题:`withTimeout` 只在父侧 reject,不取消底层 CDP 命令;`awaitPromise: true` 下页面死循环/永不 settle 会占死该 target 的 debugger 队列,后续所有命令排队挂起。
|
|
876
|
+
- 修复:超时/中止时 best-effort 发送 `Runtime.terminateExecution` 打断页面脚本(导航超时发 `Page.stopLoading`)。
|
|
877
|
+
- 涉及:`provider.ts`。
|
|
878
|
+
|
|
879
|
+
**5. 任务会话泄漏 + 模块级全局状态破坏多 context 隔离(中)**
|
|
880
|
+
- 问题:会话映射/白名单是模块级变量,多 context 共享串扰(`browser_restrict` 一个任务限制全局生效);任务结束没有任何 hook 关闭会话,窗口/视图只增不减。
|
|
881
|
+
- 修复:全部状态改为**每插件 apply(每 context)作用域**;会话生命周期绑定 agent 作用域 ctx(`agent.ctx`),agent(DSH 会话)销毁时自动关闭浏览器会话。
|
|
882
|
+
- 涉及:`tool-browser/index.ts`。
|
|
883
|
+
|
|
884
|
+
**6. 自托管窗口 resize 后视图不跟随(中)**
|
|
885
|
+
- 问题:视图只在创建时设置一次 bounds,窗口调整大小后页面尺寸错位。
|
|
886
|
+
- 修复:监听窗口 `resize` 事件,同步更新全部视图 bounds。
|
|
887
|
+
- 涉及:`host-main.ts`。
|
|
888
|
+
|
|
889
|
+
**7. 弹窗不受控(中)**
|
|
890
|
+
- 问题:页面 `window.open` / `target=_blank` 会打开未跟踪的原生窗口,破坏"标签即会话"模型。
|
|
891
|
+
- 修复:`setWindowOpenHandler` 拒绝弹窗,并把 http(s) 弹窗重定向到当前标签页内打开。
|
|
892
|
+
- 涉及:`host-main.ts`、`electron-shim.d.ts`。
|
|
893
|
+
|
|
894
|
+
**8. click/type 无超时 + 状态污染(中)**
|
|
895
|
+
- 问题:`click` 的 press/release 是两个独立命令且无超时;中途失败会留下"鼠标按住"状态。
|
|
896
|
+
- 修复:两个命令均包 30s 超时;press 成功后 release 失败时 best-effort 补发 release。
|
|
897
|
+
- 涉及:`provider.ts`。
|
|
898
|
+
|
|
899
|
+
### 🟡 正确性 / 小问题
|
|
900
|
+
|
|
901
|
+
| # | 问题 | 修复 |
|
|
902
|
+
| --- | --- | --- |
|
|
903
|
+
| 9 | `closeTab` 关闭活动标签后激活**最后一个**标签,文档写"激活下一个" | 改为激活下一个(被关闭的是最后一个时收敛到上一个) |
|
|
904
|
+
| 10 | 发布包 `.d.ts` 仍引用 `./types.ts`(TS 的 `rewriteRelativeImportExtensions` 只改 `.js` 不改 `.d.ts`,见 TS#61037) | 源码相对导入改 `.js` 后缀,移除两个不再需要的编译选项,重新构建 `lib/` |
|
|
905
|
+
| 11 | 快照 selector 用 `'#' + id`,含特殊字符的 id 生成无效 CSS | 改用 `CSS.escape(id)` |
|
|
906
|
+
| 12 | `browser_history` 暴露 `type` 输入文本(可能含密码) | 工具输出脱敏(provider 内部保留原文供 `browser_replay` 使用) |
|
|
907
|
+
| 13 | `browser_open` 等 5 个会变更会话状态的工具标记 `isConcurrencySafe: true` | 全部改为 `false`(框架内 exclusive 串行) |
|
|
908
|
+
| 14 | 类型注释声称"拒绝凭据/私网目标",实现并未做 | 修正注释为"仅 HTTP(S) 准入",明确不拦 localhost(共享浏览器合法访问本地开发服务) |
|
|
909
|
+
| 15 | `browser_replay` 只保留 string 类型的 args | 保留 string/number/boolean |
|
|
910
|
+
| 16 | README 声称"见仓库测试脚本"(仓库实际无测试),版本号写 0.1.11(实际 0.1.15) | 修正两处表述,并同步补充新行为说明 |
|
|
911
|
+
|
|
912
|
+
---
|
|
913
|
+
|
|
914
|
+
## 三、验证情况
|
|
915
|
+
|
|
916
|
+
- `tsc --noEmit`:零错误(本地临时安装 TypeScript 5.9.3 + peer 依赖验证,验证后已清理 `node_modules`)。
|
|
917
|
+
- 构建产物:重新编译 `lib/`,声明文件 `.d.ts` 的导入后缀已全部为 `.js`。
|
|
918
|
+
- 冒烟测试(用假 `ElectronBrowserViewHost` 驱动编译后的 provider,共 15 项断言全部通过):
|
|
919
|
+
- closeTab 激活逻辑(关闭活动/非活动/中间标签);
|
|
920
|
+
- 下载准入:非 http(s) 拦截、相对路径拦截、`..` 逃逸拦截、目录内放行;
|
|
921
|
+
- 挂起的 `Runtime.evaluate` 超时返回 `BROWSER_EXECUTE_TIMEOUT` 且触发 `Runtime.terminateExecution`;
|
|
922
|
+
- click 第一次 release 失败后自动补发 release(不留"按住"状态)。
|
|
923
|
+
|
|
924
|
+
---
|
|
925
|
+
|
|
926
|
+
## 四、备注
|
|
927
|
+
|
|
928
|
+
- 本次提交**未 bump 版本号**(当前仍为 0.1.15);如需发布,按仓库惯例应 bump 到 0.1.16 并重新 `prepack`。
|
|
929
|
+
- 改动已提交到本地 git(**未推送**);如需上传请另行告知。
|
|
930
|
+
|
|
931
|
+
---
|
|
932
|
+
|
|
933
|
+
# 第二轮:功能补全与体验优化(2026-08-18)
|
|
934
|
+
|
|
935
|
+
在第一轮修复之后,针对评审列出的剩余不足做了一轮功能补全。同样已作为单独一次提交保存在本地 git,未推送。
|
|
936
|
+
|
|
937
|
+
## 改了什么
|
|
938
|
+
|
|
939
|
+
| 文件 | 改动内容 |
|
|
940
|
+
| --- | --- |
|
|
941
|
+
| `src/browser-electron/host-main.ts` | 窗口标题实时显示当前可见会话(任务)+ 页面标题/URL;重复 showView 跳过 remove/re-add 消除闪烁;下载改为子进程直接落盘(临时文件 + rename,不再经 RPC 传 base64);截图支持 JPEG/缩放 |
|
|
942
|
+
| `src/browser-electron/provider.ts` | 新增 `waitFor`/`scroll`/`back`/`forward`/`key`;快照穿透 iframe/Shadow DOM(绝对坐标 + frame 标记);content 的 txt/markdown 穿透;challenge 检测扫同源 iframe/shadow;`available()` 委托 host 探测;`downloadDir` 默认收敛到 `~/Downloads`;快照脚本先 rect 后 style |
|
|
943
|
+
| `src/browser-electron/remote-host.ts` | `showView` 携带会话 label;`available()` 探测 Electron 二进制(带缓存);Electron 扫描范围收敛(去掉 cwd/execPath);capture 透传格式/缩放参数 |
|
|
944
|
+
| `src/browser/types.ts` | 新增 `BrowserWaitRequest/Result`、`BrowserScrollRequest`、`BrowserKeyRequest`;`open(label?)`;快照元素 `frame` 标记;截图 `format/quality/maxWidth/maxHeight` |
|
|
945
|
+
| `src/browser/runtime.ts` | seam 透传 `waitFor`/`scroll`/`back`/`forward`/`key`/`open(label)` |
|
|
946
|
+
| `src/tool-browser/index.ts` | 新增 6 个工具:`browser_wait`、`browser_scroll`、`browser_back`、`browser_forward`、`browser_key`;`browser_screenshot` 增加 JPEG/缩放参数;`browser_restrict` 标注为软护栏 |
|
|
947
|
+
| `src/types/electron-shim.d.ts` | `setTitle`/`getTitle`/`getURL`/`toJPEG`/`resize` |
|
|
948
|
+
| `package.json` | 新增 `test` 脚本 |
|
|
949
|
+
| `tests/provider.test.mjs` | 新增 11 项单元测试(假 host,无需 Electron) |
|
|
950
|
+
| `.github/workflows/ci.yml` | 新增 CI(安装/构建/测试) |
|
|
951
|
+
| `README.md`、`README.en.md`、`docs/*` | 工具表、配置表、已知限制、操作纪律、自托管说明同步更新 |
|
|
952
|
+
| `lib/**` | 重新构建 |
|
|
953
|
+
|
|
954
|
+
## 每个问题怎么解决的
|
|
955
|
+
|
|
956
|
+
| # | 问题 | 解决 |
|
|
957
|
+
| --- | --- | --- |
|
|
958
|
+
| 7 | 多任务可见性:多会话抢同一窗口、无归属标识、每次操作视图闪烁 | 窗口标题实时显示当前可见会话(任务)+ 页面标题/URL;重复 showView 跳过 remove/re-add(消除每次操作的闪烁);resize 自动跟随 |
|
|
959
|
+
| 1 | 看不到 iframe / Shadow DOM 内容 | 快照穿透同源 iframe 与 shadow root(跨域 iframe 受浏览器安全限制仍不可读),iframe 内元素标注 `frame` 且坐标换算为顶层文档绝对坐标;content 的 txt/markdown、challenge 检测同步穿透 |
|
|
960
|
+
| 2 | 没有等待页面就绪原语(白屏快照重试) | 新增 `browser_wait`(等加载完成 + 可选期望 URL/选择器,未就绪返回原因不抛错) |
|
|
961
|
+
| 3 | 交互原语太裸 | 新增 `browser_scroll`(增量/选择器/顶底)、`browser_back`/`browser_forward`(历史)、`browser_key`(Enter/Tab/方向键等命名按键) |
|
|
962
|
+
| 4 | `available()` 恒为 true,provider 选择逻辑形同虚设 | host 增加可选 `available()` 探测;自托管 host 探测 Electron 二进制(缓存),缺失时返回 `BROWSER_PROVIDER_UNAVAILABLE` |
|
|
963
|
+
| 5 | 桌面壳下 download/auth 不可用 | 属宿主层能力缺口(宿主未实现该接缝方法),文档明确标注自托管专用;接缝已就绪 |
|
|
964
|
+
| 6 | 平台承诺窄(仅 Windows 实测) | 属实测范围问题,保持文档如实声明(macOS/Linux 未验证) |
|
|
965
|
+
| 8 | `browser_restrict` 是软护栏但未说明 | 工具描述与文档明确标注:防误操作软护栏,模型可自行解除,非安全边界 |
|
|
966
|
+
| 9 | `downloadDir` 默认未设置,agent 仍可写任意路径 | 默认收敛到 `~/Downloads`(系统下载目录,人类可见),可配置 `downloadDir` 覆盖为沙箱目录 |
|
|
967
|
+
| 10 | cookie 明文落盘;下载走 base64 大消息(内存峰值高) | 下载改为**子进程直接写文件**(临时文件 + rename),body 不再经 RPC 传输,彻底消除大消息;cookie 明文属 Electron 默认行为,文档标注需宿主层钥匙串/DPAPI |
|
|
968
|
+
| 11 | Electron 二进制扫描范围过广(可能捡到无关项目) | 扫描范围收敛为插件自身安装树 + DSH 锚点,去掉 cwd/execPath |
|
|
969
|
+
| 12 | 零测试、零 CI | 新增 `tests/provider.test.mjs`(11 项,假 host 无需 Electron)+ `package.json test` + GitHub Actions CI |
|
|
970
|
+
| 13 | 快照性能(O(n) 布局抖动) | 先 `getBoundingClientRect` 后 `getComputedStyle`(只有尺寸通过才强制样式重算) |
|
|
971
|
+
| 14 | 截图仅 PNG 且不可缩放 | 自托管原生路径支持 JPEG(`toJPEG`)+ 等比缩放(`maxWidth`/`maxHeight`);桌面壳 CDP 回退仍 PNG(平台缺陷) |
|
|
972
|
+
|
|
973
|
+
## 验证
|
|
974
|
+
|
|
975
|
+
- `tsc --noEmit` 零错误;构建通过。
|
|
976
|
+
- `node --test tests/*.test.mjs` 11 项全部通过:标签生命周期、`open(label)` 透传、`available()` 委托、下载准入(协议/相对路径/`..` 逃逸/默认目录)、`waitFor` 就绪与超时、挂起 execute 超时并触发 terminate、click 失败补发 release、key 支持/拒绝未知、back/forward 步进与边界空操作、scroll 记录。
|
|
977
|
+
|
|
978
|
+
## 备注
|
|
979
|
+
|
|
980
|
+
- 新增工具后共 25 个 `browser_*` 工具(原 20 + wait/scoll/back/forward/key)。
|
|
981
|
+
- 未 bump 版本号;未推送。
|
|
982
|
+
|
|
983
|
+
---
|
|
984
|
+
|
|
985
|
+
# 第三轮 + 审查修复说明 (commit 87508cc)
|
|
986
|
+
|
|
987
|
+
- **日期**:2026-08-18
|
|
988
|
+
- **状态**:已提交,未推送。22 个文件,+4271/-429 行。
|
|
989
|
+
|
|
990
|
+
## 一、第三轮新增功能
|
|
991
|
+
|
|
992
|
+
| 特性 | 文件 |
|
|
993
|
+
| --- | --- |
|
|
994
|
+
| browser_a11y:无障碍树工具(role/name/value/states/坐标,iframe/shadow 穿透) | provider.ts, types.ts, runtime.ts, index.ts |
|
|
995
|
+
| browser_scrape:静态 CSS 结构化提取(不执行任意代码) | provider.ts, types.ts, runtime.ts, index.ts |
|
|
996
|
+
| browser_set_value/check/select/clear/get_value:完整表单控件 | provider.ts, types.ts, runtime.ts, index.ts |
|
|
997
|
+
| browser_click/type 支持 target {by: css/text/xpath, value} 语义定位 | provider.ts, types.ts, runtime.ts, index.ts |
|
|
998
|
+
| browser_refresh:刷新当前页 | provider.ts, types.ts, runtime.ts, index.ts |
|
|
999
|
+
| 每个会话独立 BrowserWindow + 真实工具栏(地址栏/后退前进/刷新/标签条) | host-main.ts |
|
|
1000
|
+
| 用户工具栏操作路由回会话模型(人和 agent 共享标签/导航) | host-main.ts, remote-host.ts, provider.ts |
|
|
1001
|
+
|
|
1002
|
+
## 二、审查修复 (5 HIGH + 10 MEDIUM)
|
|
1003
|
+
|
|
1004
|
+
| # | 级别 | 问题 | 修复 |
|
|
1005
|
+
| --- | --- | --- | --- |
|
|
1006
|
+
| 1 | HIGH | RPC token 通过 --rpc-token argv 暴露给同用户进程 | token 改从 stdin 传入(首行);spawn stdio 改为 pipe |
|
|
1007
|
+
| 2 | HIGH | browser_replay 的 type 条目绕过脱敏 | 挂载对 replay+type / replay+setValue 同样掩码;execute 脚本/参数/结果全部脱敏 |
|
|
1008
|
+
| 3 | HIGH | host-main Cookie.domain? 合并 string 类型错误 | `c.domain ?? ''` 兜底 |
|
|
1009
|
+
| 4 | HIGH | CI pnpm cache 需要 lockfile(仓库无) | 改用 npm + npx tsc + node --test |
|
|
1010
|
+
| 5 | HIGH | README 声称"无测试套件"但已新增 | 修正 README/README.en |
|
|
1011
|
+
| 6 | MEDIUM | download() 超时无打断 | 文档化(子进程无 abort 机制,父侧 pending 已清理) |
|
|
1012
|
+
| 7 | MEDIUM | downloadDir 大小写敏感(Windows 假拒绝合法路径) | 比较前 toLowerCase() |
|
|
1013
|
+
| 8 | MEDIUM | 默认 ~/Downloads 可能是 OneDrive 重定向 | 注释+文档说明非安全边界 |
|
|
1014
|
+
| 9 | MEDIUM | terminatePage 无界 fire-and-forget | WeakMap 计数,上限 3 并发 |
|
|
1015
|
+
| 10 | MEDIUM | 无 agent 的 default 会话永不关闭 | process.on('exit') 清理 |
|
|
1016
|
+
| 11 | MEDIUM | agent.ctx.effect 抛错后 pendingOpens 永久毒化 | 先 set/delete 再 effect,catch 中 undo |
|
|
1017
|
+
| 12 | MEDIUM | browser_close_tab 缺 assertAllowed | 补上 |
|
|
1018
|
+
| 13 | MEDIUM | restrictedTo 跨任务泄漏疑虑 | 注释澄清:per-Cordis-context = per-task |
|
|
1019
|
+
| 14 | MEDIUM | 窗口销毁后标题残留;OAuth window.open 被拒 | 销毁可见视图后重置标题;非 http(S) popup 放行 |
|
|
1020
|
+
|
|
1021
|
+
## 三、验证
|
|
1022
|
+
|
|
1023
|
+
- `tsc --noEmit` 零错误。
|
|
1024
|
+
- `node --test tests/*.test.mjs` 18 项全部通过(含第三轮新增: a11y、form ops、scrape、target、userAction、reload)。
|
|
1025
|
+
- 版本号未 bump。三轮合计 3 个提交未推送。
|
|
1026
|
+
|
|
1027
|
+
---
|
|
1028
|
+
|
|
1029
|
+
# 第四轮:DSH 更新对齐 + 复查修复
|
|
1030
|
+
|
|
1031
|
+
## 一、DSH 更新对齐
|
|
1032
|
+
|
|
1033
|
+
- DSH(Harness)已更新到 **0.1.1-rc.2**(`@deepseek-ai/dsh` latest = next = 0.1.1-rc.2;`dsh-llm`/`dsh-tools`/`dsh-system-prompt` 同版本)。
|
|
1034
|
+
- 验证:用 0.1.1-rc.2 安装后 `tsc --noEmit` 零错误、18 项既有测试全绿 —— 插件与新版 DSH 完全兼容。
|
|
1035
|
+
- `package.json` peerDependencies 下限从 `^0.1.0-rc.1` 对齐到 **`^0.1.1-rc.2`**(语义上旧范围已覆盖,显式对齐当前基线)。
|
|
1036
|
+
- `cordis`(4.0.1)、`schemastery`(3.18.1)无更新。
|
|
1037
|
+
|
|
1038
|
+
## 二、复查发现并修复的问题
|
|
1039
|
+
|
|
1040
|
+
| # | 级别 | 问题 | 修复 |
|
|
1041
|
+
|---|---|---|---|
|
|
1042
|
+
| 1 | **HIGH** | `type()` 带 target 时把文本丢弃(只发空 insertText)——上次审查已点名,本轮确认仍存在 | `'text' in request ? request.text : ''`,保留文本并记录进 history |
|
|
1043
|
+
| 2 | MEDIUM | `key()` Space 缺 CDP `text` 字段,输入框收不到空格字符 | KEY_SPECS.Space 增加 `text: ' '`,keyDown 参数透传 |
|
|
1044
|
+
| 3 | MEDIUM | `key()` keyUp 失败无释放恢复(键可能卡住按下) | 仿 click 的 release 恢复:press/release 分离,失败重试 release |
|
|
1045
|
+
| 4 | MEDIUM | `waitFor` URL 前缀匹配跨域误匹配(https://a.com 匹配 https://a.com.evil.com) | 仅同 origin 内允许前缀匹配,否则要求精确相等 |
|
|
1046
|
+
| 5 | LOW | hello 竞态:子进程在 stdin token 到达前连接会发空 token(被父进程当伪造连接) | 连接回调等待 stdin 首行/超时兜底后再发 hello;顺带修复 stdin readline 变量名与 socket readline 遮蔽问题 |
|
|
1047
|
+
| 6 | LOW | `snapshotMaxElements`/`contentMaxChars` 配置未接线(provider 接受但入口不暴露) | entry.ts Config 增加两个字段并透传 |
|
|
1048
|
+
| 7 | LOW | index.ts 缺第三轮新类型导出(a11y/scrape/target/form ops 等) | 全部补上 |
|
|
1049
|
+
| 8 | LOW | 下载 `.part` 临时文件 rename 失败时残留 | catch 中 unlinkSync 清理后重抛 |
|
|
1050
|
+
|
|
1051
|
+
## 三、验证
|
|
1052
|
+
|
|
1053
|
+
- `tsc --noEmit` 零错误;构建通过。
|
|
1054
|
+
- `node --test tests/*.test.mjs` **20 项**全部通过(新增 3 项回归:type-with-target 不丢文本、Space 带 text、waitFor 同源判定)。
|
|
1055
|
+
- 版本号未 bump。四轮合计 4 个提交未推送。
|
|
1056
|
+
|
|
1057
|
+
---
|
|
1058
|
+
|
|
1059
|
+
# 第五轮:修复 electron 44 懒下载导致插件不可用 + 构建回归
|
|
1060
|
+
|
|
1061
|
+
## 一、症状与根因
|
|
1062
|
+
|
|
1063
|
+
- 症状:安装后用 pnpm 装依赖,插件报不可用/浏览器起不来。
|
|
1064
|
+
- 根因一:electron 44+ **不再有 postinstall**,改为**首次 `require('electron')` 时懒下载**二进制。pnpm 安装后 `node_modules/electron/` 里没有 `dist/` 与 `path.txt`(二进制缺失)。插件的 `available()` 探测走 `require('electron')`,会在 DSH 启动/provider 选择时**同步阻塞下载**(或离线失败),表现为插件不可用。
|
|
1065
|
+
- 根因二:装上 electron 44 后其自带类型把 `Cookie.domain` 标为可选,`flushAuth` 里两处裸用 `c.domain` 触发 TS18048,`tsc` 构建必挂(HIGH 3 的修复当时只覆盖了导出对象一处)。
|
|
1066
|
+
|
|
1067
|
+
## 二、修复
|
|
1068
|
+
|
|
1069
|
+
| # | 问题 | 修复 |
|
|
1070
|
+
|---|---|---|
|
|
1071
|
+
| 1 | `available()` 探测触发 electron 懒下载,阻塞/失败 | `resolveElectronPath()` 改为无副作用探测:先检查 `path.txt`/`dist` 是否已下载,已下载才 `require('electron')`;未下载走 ELECTRON_PATH/锚点/自身安装树扫描,全部缺失时抛错(不触发网络下载) |
|
|
1072
|
+
| 2 | 二进制缺失时错误信息没给指引 | 错误信息补充:Electron 44+ 首次使用自动下载(需网络),可先跑 `npx install-electron` 或设 `ELECTRON_PATH` |
|
|
1073
|
+
| 3 | `flushAuth` 的 `c.domain` 两处裸用导致构建失败 | `const domain = c.domain ?? ''`,host/hostPart/导出均基于它 |
|
|
1074
|
+
| 4 | 首次使用无感知 | 懒下载仍保留在真正 spawn 时触发(electron 44 官方机制);首次运行需网络 |
|
|
1075
|
+
|
|
1076
|
+
## 三、补充修复:Windows 握手回归(RPC token 收不到)
|
|
1077
|
+
|
|
1078
|
+
- 症状:真机端到端验证时子进程日志 `warning: no token received on stdin`,hello 带空 token,认证失败,插件完全不可用。
|
|
1079
|
+
- 根因:Windows 上 **Electron 是 GUI 子系统进程,收不到 piped stdin**。第一轮把 token 从 argv 改为 stdin 传递(安全修复)在 Windows 上直接断了握手——旧版(argv)能跑,新版(stdin)跑不了。
|
|
1080
|
+
- 修复:token 双通道传递 —— spawn 时同时写入 stdin **和** `DSH_BROWSER_RPC_TOKEN` 环境变量;子进程 **stdin 优先、env 兜底**(Windows GUI 收不到 stdin 时用 env;env 比 argv 隐蔽,默认进程列表工具不可见)。
|
|
1081
|
+
|
|
1082
|
+
## 四、验证
|
|
1083
|
+
|
|
1084
|
+
- `tsc --noEmit` 零错误;构建通过。
|
|
1085
|
+
- `node --test tests/*.test.mjs` 20 项全部通过。
|
|
1086
|
+
- `RemoteElectronViewHost.available()` 实测 **6ms** 返回(纯文件系统探测,不再触发下载);electron 44 二进制下载后正常定位。
|
|
1087
|
+
- **真实端到端验证通过**(真机 Windows):spawn electron → stdin/env token 握手 → `navigate` → `snapshot` → `listTabs` → `close` 全链路 E2E PASS。
|
|
1088
|
+
- 版本号未 bump。五轮合计 5 个提交未推送。
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
1092
|
+
# 第六轮:修复自托管模式下 switch_tab/close_tab 按 id 操作失败
|
|
1093
|
+
|
|
1094
|
+
## 一、症状
|
|
1095
|
+
|
|
1096
|
+
自托管(plain dsh web)模式下:`browser_switch_tab` 对任何 tab id 都报 `tab "<id>" is not open in this session`;`browser_close_tab` 返回 `closed: true` 但标签没关。其余工具全部正常。
|
|
1097
|
+
|
|
1098
|
+
## 二、根因
|
|
1099
|
+
|
|
1100
|
+
- **closeTab 静默假成功**:tab 找不到时 `index < 0` 直接 resolve(幂等设计),返回 `closed: true` 掩盖了真实错误——这就是"返回 Closed. 但没关闭"。
|
|
1101
|
+
- **按 id 查找被限定在"调用方 session"内**:工具层 `ensureSession` 解析出的 session 与实际持有该 tab 的 session 可能错位(宿主工具执行上下文差异),导致 findIndex 找不到。tab id 是全局唯一的 UUID,但查找范围错了。
|
|
1102
|
+
|
|
1103
|
+
## 三、修复
|
|
1104
|
+
|
|
1105
|
+
| # | 问题 | 修复 |
|
|
1106
|
+
|---|---|---|
|
|
1107
|
+
| 1 | closeTab 静默假成功 | 找不到时改为抛出 `BROWSER_TAB_UNKNOWN`(与 switchTab 一致),不再返回假成功 |
|
|
1108
|
+
| 2 | 按 id 查找限定调用方 session | 新增 `locateTab()`:优先调用方 session,找不到则**跨全部 session 按 id 兜底**(tab id 全局唯一,不会误命中其他任务);真的不存在才抛错。**该兜底后来被移除**:跨会话命中意味着陈旧 id 能关掉别的任务(或人)的标签页还报成功,与工具承诺的隔离矛盾 —— 现在只查调用方会话,仅保留"裸 uuid 与 `tab:<uuid>` 两种写法都接受" |
|
|
1109
|
+
| 3 | 错误无诊断信息 | `BROWSER_TAB_UNKNOWN` 信息附带调用方 session 的现有 tab id 列表,便于线上定位 |
|
|
1110
|
+
|
|
1111
|
+
## 四、验证
|
|
1112
|
+
|
|
1113
|
+
- `tsc --noEmit` 零错误;构建通过。
|
|
1114
|
+
- `node --test tests/*.test.mjs` **21 项**全部通过(新增:跨 session 切换/关闭兜底、未知 id 抛错回归测试)。
|
|
1115
|
+
- 版本号未 bump。六轮合计 6 个提交未推送。
|
|
1116
|
+
|
|
1117
|
+
---
|
|
1118
|
+
|
|
1119
|
+
## 第七轮:内置浏览器工具栏交互修复(焦点路由)
|
|
1120
|
+
|
|
1121
|
+
- **日期**:2026-08-26
|
|
1122
|
+
- **状态**:修复已完成并通过验证,已作为单独提交保存在本地 git,**未推送**。
|
|
1123
|
+
|
|
1124
|
+
### 问题
|
|
1125
|
+
|
|
1126
|
+
"让内置浏览器同时可以作为真正浏览器使用"(地址栏导航、工具栏按钮、标签条)在 Windows 上交互不可用:点击按钮无反应、地址栏无法输入。经真机探针(真实系统鼠标/键盘事件)定位:
|
|
1127
|
+
|
|
1128
|
+
1. **`before-input-event` 只触发键盘事件**——此前用它检测鼠标点击是探针误判;修正后用 `input-event` + DOM title 变化实测:**真实鼠标点击能到达工具栏 view**,DOM 处理器与 IPC 链路本身正常。
|
|
1129
|
+
2. **真正的问题在键盘焦点路由**:Windows 上键盘输入只派发给**有焦点的 webContents**,而窗口重新获得焦点(alt-tab、点击)时 Electron **不会自动恢复任何 view 的焦点**(electron#28163)——最后一个 `addChildView` 的 view 抢占焦点。插件里页面 view 抢走焦点后,点击地址栏无法聚焦,键盘输入全部进页面,表现为"UI 不能正常使用"。
|
|
1130
|
+
|
|
1131
|
+
### 修复(host-main.ts)
|
|
1132
|
+
|
|
1133
|
+
| # | 问题 | 修复 |
|
|
1134
|
+
|---|---|---|
|
|
1135
|
+
| 1 | 键盘输入只进有焦点的 view,页面 view 抢占焦点 | 新增 `wireFocusRouting()`:任何 view 收到 `input-event` mouseDown 即 `webContents.focus()`——**点击地址栏 → 焦点切到工具栏 → 可输入网址;点击页面 → 焦点切回页面** |
|
|
1136
|
+
| 2 | 窗口重新聚焦时不恢复 view 焦点 | `win.on('focus')` 恢复**上次点击的 view**(`lastFocusedViewId`,工具栏用哨兵 key),回退到可见页面 view |
|
|
1137
|
+
| 3 | 工具栏 view 不在 `win.views` 里 | 定义 `TOOLBAR_FOCUS` 哨兵区分工具栏/页面焦点目标 |
|
|
1138
|
+
|
|
1139
|
+
### 验证
|
|
1140
|
+
|
|
1141
|
+
- 真机探针(hit2/hit3):真实 OS 点击到达工具栏 view(`input-event mouseDown`)、DOM mousedown 触发;焦点路由生效(点击工具栏 → `toolbar.isFocused()=true`,点击页面 → `page.isFocused()=true`)。
|
|
1142
|
+
- `tsc --noEmit` 零错误;`node --test tests/*.test.mjs` **21 项**全部通过。
|
|
1143
|
+
- 版本号未 bump。七轮合计 7 个提交未推送。
|
|
1144
|
+
|
|
1145
|
+
---
|
|
1146
|
+
|
|
1147
|
+
## 0.1.16 发布(2026-08-26)
|
|
1148
|
+
|
|
1149
|
+
bump `0.1.15 → 0.1.16`,将第一至第七轮全部修复随版本发布(本地 11 个提交,origin/master 自 `9ffe5d6` 起):
|
|
1150
|
+
|
|
1151
|
+
- **第一轮**:安全与健壮性修复(详见上文)
|
|
1152
|
+
- **第二轮**:功能补全 + 测试 + CI(详见上文)
|
|
1153
|
+
- **第三轮**:对标 browser-bridge 的功能 + 审查修复(详见上文)
|
|
1154
|
+
- **第四轮**:DSH 0.1.1-rc.2 对齐 + 复查修复(详见上文)
|
|
1155
|
+
- **第五轮**:`available()` 无副作用探测(electron 44 懒下载)+ flushAuth 构建修复
|
|
1156
|
+
- **第六轮**:Windows RPC 握手 token env 兜底;switch/close_tab 定位(当时的跨 session 兜底后续已收紧为只查本会话)+ closeTab 不再假成功
|
|
1157
|
+
- **第七轮**:工具栏焦点路由——点击聚焦目标 view,地址栏可输入,窗口 refocus 恢复上次 view
|
|
1158
|
+
|
|
1159
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` 21 项全部通过。tag `v0.1.16`。
|
|
1160
|
+
|
|
1161
|
+
---
|
|
1162
|
+
|
|
1163
|
+
# 第八轮:DSH Desktop 宿主 Electron 复用(2026-08-27)
|
|
1164
|
+
|
|
1165
|
+
## 症状与根因
|
|
1166
|
+
|
|
1167
|
+
- **症状**:在 DSH Desktop(基于 Electron 43 宿主,`desktop` profile)装好插件后,调用 `browser_*` 工具直接报 `cannot locate the Electron binary`。安装插件不带来 electron(optional peer,`dependencies` 为空),`ELECTRON_PATH` 未设置,desktop / web / profiles 的 `node_modules` 均无 electron 包——共享浏览器窗口永远起不来。
|
|
1168
|
+
- **根因**:插件自托管模式需要可启动的 Electron 二进制,而 `resolveElectronPath()` 只查 peer 依赖 / `ELECTRON_PATH` / DSH 锚点与 pnpm store。DSH Desktop 宿主本身基于 Electron 运行,宿主二进制就在本机,却从未被利用;报错示例 `--profile web` 对 DSH Desktop 用户还有误导。
|
|
1169
|
+
|
|
1170
|
+
## 修复
|
|
1171
|
+
|
|
1172
|
+
| # | 问题 | 修复 |
|
|
1173
|
+
|---|---|---|
|
|
1174
|
+
| 1 | 插件运行在 Electron 进程内(DSH Desktop 主进程)时仍去找 peer 依赖 | `resolveElectronPath()` 第一步:检测 `process.versions.electron`,是则直接复用 `process.execPath` 宿主二进制——零额外安装,且不可能缺失(我们正运行在它上面) |
|
|
1175
|
+
| 2 | DSH Desktop 把插件跑在 Electron 主进程的**子 Node 进程**时,第 1 步不生效,依旧找不到二进制 | 新增进程祖先树回退(最后手段):向上扫描父进程可执行文件,命中 Electron 二进制(名字含 `electron`,或旁有 `resources/electron.asar`/`app.asar`/`default_app.asar`)即复用;POSIX 走 `/proc`,Windows 走 PowerShell CIM,仅在其它路径全部落空时才执行,不影响正常路径 |
|
|
1176
|
+
| 3 | 报错示例 `--profile web` 对 DSH Desktop 用户有误导 | 报错改为按当前 profile 动态提示(`<your-profile>`,并注明 DSH Desktop 的 profile 是 `desktop`) |
|
|
1177
|
+
| 4 | 文档未说明"何时需要 electron" | 环境要求 / FAQ / 架构 / README 同步:DSH Desktop 零安装;纯 `dsh web` 自托管才需要 `dsh plugin --profile <profile> add electron` 或 `ELECTRON_PATH`(44+ 懒下载可先 `npx install-electron`) |
|
|
1178
|
+
| 5 | CI 类型检查失败(electron 是 optional peer,CI 无该包,走 shim 类型;第七轮焦点路由新增的 API 未入 shim) | `electron-shim.d.ts` 补齐:`WebContents.focus()`、`WebContents.on('input-event')`、`BrowserWindow.on('focus')`——无 electron 包环境 `tsc` 同样零错误 |
|
|
1179
|
+
|
|
1180
|
+
## 验证
|
|
1181
|
+
|
|
1182
|
+
- `tsc --noEmit` 零错误(无 electron 包环境即 CI 环境同样零错误);构建通过。
|
|
1183
|
+
- `node --test tests/*.test.mjs` 21 项全部通过。
|
|
1184
|
+
|
|
1185
|
+
---
|
|
1186
|
+
|
|
1187
|
+
## 0.1.17 发布(2026-08-27)
|
|
1188
|
+
|
|
1189
|
+
bump `0.1.16 → 0.1.17`,将第八轮 DSH Desktop 宿主 Electron 复用修复随版本发布:
|
|
1190
|
+
|
|
1191
|
+
- **第八轮**:插件运行在 Electron 进程内直接复用宿主二进制;插件跑在宿主子 Node 进程时沿进程祖先树找到宿主 Electron 兜底(Windows 用 PowerShell CIM,仅最后手段)——DSH Desktop **零安装开箱可用**;报错按当前 profile 动态提示;补齐 electron shim(`WebContents.focus()` / `on('input-event')` / `BrowserWindow.on('focus')`)修复无 electron 包环境(CI)的类型检查;文档与 CHANGELOG 同步。
|
|
1192
|
+
|
|
1193
|
+
**验证**:`tsc` 构建零错误(有无 electron 包两种环境);`node --test tests/*.test.mjs` 21 项全部通过。
|
|
1194
|
+
|
|
1195
|
+
---
|
|
1196
|
+
|
|
1197
|
+
# 第九轮:electron 改为必装依赖(2026-08-27)
|
|
1198
|
+
|
|
1199
|
+
## 动机
|
|
1200
|
+
|
|
1201
|
+
issue #4 报告者的核心诉求是「装完插件即可用」:此前 electron 是 optional peer,安装插件不会自动带来 electron 包,DSH Desktop 之外(纯 `dsh web` 自托管)的用户仍需手动 `add electron`。本轮按报告者建议 ① 的 A 分支,把 electron 从 optional peer 改为**真实依赖**。
|
|
1202
|
+
|
|
1203
|
+
## 改动
|
|
1204
|
+
|
|
1205
|
+
| # | 改动 | 说明 |
|
|
1206
|
+
|---|---|---|
|
|
1207
|
+
| 1 | `package.json`:`electron` 从 `peerDependencies`(+ `peerDependenciesMeta.optional`)移入 `dependencies`(`>= 30.0.0`),删除 `peerDependenciesMeta` | 安装插件即自动带上 electron 包;electron 44+ 无 postinstall,二进制首次使用懒下载,安装不增重 |
|
|
1208
|
+
| 2 | `remote-host.ts` 注释与报错措辞同步 | 定位顺序 ③ 改为「随插件安装的 electron 包」;报错区分新旧安装(新装自带 electron,旧装仍可 `dsh plugin --profile <profile> add electron`) |
|
|
1209
|
+
| 3 | `electron-shim.d.ts` 注释同步 | electron 现在是运行时依赖,shim 仍保留(供独立 typecheck src/ 时自洽) |
|
|
1210
|
+
| 4 | 文档同步(README 中英、user-guide、architecture、CHANGELOG) | 「环境要求」改为必装依赖;定位顺序、FAQ、已知限制同步 |
|
|
1211
|
+
|
|
1212
|
+
## 说明
|
|
1213
|
+
|
|
1214
|
+
- **DSH Desktop 行为不变**:依旧优先复用宿主二进制(步骤 0/②),随包安装的 electron 仅作后备——安装体积变重是换取「装完即可用」的代价,符合报告者建议。
|
|
1215
|
+
- **CI 影响**:electron 成为 dependencies 后,CI 的 `npm install` 会装上 electron 包(44+ 无 postinstall,不下载二进制),`tsc` 将使用真实 electron 类型(shim 同步保留,双环境仍零错误)。
|
|
1216
|
+
|
|
1217
|
+
## 验证
|
|
1218
|
+
|
|
1219
|
+
- `tsc --noEmit` 零错误(有/无 electron 包两种环境);构建通过。
|
|
1220
|
+
- `node --test tests/*.test.mjs` 21 项全部通过。
|
|
1221
|
+
|
|
1222
|
+
---
|
|
1223
|
+
|
|
1224
|
+
## 0.1.18 发布(2026-08-27)
|
|
1225
|
+
|
|
1226
|
+
bump `0.1.17 → 0.1.18`,将第九轮「electron 改为必装依赖」随版本发布:
|
|
1227
|
+
|
|
1228
|
+
- **第九轮**:electron 从 optional peer 移入 `dependencies`,安装插件即自动带上 electron 包——纯 `dsh web` 自托管开箱可用,不再需要手动 `add electron`;DSH Desktop 依旧优先复用宿主二进制;文档、报错与 shim 注释同步。
|
|
1229
|
+
|
|
1230
|
+
**验证**:`tsc` 构建零错误(有/无 electron 包两种环境);`node --test tests/*.test.mjs` 21 项全部通过。
|
|
1231
|
+
|
|
1232
|
+
---
|
|
1233
|
+
|
|
1234
|
+
# 第十轮:DSH-Store 兼容性声明(2026-08-27)
|
|
1235
|
+
|
|
1236
|
+
## 背景
|
|
1237
|
+
|
|
1238
|
+
DSH STORE 自动化(AI-Scarlett/DSH-Store #243)固定 Commit 检查发现:`dsh-builtin-browser` 对官方最新 3 个 DSH 版本(`0.1.0-rc.8` / `0.1.1-rc.1` / `0.1.1-rc.2`)没有任何 compatible 的 `dshReleases` 记录 → 触发 `DSH_LATEST_THREE_COMPATIBILITY_HOLD`,插件被临时下架。仅写宽泛范围不算安装证据,必须逐版本声明。
|
|
1239
|
+
|
|
1240
|
+
## 改动
|
|
1241
|
+
|
|
1242
|
+
`package.json` 新增 `dsh.compatibility`(参照已收录插件 `dsh-vision` 的 schema):
|
|
1243
|
+
|
|
1244
|
+
- `dsh`: `>=0.1.1-rc.1 <0.2.0`(覆盖两条 compatible 声明);
|
|
1245
|
+
- `profiles`: `["web", "desktop"]`;
|
|
1246
|
+
- `dshReleases`:
|
|
1247
|
+
- `0.1.1-rc.2`: `compatible`(peer 对齐 `^0.1.1-rc.2`、CI 全绿、真实 DSH E2E 验证);
|
|
1248
|
+
- `0.1.1-rc.1`: `compatible`(真实 DSH `0.1.1-rc.1` profile-boot E2E 实测通过:open→navigate→snapshot 全链路);
|
|
1249
|
+
- `0.1.0-rc.8`: `unknown`(未实测,不宣称兼容)。
|
|
1250
|
+
|
|
1251
|
+
目的:恢复 DSH STORE 收录。推送后自动化每 8 小时复检,确定性 blocker 清除后自动更新/关闭 issue #243。
|
|
1252
|
+
|
|
1253
|
+
## 验证
|
|
1254
|
+
|
|
1255
|
+
- `tsc` 构建零错误;`node --test tests/*.test.mjs` 21 项全部通过。
|
|
1256
|
+
|
|
1257
|
+
---
|
|
1258
|
+
|
|
1259
|
+
## 0.1.19 发布(2026-08-27)
|
|
1260
|
+
|
|
1261
|
+
bump `0.1.18 → 0.1.19`,将第十轮「DSH-Store 兼容性声明」随版本发布(tag `v0.1.19`)。
|
|
1262
|
+
|
|
1263
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` 21 项全部通过。
|
|
1264
|
+
|
|
1265
|
+
---
|
|
1266
|
+
|
|
1267
|
+
# 第十一轮:自托管浏览器宿主崩溃后的会话自愈(2026-08-27)
|
|
1268
|
+
|
|
1269
|
+
## 背景
|
|
1270
|
+
|
|
1271
|
+
issue #5 报告:自托管模式下 DSH 重启 / 会话 checkpoint 恢复后,`browser_*` 工具间歇性报 `dsh-builtin-browser: browser host is not running`,且恢复不自动。典型半死态:`browser_session` / `browser_list_tabs` 能返回会话与标签,但 `browser_content` 超时或 `browser_open` 直接拒绝;手动 `browser_reset_session` 或再 `browser_open` 一次才恢复。
|
|
1272
|
+
|
|
1273
|
+
## 根因
|
|
1274
|
+
|
|
1275
|
+
`DeferredRemoteView.materializeOnce()` 把物化后的 `RemoteView` **永久缓存**,而 `RemoteView` 硬引用创建它的 `ElectronChildClient`。浏览器子进程一死(DSH 重启 → 父进程 socket 关闭 → 子进程 `app.exit(0)`;或崩溃),`onChildExit` 虽然重置了宿主(下次可起新子进程),但**已物化的视图句柄仍指向死掉的 client** → 之后每次调用都命中 `call()` 的 `dead` 检查,永远报 "browser host is not running"。会话元数据(session id / tab)仍在 → 半死态;只有新建会话(新视图句柄)才会重新物化 → 手动 reset/open 后"恢复"。
|
|
1276
|
+
|
|
1277
|
+
## 改动
|
|
1278
|
+
|
|
1279
|
+
| # | 改动 | 说明 |
|
|
1280
|
+
|---|---|---|
|
|
1281
|
+
| 1 | `remote-host.ts` 新增 `HostGoneError` 标记错误 | `fail()` 与 `call()` 的 dead 检查、`ensureView` 的 host 缺失全部标记为 host-gone,与页面级错误区分 |
|
|
1282
|
+
| 2 | `DeferredRemoteView` 新增 `withRecovery`:宿主已死时丢弃物化缓存 → 对新子进程重建视图 → **重试该操作恰好一次** | 存活会话在宿主死亡后的**第一次调用**即自动拉起新宿主并重试,不再永久报 "browser host is not running";页面级错误不触发重建 |
|
|
1283
|
+
| 3 | `ElectronChildClient` 支持注入 spawn 可执行文件(测试缝) | 默认行为不变(`resolveElectronPath()`);供无 Electron 环境的集成测试使用 |
|
|
1284
|
+
| 4 | 子进程意外退出时父侧输出一条日志 | 可观测性:宿主"已死 / 将重启"一目了然 |
|
|
1285
|
+
| 5 | 新增 `tests/remote-host-recovery.test.mjs` + `tests/fixtures/fake-host-child.mjs` | 用纯 Node 的假子进程(与 host-main.js 同协议)实测:同一视图句柄在子进程崩溃后自动重启并重试成功——旧代码下该句柄会永久失败 |
|
|
1286
|
+
| 6 | 文档同步(README 中英、user-guide、architecture) | 「崩溃后旧会话失效需 reset 重建」改为「崩溃后会话自动重建,仅页面状态丢失」 |
|
|
1287
|
+
|
|
1288
|
+
## 说明
|
|
1289
|
+
|
|
1290
|
+
- **未采用**把浏览器子进程改成 DSH 服务受管子进程(生命周期随 DSH 启停)——那是 DSH 宿主层的架构职责,不在插件能力内;插件的等价保障是:父进程断开时子进程自动退出(已有,不留孤儿),宿主死后会话在下次调用自动重建。
|
|
1291
|
+
- **未加心跳/看门狗**:子进程事件循环被卡死(RPC 无响应)与"进程已死"是两种情形;本次修复覆盖"进程已死"这一明确缺陷。卡死场景由 provider 的 withTimeout + terminateExecution 打断兜底。
|
|
1292
|
+
|
|
1293
|
+
## 验证
|
|
1294
|
+
|
|
1295
|
+
- `tsc --noEmit` 零错误(有/无 electron 包两种环境);
|
|
1296
|
+
- `node --test tests/*.test.mjs` **22 项全部通过**(新增 1 项回归测试);
|
|
1297
|
+
- 回归测试实测日志链路:`__die` → ECONNRESET → "browser host gone; will restart on next use" → 重新拉起 → 重试成功。
|
|
1298
|
+
|
|
1299
|
+
**状态**:已提交本地,未 bump 版本、未发布(发布时 bump)。
|
|
1300
|
+
|
|
1301
|
+
---
|
|
1302
|
+
|
|
1303
|
+
# 第十二轮:resolveElectronPath 排除打包应用(2026-08-27)
|
|
1304
|
+
|
|
1305
|
+
## 背景
|
|
1306
|
+
|
|
1307
|
+
issue #6 报告:0.1.19 在 DSH Desktop(打包的 Electron 应用)上 `browser_*` 报 `browser host exited (code=0)`。`resolveElectronPath()` 能找到"某个 electron",但选中的是**打包的宿主应用 exe**——spawn 它不会按脚本参数拉起,而是启动第二个 DSH Desktop 实例 → 命中单实例锁 → 秒退 code=0,报错完全不指向根因。
|
|
1308
|
+
|
|
1309
|
+
## 根因(三层,报告者已定位)
|
|
1310
|
+
|
|
1311
|
+
1. **layer 0 抢跑**:插件运行在 DSH Desktop 主进程内,`process.versions.electron` 已设置 → 直接返回 `process.execPath` = 打包的 DSH Desktop.exe;
|
|
1312
|
+
2. **layer 1 在 Electron 主进程内失效**:`require('electron')` 解析为内置 API 模块(非 npm 包路径),bundled 探测永不命中;
|
|
1313
|
+
3. **layer 4 同样选错**:`isElectronBinary()` 把 `resources/app.asar` 存在即判定为 electron → 祖先树里的 DSH Desktop.exe 被当作可复用二进制。
|
|
1314
|
+
|
|
1315
|
+
## 改动
|
|
1316
|
+
|
|
1317
|
+
| # | 改动 | 说明 |
|
|
1318
|
+
|---|---|---|
|
|
1319
|
+
| 1 | 新增 `isBareElectron()`:旁有 `resources/app.asar` 即**打包应用**,不可按脚本参数拉起,一律排除 | 裸 electron(dev 模式 `electron.exe`、`resources/electron.asar`/`default_app.asar` 而无 `app.asar`)才可 spawn |
|
|
1320
|
+
| 2 | layer 3(当前进程复用)与 layer 4(祖先树)都改为**仅裸 Electron** | DSH Desktop.exe 被正确跳过,不再秒退 |
|
|
1321
|
+
| 3 | layer 0 改为**bundled 纯文件系统探测**(`bundledElectronBinary()`),置于最优先 | 不依赖 `require` 语义(主进程内解析为内置模块)、不触发 electron 44+ 懒下载(probe 保持无副作用);覆盖普通 node_modules 布局 + pnpm store(`node_modules/.pnpm` 与 `.pnpm` 两种路径) |
|
|
1322
|
+
| 4 | 定位顺序重排:bundled → `ELECTRON_PATH` → 锚点最新者 → 当前进程裸宿主 → 祖先树裸宿主 | 打包 DSH Desktop 的正确路径是随插件安装的 electron 44 二进制;dist 缺失时给出明确报错(提示 `npx install-electron` / `ELECTRON_PATH`),不再 silent 秒退 |
|
|
1323
|
+
| 5 | 新增 `internals` 测试钩子(镜像 tool-browser 惯例)+ `tests/electron-resolution.test.mjs` | 打包应用(含名为 electron.exe 但带 app.asar 者)一律 false;裸 electron 为 true |
|
|
1324
|
+
| 6 | 文档同步(README 中英、user-guide、architecture) | 定位顺序更新 + 「打包应用不参与复用」说明 |
|
|
1325
|
+
|
|
1326
|
+
## 说明
|
|
1327
|
+
|
|
1328
|
+
- **DSH Desktop 行为变化**:不再复用宿主打包 exe(那是 #4 修复在打包宿主上的漏洞);0.1.18+ 插件自带 electron 包,优先用它的 dist 二进制——首次使用需触发 44+ 懒下载(`npx install-electron` 或首次 require),报错已给出指引。
|
|
1329
|
+
- **dev 宿主不受影响**:裸 electron 的 dev 宿主(如 `electron .` 的开发环境)仍走 layer 3/4 复用宿主二进制,零安装。
|
|
1330
|
+
|
|
1331
|
+
## 验证
|
|
1332
|
+
|
|
1333
|
+
- `tsc --noEmit` 零错误(有/无 electron 包两种环境);
|
|
1334
|
+
- `node --test tests/*.test.mjs` **23 项全部通过**(新增 1 项打包判定回归测试);
|
|
1335
|
+
- 本机实测:`resolveElectronPath()` 返回 `node_modules/electron/dist/electron.exe`(bundled 优先,非 `process.execPath`);`isBareElectron(node.exe)` = false。
|
|
1336
|
+
|
|
1337
|
+
**状态**:已提交本地,未 bump 版本、未发布(发布时 bump)。
|
|
1338
|
+
|
|
1339
|
+
---
|
|
1340
|
+
|
|
1341
|
+
## 第二次全面复审加固(2026-08-27)
|
|
1342
|
+
|
|
1343
|
+
对第十一、十二轮改动换角度重审(并发时序 / 跨平台 / 子进程侧),发现并修复第一轮审查漏掉的问题:
|
|
1344
|
+
|
|
1345
|
+
| # | 问题 | 修复 |
|
|
1346
|
+
|---|---|---|
|
|
1347
|
+
| 1 | **并发恢复双重建竞态**:child 死时同一句柄上有两个并发 op,两个 catch 各自重建 → 对新 child 发两次 `createView(同 id)` → host-main 覆盖 map 并再挂一个 WebContentsView → 第一个视图泄漏(堆叠、不可销毁) | host-main 的 `createView` 幂等化:同 id 已存在直接回 ok,双重建收敛到同一视图 |
|
|
1348
|
+
| 2 | **isBareElectron 在 macOS 上失效**:resources 目录检查用 `dirname(exe)/resources`,而 macOS bundle 的资源在 `Contents/Resources` → macOS dev 宿主被误判为非裸 electron,宿主复用失效 | darwin 检查 `Contents/Resources`;打包判定扩展到解包 `app` 目录;「无 resources 目录」排除 portable(全平台一致,测试平台无关) |
|
|
1349
|
+
| 3 | **anchors 探测在 Electron 主进程内可返回内置名** `require.resolve('electron')` → `'electron'`(非路径),`electronExeBeside` 退化为检查 CWD 相对路径 | 加 `isAbsolute` 守卫,非绝对路径一律跳过 |
|
|
1350
|
+
| 4 | **dispose 期间 in-flight 重建可 spawn 僵尸 child** | `ensureView` 加 disposed 守卫,disposed 后直接抛 host-gone |
|
|
1351
|
+
| 5 | 新增测试:解包 `app` 目录的打包应用判定;既有打包/裸/portable 断言保持全平台一致 | |
|
|
1352
|
+
|
|
1353
|
+
**验证**:`node --test tests/*.test.mjs` **24 项全部通过**;lib(src+host-main)重建同步;本机实测解析行为不变(bundled 优先、node.exe 非 bare)。
|
|
1354
|
+
|
|
1355
|
+
**状态**:已提交本地,未 bump 版本、未发布(发布时 bump)。
|
|
1356
|
+
|
|
1357
|
+
---
|
|
1358
|
+
|
|
1359
|
+
# 项目整体交叉复审(2026-08-27)
|
|
1360
|
+
|
|
1361
|
+
超出两份 issue 的范围,对整个项目做系统性重审:provider.ts 全部 2167 行、tool-browser 工具层(会话/白名单/参数)、browser seam(runtime.ts + types.ts)、provider↔host-main 协议逐字段比对、错误流。结论:协议两端 10 个 op 的消息形状完全匹配(含 hello 认证与 userAction 单行通知),tool 层的会话去重(pendingOpens)与 agent 绑定生命周期正确,seam 的 provider 选择语义与 close() 的容错吞没符合契约。
|
|
1362
|
+
|
|
1363
|
+
## 发现并修复
|
|
1364
|
+
|
|
1365
|
+
| # | 问题 | 修复 |
|
|
1366
|
+
|---|---|---|
|
|
1367
|
+
| 1 | **dispose() vs start() 僵尸 child 竞态**(第二次复审的 disposed 守卫只挡住了 ensureView 入口):恢复路径会在 dispose 清空状态后重新进入 `ready()` → `start()` 从 listen 挂起点恢复,新建 server 并 **spawn 出一个没人会 kill 的 electron child**(`onChildExit` 在 disposed 时提前返回,`dispose()` 又已跑完) | `ready()` 入口 disposed 快速失败;`start()` 在 spawn 完成后复检 disposed 并完整自清理(kill child + close server);`ensureView` 在 `ready()` 之后复检 disposed |
|
|
1368
|
+
| 2 | **pendingSocket 泄漏**:三处(onChildExit / ready 失败清理 / dispose)都只把 `pendingSocket` 置 undefined,不 destroy | 统一改为 `destroy()` 后再置空 |
|
|
1369
|
+
| 3 | 新增回归测试:dispose 后的宿主拒绝操作且不再 spawn(旧守卫下第一次调用会真实拉起 child 并留下僵尸窗口) | `tests/remote-host-recovery.test.mjs` 新增 dispose 快速失败用例 |
|
|
1370
|
+
|
|
1371
|
+
## 确认无问题(交叉审查覆盖面)
|
|
1372
|
+
|
|
1373
|
+
- **协议比对**:客户端 `command/capture/download/flushAuth/restoreAuth/userActionError/groupView/showView/destroyView/createView` 与 host-main 各 case 的字段读取逐一匹配;hello 认证前命令排队、乱序 hello 拒绝、2 秒 token 等待兜底均正确;
|
|
1374
|
+
- **生命周期**:`ready()` 失败自清理(promise 身份校验防误杀新启动)、`onChildExit` 保留 views map(配合 #5 自愈)、`kill()` 幂等;
|
|
1375
|
+
- **withRecovery 并发**:双 catch 交错最多产生两次 createView(子进程幂等收敛),重试有界;
|
|
1376
|
+
- **tool 层**:每 context 状态隔离、`assertAllowed` 白名单、`browser_restrict` 自身始终放行、history 脱敏;
|
|
1377
|
+
- **seam**:选择语义无注册顺序依赖,`close()` 对四类 provider 缺失错误吞没为 no-op。
|
|
1378
|
+
|
|
1379
|
+
**验证**:`node --test tests/*.test.mjs` **25 项全部通过**;`tsc --noEmit` 有/无 electron 包两种环境零错误;lib 重建同步。
|
|
1380
|
+
|
|
1381
|
+
**状态**:已提交本地,未 bump 版本、未发布(发布时 bump)。
|
|
1382
|
+
|
|
1383
|
+
---
|
|
1384
|
+
|
|
1385
|
+
## 0.1.20 发布(2026-08-28)
|
|
1386
|
+
|
|
1387
|
+
bump `0.1.19 → 0.1.20`,将第十一轮(issue #5 会话自愈)、第十二轮(issue #6 打包应用排除)与三轮审查加固随版本发布(tag `v0.1.20`)。README 中英同步:更新记录新增三行、Electron 定位顺序与环境要求与修复后的代码对齐(打包宿主不复用、bundled 优先、ELECTRON_PATH 最优先)、验证版本表 bump 0.1.20。
|
|
1388
|
+
|
|
1389
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` **25 项全部通过**。
|
|
1390
|
+
|
|
1391
|
+
---
|
|
1392
|
+
|
|
1393
|
+
## 第十三轮(2026-08-28,issue #7 macOS 输入框无法键入)
|
|
1394
|
+
|
|
1395
|
+
**根因**:0.1.16 引入的 `4a13e20 fix(host): route keyboard focus to the clicked view on Windows` 把 Windows 专属 workaround(`wireFocusRouting` mousedown 焦点引导 + 窗口 `focus` 时恢复上次视图焦点,针对 electron#28163)无平台判断地应用到所有平台。macOS/Linux 原生会把键盘输入路由到被点击的视图,在 mousedown 的 `input-event` 里强制 `webContents.focus()` 反而与原生 click-to-focus 打架,页面输入框收不到键入字符。0.1.15(无此代码)正常、0.1.16+ 异常,与报告者降级 0.1.15 即恢复完全吻合。
|
|
1396
|
+
|
|
1397
|
+
**修复**:两处 Windows 专属焦点逻辑加 `process.platform === 'win32'` 门——非 Windows 平台恢复 0.1.15 的原生行为,Windows 保留 workaround(修复 #6 期间引入的工具栏/页面点击聚焦行为不变)。
|
|
1398
|
+
|
|
1399
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` **25 项全部通过**;lib 重建同步。
|
|
1400
|
+
|
|
1401
|
+
**状态**:已改未提交,未 bump 版本、未发布(发布时 bump)。
|
|
1402
|
+
|
|
1403
|
+
---
|
|
1404
|
+
|
|
1405
|
+
## 第十四轮(2026-08-28,issue #8 window.open/target=_blank 覆盖当前视图致 403)
|
|
1406
|
+
|
|
1407
|
+
**根因**:`setWindowOpenHandler` 把所有 HTTP(S) 弹窗直接 `loadURL` 到当前视图并 deny——原页面被覆盖、opener 上下文丢失,依赖新窗口携带 token/referer 的页面(门户「工作台」类跳转)被后端判无权限返回 403。报告者定位准确。
|
|
1408
|
+
|
|
1409
|
+
**修复**(采纳报告者方案 + 一个防御分支):HTTP(S) 弹窗改为 `sendUserAction({ type: 'newTab', windowId, url })`——父进程 provider 的 newTab 动作在同一会话窗口建新标签、导航并计入会话历史,原页面与 opener 上下文保留;新标签成为活动标签,agent 的 snapshot 自然跟随。未分组的共享窗口视图(无会话归属,防御路径)保留旧的 loadURL 行为避免退化。非 HTTP(S) 弹窗(OAuth/mailto/自定义协议)仍放行系统处理。附带修正:旧 loadURL 路径绕过父进程历史记录,新路径走 `openUrl` 正常记账。
|
|
1410
|
+
|
|
1411
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` **25 项全部通过**(父侧 newTab user-action 已有回归覆盖);lib 重建同步;README 中英弹窗行为描述同步。
|
|
1412
|
+
|
|
1413
|
+
**状态**:已改未提交(#7 + #8 两轮),未 bump 版本、未发布(发布时 bump)。
|
|
1414
|
+
|
|
1415
|
+
---
|
|
1416
|
+
|
|
1417
|
+
## 0.1.21 发布(2026-09-02)
|
|
1418
|
+
|
|
1419
|
+
bump `0.1.20 → 0.1.21`,将第十三轮(issue #7 macOS/Linux 输入框无法键入)与第十四轮(issue #8 window.open/target=_blank 覆盖当前视图)随版本发布(tag `v0.1.21`)。README 中英同步:更新记录新增三行、验证版本表 bump 0.1.21、弹窗行为描述与修复后的代码对齐。
|
|
1420
|
+
|
|
1421
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` **25 项全部通过**。
|
|
1422
|
+
|
|
1423
|
+
---
|
|
1424
|
+
|
|
1425
|
+
## macOS 二进制探测修复(2026-09-09,issue #9 / #14)
|
|
1426
|
+
|
|
1427
|
+
**根因**:`electronDistExe()` 只探测 `dist/electron(.exe)`,而 macOS 的 Electron 二进制在 bundle 内部(`dist/Electron.app/Contents/MacOS/Electron`)。bundled 与 profile/anchor 两条解析层共用这个探测函数,于是 macOS 上永远定位不到二进制,任何 `browser_*` 调用都以 "no usable browser provider is registered"(或 "cannot locate the Electron binary")失败。
|
|
1428
|
+
|
|
1429
|
+
**修复**:在共用的平台探测里补上 darwin 候选路径,两层同时受益;新增覆盖该路径的回归测试。
|
|
1430
|
+
|
|
1431
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` **26/26 全部通过**(含新增的 macOS 布局用例);lib 重建同步。
|
|
1432
|
+
|
|
1433
|
+
**状态**:已提交并推送(`8bef6f8`,upstream master),**尚未随版本发布**(npm 最新仍为 0.1.21)——macOS 用户需等下一次发版,或从 git 安装。
|
|
1434
|
+
|
|
1435
|
+
---
|
|
1436
|
+
|
|
1437
|
+
## 第十五轮(2026-09-16,issue #11 工具栏脚本解析期 SyntaxError 导致整个工具栏失效)
|
|
1438
|
+
|
|
1439
|
+
**根因**:`host-main.ts` 的工具栏内联脚本用 `const bridge = window.bridge` 取 preload 通过 `contextBridge.exposeInMainWorld('bridge', …)` 装上的句柄。`exposeInMainWorld` 装的是**不可配置**的全局属性,而全局作用域再声明同名 `const`(或 `let`/`class`)会命中规范里的 `HasRestrictedGlobalProperty` —— 这是**解析期 early error**,整段 `<script>` 一行都不执行:`tb`/地址栏/回退前进刷新/新标签/标签条/错误条全部失效(报告者的根因分析完全正确)。
|
|
1440
|
+
|
|
1441
|
+
**修复**:两件一起做,而不只改名 ——
|
|
1442
|
+
|
|
1443
|
+
1. 整段脚本包进 IIFE,所有绑定落进函数作用域:这一类与 `exposeInMainWorld` 全局的冲突从结构上不可能再出现(以后新增 expose 也不会重演);
|
|
1444
|
+
2. 局部名改为 `tb`,断掉"照抄 `const bridge`"的习惯(报告者建议);
|
|
1445
|
+
3. 注释写明为什么不能改回顶层 `const`,并注明该段位于 TS 模板字符串内(反引号会触发 TS1005)。
|
|
1446
|
+
|
|
1447
|
+
**验证**:新增 `tests/toolbar.test.mjs`(3 用例)—— 从**随包发布的** `lib/browser-electron/host-main.js` 里按 JS 语义解析出 `TOOLBAR_HTML` / `TOOLBAR_PRELOAD`,再在 `vm` 里真执行:全局按 `contextBridge` 的方式装成 non-configurable,配一套 stub DOM。用例 1「脚本在 contextBridge 全局下解析并接线」、用例 2「显式复现 #11 故障模式」(对未修版本抛 `Identifier 'bridge' has already been declared`,与 CDP 抓到的一字不差)、用例 3「全局作用域零声明」是真守卫。顺带审计:全仓只有这一处内联 `<script>`,工具栏 HTML 无内联 `on*=` 与 `javascript:` URL,IIFE 包裹不破坏其它绑定。
|
|
1448
|
+
|
|
1449
|
+
---
|
|
1450
|
+
|
|
1451
|
+
## 第十六轮(2026-09-16,issue #10 自托管三连:空快照 / 点击静默失效 / 崩溃不自愈)
|
|
1452
|
+
|
|
1453
|
+
**缺陷 1 · `browser_open` 返回「有标题、0 个交互元素」的空快照**
|
|
1454
|
+
|
|
1455
|
+
- 根因:`Page.navigate` 在导航 commit 时就 resolve,而工具层紧接着取快照,此时新文档已 commit(`location.href`/`document.title` 已是新的)但 DOM 尚未解析 → 标题正确、元素为空。
|
|
1456
|
+
- 修复:`navigate()` 先读导航前的文档指纹(`performance.timeOrigin`),导航后在有界 5s、best-effort 前提下等新文档 `readyState ∈ interactive/complete`。指纹用于区分「同 URL 重载」与「A→B→A 重定向」,不会把旧文档误判成新文档;commit 期间 `Execution context was destroyed` 继续轮询而非放弃;`reload()` 与 back/forward 同样处理。成本受控:文档看着已 settle 但指纹未变(同文档/锚点跳转)只等 150ms grace,不烧光预算。
|
|
1457
|
+
|
|
1458
|
+
**缺陷 2 · 点击静默失效(报成功但页面收不到任何事件)**
|
|
1459
|
+
|
|
1460
|
+
- 根因:`showViewInWindow()` 只对**已存在**的视图置可见,而宿主侧 `showActive` 是 fire-and-forget;新标签页里 `newTab()` 的 `showActive()` 与子进程 `createView` 竞争——`showView` 先到时 `windowOfView(viewId)` 查不到视图、静默跳过,视图随后才创建,于是从未被 present。没有显示表面的 `WebContentsView`,Chromium 会静默丢弃合成的 `Input.*` 事件(`Runtime.evaluate` 不受影响,所以快照/执行都正常,只有点击与输入无效),而工具层无从区分"成功"与"无效"。
|
|
1461
|
+
- 修复:新增**等待式** host seam `presentView?(handle, label)`:先 materialize 视图,再 `showView`,最后发一个 ping 屏障;同时把子进程消息处理改为严格串行(原 `void handle(...)` 会让 op 互相超车)。`click()`/`type()`/`key()` 在派发任何 `Input.*` 前调用它,present 不了就报 `BROWSER_VIEW_NOT_PRESENTED` 并带原因,不再假报 "Clicked."。
|
|
1462
|
+
|
|
1463
|
+
**缺陷 3-A · userData 隔离被未闭合的文档注释吞掉**
|
|
1464
|
+
|
|
1465
|
+
- 根因:`host-main.ts` 的块注释缺少收尾 `*/`,`try { app.setPath('userData', …) }` 整段被注释吞掉,产出物里根本没有这段代码。
|
|
1466
|
+
- 修复:闭合注释,`app.setPath('userData', join(base, 'dsh-builtin-browser-host'))` 已是真实代码(已在发布产物中核对)。
|
|
1467
|
+
|
|
1468
|
+
**缺陷 3-B · 新建视图无渲染进程时 CDP 永不 settle(「宿主重启后不自愈」卡死的那一步)**
|
|
1469
|
+
|
|
1470
|
+
- 根因:新建的空白视图还没有可响应的渲染进程,而宿主重启后的第一个动作就是读 `location.href` → 命令永不 settle,表现为一串 timeout。
|
|
1471
|
+
- 修复:`createView` 在应答前先有界(3s)加载 `about:blank`,视图一存在就有可响应的渲染进程。
|
|
1472
|
+
|
|
1473
|
+
**补充修复**
|
|
1474
|
+
|
|
1475
|
+
- 导航后第一次输入被丢:`did-navigate` 给视图打 `needsRepresent` 标记,`showViewInWindow` 对带标记的视图强制走一次 hide/show + remove/add —— 原来「已经可见就跳过」的防闪烁快速路径,恰好把新渲染进程建立显示表面这一步跳掉了。
|
|
1476
|
+
- 宿主侧 `command` 加 20s 有界超时:渲染进程卡死得到明确错误而不是挂死。
|
|
1477
|
+
- 子进程 stderr + 退出码/信号落到 `$DSH_HOME/logs/dsh-builtin-browser-host.log`(2MB 自截断):纯 `dsh web` 自托管场景终于能自助排查宿主崩溃循环。
|
|
1478
|
+
- `locateTab()` 同时接受裸 uuid 与 `tab:<uuid>`,消除自相矛盾的 "is not open in this session"(却把该 tab 列出来)。
|
|
1479
|
+
|
|
1480
|
+
**验证**:`tsc -p tsconfig.json` 零错误;由 `src/` 重新编译 `lib/` 与已提交产物**逐字节一致**(本仓库提交 lib 产物);`node --test tests/*.test.mjs` **31/31 全部通过**(未修版本 739ms → 修复后约 993ms,无明显变慢),其中新增 2 条针对缺陷 1/2 的回归测试对未修版本失败、对修复版本通过(真守卫);行为脚本模拟 commit→loading→interactive 时 navigate 轮询 3 次才返回(证明真的等了解析),同文档导航 152ms 返回(证明没卡满预算)。
|
|
1481
|
+
|
|
1482
|
+
**边界**:本环境起不了 Electron,未做真机点击冒烟;缺陷 3-A/3-B、导航后首次输入、`locateTab` 按代码 + 产物核对验证,缺陷 1/2 有真测试兜底。Windows 上手动确认一次工具栏与首击行为仍然值得。
|
|
1483
|
+
|
|
1484
|
+
**状态**:第十五、十六两轮随一次提交落库(源码 + lib 产物 + 测试);未 bump 版本、未发布(发布时 bump)。宿主侧改动需推送并重装依赖、重启浏览器宿主子进程后才在安装副本上生效。
|
|
1485
|
+
|
|
1486
|
+
---
|
|
1487
|
+
|
|
1488
|
+
## 第十七轮(2026-09-16,issue #13 截图 savePath 沙盒逃逸 + 下载目录本地化)
|
|
1489
|
+
|
|
1490
|
+
**根因**:`browser_screenshot` 的 `savePath` 直接 `writeFileSync`,不走任何准入 —— 可写到 DSH 进程有权限的任意路径(工作区之外、`$HOME`、`/.bashrc` 等),并会静默覆盖已存在文件(报告者实测把一个 13 字节文本文件覆盖成 17KB PNG)。而 `browser_download` 自第一轮就有准入(仅 HTTP(S)、绝对路径、`downloadDir` 限定),两条写盘路径的准入不对称,等于从截图侧绕过了只读沙箱的写保护。
|
|
1491
|
+
|
|
1492
|
+
**修复**:抽出唯一的准入门 `admitSavePath(savePath, kind)`,下载与截图共用同一套规则 ——
|
|
1493
|
+
|
|
1494
|
+
1. 必须为绝对路径;
|
|
1495
|
+
2. 解析后必须位于 `downloadDir` 内(大小写不敏感比较,拒绝 `..` 逃逸);
|
|
1496
|
+
3. 目标不得已存在:绝不静默替换现有文件(原有内容不可恢复,且该目录可能存放用户自己的文件),换一个文件名即可。
|
|
1497
|
+
|
|
1498
|
+
截图路径另外补上父目录自动创建(`mkdirSync(dirname(target), { recursive: true })`),与下载"自动建目录"的行为对齐。准入失败按操作分别报 `BROWSER_DOWNLOAD_BLOCKED` / `BROWSER_SCREENSHOT_BLOCKED`(既有错误码保持不变)。
|
|
1499
|
+
|
|
1500
|
+
**下载目录本地化**:默认下载目录不再写死 `~/Downloads`,改为按序探测 —— 配置的 `downloadDir` → 存在的 `XDG_DOWNLOAD_DIR`(freedesktop 标准,中文 Linux 桌面写的就是它)→ home 下第一个存在的 `Downloads`/`下载`/`下載` → 回退 `~/Downloads`(首次使用时创建)。中文桌面不再需要手动配置 `downloadDir`。
|
|
1501
|
+
|
|
1502
|
+
**文档同步**:`browser_screenshot`/`browser_download` 的 `savePath` 参数描述、README 中英、`docs/tool-reference.md`、`docs/user-guide.md` 均改为"限定在 `downloadDir` 内且不覆盖已有文件";`downloadDir` 配置行的默认值与适用范围(下载 + 截图)一并更新。
|
|
1503
|
+
|
|
1504
|
+
**验证**:`tsc` 构建零错误;新增 `tests/save-path-admission.test.mjs` 4 个用例 —— 截图越界/相对路径/`..` 逃逸被拒、截图正常写入且拒绝覆盖、下载同样拒绝覆盖、中文目录(显式配置与 `XDG_DOWNLOAD_DIR` 默认值)可用且仍受限;`node --test tests/*.test.mjs` **35/35 全部通过**(原 31 项保持通过;两条下载准入测试顺带改用临时目录,不再依赖 `~/Downloads`/`C:/dl` 的真实状态)。
|
|
1505
|
+
|
|
1506
|
+
**行为变更提示**:下载现在同样拒绝覆盖已存在文件(此前会覆盖)。这是与截图对齐同一准入门时的刻意选择;需要替换同名文件时换一个文件名。
|
|
1507
|
+
|
|
1508
|
+
**状态**:已改并随本次提交落库(源码 + lib 产物 + 测试 + 文档);未 bump 版本、未发布(发布时 bump)。
|
|
1509
|
+
|
|
1510
|
+
---
|
|
1511
|
+
|
|
1512
|
+
## 0.1.22 发布(2026-09-16)
|
|
1513
|
+
|
|
1514
|
+
bump `0.1.21 → 0.1.22`,把此前未发版的全部修复随一个版本交付:macOS 二进制探测(issue #9 / #14)、第十五轮(issue #11 工具栏解析期 SyntaxError)、第十六轮(issue #10 自托管三连)、第十七轮(issue #13 截图 savePath 逃逸 + 下载目录本地化)。tag `v0.1.22`。
|
|
1515
|
+
|
|
1516
|
+
README 中英同步:更新记录新增五行;`browser_download`/`browser_screenshot` 的 `savePath` 限制与 `downloadDir` 默认值(含本地化目录)说明对齐;崩溃自愈条目补上 `about:blank` 预加载、20s 命令超时与宿主日志路径。
|
|
1517
|
+
|
|
1518
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` **35/35 全部通过**;`lib/` 由 `src/` 重新编译后与提交产物逐字节一致。
|
|
1519
|
+
|
|
1520
|
+
---
|
|
1521
|
+
|
|
1522
|
+
## 第十八轮(2026-09-20,Windows 真机三连:页面被判定不可见 / 首击丢失 / 首键丢失 + 探测自愈 + 定位失败可读性)
|
|
1523
|
+
|
|
1524
|
+
本轮在真实 Windows 11 + Electron 44.0.0 自托管宿主(`dsh web` profile)上实测,缺陷 1–4 都是「CDP 报成功、页面什么都没收到」这一类:快照与脚本执行始终正常,只有输入无效,因此从工具输出完全看不出区别。缺陷 5 是修好之后照真实调用走一遍才撞出来的报错可读性问题。
|
|
1525
|
+
|
|
1526
|
+
**缺陷 1 · 页面被 Windows 判定为「不可见」,整站停帧且所有合成输入被丢**
|
|
1527
|
+
|
|
1528
|
+
- 根因:Chromium 在 Windows 上的 `CalculateNativeWinOcclusion` 特性会把被其他窗口遮挡的窗口置为 `Visibility::HIDDEN`。插件窗口长期排在人的窗口之后 → 页内 `visibilityState` 恒为 `"hidden"`、`requestAnimationFrame` 一帧都不发,而 `RenderWidgetHostImpl::CanReceiveInput()` 同时返回 false,于是每一条 `Input.dispatchMouseEvent` / `dispatchKeyEvent` 都被渲染端**静默丢弃**,CDP 依旧回 `{}`。
|
|
1529
|
+
- 修复:子进程在 `app.whenReady()` 之前追加 `disable-features=CalculateNativeWinOcclusion`(仅 `win32`,其他平台行为不变)。验证:窗口仍排在后序时 `visibilityState` 回到 `"visible"`、帧持续产出。
|
|
1530
|
+
|
|
1531
|
+
**缺陷 2 · 新视图的第一次点击落空,第二次才生效**
|
|
1532
|
+
|
|
1533
|
+
- 根因:`click()` 只发 `mousePressed` + `mouseReleased`,没有前置 `mouseMoved`。Chromium 对合成按下事件是按 widget 当前 hover 目标路由的(未先移动就会路由到旧位置/无目标),首次点击被丢。
|
|
1534
|
+
- 修复:改为 **move → press → release**(见 `provider.ts` 的 `send()` 辅助与 `CdpMouseParams`);新增回归测试断言派发序列严格为 `['mouseMoved','mousePressed','mouseReleased']`。
|
|
1535
|
+
- 顺带实测点击投递延迟:6 次采样,`click()` resolve 后效果立即可见(往返 6–110ms),因此**不加** settle 或重试步骤。
|
|
1536
|
+
|
|
1537
|
+
**缺陷 3 · 键盘首键丢失(`browser_key` 第一次调用无效)**
|
|
1538
|
+
|
|
1539
|
+
- 根因:新建的视图从未持有 web focus,`Input.dispatchKeyEvent` 无处投递,而 CDP 仍回 `{}`。页内 `document.hasFocus()` 两种情况都读 `true`,不能当信号用。
|
|
1540
|
+
- 修复:宿主新增 `focus` op(`webContents.focus()`,返回是否已聚焦),view handle 接口加可选 `focus?()`(DSH Desktop 宿主与既有测试替身不实现也照常编译),`provider.key()` 在派发前 best-effort 调用 `focusView()`。焦点落地是异步的:同一轮内紧随派发的键仍会被丢,所以**只在焦点确实需要移动时**等 `FOCUS_SETTLE_MS`(80ms)。窗口激活(`win.focus()`)试过,证明多余,已去掉以免抢前台。
|
|
1541
|
+
- 实测(真实宿主,进程内第一个视图、且键盘是该视图收到的第一个输入):修复前 `[]`,修复后 `["keydown:Escape","keyup:Escape"]`;冷 Escape / 冷 Enter / 先执行脚本再按键等四种组合全部落屏。
|
|
1542
|
+
|
|
1543
|
+
**缺陷 4 · Electron 探测的「失败」被永久缓存,provider 再也无法自愈**
|
|
1544
|
+
|
|
1545
|
+
- 根因:`available()` 把失败结果按宿主生命周期缓存,而 provider 选择每进程只发生一次。Electron 到位晚于 DSH 启动(懒下载、pnpm 拦下 postinstall 等)时,此后每次 browser 调用都报 `no usable browser provider is registered`,只能重启 DSH。
|
|
1546
|
+
- 修复:成功仍永久缓存;失败改为冷却窗口(默认 30s,可用 `DSH_BROWSER_PROBE_RETRY_MS` 调整)后重探,晚到的 Electron 自己就能被接上。`resolveProvider()` 的报错同时区分「一个 provider 都没注册」与「注册了但自报不可用」,后者附上可执行处置(`npx install-electron` / `ELECTRON_PATH`)。新增 `tests/electron-probe.test.mjs`:失败不重扫、窗口过后重探并成功、成功后不再扫(构造器第三参为探测 seam)。
|
|
1547
|
+
- 实测:插件目录布局下探测返回 `available=true`(profile 内 `electron@44.0.0` 带 `dist/electron.exe`)。
|
|
1548
|
+
|
|
1549
|
+
**缺陷 5 · 定位失败被外层超时掩盖,报错说不出「查了什么」**
|
|
1550
|
+
|
|
1551
|
+
- 根因(两条凑在一起):`buildTargetScript` 生成的页内脚本会把预算**轮询到底**才回答,而 `runTargetScript` 的外层超时用的是**同一个**预算 —— 两边同一刻到期,外层那句 `browser: click timed out after 10000ms` 抢先返回,页内已经写好的判词(哪个选择器、哪种策略没找到)永远发不出来。另一条:css/xpath **解析失败**(选择器根本不合法)被写成 `return null`,也就是「还没找到」,于是对着一个永远不可能变合法的选择器把预算轮完。
|
|
1552
|
+
- 修复:①解析失败即刻终止 —— 返回 `invalid CSS selector "…" (…)` / `invalid XPath …`,不再重试;②外层预算改为页内预算 + `TARGET_SCRIPT_GRACE_MS`(2s),让页内判词赶得上;③未命中判词补上提供方**实际采用的策略**(`by` 缺省即按 css,判词里就写 `"by":"css"`)与实际耗时;④`scrape` 的 item 选择器同样改成立刻失败并给同样的措辞。
|
|
1553
|
+
- 实测(真实宿主 + 本地页):同一个漏写 `by` 的调用,修复前 `browser: click timed out after 10000ms`;修复后 `browser: click failed: element not found: {"value":"Learn more","by":"css"} (looked for 10000ms)`;`div[` 这类不可解析选择器 **2ms** 返回 `invalid CSS selector "div[" (… is not a valid selector.)`。
|
|
1554
|
+
- 说明:**未**改成「页面加载完就只试一次」。合法选择器在 SPA 注水完成后才出现是常态,提前判死会伤掉真正的等待场景 —— 这里只把「说不清原因」修成「说清原因」,不缩短等待。
|
|
1555
|
+
- 新增 3 条单测跑在**真正执行**页内脚本的小 DOM(`node:vm` + 会解析选择器的 `querySelectorAll`)上:解析错误只试一次且一个鼠标事件都不派发、xpath 解析错误单独命名且不回落到 css、合法但不存在的选择器确实逐轮轮询并给出带策略的判词。
|
|
1556
|
+
|
|
1557
|
+
**环境备注(未改代码)**:pnpm v10+ 默认不执行依赖 install 脚本,`electron` 的 postinstall(下载二进制)会被 `allowBuilds` 门拦下 —— 包在但 `dist/electron.exe` 不在,正是缺陷 4 的那类触发场景;`npx install-electron` 或放行构建即可。另外所有宿主实例共用一个 Chromium userData(`$DSH_HOME/dsh-builtin-browser-host`,代码注释本身也点了锁的问题),同时开多个宿主实例会在 profile/GPU cache 锁上互相争用,表现为偶发一次操作不生效、单独运行不复现;建议按宿主实例分目录,本轮未改。
|
|
1558
|
+
|
|
1559
|
+
**验证**:`tsc -p tsconfig.json` 零错误;`node --test tests/*.test.mjs` **47/47 全绿**(新增 7 条:3 条键盘聚焦(次序 / 宿主无 `focus` / 聚焦抛错也不影响按键)+ 1 条探测冷却窗口 + 3 条定位判词);真实宿主端到端脚本 **17/17**(新增一步:从未被点击过的页面收到第一个键);真实宿主定位判词脚本 **4/4**(合法但不存在 / 解析错误即刻终止 / 文本目标仍可点击 / 判词抢先于外层超时)。
|
|
1560
|
+
|
|
1561
|
+
**边界与状态**:未 bump 版本、未发布(发布时 bump)。缺陷 1 仅影响 Windows,缺陷 2/3/4 与平台无关,但 macOS / Linux 真机未复测。
|
|
1562
|
+
|
|
1563
|
+
---
|
|
1564
|
+
|
|
1565
|
+
## 第十九轮(2026-10-01,DSH 0.2.0-rc.2 兼容性)
|
|
1566
|
+
|
|
1567
|
+
**背景**:DSH 进入 0.2 线(`@deepseek-ai/dsh@0.2.0-rc.2`,peer 包 `dsh-llm` / `dsh-tools` / `dsh-system-prompt` 同步到 `0.2.0-rc.2`),而插件此前的兼容性声明是 `>=0.1.1-rc.1 <0.2.0` —— 声明层面把 0.2 挡在外面(DSH-Store 会据此判定不兼容)。
|
|
1568
|
+
|
|
1569
|
+
**核对与实测**:插件的运行时依赖面很窄 —— `@deepseek-ai/cordis`(`Context`/`Service`)、`@deepseek-ai/dsh-tools`(`defineTool`)、`@deepseek-ai/dsh-llm`(`HarnessError`)、`@deepseek-ai/schemastery`。在**真实 0.2.0-rc.2 宿主**上跑通了完整链路:`browser_session` 返回会话与标签;`browser_open https://example.com` 导航成功并返回快照元素;`browser_screenshot` 三态验证 —— 越界路径被 `must be inside downloadDir "C:\Users\<user>\Downloads"` 拒绝、合法路径写入成功、同名文件被 `refusing to overwrite existing file` 拒绝。结论:核心 API 未发生破坏性变更,**无需改代码**。
|
|
1570
|
+
|
|
1571
|
+
**改动**:`peerDependencies` 里三个 dsh 包的范围由 `^0.1.1-rc.2` 改为 `>=0.1.1-rc.2 <0.3.0`(`cordis` 的 `^4.0.1`、`schemastery` 的 `^3.18.1` 本已覆盖 0.2 所用的 4.0.4 / 3.18.4);`dsh.compatibility.dsh` 由 `>=0.1.1-rc.1 <0.2.0` 放宽为 `>=0.1.1-rc.1 <0.3.0`;`dshReleases` 增加 `0.2.0-rc.1` / `0.2.0-rc.2` = `compatible`。
|
|
1572
|
+
|
|
1573
|
+
**验证**:`tsc` 零错误;`node --test tests/*.test.mjs` **47/47 全绿**;上述真实宿主端到端三态验证。
|
|
1574
|
+
|
|
1575
|
+
**状态**:已改未提交,未 bump 版本、未发布(发布时 bump)。
|
|
1576
|
+
|
|
1577
|
+
---
|
|
1578
|
+
|
|
1579
|
+
## 0.1.23 发布(2026-10-01)
|
|
1580
|
+
|
|
1581
|
+
bump `0.1.22 → 0.1.23`,把**第十八轮**(PR #15:Windows 合成输入三连 —— 遮挡导致输入被丢弃 / 首击丢失 / 首键丢失,外加 Electron 探测自愈与定位判词可读性;此前已合入 master 但一直未发版)与**第十九轮**(DSH 0.2 兼容性声明)随版本发布,tag `v0.1.23`。
|
|
1582
|
+
|
|
1583
|
+
README 中英同步:更新记录新增两行;兼容性说明从 `<0.2.0` 更新为覆盖 0.2 线。
|
|
1584
|
+
|
|
1585
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` **47/47 全部通过**;真实 `dsh web` 0.2.0-rc.2 宿主上跑通会话 / 导航 / 快照 / 截图三态。
|
|
1586
|
+
|
|
1587
|
+
---
|
|
1588
|
+
|
|
1589
|
+
## 第二十轮(2026-10-01,浏览历史持久化 / 设置栏 / 可视化鼠标 / 收尾语义)
|
|
1590
|
+
|
|
1591
|
+
**背景**:使用反馈集中在四件事 —— 浏览器操作后的收尾不符合预期、访问过的页面事后无从追溯、展示方式打扰、以及看不到 agent 究竟在操作哪里。本轮按"不依赖宿主新接口的先做"落地,并为桌面端侧栏集成留出可降级的位置。
|
|
1592
|
+
|
|
1593
|
+
**1. 浏览历史持久化(新增 `browser_visited` 工具)**
|
|
1594
|
+
|
|
1595
|
+
- 新模块 `src/browser-electron/history-store.ts`:追加式 JSONL,落在浏览器 profile 旁(`$DSH_HOME/dsh-builtin-browser-host/history.jsonl`),与 cookies 同一套持久化规则 —— 关掉界面、重启 DSH 都还在;截断或损坏的行只丢那一行,不会毁掉整份历史。
|
|
1596
|
+
- 记录点:`navigate` / 前进 / 后退 / 刷新,在 `settleDocument` 之后读 `location.href` + `document.title` 写入;写入是 fire-and-forget(历史永远不拖慢、也不弄挂一次导航),`about:` / `devtools:` 不计入。保留上限 **5000 条或 90 天**(先到者为准),超限自动裁剪。
|
|
1597
|
+
- 工具层新增 **`browser_visited`**(可按域名过滤、限量);重开页面沿用现有 `browser_open`。现有 `browser_history`(会话内操作日志,内存态)语义不变,两者分工写进 README。
|
|
1598
|
+
- 配置:`history: { enabled, maxEntries, maxAgeDays, file }`。
|
|
1599
|
+
|
|
1600
|
+
**2. 设置栏(客户端插件 + 设置端点)**
|
|
1601
|
+
|
|
1602
|
+
- 新增 `client.js`(手写 ModuleLoader bundle)与 `package.json` 的 `dsh.client` 声明(`platform: web`,inject `dsh-client-locale` / `dsh-client-ui-slots` / `dsh-client-ui-settings`),注册到 `settings.section` 插槽并取 `order: 60` —— 排在宿主自带栏目(最靠后 `order: 40`)的**下面**。
|
|
1603
|
+
- 服务端:`src/browser-electron/settings-store.ts`(设置文档:字段逐个校验、未知键丢弃、损坏文件按默认值处理、mtime 变化即重读)+ `entry.ts` 中的 `GET/PUT /dsh-builtin-browser/settings`(带**同源防护**与 64 KiB 体积上限)。
|
|
1604
|
+
- **开关实时生效**:provider 通过 `settings: () => store.get()` 每次读取,不重启即改变行为(实测:关掉历史后新访问不再记录,既有记录保留)。
|
|
1605
|
+
- 开关清单:保留浏览历史 / 保留 cookies / 侧栏自动展开(每轮一次)/ 会话结束时关闭浏览器 / 显示可视化鼠标 / 视觉策略(Auto 或纯非视觉)/ 允许读取凭据;面板同时显示设置文件路径便于排查。
|
|
1606
|
+
|
|
1607
|
+
**3. 可视化鼠标(新增 `src/browser-electron/virtual-cursor.ts`)**
|
|
1608
|
+
|
|
1609
|
+
- 页面内叠加层(不动系统鼠标):`position: fixed` + 视口坐标 —— 与元素定位的 `getBoundingClientRect`、`Input.dispatchMouseEvent` 处于同一坐标空间。**全部内联样式 + Web Animations**,不使用 `<style>` 元素,否则页面的 `style-src` CSP 会把它拦掉。
|
|
1610
|
+
- 落点来源:`buildTargetScript` 统一在脚本返回值上附加元素中心 `__point`,于是 `click`(语义与坐标两种)、`type`、`setValue`、`check`、`select`、`clear` **都有落点** —— DOM 级操作不再"看不见"。已有 x/y 的 click 不受影响(附加键名不覆盖)。
|
|
1611
|
+
- 语义:**光标出现即表示 agent 已接管该标签页**;点击带涟漪动画。可由设置 `ui.virtualCursor` 关闭。
|
|
1612
|
+
|
|
1613
|
+
**4. 收尾语义(关界面 = 结束该会话)**
|
|
1614
|
+
|
|
1615
|
+
- `host-main` 在窗口 `closed` 时:①释放该窗口全部视图的 `webContents`(BrowserWindow 不连带销毁子视图,否则每关一个窗口就泄漏一个渲染进程);②向父进程发送 `viewClosed` 通知(复用既有 `id: 0` 通知通道)。
|
|
1616
|
+
- `remote-host` 新增 `onViewClosed(handler)` 注册(与既有 `onUserAction` 同构);provider 收到后**结束对应会话**。`browser` seam 新增 `exists(session)`,工具层的会话缓存先核验再复用 —— 人关掉窗口后,下一次调用开的是一个**干净的新会话**,而不是继续驱动一个谁也看不见的窗口。
|
|
1617
|
+
- 浏览历史与登录状态不受影响(它们保存在磁盘上)。
|
|
1618
|
+
|
|
1619
|
+
**5. 设置栏一度"隐形"的根因与修复(真机验证后追加)**
|
|
1620
|
+
|
|
1621
|
+
- **现象**:安装后浏览器功能全部正常,但设置页里**看不到**「浏览器」栏;`dsh web` 启动日志也没有报任何与插件相关的失败,启动图(`window.__DSH_BOOT__.entries`)里**没有** `dsh-builtin-browser` 条目。
|
|
1622
|
+
- **根因**:宿主的客户端模块扫描(`client-modules`)从 **Loader 行的 specifier** 解析包根,再读该包的 `dsh.client` 声明;而它只接受**精确包名**——
|
|
1623
|
+
|
|
1624
|
+
```ts
|
|
1625
|
+
function exactPackageSpecifier(specifier) {
|
|
1626
|
+
return specifier.length > 0 && !specifier.includes('/') && !specifier.includes(':') ? specifier : undefined
|
|
1627
|
+
}
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
本插件的 `cordis.patch.yml` 三行全部使用**子路径名**(`dsh-builtin-browser/browser`、`/browser-electron`、`/tool-browser`),含 `/` 即被判为"不是客户端包"并**静默跳过**——于是这个包没有任何一行能让宿主读到它的 `dsh.client`,`ui-slots` / `locale` / `ui-settings` 三个 inject 目标也就无从解析。对照:同环境里能进启动图的第三方插件(purge、vdesktop)都有一行 `name:` 写着**包名本身**。
|
|
1631
|
+
- **修复**:①`cordis.patch.yml` 增加一行 `id/name: dsh-builtin-browser`(置于最前,惰性);②`src/index.ts` 补上 `export const name` 与一个空的 `apply()` —— 原先根入口只有 re-export,不足以成为一个合法的 Loader 行。
|
|
1632
|
+
- **验证(真实 `dsh web` 0.2.0-rc.2 宿主,带 token 打开 GUI 实测)**:启动图条目 67 → **68**,出现 `{ id: 'dsh-builtin-browser', url: 'plugins/??dsh-builtin-browser/client.js&rev=b0ff3e951670' }`;设置页出现「浏览器」栏并排在「规则设定」下方,**6 个开关**全部渲染(保留浏览历史 / 保留 cookies / 侧栏自动展开 / 会话结束时关闭浏览器 / 显示可视化鼠标 / 允许读取凭据)与视觉策略下拉;拨动开关后 `$DSH_HOME/dsh-builtin-browser-host/settings.json` 立即落盘,`history.jsonl` 同步开始记录。
|
|
1633
|
+
- **附带说明**:界面显示的版本号 `0.1.7-rc.2-<hash>-dirty` 来自 `git describe --tags` 的**最近 tag**,而实际代码是 `package.json` 的 **0.2.0-rc.2**(HEAD 为 "release-dsh-0.2.0-rc.2" 合并提交);本地未拉取 0.2 的 tag,与插件无关。
|
|
1634
|
+
|
|
1635
|
+
**验证**:`tsc -p tsconfig.json` 零错误;`node --test tests/*.test.mjs` **68/68 全绿**(新增 21 条:历史 7、设置 5、可视化鼠标 5、关窗口 4)。
|
|
1636
|
+
|
|
1637
|
+
**边界与状态**:桌面端"官方侧栏承载 agent 浏览器(人机同页)"需要宿主提供 guest 控制权接口,本轮**未做** —— 接口缺席时插件按现状(自托管弹窗)工作,以上功能均不受影响。
|
|
1638
|
+
|
|
1639
|
+
---
|
|
1640
|
+
|
|
1641
|
+
## 第二十轮补记(2026-10-01,真机复现后追加)
|
|
1642
|
+
|
|
1643
|
+
**6. 宿主日志与崩溃诊断**
|
|
1644
|
+
|
|
1645
|
+
- 真机复现(隔离 `DSH_HOME`、带真 TCP 监听的 `repro2.mjs`,7 个用例)把一类崩溃锁到唯一签名:**`code=1` + 零 stderr**,只对应"Electron 加载不了 app 入口脚本";同时否证了"抢 GPU cache/session 锁""信息被吞进没人读的 stdout""父进程未监听"三条假设。
|
|
1646
|
+
- 由此暴露日志本身的三处缺陷,已修:①**无时间戳** → `hostStamp()` 给每行加 ISO 前缀;②**入口与二进制路径没被记录** → spawn 前写 `spawning: electron=<path> (exists=…) hostMain=<path> (exists=…) port=…`;③**2 MiB 轮转整体清空** → 改为写入带时间戳的轮转标记(就是它把几周历史销毁的)。`exit` 行另加 `pid=` 与 `entryExists=`(退出时复查入口),于是"启动时存在、退出时不存在"这一组合**自己就能命名**"安装被就地替换"这一根因。
|
|
1647
|
+
- **测试污染修复**:`host-log` / `remote-host-recovery` / `electron-probe` 三个测试会真实 spawn 子进程,而 `dispose()` 是**异步**杀子进程的 —— 它的 exit 行写在测试 `finally` 恢复 `DSH_HOME` **之后**,于是合成记录进了操作者真实的 `$DSH_HOME/logs/dsh-builtin-browser-host.log`(正是崩溃诊断所依赖的那份)。三处改为**模块级**隔离且不再恢复,并以"跑测试前后真实日志行数不变"验证。
|
|
1648
|
+
|
|
1649
|
+
**验证**:`tsc` 零错误;`node --test tests/*.test.mjs` **70/70 全绿**(新增 host-log 2 条)。
|
|
1650
|
+
|
|
1651
|
+
---
|
|
1652
|
+
|
|
1653
|
+
## 0.2.0 发布(2026-10-01)
|
|
1654
|
+
|
|
1655
|
+
bump `0.1.23 → 0.2.0`,发布**第二十轮**(浏览历史持久化 / 设置页「浏览器」栏 / 可视化鼠标 / 收尾语义)及其真机补记(客户端设置栏"隐形"的根因修复、宿主日志与崩溃诊断)。这是插件的第一个**行为可见面**发生变化的版本:工具数 **33 → 34**(新增 `browser_visited`),设置页多出一栏。
|
|
1656
|
+
|
|
1657
|
+
**验证**:`tsc` 构建零错误;`node --test tests/*.test.mjs` **70/70 全部通过**;真实 `dsh web` 0.2.0-rc.2 宿主上带 token 打开 GUI 实测 —— 启动图条目 67 → 68、设置栏出现并排在「规则设定」下方、6 个开关与视觉策略渲染正常、拨动开关即时落盘、`history.jsonl` 同步开始记录。
|
|
1658
|
+
|
|
1659
|
+
**顺手记录**:`git describe` 显示 `0.1.7-rc.2-…` 只是本地 tag 未含 0.2 线;`git fetch --tags` 后为 `dsh-v0.2.0-rc.2`。
|
|
1660
|
+
|
|
1661
|
+
---
|
|
1662
|
+
|
|
1663
|
+
## 第二十一轮(2026-10-01,桌面端侧栏接管:agent 的页面 = 人看到的页面)
|
|
1664
|
+
|
|
1665
|
+
**背景**:需求表 §1/§2 要求桌面端**由官方侧栏承载 agent 的浏览器**(人机同页),而不是插件再开一个平行窗口。此前插件在桌面端的真实表现已实测确认:它 spawn 自己的 Electron,弹出一个与桌面主窗口毫无隶属关系的独立窗口(`dsh-browser — about:blank`)。
|
|
1666
|
+
|
|
1667
|
+
**关键调查(全部真机验证,非推断)**
|
|
1668
|
+
|
|
1669
|
+
- 桌面端是**两层**:`DeepSeek Harness.exe`(Electron 外壳)+ `--expose-internals` 的 **Node 模式宿主**(`dsh-desktop-host`,插件就跑在这里,**没有 Electron API**);窗口只是加载宿主给出的 `dsh-app://app/`。
|
|
1670
|
+
- 宿主与外壳之间的事件集(`host-process.ts`)**没有任何与视图/窗口相关的通道**;0.2 又移除了 `electronViewHost` —— 所以"把视图贴进桌面窗口"没有现成机制可复用。
|
|
1671
|
+
- 桌面端安装目录里的 `app.asar` 已被替换为**解包的 `resources/app/`**(旁边还有 `app.asar.bak` 与 `app.asar.rename-pending`),因此**主进程代码可直接修改**;`lib/main.js` 旁还躺着 `main.js.dshpurge.bak`,说明改这台机器的桌面端是既有做法。
|
|
1672
|
+
- 侧栏浏览器的 guest **是懒创建的**:未导航时只有一个地址栏,**不存在任何 webContents**;导航后才出现 `type=webview` 的 guest(URL 形如 `about:blank#<leaseId>`,即 `browser-guests` 的租约)。
|
|
1673
|
+
|
|
1674
|
+
**实现:一个 loopback bridge + 插件侧换一个宿主**
|
|
1675
|
+
|
|
1676
|
+
| 位置 | 改动 |
|
|
1677
|
+
| --- | --- |
|
|
1678
|
+
| `apps/desktop/bridge/plugin-browser-bridge.js`(新增) | 主进程内的 bridge:loopback TCP + 随机 token;`list`(列出全部 webContents,标注 sidebar guest)、`cdp`(经 `webContents.debugger` 转发任意 CDP,按 guest 串行)、`ensureSidebar`(按需逼出侧栏 guest);endpoint 写入 `$DSH_HOME/dsh-builtin-browser-bridge.json` 并**每 15 秒刷新** |
|
|
1679
|
+
| `apps/desktop/bridge/install.mjs`(新增) | 幂等安装/回滚:备份 `main.js.before-bridge`、在 `export {};` 前注入启动片段、`--revert` 还原 |
|
|
1680
|
+
| `resources/app/lib/main.js`(已安装的桌面端) | 尾部 +8 行:app ready 后启动 bridge;失败不影响外壳 |
|
|
1681
|
+
| 插件 `src/browser-electron/desktop-bridge-host.ts`(新增) | 实现**同一个** `ElectronBrowserViewHost` seam,但命令走 bridge;缓存 guest 且**每次复用前核对存活** |
|
|
1682
|
+
| 插件 `src/browser-electron/entry.ts` | 先注册自托管(任何 surface 从第一次调用起就可用),发现 bridge 后**热切换**到侧栏并释放子进程;发现不到则照旧 |
|
|
1683
|
+
|
|
1684
|
+
**实测(桌面端 `0.2.0-rc.2`,带 token 的真机)**
|
|
1685
|
+
|
|
1686
|
+
- `browser_open https://example.com/` → 返回 `Example Domain`,`browser_content` 直接读到侧栏页面正文;
|
|
1687
|
+
- bridge 侧:`[webview] id=2 Example Domain https://example.com/` + 主窗口;
|
|
1688
|
+
- **零 `electron.exe` 进程、本次会话零 spawn、桌面端只有主窗口一个窗口** —— 独立浏览器窗口不再出现。
|
|
1689
|
+
|
|
1690
|
+
**过程中被实测纠正的三个想当然**
|
|
1691
|
+
|
|
1692
|
+
1. **地址栏那条路不可靠**:React 受控输入忽略合成的 `KeyboardEvent`(字段显示地址而组件状态未变),且字段的 focus 会被重渲染夺走(`document.activeElement` 实测为 `BODY`)。改为优先点侧栏自带的**「恢复页面」**(1 秒内逼出 guest),再用纯 CDP `Page.navigate` 导航 —— 不经过 UI。
|
|
1693
|
+
2. **endpoint 必须反复刷新**:一次性写入会让读者拿到**已退出实例**的地址(现象是莫名 `ECONNREFUSED`);改为每 15 秒重写并附带 `pid`/`updatedAt`。
|
|
1694
|
+
3. **认证消息不是命令**:把 token 单独一行发送时,服务端会把它也送进命令处理并回 `unknown op ""`;现在认证后立即 `continue`。
|
|
1695
|
+
|
|
1696
|
+
**收尾语义(需求表 §3 按新载体重写)**:插件在此载体上**没有自己的窗口**,故"关界面 = 释放进程"不再适用 —— 改为:人关掉侧栏那个 tab → 插件下次操作前 `guestAlive` 核对失败 → **自动开一页新的**(等价于"这一页结束了"),而浏览历史与登录状态照旧保留在磁盘。
|
|
1697
|
+
|
|
1698
|
+
**验证**:`tsc` 零错误;`node --test tests/*.test.mjs` **70/70 全绿**;上述真机端到端。
|
|
1699
|
+
|
|
1700
|
+
**状态**:插件侧未 bump 版本、未发布;桌面端改动以 `install.mjs` 形式可重放(桌面端升级后需重跑)。
|
|
1701
|
+
|
|
1702
|
+
---
|
|
1703
|
+
|
|
1704
|
+
## 第二十二轮(2026-10-01,issue #16:错误上报路径不得致命)
|
|
1705
|
+
|
|
1706
|
+
**报告**:`notifyUserActionError detaches the host method, crashing the whole DSH host`。诊断准确,两条路径都成立:
|
|
1707
|
+
|
|
1708
|
+
1. `ElectronBrowserProvider.notifyUserActionError` **把宿主方法取出来再非绑定调用**,于是宿主实现第一句 `void this.ready()` 抛出 `Cannot read properties of undefined (reading 'ready')`;该异常位于 async 的 catch 里,变成 unhandled rejection,宿主退出 1。
|
|
1709
|
+
2. 即便绑定正确,`RemoteElectronViewHost.ready()` 在宿主已 dispose 时**同步抛**,`.catch()` 接不住 —— 同一类 fire-and-forget 方法(`destroyView`/`groupView`/`showView`)形状相同。
|
|
1710
|
+
|
|
1711
|
+
**修复**:provider 改为**在属主上调用**(`notify.call(host, …)`)并全程容错(上报失败只记一行 stderr);`ready()` 不再同步抛,改为返回 rejected promise(并标记已处理,忘了挂 catch 也不会变成 unhandled)。**回归测试** 4 条(含用报告里那个会读 `this` 的 stub 驱动的失败工具栏动作)。
|
|
1712
|
+
|
|
1713
|
+
## 第二十三轮(2026-10-01,undici CVE + 依赖卫生)
|
|
1714
|
+
|
|
1715
|
+
**报告**:issue/PR #18 —— 自动安全修复建议升级 `undici`(CVE-2026-84961,BalancedPool 选项处理)。
|
|
1716
|
+
|
|
1717
|
+
**核实**:CVE 真实;**但 PR 的做法在本仓库失效** —— `pnpm.overrides` 写在 `package.json` 里,pnpm 10 起已不再读取该字段(实测打印警告并忽略,lock 里仍是 7.29.0)。
|
|
1718
|
+
|
|
1719
|
+
**修复**:新建 `pnpm-workspace.yaml`(pnpm 11 的正确位置),锁 `undici: 7.29.1`(同大版本,不做无收益的 8.x 跳跃)。影响面已说明:undici 只是传递依赖,插件不 import 它,发布物也不含 `node_modules` —— 价值在于保持仓库依赖树干净。
|
|
1720
|
+
|
|
1721
|
+
## 第二十四轮(2026-10-01,需求表 v2 逐条落地)
|
|
1722
|
+
|
|
1723
|
+
| 需求 | 实现 |
|
|
1724
|
+
| --- | --- |
|
|
1725
|
+
| §7 视觉策略 | `nonVisual` 下**拒绝坐标点击**并给出可执行替代;工具描述改为语义优先;设置面板文案与真实行为对齐(**此前该设置项是死的**) |
|
|
1726
|
+
| 非视觉输出 | 快照用 `depth` 做**层级缩进**、坐标改按需(`coords: true`)、去空 `states`;`content(txt)` 改用浏览器渲染文本 |
|
|
1727
|
+
| §8 明示 | README 中英 + 设置面板写明沙箱边界变化(三个后果 + 两个开关 + 回退方式) |
|
|
1728
|
+
| §3 生命周期 | `closeWithSession`(会话结束释放页面)/ `autoExpandOnce`(不抢屏则折叠)真正接线;bridge 新增 `closeSidebarBrowser`、`collapseSidebar` |
|
|
1729
|
+
| §4 多会话隔离 | 每个 view 独占一个侧栏标签;释放时**只关自己的**(按 guest id 反查标题) |
|
|
1730
|
+
| §5 历史检索 | 新增 `query`(URL/标题)与 `session`(来源会话)过滤,可组合 |
|
|
1731
|
+
| §6 可视化鼠标 | 新增**操作气泡**,在指针旁叙述当前动作 |
|
|
1732
|
+
|
|
1733
|
+
**顺带修掉的 6 个真 bug**(均为实测发现):`content(txt)` 把逐字动画页面的文字拆成一列字母;`ensureSidebar` 因找不到地址栏而直接抛错,**重启后第一次调用必失败**;`vision.strategy`/`closeWithSession`/`autoExpandOnce` 三个设置项**存了却不被读取**;`releasePage` 会**关掉别人的标签**(多会话时结束一个会带走另一个的页面,自己引入的);**设置文件带 BOM 时全部设置被静默丢弃**(记事本/PowerShell 写入即触发)。
|
|
1734
|
+
|
|
1735
|
+
## 第二十五轮(2026-10-01,速度优化 + 结构拆分)
|
|
1736
|
+
|
|
1737
|
+
**量化**(真机测量,10 次均值):每次**新建连接** 24.8ms、复用连接 **0.2ms**;每条命令原本要付 `连接 + list 存活检查 + 命令` = **49.1ms**,而一次 `browser_click` 会发多条 CDP,开销成倍。
|
|
1738
|
+
|
|
1739
|
+
**优化**:`BridgeConnection` 改为**长连接 + 请求串行**(0.6ms);**去掉每条命令前的 `list` 存活检查**,改为"命令失败才重建 guest"(真实错误照旧上抛)。传输层拆成独立模块 `bridge-connection.ts`(`desktop-bridge-host.ts` 426 → 306 行)。
|
|
1740
|
+
|
|
1741
|
+
**鼠标**:位置未变时**不再重绘**(省一次往返,也消除同点重绘造成的顿卡),点击仍强制播放涟漪;缓动改为 `190ms cubic-bezier(.22,.85,.24,1)`;新增 `forgetCursor` —— 文档被替换后必须清缓存,否则导航后**指针再也不会出现**。
|
|
1742
|
+
|
|
1743
|
+
## 第二十六轮(2026-10-01,可选用本机 Chrome / Edge)
|
|
1744
|
+
|
|
1745
|
+
**新增载体**:设置里可选 `bundled` / `auto` / `chrome` / `edge`(`browser.channel`),做法与 Codex Browser Use 一致 —— `--remote-debugging-port=0` 启动,读浏览器自己写下的 `DevToolsActivePort` 取端口,全程走 CDP(Node 22 内置 `WebSocket`,**零新增依赖**)。
|
|
1746
|
+
|
|
1747
|
+
**数据隔离**:使用**独立 profile**(`$DSH_HOME/dsh-builtin-browser-host/<chrome|edge>-profile`),绝不打开、占用或修改用户日常的窗口、书签与登录状态;插件退出也不会关掉用户的浏览器。
|
|
1748
|
+
|
|
1749
|
+
**登录态**:`cookies.persist` **开**(默认)→ 固定 profile 保留,重启 DSH 仍是登录状态,`browser_auth` 照常可导出/恢复;`persist` **关** → 临时 profile,释放时整个目录删除。
|
|
1750
|
+
|
|
1751
|
+
**优先级与回退**:显式选择的本机浏览器 > 桌面端侧栏 > 自托管。载体缺失分两种:`自动` 找不到 Chrome/Edge 时**记警告并继续用内置**;**明确选了某个浏览器**而它没装时不再只写日志 —— 改挂 `MissingSystemBrowserHost`,**每次命令都向调用方报出找不到的浏览器、查过哪些名字与位置,以及三条出路**(装上它 / 用 `DSH_BROWSER_CHROME_PATH`、`DSH_BROWSER_EDGE_PATH` 指定路径 / 把载体改回内置或自动),不静默替换。
|
|
1752
|
+
|
|
1753
|
+
**结构**:bridge 纳入插件仓库(`desktop-bridge/`,`install.mjs` 幂等 + `--revert`),并写进 npm 打包清单 —— 此前它只存在于 DSH 仓库,用户装了插件也拿不到。
|