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 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 33 model-facing `browser_*` tools, maintaining one browser session per calling task (DSH session).
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
- There are three carriers, and the plugin picks one automatically — **no configuration**:
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.1.1-rc.2` (peer range `^0.1.1-rc.2`) |
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.1.21` |
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
- **自托管浏览器就是一台真正的浏览器**:每个任务(DSH 会话)拥有**独立的浏览器窗口**,窗口自带完整工具栏——地址栏、后退/前进/刷新按钮、标签条(新建/切换/关闭标签)。人可以直接像用 Chrome 一样使用它:在地址栏输入网址(自动补 `https://`)、点标签切换页面、开新标签;键盘焦点跟随点击——**点地址栏即可输入、点页面即可操作**(Windows 焦点路由,修复了点击不转移焦点导致地址栏无法输入的问题)。agent 与人的操作都汇入**同一个会话模型**(同一套标签、历史与导航),窗口标题实时显示当前任务标识与页面标题/URL,窗口缩放时视图自动跟随。任务结束后窗口随会话自动关闭。
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.1.1-rc.2`(peer 声明 `^0.1.1-rc.2`) |
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
- | dsh-builtin-browser | `0.1.21` |
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
- void (async () => {
94
- const channel = settings.get().browser.channel;
95
- if (channel === 'bundled')
96
- return;
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
- const home = process.env.DSH_HOME ?? homedir();
103
- // Login state lives in the browser profile, so the cookies setting decides where
104
- // that profile goes: a stable directory keeps the user signed in across restarts
105
- // (the point of using their own browser), while turning persistence off gets a
106
- // throwaway directory that is removed when the browser is released.
107
- const persist = settings.get().cookies.persist;
108
- const profileDir = persist
109
- ? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
110
- : join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`);
111
- const host = await SystemBrowserViewHost.launch(detected, profileDir, [], persist ? undefined : profileDir);
112
- if (host === undefined) {
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
- adopt(host, `driving the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
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 child;
50
- private readonly client;
51
- readonly kind: DetectedBrowser['kind'];
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
- * @param child - the launched browser process.
57
- * @param client - the CDP connection to it.
58
- * @param kind - which product this is (for diagnostics).
59
- * @param ephemeralDir - a throwaway profile to remove on release, when the user
60
- * has turned persistence off and no login state should outlive the session.
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 constructor();
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 browser is released, used
68
- * when persistence is switched off so no login state outlives the session.
69
- * @returns the host, or undefined when the browser refuses to come up.
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
- /** Close the browser we launched. The user's own windows are a different process. */
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
- child;
159
- client;
160
- kind;
158
+ browser;
159
+ profileDir;
160
+ extraArgs;
161
161
  ephemeralDir;
162
162
  views = new Map();
163
163
  disposed = false;
164
164
  /**
165
- * @param child - the launched browser process.
166
- * @param client - the CDP connection to it.
167
- * @param kind - which product this is (for diagnostics).
168
- * @param ephemeralDir - a throwaway profile to remove on release, when the user
169
- * has turned persistence off and no login state should outlive the session.
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
- constructor(child, client, kind, ephemeralDir) {
172
- this.child = child;
173
- this.client = client;
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 browser is released, used
182
- * when persistence is switched off so no login state outlives the session.
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
- static async launch(browser, profileDir, extraArgs = [], ephemeralDir) {
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
- const portFile = join(profileDir, 'DevToolsActivePort');
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
- return undefined;
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
- return new SystemBrowserViewHost(child, client, browser.kind, ephemeralDir);
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
- /** Whether this host can back views: the CDP connection is live. */
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 this.client.send(method, params, session);
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 (targetId !== undefined) {
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.kill('SIGCONT');
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
- /** Close the browser we launched. The user's own windows are a different process. */
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.close();
337
+ this.client?.close();
288
338
  try {
289
- this.child.kill();
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 created = await this.client.send('Target.createTarget', { url: 'about:blank' });
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 this.client.send('Target.attachToTarget', { targetId, flatten: true });
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.0",
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
- void (async () => {
129
- const channel = settings.get().browser.channel
130
- if (channel === 'bundled') return
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
- return
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
- const home = process.env.DSH_HOME ?? homedir()
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 child - the launched browser process.
168
- * @param client - the CDP connection to it.
169
- * @param kind - which product this is (for diagnostics).
170
- * @param ephemeralDir - a throwaway profile to remove on release, when the user
171
- * has turned persistence off and no login state should outlive the session.
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
- private constructor(
174
- private readonly child: ChildProcess,
175
- private readonly client: CdpClient,
176
- readonly kind: DetectedBrowser['kind'],
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
- * @param browser - the detected installation to launch.
182
- * @param profileDir - a plugin-owned directory; the user's own profile is never touched.
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
- static async launch(browser: DetectedBrowser, profileDir: string, extraArgs: readonly string[] = [], ephemeralDir?: string): Promise<SystemBrowserViewHost | undefined> {
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
- const portFile = join(profileDir, 'DevToolsActivePort')
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(); return undefined }
207
- if (child.exitCode !== null) return undefined
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
- return new SystemBrowserViewHost(child, client, browser.kind, ephemeralDir)
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
- /** Whether this host can back views: the CDP connection is live. */
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 this.client.send(method, params, session)
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 (targetId !== undefined) {
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.kill('SIGCONT') } catch { /* not supported on Windows; harmless */ }
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
- /** Close the browser we launched. The user's own windows are a different process. */
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.close()
291
- try { this.child.kill() } catch { /* already gone */ }
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 created = await this.client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
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 this.client.send('Target.attachToTarget', { targetId, flatten: true }) as { sessionId?: string }
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.