dsh-builtin-browser 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +5 -5
- package/README.md +5 -4
- package/lib/browser-electron/entry.js +19 -20
- package/lib/browser-electron/system-browser.d.ts +48 -16
- package/lib/browser-electron/system-browser.js +95 -37
- package/package.json +1 -1
- package/src/browser-electron/entry.ts +20 -20
- package/src/browser-electron/system-browser.ts +86 -33
package/README.en.md
CHANGED
|
@@ -232,7 +232,7 @@ agent (browser_* tools)
|
|
|
232
232
|
|
|
233
233
|
- **Seam** (`browser` row): provides the `ctx.browser` service — provider registration, session lifecycle, error codes — decoupled from any implementation.
|
|
234
234
|
- **Provider** (`browser-electron` row): operates views through the `ElectronBrowserViewHost` seam (create/destroy/show, `sendCommand`), implemented with real Electron objects by the shell.
|
|
235
|
-
- **Tools** (`tool-browser` row): the
|
|
235
|
+
- **Tools** (`tool-browser` row): the 34 model-facing `browser_*` tools, maintaining one browser session per calling task (DSH session).
|
|
236
236
|
|
|
237
237
|
**Self-hosted mode**: without a desktop shell, the plugin spawns its own Electron child process (`host-main.js`) and drives it over loopback TCP JSON-RPC. The RPC is authenticated with a random per-spawn token delivered over **both stdin and an environment variable** — on Windows the Electron GUI process never receives piped stdin, so the env fallback keeps the handshake reliable. The child auto-restarts after a crash; the plugin prefers its own bundled electron package — packaged app executables (e.g. DSH Desktop.exe) are never reused as the spawnable binary, which would launch the app itself and exit immediately; screenshots prefer Electron's native `capturePage` (CDP capture can hang with multiple views in the window); the Electron lookup order follows below (33.x has a compositor defect; ≥ 40 recommended; the electron 44+ package no longer downloads its binary at install time — if it is missing on first use, the tool errors and tells you to run `npx install-electron` first, needs network).
|
|
238
238
|
|
|
@@ -242,7 +242,7 @@ agent (browser_* tools)
|
|
|
242
242
|
|
|
243
243
|
## Division of labor with the desktop shell
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
The plugin picks a carrier automatically, and the setting can override it (four in total):
|
|
246
246
|
|
|
247
247
|
**① Desktop: drive the official sidebar's page (one page for both parties)**
|
|
248
248
|
|
|
@@ -301,10 +301,10 @@ The settings panel can point the plugin at an **installed Chrome or Edge** (`bro
|
|
|
301
301
|
|
|
302
302
|
| Component | Version |
|
|
303
303
|
| --- | --- |
|
|
304
|
-
| DeepSeek Harness (dsh) | `0.
|
|
304
|
+
| DeepSeek Harness (dsh) | `0.2.0-rc.2` (peer range `>=0.1.1-rc.2 <0.3.0`) |
|
|
305
305
|
| Electron | `44.0.0` (≥ 40 recommended; 33.x has a compositor defect) |
|
|
306
306
|
| Node.js | `22.20.0` |
|
|
307
|
-
| dsh-builtin-browser | `0.
|
|
307
|
+
| dsh-builtin-browser | `0.3.0` |
|
|
308
308
|
| OS | Windows 10 (10.0.26200) |
|
|
309
309
|
|
|
310
310
|
> The plugin declares `electron >= 30`; it has **only been verified on Windows** (macOS/Linux untested, not yet promised).
|
|
@@ -403,7 +403,7 @@ Code layout:
|
|
|
403
403
|
| Round 20 addendum | 2026-10-01 | **Root cause of the invisible settings panel, plus crash diagnostics**: (1) On a real host the panel did not appear — the cause is that all three `cordis.patch.yml` rows were registered under **subpath** names (`dsh-builtin-browser/browser`), while the host's client-module scan resolves a row's specifier to a **package root** (`exactPackageSpecifier` returns `undefined` as soon as it sees a `/`), leaving the package with no row to read its `dsh.client` from. Fixed by adding an inert root row under the bare package name and giving the root entry `export const name` plus an empty `apply()`. Measured: boot entries 67 → **68**, the panel appears below "规则设定", and switches persist immediately. (2) Three host-log defects fixed: ISO timestamps, both paths plus their existence recorded before spawning, and a 2 MiB rotation that leaves a dated marker instead of wiping the file (which is how weeks of history vanished); the `exit` line gained `pid=` and `entryExists=` so "present at spawn, absent at exit" names an installation replaced underneath a running host. (3) Test pollution of the real log fixed: three spawn-based tests restored `DSH_HOME` in a `finally` while `dispose()` kills children asynchronously, so their synthetic exit lines landed in the operator's real log — now isolated at module scope. **70/70 tests** |
|
|
404
404
|
| **0.2.0** | 2026-10-01 | **Release**: round 20 (persistent browsing history / the "Browser" settings section / synthetic cursor / teardown semantics) and its addendum ship as **0.2.0** — tool count **33 → 34** (new `browser_visited`), plus a new "Browser" section in Settings. Build clean, **70/70 tests pass**, tag `v0.2.0` |
|
|
405
405
|
|
|
406
|
-
> The npm badge at the top is the authority on the registry's latest version: `0.1.23` is committed and tagged; if the badge still reads `0.1.22`, that version is not published yet.
|
|
406
|
+
> The npm badge at the top is the authority on the registry's latest version. (was: `0.1.23` is committed and tagged; if the badge still reads `0.1.22`, that version is not published yet.
|
|
407
407
|
|
|
408
408
|
## Acknowledgements
|
|
409
409
|
|
package/README.md
CHANGED
|
@@ -236,13 +236,13 @@ agent (browser_* 工具)
|
|
|
236
236
|
|
|
237
237
|
**自托管模式**:没有桌面外壳时,插件自己拉起一个 Electron 子进程(`host-main.js`),通过本机 TCP JSON-RPC 驱动。RPC 带随机 token 认证,token 经 **stdin + 环境变量双通道**传递——Windows 上 Electron 是 GUI 子系统进程、收不到 piped stdin,环境变量兜底保证握手稳定。子进程崩溃会自动重启;优先使用随插件安装的 electron 包(**打包应用如 DSH Desktop.exe 不会被误当作可复用二进制**,避免 spawn 秒退);截图优先走 Electron 原生 `capturePage`(CDP 截图在多视图下会挂起);Electron 的定位顺序见下(33.x 有合成器缺陷,建议 ≥ 40;44+ 的 electron 包不再随安装自动下载二进制,首次使用若缺失会按报错提示先 `npx install-electron`,需联网)。
|
|
238
238
|
|
|
239
|
-
|
|
239
|
+
**自托管浏览器就是一台真正的浏览器**(Web 端、或没有桌面外壳时的形态):每个任务(DSH 会话)拥有**独立的浏览器窗口**,窗口自带完整工具栏——地址栏、后退/前进/刷新按钮、标签条(新建/切换/关闭标签)。人可以直接像用 Chrome 一样使用它:在地址栏输入网址(自动补 `https://`)、点标签切换页面、开新标签;键盘焦点跟随点击——**点地址栏即可输入、点页面即可操作**(Windows 焦点路由,修复了点击不转移焦点导致地址栏无法输入的问题)。agent 与人的操作都汇入**同一个会话模型**(同一套标签、历史与导航),窗口标题实时显示当前任务标识与页面标题/URL,窗口缩放时视图自动跟随。任务结束后窗口随会话自动关闭。
|
|
240
240
|
|
|
241
241
|
**Electron 定位顺序**:① `ELECTRON_PATH`(显式覆盖,用户显式意图最优先)→ ② 随插件安装的 electron 包(纯文件系统探测,不触发 44+ 懒下载;覆盖 node_modules 与 pnpm store 两种布局)→ ③ DSH 安装锚点与 pnpm 虚拟仓库中**版本最新**者 → ④ 当前进程为**裸** Electron 时复用宿主二进制(开发模式)→ ⑤ 进程祖先树中的**裸** Electron 宿主(Windows 走 PowerShell CIM,仅最后手段)。**打包应用(如 `DSH Desktop.exe`)一律不复用**——它们不能按脚本参数拉起,误用会导致 spawn 秒退(issue #6);找不到时工具会报清晰的错误提示(含 `npx install-electron` 指引)。
|
|
242
242
|
|
|
243
243
|
## 与桌面外壳的分工
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
插件会自动选载体,也可以在设置里指定(共四种,见下):
|
|
246
246
|
|
|
247
247
|
**① 桌面端:驱动官方侧栏的页面(人机同页)**
|
|
248
248
|
|
|
@@ -301,10 +301,11 @@ node desktop-bridge/install.mjs --revert # 回滚
|
|
|
301
301
|
|
|
302
302
|
| 组件 | 版本 |
|
|
303
303
|
| --- | --- |
|
|
304
|
-
| DeepSeek Harness(dsh) | `0.
|
|
304
|
+
| DeepSeek Harness(dsh) | `0.2.0-rc.2`(peer 声明 `>=0.1.1-rc.2 <0.3.0`) |
|
|
305
305
|
| Electron | `44.0.0`(推荐 ≥ 40;33.x 存在合成器缺陷) |
|
|
306
306
|
| Node.js | `22.20.0` |
|
|
307
|
-
|
|
|
307
|
+
| 本机 Chrome / Edge(可选载体) | `154.0.8037.58` / `154.0.4258.37` |
|
|
308
|
+
| dsh-builtin-browser | `0.3.0` |
|
|
308
309
|
| 操作系统 | Windows 10 (10.0.26200) |
|
|
309
310
|
|
|
310
311
|
> 插件声明 `electron >= 30`;**当前仅在 Windows 环境实测**(macOS/Linux 未验证,暂不承诺)。
|
|
@@ -90,31 +90,30 @@ export function apply(ctx, config) {
|
|
|
90
90
|
// A browser the user explicitly asked for outranks every automatic choice: the
|
|
91
91
|
// whole point of the setting is that they know which browser they want. It is
|
|
92
92
|
// launched with a plugin-owned profile, so their own windows are untouched.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
//
|
|
94
|
+
// Registration is INERT: constructing the host starts nothing. The browser only
|
|
95
|
+
// launches when a command actually needs a page, so merely running DSH never
|
|
96
|
+
// spawns a browser window. (An earlier version launched it here, which meant a
|
|
97
|
+
// browser appeared the moment the plugin loaded — reported and fixed.)
|
|
98
|
+
const channel = settings.get().browser.channel;
|
|
99
|
+
if (channel !== 'bundled') {
|
|
97
100
|
const detected = detectBrowser(channel);
|
|
98
101
|
if (detected === undefined) {
|
|
99
102
|
ctx.logger?.warn?.(`dsh-builtin-browser: ${channel} was requested but no installation was found; using the bundled browser`);
|
|
100
|
-
return;
|
|
101
103
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
ctx.logger?.warn?.(`dsh-builtin-browser: ${detected.kind} did not start; using the bundled browser`);
|
|
114
|
-
return;
|
|
104
|
+
else {
|
|
105
|
+
const home = process.env.DSH_HOME ?? homedir();
|
|
106
|
+
// Login state lives in the browser profile, so the cookies setting decides where
|
|
107
|
+
// that profile goes: a stable directory keeps the user signed in across restarts
|
|
108
|
+
// (the point of using their own browser), while turning persistence off gets a
|
|
109
|
+
// throwaway directory that is removed when the browser is released.
|
|
110
|
+
const persist = settings.get().cookies.persist;
|
|
111
|
+
const profileDir = persist
|
|
112
|
+
? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
|
|
113
|
+
: join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`);
|
|
114
|
+
adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
|
|
115
115
|
}
|
|
116
|
-
|
|
117
|
-
})().catch(() => { });
|
|
116
|
+
}
|
|
118
117
|
void DesktopBridgeViewHost.discover().then(sidebar => {
|
|
119
118
|
// No bridge (plain `dsh web`, an older desktop build, or a shell that already
|
|
120
119
|
// exited): keep self-hosting, which is what every surface has always done.
|
|
@@ -46,30 +46,56 @@ export interface DetectedBrowser {
|
|
|
46
46
|
export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.ProcessEnv): DetectedBrowser | undefined;
|
|
47
47
|
/** A system browser driven over CDP, presented as a browser view host. */
|
|
48
48
|
export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
49
|
-
private readonly
|
|
50
|
-
private readonly
|
|
51
|
-
readonly
|
|
49
|
+
private readonly browser;
|
|
50
|
+
private readonly profileDir;
|
|
51
|
+
private readonly extraArgs;
|
|
52
52
|
private readonly ephemeralDir?;
|
|
53
53
|
private readonly views;
|
|
54
54
|
private disposed;
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* The browser is started on FIRST USE, never on construction.
|
|
57
|
+
*
|
|
58
|
+
* A plugin that launches Chrome the moment its host loads is a plugin that takes
|
|
59
|
+
* over the machine before anyone asked it to: simply starting DSH would spawn a
|
|
60
|
+
* browser window. Registration must therefore be inert, and the process is only
|
|
61
|
+
* spawned when a view is actually needed.
|
|
61
62
|
*/
|
|
62
|
-
private
|
|
63
|
+
private client;
|
|
64
|
+
private child;
|
|
65
|
+
private starting;
|
|
63
66
|
/**
|
|
64
|
-
* @param browser - the detected installation to launch.
|
|
67
|
+
* @param browser - the detected installation to launch on first use.
|
|
65
68
|
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
66
69
|
* @param extraArgs - additional Chromium switches.
|
|
67
|
-
* @param ephemeralDir - a directory to delete when the
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
+
* @param ephemeralDir - a directory to delete on release, when the user has turned
|
|
71
|
+
* persistence off so no login state outlives the session.
|
|
72
|
+
*/
|
|
73
|
+
constructor(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string | undefined);
|
|
74
|
+
/** Which product this host would drive (for diagnostics). */
|
|
75
|
+
get kind(): DetectedBrowser['kind'];
|
|
76
|
+
/** Whether the browser has actually been started yet (diagnostics and tests). */
|
|
77
|
+
get started(): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* The CDP client, starting the browser on first use.
|
|
80
|
+
* @returns the connected client.
|
|
81
|
+
*/
|
|
82
|
+
private ensureClient;
|
|
83
|
+
/**
|
|
84
|
+
* Launch the browser with a private profile and connect over CDP.
|
|
85
|
+
*
|
|
86
|
+
* `--remote-debugging-port=0` plus the `DevToolsActivePort` file is the only
|
|
87
|
+
* reliable way to learn the port: a fixed port collides with whatever else the
|
|
88
|
+
* machine is running, and parsing stderr is fragile across versions.
|
|
89
|
+
* @returns the connected client.
|
|
90
|
+
*/
|
|
91
|
+
private start;
|
|
92
|
+
/**
|
|
93
|
+
* Whether this host can back views.
|
|
94
|
+
*
|
|
95
|
+
* True while the host is usable — including before the browser has been started,
|
|
96
|
+
* since the first command is what starts it. Reporting "unavailable" here would
|
|
97
|
+
* make the plugin fall back for no reason.
|
|
70
98
|
*/
|
|
71
|
-
static launch(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string): Promise<SystemBrowserViewHost | undefined>;
|
|
72
|
-
/** Whether this host can back views: the CDP connection is live. */
|
|
73
99
|
available(): boolean;
|
|
74
100
|
createView(): ElectronViewHandle;
|
|
75
101
|
destroyView(handle: ElectronViewHandle): void;
|
|
@@ -91,7 +117,13 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
91
117
|
onUserAction(): void;
|
|
92
118
|
/** The browser reports its own window lifecycle. */
|
|
93
119
|
onViewClosed(): void;
|
|
94
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Close the browser we launched, if we ever launched one.
|
|
122
|
+
*
|
|
123
|
+
* The user's own windows belong to a different process and are never touched. A
|
|
124
|
+
* host whose browser was never started (the common case when nobody used the
|
|
125
|
+
* browser) simply tears down its bookkeeping.
|
|
126
|
+
*/
|
|
95
127
|
dispose(): void;
|
|
96
128
|
/** The flattened session for a view, creating its page on first use. */
|
|
97
129
|
private ensureSession;
|
|
@@ -155,37 +155,67 @@ class CdpClient {
|
|
|
155
155
|
}
|
|
156
156
|
/** A system browser driven over CDP, presented as a browser view host. */
|
|
157
157
|
export class SystemBrowserViewHost {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
browser;
|
|
159
|
+
profileDir;
|
|
160
|
+
extraArgs;
|
|
161
161
|
ephemeralDir;
|
|
162
162
|
views = new Map();
|
|
163
163
|
disposed = false;
|
|
164
164
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
165
|
+
* The browser is started on FIRST USE, never on construction.
|
|
166
|
+
*
|
|
167
|
+
* A plugin that launches Chrome the moment its host loads is a plugin that takes
|
|
168
|
+
* over the machine before anyone asked it to: simply starting DSH would spawn a
|
|
169
|
+
* browser window. Registration must therefore be inert, and the process is only
|
|
170
|
+
* spawned when a view is actually needed.
|
|
170
171
|
*/
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
this.kind = kind;
|
|
175
|
-
this.ephemeralDir = ephemeralDir;
|
|
176
|
-
}
|
|
172
|
+
client;
|
|
173
|
+
child;
|
|
174
|
+
starting;
|
|
177
175
|
/**
|
|
178
|
-
* @param browser - the detected installation to launch.
|
|
176
|
+
* @param browser - the detected installation to launch on first use.
|
|
179
177
|
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
180
178
|
* @param extraArgs - additional Chromium switches.
|
|
181
|
-
* @param ephemeralDir - a directory to delete when the
|
|
182
|
-
*
|
|
183
|
-
* @returns the host, or undefined when the browser refuses to come up.
|
|
179
|
+
* @param ephemeralDir - a directory to delete on release, when the user has turned
|
|
180
|
+
* persistence off so no login state outlives the session.
|
|
184
181
|
*/
|
|
185
|
-
|
|
182
|
+
constructor(browser, profileDir, extraArgs = [], ephemeralDir) {
|
|
183
|
+
this.browser = browser;
|
|
184
|
+
this.profileDir = profileDir;
|
|
185
|
+
this.extraArgs = extraArgs;
|
|
186
|
+
this.ephemeralDir = ephemeralDir;
|
|
187
|
+
}
|
|
188
|
+
/** Which product this host would drive (for diagnostics). */
|
|
189
|
+
get kind() {
|
|
190
|
+
return this.browser.kind;
|
|
191
|
+
}
|
|
192
|
+
/** Whether the browser has actually been started yet (diagnostics and tests). */
|
|
193
|
+
get started() {
|
|
194
|
+
return this.child !== undefined;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The CDP client, starting the browser on first use.
|
|
198
|
+
* @returns the connected client.
|
|
199
|
+
*/
|
|
200
|
+
async ensureClient() {
|
|
201
|
+
if (this.client !== undefined)
|
|
202
|
+
return this.client;
|
|
203
|
+
// Concurrent first calls share one startup rather than racing two browsers.
|
|
204
|
+
this.starting ??= this.start().finally(() => { this.starting = undefined; });
|
|
205
|
+
return await this.starting;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Launch the browser with a private profile and connect over CDP.
|
|
209
|
+
*
|
|
210
|
+
* `--remote-debugging-port=0` plus the `DevToolsActivePort` file is the only
|
|
211
|
+
* reliable way to learn the port: a fixed port collides with whatever else the
|
|
212
|
+
* machine is running, and parsing stderr is fragile across versions.
|
|
213
|
+
* @returns the connected client.
|
|
214
|
+
*/
|
|
215
|
+
async start() {
|
|
186
216
|
const args = [
|
|
187
217
|
'--remote-debugging-port=0',
|
|
188
|
-
`--user-data-dir=${profileDir}`,
|
|
218
|
+
`--user-data-dir=${this.profileDir}`,
|
|
189
219
|
// Behave like a fresh, unattended browser: no first-run UI, no default-browser
|
|
190
220
|
// prompt, no restore bubble, and no "Chrome is being controlled" infobar.
|
|
191
221
|
'--no-first-run',
|
|
@@ -193,19 +223,23 @@ export class SystemBrowserViewHost {
|
|
|
193
223
|
'--disable-features=Translate,MediaRouter',
|
|
194
224
|
'--disable-session-crashed-bubble',
|
|
195
225
|
'--hide-crash-restore-bubble',
|
|
196
|
-
...extraArgs,
|
|
226
|
+
...this.extraArgs,
|
|
197
227
|
'about:blank',
|
|
198
228
|
];
|
|
199
|
-
const child = spawn(browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
|
|
200
|
-
|
|
229
|
+
const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
|
|
230
|
+
this.child = child;
|
|
231
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort');
|
|
201
232
|
const deadline = Date.now() + 30_000;
|
|
202
233
|
for (;;) {
|
|
203
234
|
if (Date.now() > deadline) {
|
|
204
235
|
child.kill();
|
|
205
|
-
|
|
236
|
+
this.child = undefined;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
if (child.exitCode !== null) {
|
|
240
|
+
this.child = undefined;
|
|
241
|
+
break;
|
|
206
242
|
}
|
|
207
|
-
if (child.exitCode !== null)
|
|
208
|
-
return undefined;
|
|
209
243
|
if (existsSync(portFile)) {
|
|
210
244
|
try {
|
|
211
245
|
const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '';
|
|
@@ -214,7 +248,8 @@ export class SystemBrowserViewHost {
|
|
|
214
248
|
if (typeof version.webSocketDebuggerUrl === 'string') {
|
|
215
249
|
const client = new CdpClient(version.webSocketDebuggerUrl);
|
|
216
250
|
await client.whenReady();
|
|
217
|
-
|
|
251
|
+
this.client = client;
|
|
252
|
+
return client;
|
|
218
253
|
}
|
|
219
254
|
}
|
|
220
255
|
}
|
|
@@ -224,8 +259,15 @@ export class SystemBrowserViewHost {
|
|
|
224
259
|
}
|
|
225
260
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
226
261
|
}
|
|
262
|
+
throw new Error(`dsh-builtin-browser: ${this.browser.kind} did not expose CDP within 30s (${this.browser.path})`);
|
|
227
263
|
}
|
|
228
|
-
/**
|
|
264
|
+
/**
|
|
265
|
+
* Whether this host can back views.
|
|
266
|
+
*
|
|
267
|
+
* True while the host is usable — including before the browser has been started,
|
|
268
|
+
* since the first command is what starts it. Reporting "unavailable" here would
|
|
269
|
+
* make the plugin fall back for no reason.
|
|
270
|
+
*/
|
|
229
271
|
available() {
|
|
230
272
|
return !this.disposed;
|
|
231
273
|
}
|
|
@@ -234,8 +276,9 @@ export class SystemBrowserViewHost {
|
|
|
234
276
|
return {
|
|
235
277
|
id: viewId,
|
|
236
278
|
sendCommand: async (method, params) => {
|
|
279
|
+
const client = await this.ensureClient();
|
|
237
280
|
const session = this.views.get(viewId) ?? await this.ensureSession(viewId);
|
|
238
|
-
const result = await
|
|
281
|
+
const result = await client.send(method, params, session);
|
|
239
282
|
return (result ?? {});
|
|
240
283
|
},
|
|
241
284
|
};
|
|
@@ -249,7 +292,8 @@ export class SystemBrowserViewHost {
|
|
|
249
292
|
// was attached to, which is why they are tracked separately.
|
|
250
293
|
const targetId = this.sessions.get(session);
|
|
251
294
|
this.sessions.delete(session);
|
|
252
|
-
if
|
|
295
|
+
// Nothing to close if the browser was never started.
|
|
296
|
+
if (targetId !== undefined && this.client !== undefined) {
|
|
253
297
|
// Best-effort: a page that is already gone is not an error.
|
|
254
298
|
void this.client.send('Target.closeTarget', { targetId }).catch(() => undefined);
|
|
255
299
|
}
|
|
@@ -269,7 +313,7 @@ export class SystemBrowserViewHost {
|
|
|
269
313
|
*/
|
|
270
314
|
async focus() {
|
|
271
315
|
try {
|
|
272
|
-
this.child
|
|
316
|
+
this.child?.kill('SIGCONT');
|
|
273
317
|
}
|
|
274
318
|
catch { /* not supported on Windows; harmless */ }
|
|
275
319
|
}
|
|
@@ -277,20 +321,26 @@ export class SystemBrowserViewHost {
|
|
|
277
321
|
onUserAction() { }
|
|
278
322
|
/** The browser reports its own window lifecycle. */
|
|
279
323
|
onViewClosed() { }
|
|
280
|
-
/**
|
|
324
|
+
/**
|
|
325
|
+
* Close the browser we launched, if we ever launched one.
|
|
326
|
+
*
|
|
327
|
+
* The user's own windows belong to a different process and are never touched. A
|
|
328
|
+
* host whose browser was never started (the common case when nobody used the
|
|
329
|
+
* browser) simply tears down its bookkeeping.
|
|
330
|
+
*/
|
|
281
331
|
dispose() {
|
|
282
332
|
if (this.disposed)
|
|
283
333
|
return;
|
|
284
334
|
this.disposed = true;
|
|
285
335
|
this.views.clear();
|
|
286
336
|
this.sessions.clear();
|
|
287
|
-
this.client
|
|
337
|
+
this.client?.close();
|
|
288
338
|
try {
|
|
289
|
-
this.child
|
|
339
|
+
this.child?.kill();
|
|
290
340
|
}
|
|
291
341
|
catch { /* already gone */ }
|
|
292
342
|
const ephemeral = this.ephemeralDir;
|
|
293
|
-
if (ephemeral !== undefined) {
|
|
343
|
+
if (ephemeral !== undefined && this.child !== undefined) {
|
|
294
344
|
// The browser flushes its profile while shutting down, so deleting later avoids
|
|
295
345
|
// racing it. Leftovers are only a stray temp directory, never a live credential.
|
|
296
346
|
setTimeout(() => { try {
|
|
@@ -298,17 +348,25 @@ export class SystemBrowserViewHost {
|
|
|
298
348
|
}
|
|
299
349
|
catch { /* best effort */ } }, 3_000).unref?.();
|
|
300
350
|
}
|
|
351
|
+
else if (ephemeral !== undefined) {
|
|
352
|
+
// Never started, so nothing can be holding it open.
|
|
353
|
+
try {
|
|
354
|
+
rmSync(ephemeral, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
catch { /* best effort */ }
|
|
357
|
+
}
|
|
301
358
|
}
|
|
302
359
|
/** The flattened session for a view, creating its page on first use. */
|
|
303
360
|
async ensureSession(viewId) {
|
|
304
361
|
const existing = this.views.get(viewId);
|
|
305
362
|
if (existing !== undefined)
|
|
306
363
|
return existing;
|
|
307
|
-
const
|
|
364
|
+
const client = await this.ensureClient();
|
|
365
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
308
366
|
const targetId = created?.targetId;
|
|
309
367
|
if (typeof targetId !== 'string')
|
|
310
368
|
throw new Error('dsh-builtin-browser: the browser did not create a page');
|
|
311
|
-
const attached = await
|
|
369
|
+
const attached = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
|
312
370
|
const sessionId = attached?.sessionId;
|
|
313
371
|
if (typeof sessionId !== 'string')
|
|
314
372
|
throw new Error('dsh-builtin-browser: the browser did not attach to the new page');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -125,30 +125,30 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
125
125
|
// A browser the user explicitly asked for outranks every automatic choice: the
|
|
126
126
|
// whole point of the setting is that they know which browser they want. It is
|
|
127
127
|
// launched with a plugin-owned profile, so their own windows are untouched.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
//
|
|
129
|
+
// Registration is INERT: constructing the host starts nothing. The browser only
|
|
130
|
+
// launches when a command actually needs a page, so merely running DSH never
|
|
131
|
+
// spawns a browser window. (An earlier version launched it here, which meant a
|
|
132
|
+
// browser appeared the moment the plugin loaded — reported and fixed.)
|
|
133
|
+
const channel = settings.get().browser.channel
|
|
134
|
+
if (channel !== 'bundled') {
|
|
131
135
|
const detected = detectBrowser(channel)
|
|
132
136
|
if (detected === undefined) {
|
|
133
137
|
ctx.logger?.warn?.(`dsh-builtin-browser: ${channel} was requested but no installation was found; using the bundled browser`)
|
|
134
|
-
|
|
138
|
+
} else {
|
|
139
|
+
const home = process.env.DSH_HOME ?? homedir()
|
|
140
|
+
// Login state lives in the browser profile, so the cookies setting decides where
|
|
141
|
+
// that profile goes: a stable directory keeps the user signed in across restarts
|
|
142
|
+
// (the point of using their own browser), while turning persistence off gets a
|
|
143
|
+
// throwaway directory that is removed when the browser is released.
|
|
144
|
+
const persist = settings.get().cookies.persist
|
|
145
|
+
const profileDir = persist
|
|
146
|
+
? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
|
|
147
|
+
: join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`)
|
|
148
|
+
adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir),
|
|
149
|
+
`will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`)
|
|
135
150
|
}
|
|
136
|
-
|
|
137
|
-
// Login state lives in the browser profile, so the cookies setting decides where
|
|
138
|
-
// that profile goes: a stable directory keeps the user signed in across restarts
|
|
139
|
-
// (the point of using their own browser), while turning persistence off gets a
|
|
140
|
-
// throwaway directory that is removed when the browser is released.
|
|
141
|
-
const persist = settings.get().cookies.persist
|
|
142
|
-
const profileDir = persist
|
|
143
|
-
? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
|
|
144
|
-
: join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`)
|
|
145
|
-
const host = await SystemBrowserViewHost.launch(detected, profileDir, [], persist ? undefined : profileDir)
|
|
146
|
-
if (host === undefined) {
|
|
147
|
-
ctx.logger?.warn?.(`dsh-builtin-browser: ${detected.kind} did not start; using the bundled browser`)
|
|
148
|
-
return
|
|
149
|
-
}
|
|
150
|
-
adopt(host, `driving the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`)
|
|
151
|
-
})().catch(() => { /* the bundled browser stays in place */ })
|
|
151
|
+
}
|
|
152
152
|
|
|
153
153
|
void DesktopBridgeViewHost.discover().then(sidebar => {
|
|
154
154
|
// No bridge (plain `dsh web`, an older desktop build, or a shell that already
|
|
@@ -162,33 +162,65 @@ class CdpClient {
|
|
|
162
162
|
export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
163
163
|
private readonly views = new Map<string, string>()
|
|
164
164
|
private disposed = false
|
|
165
|
+
/**
|
|
166
|
+
* The browser is started on FIRST USE, never on construction.
|
|
167
|
+
*
|
|
168
|
+
* A plugin that launches Chrome the moment its host loads is a plugin that takes
|
|
169
|
+
* over the machine before anyone asked it to: simply starting DSH would spawn a
|
|
170
|
+
* browser window. Registration must therefore be inert, and the process is only
|
|
171
|
+
* spawned when a view is actually needed.
|
|
172
|
+
*/
|
|
173
|
+
private client: CdpClient | undefined
|
|
174
|
+
private child: ChildProcess | undefined
|
|
175
|
+
private starting: Promise<CdpClient> | undefined
|
|
165
176
|
|
|
166
177
|
/**
|
|
167
|
-
* @param
|
|
168
|
-
* @param
|
|
169
|
-
* @param
|
|
170
|
-
* @param ephemeralDir - a
|
|
171
|
-
*
|
|
178
|
+
* @param browser - the detected installation to launch on first use.
|
|
179
|
+
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
180
|
+
* @param extraArgs - additional Chromium switches.
|
|
181
|
+
* @param ephemeralDir - a directory to delete on release, when the user has turned
|
|
182
|
+
* persistence off so no login state outlives the session.
|
|
172
183
|
*/
|
|
173
|
-
|
|
174
|
-
private readonly
|
|
175
|
-
private readonly
|
|
176
|
-
readonly
|
|
184
|
+
constructor(
|
|
185
|
+
private readonly browser: DetectedBrowser,
|
|
186
|
+
private readonly profileDir: string,
|
|
187
|
+
private readonly extraArgs: readonly string[] = [],
|
|
177
188
|
private readonly ephemeralDir?: string,
|
|
178
189
|
) {}
|
|
179
190
|
|
|
191
|
+
/** Which product this host would drive (for diagnostics). */
|
|
192
|
+
get kind(): DetectedBrowser['kind'] {
|
|
193
|
+
return this.browser.kind
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether the browser has actually been started yet (diagnostics and tests). */
|
|
197
|
+
get started(): boolean {
|
|
198
|
+
return this.child !== undefined
|
|
199
|
+
}
|
|
200
|
+
|
|
180
201
|
/**
|
|
181
|
-
*
|
|
182
|
-
* @
|
|
183
|
-
* @param extraArgs - additional Chromium switches.
|
|
184
|
-
* @param ephemeralDir - a directory to delete when the browser is released, used
|
|
185
|
-
* when persistence is switched off so no login state outlives the session.
|
|
186
|
-
* @returns the host, or undefined when the browser refuses to come up.
|
|
202
|
+
* The CDP client, starting the browser on first use.
|
|
203
|
+
* @returns the connected client.
|
|
187
204
|
*/
|
|
188
|
-
|
|
205
|
+
private async ensureClient(): Promise<CdpClient> {
|
|
206
|
+
if (this.client !== undefined) return this.client
|
|
207
|
+
// Concurrent first calls share one startup rather than racing two browsers.
|
|
208
|
+
this.starting ??= this.start().finally(() => { this.starting = undefined })
|
|
209
|
+
return await this.starting
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Launch the browser with a private profile and connect over CDP.
|
|
214
|
+
*
|
|
215
|
+
* `--remote-debugging-port=0` plus the `DevToolsActivePort` file is the only
|
|
216
|
+
* reliable way to learn the port: a fixed port collides with whatever else the
|
|
217
|
+
* machine is running, and parsing stderr is fragile across versions.
|
|
218
|
+
* @returns the connected client.
|
|
219
|
+
*/
|
|
220
|
+
private async start(): Promise<CdpClient> {
|
|
189
221
|
const args = [
|
|
190
222
|
'--remote-debugging-port=0',
|
|
191
|
-
`--user-data-dir=${profileDir}`,
|
|
223
|
+
`--user-data-dir=${this.profileDir}`,
|
|
192
224
|
// Behave like a fresh, unattended browser: no first-run UI, no default-browser
|
|
193
225
|
// prompt, no restore bubble, and no "Chrome is being controlled" infobar.
|
|
194
226
|
'--no-first-run',
|
|
@@ -196,15 +228,16 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
196
228
|
'--disable-features=Translate,MediaRouter',
|
|
197
229
|
'--disable-session-crashed-bubble',
|
|
198
230
|
'--hide-crash-restore-bubble',
|
|
199
|
-
...extraArgs,
|
|
231
|
+
...this.extraArgs,
|
|
200
232
|
'about:blank',
|
|
201
233
|
]
|
|
202
|
-
const child = spawn(browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
|
|
203
|
-
|
|
234
|
+
const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
|
|
235
|
+
this.child = child
|
|
236
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort')
|
|
204
237
|
const deadline = Date.now() + 30_000
|
|
205
238
|
for (;;) {
|
|
206
|
-
if (Date.now() > deadline) { child.kill();
|
|
207
|
-
if (child.exitCode !== null)
|
|
239
|
+
if (Date.now() > deadline) { child.kill(); this.child = undefined; break }
|
|
240
|
+
if (child.exitCode !== null) { this.child = undefined; break }
|
|
208
241
|
if (existsSync(portFile)) {
|
|
209
242
|
try {
|
|
210
243
|
const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? ''
|
|
@@ -213,7 +246,8 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
213
246
|
if (typeof version.webSocketDebuggerUrl === 'string') {
|
|
214
247
|
const client = new CdpClient(version.webSocketDebuggerUrl)
|
|
215
248
|
await client.whenReady()
|
|
216
|
-
|
|
249
|
+
this.client = client
|
|
250
|
+
return client
|
|
217
251
|
}
|
|
218
252
|
}
|
|
219
253
|
} catch {
|
|
@@ -222,9 +256,16 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
222
256
|
}
|
|
223
257
|
await new Promise(resolve => setTimeout(resolve, 250))
|
|
224
258
|
}
|
|
259
|
+
throw new Error(`dsh-builtin-browser: ${this.browser.kind} did not expose CDP within 30s (${this.browser.path})`)
|
|
225
260
|
}
|
|
226
261
|
|
|
227
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* Whether this host can back views.
|
|
264
|
+
*
|
|
265
|
+
* True while the host is usable — including before the browser has been started,
|
|
266
|
+
* since the first command is what starts it. Reporting "unavailable" here would
|
|
267
|
+
* make the plugin fall back for no reason.
|
|
268
|
+
*/
|
|
228
269
|
available(): boolean {
|
|
229
270
|
return !this.disposed
|
|
230
271
|
}
|
|
@@ -234,8 +275,9 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
234
275
|
return {
|
|
235
276
|
id: viewId,
|
|
236
277
|
sendCommand: async (method: string, params?: Record<string, unknown>) => {
|
|
278
|
+
const client = await this.ensureClient()
|
|
237
279
|
const session = this.views.get(viewId) ?? await this.ensureSession(viewId)
|
|
238
|
-
const result = await
|
|
280
|
+
const result = await client.send(method, params, session)
|
|
239
281
|
return (result ?? {}) as Record<string, unknown>
|
|
240
282
|
},
|
|
241
283
|
}
|
|
@@ -249,7 +291,8 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
249
291
|
// was attached to, which is why they are tracked separately.
|
|
250
292
|
const targetId = this.sessions.get(session)
|
|
251
293
|
this.sessions.delete(session)
|
|
252
|
-
if
|
|
294
|
+
// Nothing to close if the browser was never started.
|
|
295
|
+
if (targetId !== undefined && this.client !== undefined) {
|
|
253
296
|
// Best-effort: a page that is already gone is not an error.
|
|
254
297
|
void this.client.send('Target.closeTarget', { targetId }).catch(() => undefined)
|
|
255
298
|
}
|
|
@@ -272,7 +315,7 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
272
315
|
* window, a locked session) is never worth an error.
|
|
273
316
|
*/
|
|
274
317
|
async focus(): Promise<void> {
|
|
275
|
-
try { this.child
|
|
318
|
+
try { this.child?.kill('SIGCONT') } catch { /* not supported on Windows; harmless */ }
|
|
276
319
|
}
|
|
277
320
|
|
|
278
321
|
/** The browser reports its own window lifecycle. */
|
|
@@ -281,19 +324,28 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
281
324
|
/** The browser reports its own window lifecycle. */
|
|
282
325
|
onViewClosed(): void {}
|
|
283
326
|
|
|
284
|
-
/**
|
|
327
|
+
/**
|
|
328
|
+
* Close the browser we launched, if we ever launched one.
|
|
329
|
+
*
|
|
330
|
+
* The user's own windows belong to a different process and are never touched. A
|
|
331
|
+
* host whose browser was never started (the common case when nobody used the
|
|
332
|
+
* browser) simply tears down its bookkeeping.
|
|
333
|
+
*/
|
|
285
334
|
dispose(): void {
|
|
286
335
|
if (this.disposed) return
|
|
287
336
|
this.disposed = true
|
|
288
337
|
this.views.clear()
|
|
289
338
|
this.sessions.clear()
|
|
290
|
-
this.client
|
|
291
|
-
try { this.child
|
|
339
|
+
this.client?.close()
|
|
340
|
+
try { this.child?.kill() } catch { /* already gone */ }
|
|
292
341
|
const ephemeral = this.ephemeralDir
|
|
293
|
-
if (ephemeral !== undefined) {
|
|
342
|
+
if (ephemeral !== undefined && this.child !== undefined) {
|
|
294
343
|
// The browser flushes its profile while shutting down, so deleting later avoids
|
|
295
344
|
// racing it. Leftovers are only a stray temp directory, never a live credential.
|
|
296
345
|
setTimeout(() => { try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ } }, 3_000).unref?.()
|
|
346
|
+
} else if (ephemeral !== undefined) {
|
|
347
|
+
// Never started, so nothing can be holding it open.
|
|
348
|
+
try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
297
349
|
}
|
|
298
350
|
}
|
|
299
351
|
|
|
@@ -301,10 +353,11 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
301
353
|
private async ensureSession(viewId: string): Promise<string> {
|
|
302
354
|
const existing = this.views.get(viewId)
|
|
303
355
|
if (existing !== undefined) return existing
|
|
304
|
-
const
|
|
356
|
+
const client = await this.ensureClient()
|
|
357
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
|
|
305
358
|
const targetId = created?.targetId
|
|
306
359
|
if (typeof targetId !== 'string') throw new Error('dsh-builtin-browser: the browser did not create a page')
|
|
307
|
-
const attached = await
|
|
360
|
+
const attached = await client.send('Target.attachToTarget', { targetId, flatten: true }) as { sessionId?: string }
|
|
308
361
|
const sessionId = attached?.sessionId
|
|
309
362
|
if (typeof sessionId !== 'string') throw new Error('dsh-builtin-browser: the browser did not attach to the new page')
|
|
310
363
|
// The session id is what page commands ride; the target id is what closes it.
|