dsh-builtin-browser 0.4.5 → 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 CHANGED
@@ -4,6 +4,60 @@
4
4
 
5
5
  ---
6
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
+
7
61
  # 第四十一轮(2026-10-06,桌面端:会话串扰的真正根因,以及一次架构级改正)
8
62
 
9
63
  用户报的症状只有一句:**"还是其他窗口的侧栏"**。前几轮都在 owner 台账上加检查,这一轮做的第一件事是**不采信台账的立论,把它实测一遍**。
package/client.js CHANGED
@@ -617,15 +617,30 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
617
617
  return null
618
618
  }
619
619
 
620
- /** Every live `<webview>` guest inside this conversation's panel subtree. */
620
+ /**
621
+ * Every live `<webview>` guest inside this conversation's panel subtree.
622
+ *
623
+ * The walk up stops the moment an ancestor carries ANOTHER conversation's sidebar, and that stop
624
+ * condition is the whole point of this function. The shell mounts every conversation's panel in
625
+ * the same document — hidden ones included — so one step too far lands on a subtree that holds
626
+ * all of them, and the first guest found there belongs to whichever conversation the shell
627
+ * happens to render first. Measured: a GitHub login page opened in a different conversation was
628
+ * reported as this one's guest, which is the cross-conversation bleed this service exists to
629
+ * prevent. A sibling strip is the exact, class-name-independent signature of having stepped
630
+ * outside; a conversation may legitimately own more than one strip of its own, so the test is
631
+ * "is there one that is NOT mine", never a count.
632
+ */
621
633
  function guestsInPanel(sessionId) {
622
634
  const strip = tabStripFor(sessionId)
623
635
  if (strip === null) return []
624
- // Walk up to the first ancestor that owns a guest: the strip itself is outside the
625
- // body region, so the search starts low and stops as soon as it has one. It never
626
- // crosses out of this conversation's branch, which is what keeps it scoped.
636
+ // Start at the strip itself: it is by construction this conversation's own, so the foreign test
637
+ // below cannot trip on it, and a shell that renders the guest as a sibling of the strip is
638
+ // still reached.
627
639
  let node = strip
628
640
  while (node !== null && node !== undefined && node !== document.body) {
641
+ const foreign = Array.from(node.querySelectorAll("[class*=_tabStrip]"))
642
+ .some(el => sessionIdOfElement(el) !== sessionId)
643
+ if (foreign) break
629
644
  const found = Array.from(node.querySelectorAll("webview")).filter(view => {
630
645
  try { return typeof view.getWebContentsId === "function" && view.getWebContentsId() > 0 }
631
646
  catch (error) { return false }
@@ -238,17 +238,26 @@ async function sendCdp(id, method, params) {
238
238
  *
239
239
  * @param shellId - the shell window's webContents id.
240
240
  * @param sessionId - the conversation that asked (the request's `owner`).
241
- * @param url - address to load when a panel has to be opened; empty to only read.
241
+ * @param url - address to load when a panel has to be opened; `null` to only READ.
242
242
  * @returns the renderer's verdict, normalized so a malformed answer is a named failure.
243
243
  */
244
244
  async function evaluatePanelService(shellId, sessionId, url) {
245
+ // `null` means read-only. This distinction is not cosmetic: `openPanel` CREATES a tab when the
246
+ // conversation has none, so polling with it turned a 20-second wait into one empty tab every
247
+ // 250ms — the "it opens a pile of blank pages at once" report. A wait must never be a write.
248
+ const reading = url === null
249
+ const call = reading
250
+ // `panelGuest` reports state, not success, so the read form supplies the verdict itself: a
251
+ // conversation with no panel yet is a legitimate answer to "what do you have", not a failure.
252
+ ? `JSON.stringify({ ok: true, ...api.panelGuest(${JSON.stringify(String(sessionId))}) })`
253
+ : `JSON.stringify(api.openPanel(${JSON.stringify(String(sessionId))}, ${JSON.stringify(String(url ?? ''))}))`
245
254
  const expression = `(() => {
246
255
  const api = globalThis.__dshBuiltinBrowser;
247
256
  if (api === undefined || api === null || typeof api.openPanel !== 'function') {
248
257
  return JSON.stringify({ ok: false, reason: 'the plugin client half is not loaded in this window' });
249
258
  }
250
259
  try {
251
- return JSON.stringify(api.openPanel(${JSON.stringify(String(sessionId))}, ${JSON.stringify(String(url ?? ''))}));
260
+ return ${call};
252
261
  } catch (error) {
253
262
  return JSON.stringify({ ok: false, reason: String((error && error.message) || error) });
254
263
  }
@@ -361,7 +370,9 @@ async function handle(request) {
361
370
  }
362
371
 
363
372
  const ask = async (withUrl) => {
364
- const answer = await evaluatePanelService(shell.id, owner, withUrl ? url : '')
373
+ // `null` reads, a string places or navigates. Every round of the wait below uses the READ
374
+ // form, because a wait must never create anything.
375
+ const answer = await evaluatePanelService(shell.id, owner, withUrl ? url : null)
365
376
  if (answer.ok !== true) {
366
377
  // Carry the renderer's own findings into the failure: it is the side that can see why the
367
378
  // lookup missed, and a bare "not mounted" leaves the next reader guessing between a real
@@ -423,9 +434,9 @@ async function handle(request) {
423
434
  // human was looking at. Reported as issue #23; the other two carriers already did this
424
435
  // (the self-hosted window re-adds the view, the system browser calls Page.bringToFront).
425
436
  //
426
- // The tab belongs to the renderer, so the same reasoning as `ensureTabs` applies: drive the
427
- // sidebar's own control. A tab is matched by the title it shows, which is what the sidebar
428
- // renders from the guest — the guest's id is not exposed in the DOM.
437
+ // The tab strip belongs to the renderer, so raising one means asking the sidebar to do it. A
438
+ // tab is matched by the title it shows, which is what the sidebar renders from the guest —
439
+ // the guest's id is not exposed in the DOM.
429
440
  const viewId = Number(request.viewId)
430
441
  if (!Number.isFinite(viewId)) throw new Error('showTab needs a viewId')
431
442
  const guest = webContents.fromId(viewId)
@@ -42,3 +42,23 @@ export declare const Config: z<Config>;
42
42
  export declare function apply(ctx: Context & {
43
43
  browser: BrowserRuntime;
44
44
  }, config: Config): void;
45
+ /**
46
+ * Move a browser profile left behind by the old layout into the directory the plugin uses now.
47
+ *
48
+ * Before the `?.dsh` component was added here, a desktop launched from a shortcut (no `DSH_HOME`
49
+ * in the environment) put its browser profile in `<homedir>/dsh-builtin-browser-host/` while its
50
+ * settings and history lived in `<homedir>/.dsh/dsh-builtin-browser-host/`. Login state lives
51
+ * INSIDE the browser profile, so leaving the old directory behind silently signs the user out of
52
+ * everything they had signed into — reported after the fix shipped, as a one-time cost of it.
53
+ *
54
+ * That cost does not have to be paid. The move happens only when the new location is absent, so a
55
+ * profile in use is never overwritten, and every failure — a cross-device rename, a permission
56
+ * problem, a profile another process holds — falls through to an empty profile, which is exactly
57
+ * what would have happened without this function. The old directory is then left untouched rather
58
+ * than deleted, because a half-moved profile is worse than a stale one.
59
+ *
60
+ * @param profileRoot - the root the plugin resolves today (`<DSH_HOME>/dsh-builtin-browser-host`).
61
+ * @param legacyRoot - the pre-`DSH_HOME` layout's root. Injected so the move can be exercised
62
+ * without touching the real home directory.
63
+ */
64
+ export declare function adoptLegacyProfileRoot(profileRoot: string, legacyRoot?: string): void;
@@ -12,8 +12,9 @@
12
12
  import z from '@deepseek-ai/schemastery';
13
13
  import { ElectronBrowserProvider } from './provider.js';
14
14
  import { randomUUID } from 'node:crypto';
15
+ import { existsSync, mkdirSync, renameSync } from 'node:fs';
15
16
  import { homedir } from 'node:os';
16
- import { join } from 'node:path';
17
+ import { dirname, join } from 'node:path';
17
18
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
18
19
  import { DesktopBridgeViewHost } from './desktop-bridge-host.js';
19
20
  import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js';
@@ -195,6 +196,7 @@ export function apply(ctx, config) {
195
196
  // throwaway directory that is removed when the browser is released.
196
197
  const persist = settings.get().cookies.persist;
197
198
  const profileRoot = join(home, BROWSER_PROFILE_DIR);
199
+ adoptLegacyProfileRoot(profileRoot);
198
200
  const profileDir = persist
199
201
  ? join(profileRoot, `${detected.kind}-profile`)
200
202
  : join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()));
@@ -259,6 +261,40 @@ const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
259
261
  * root than the settings document is a pair that silently never meets.
260
262
  */
261
263
  const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host';
264
+ /**
265
+ * Move a browser profile left behind by the old layout into the directory the plugin uses now.
266
+ *
267
+ * Before the `?.dsh` component was added here, a desktop launched from a shortcut (no `DSH_HOME`
268
+ * in the environment) put its browser profile in `<homedir>/dsh-builtin-browser-host/` while its
269
+ * settings and history lived in `<homedir>/.dsh/dsh-builtin-browser-host/`. Login state lives
270
+ * INSIDE the browser profile, so leaving the old directory behind silently signs the user out of
271
+ * everything they had signed into — reported after the fix shipped, as a one-time cost of it.
272
+ *
273
+ * That cost does not have to be paid. The move happens only when the new location is absent, so a
274
+ * profile in use is never overwritten, and every failure — a cross-device rename, a permission
275
+ * problem, a profile another process holds — falls through to an empty profile, which is exactly
276
+ * what would have happened without this function. The old directory is then left untouched rather
277
+ * than deleted, because a half-moved profile is worse than a stale one.
278
+ *
279
+ * @param profileRoot - the root the plugin resolves today (`<DSH_HOME>/dsh-builtin-browser-host`).
280
+ * @param legacyRoot - the pre-`DSH_HOME` layout's root. Injected so the move can be exercised
281
+ * without touching the real home directory.
282
+ */
283
+ export function adoptLegacyProfileRoot(profileRoot, legacyRoot = join(homedir(), BROWSER_PROFILE_DIR)) {
284
+ try {
285
+ if (legacyRoot === profileRoot)
286
+ return;
287
+ if (!existsSync(legacyRoot) || existsSync(profileRoot))
288
+ return;
289
+ // `renameSync` needs the destination's PARENT to exist, and on a fresh install it does not:
290
+ // the old layout never created `.dsh` at all. Creating it is harmless if it is already there.
291
+ mkdirSync(dirname(profileRoot), { recursive: true });
292
+ renameSync(legacyRoot, profileRoot);
293
+ }
294
+ catch {
295
+ // Nothing here is worth failing an apply over; the fallback is the pre-existing behaviour.
296
+ }
297
+ }
262
298
  /**
263
299
  * Expose the settings document over the host's web server, which is how the
264
300
  * settings panel reads and writes it. A host without that service (headless /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
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",
@@ -17,8 +17,9 @@ import type { BrowserRuntime } from '../browser/runtime.js'
17
17
  import { ElectronBrowserProvider } from './provider.js'
18
18
  import type { ElectronBrowserViewHost } from './provider.js'
19
19
  import { randomUUID } from 'node:crypto'
20
+ import { existsSync, mkdirSync, renameSync } from 'node:fs'
20
21
  import { homedir } from 'node:os'
21
- import { join } from 'node:path'
22
+ import { dirname, join } from 'node:path'
22
23
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
23
24
  import { DesktopBridgeViewHost } from './desktop-bridge-host.js'
24
25
  import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js'
@@ -225,6 +226,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
225
226
  // throwaway directory that is removed when the browser is released.
226
227
  const persist = settings.get().cookies.persist
227
228
  const profileRoot = join(home, BROWSER_PROFILE_DIR)
229
+ adoptLegacyProfileRoot(profileRoot)
228
230
  const profileDir = persist
229
231
  ? join(profileRoot, `${detected.kind}-profile`)
230
232
  : join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()))
@@ -293,6 +295,41 @@ const SETTINGS_ROUTE = '/dsh-builtin-browser/settings'
293
295
  */
294
296
  const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host'
295
297
 
298
+ /**
299
+ * Move a browser profile left behind by the old layout into the directory the plugin uses now.
300
+ *
301
+ * Before the `?.dsh` component was added here, a desktop launched from a shortcut (no `DSH_HOME`
302
+ * in the environment) put its browser profile in `<homedir>/dsh-builtin-browser-host/` while its
303
+ * settings and history lived in `<homedir>/.dsh/dsh-builtin-browser-host/`. Login state lives
304
+ * INSIDE the browser profile, so leaving the old directory behind silently signs the user out of
305
+ * everything they had signed into — reported after the fix shipped, as a one-time cost of it.
306
+ *
307
+ * That cost does not have to be paid. The move happens only when the new location is absent, so a
308
+ * profile in use is never overwritten, and every failure — a cross-device rename, a permission
309
+ * problem, a profile another process holds — falls through to an empty profile, which is exactly
310
+ * what would have happened without this function. The old directory is then left untouched rather
311
+ * than deleted, because a half-moved profile is worse than a stale one.
312
+ *
313
+ * @param profileRoot - the root the plugin resolves today (`<DSH_HOME>/dsh-builtin-browser-host`).
314
+ * @param legacyRoot - the pre-`DSH_HOME` layout's root. Injected so the move can be exercised
315
+ * without touching the real home directory.
316
+ */
317
+ export function adoptLegacyProfileRoot(
318
+ profileRoot: string,
319
+ legacyRoot: string = join(homedir(), BROWSER_PROFILE_DIR),
320
+ ): void {
321
+ try {
322
+ if (legacyRoot === profileRoot) return
323
+ if (!existsSync(legacyRoot) || existsSync(profileRoot)) return
324
+ // `renameSync` needs the destination's PARENT to exist, and on a fresh install it does not:
325
+ // the old layout never created `.dsh` at all. Creating it is harmless if it is already there.
326
+ mkdirSync(dirname(profileRoot), { recursive: true })
327
+ renameSync(legacyRoot, profileRoot)
328
+ } catch {
329
+ // Nothing here is worth failing an apply over; the fallback is the pre-existing behaviour.
330
+ }
331
+ }
332
+
296
333
  /** The host web-server surface this plugin uses, described structurally. */
297
334
  interface WebServerHost {
298
335
  readonly webServer: {