dsh-builtin-browser 0.4.0 → 0.4.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
@@ -133,7 +133,7 @@ See the full list in [Tool reference](#tool-reference).
133
133
  <tr>
134
134
  <td width="50%" valign="top">
135
135
  <h3>Safety restriction</h3>
136
- <p><code>browser_restrict</code> limits which browser actions are allowed (allow-list) to prevent stray clicks/navigation; read-only tools (snapshot/content/screenshot) are never blocked.</p>
136
+ <p><code>browser_restrict</code> limits which browser actions are allowed (allow-list) to prevent stray clicks/navigation; read-only tools (snapshot / a11y / content / scrape / screenshot / get_value / challenge / list_tabs / session / history / visited / auth) plus the "lift the restriction / reset the session" tools are always exempt.</p>
137
137
  </td>
138
138
  <td width="50%" valign="top">
139
139
  <h3>Screenshot, save and read</h3>
@@ -153,7 +153,7 @@ See the full list in [Tool reference](#tool-reference).
153
153
  <tr>
154
154
  <td width="50%" valign="top">
155
155
  <h3>A "Browser" section in Settings</h3>
156
- <p><b>Which browser</b> (bundled Electron / installed Chrome / installed Edge / automatic), keep history / keep cookies / auto-expand the side panel / close the browser when a session ends / show the cursor / vision strategy / allow credential reads — switches take effect <b>immediately</b>, no restart.</p>
156
+ <p><b>Which browser</b> (bundled Electron / installed Chrome / installed Edge / automatic), keep history, auto-expand the side panel, close the browser when a session ends, show the cursor, vision strategy, allow credential reads — every one of these is <b>read fresh on each use</b>, so a change takes effect immediately with no restart. <b>Two exceptions</b>: <code>browser.channel</code> (which browser) and <code>cookies.persist</code> (keep cookies) are read once when the plugin is applied, so changing either needs a <b>plugin reload</b> (restart dsh / refresh the page).</p>
157
157
  </td>
158
158
  <td width="50%" valign="top">
159
159
  <h3>Unambiguous teardown</h3>
@@ -192,12 +192,12 @@ See the full list in [Tool reference](#tool-reference).
192
192
  | `browser_check` | Check/uncheck a checkbox or radio (`target`-located) | ✅ |
193
193
  | `browser_select` | Select an option of a `<select>` by value/text/index (`target`-located) | ✅ |
194
194
  | `browser_clear` | Clear an input/textarea/contenteditable, or uncheck (`target`-located) | ✅ |
195
- | `browser_get_value` | Read an element's current value for verification (`target`-located) | – |
196
- | `browser_scrape` | Structured extraction: container selector + field map (`selector@attr`), static CSS only, CSP-safe | – |
195
+ | `browser_get_value` | Read an element's current value for verification (`target`-located) | – exempt |
196
+ | `browser_scrape` | Structured extraction: container selector + field map (`selector@attr`), static CSS only, CSP-safe | – exempt |
197
197
  | `browser_screenshot` | Capture, optional `fullPage`, `savePath`, JPEG (`format`/`quality`) and scaling (`maxWidth`/`maxHeight`); `savePath` shares the download gate (confined to `downloadDir`, never overwrites) | – |
198
198
  | `browser_list_tabs` | List the session's tabs | – |
199
199
  | `browser_switch_tab` | Switch to a tab by id (also switches the visible view when self-hosted) | ✅ |
200
- | `browser_close_tab` | Close a tab by id; closing the active tab activates the next | – |
200
+ | `browser_close_tab` | Close a tab by id; closing the active tab activates the next | ✅ |
201
201
  | `browser_reset` | Close all tabs of this task, back to one blank tab | ✅ |
202
202
  | `browser_session` | Show this task's browser session and tabs | – |
203
203
  | `browser_reset_session` | Close and rebuild this task's browser session | ✅ |
@@ -208,7 +208,7 @@ See the full list in [Tool reference](#tool-reference).
208
208
  | `browser_challenge` | Detect a human-verification challenge (CAPTCHA / Cloudflare / reCAPTCHA / hCaptcha / Turnstile) | – |
209
209
  | `browser_restrict` | Restrict allowed browser actions (allow-list; empty list lifts it). **Soft guardrail** — the model can lift it itself; not a security boundary | – |
210
210
 
211
- > "Guard" column: ✅ actions are governed by the `browser_restrict` allow-list; read-only tools (snapshot/content/screenshot/list_tabs/session/challenge/history) are never blocked.
211
+ > "Guard" column: ✅ actions are governed by the `browser_restrict` allow-list; **rows marked "– exempt" are never blocked, whatever the allow-list says**, and **a plain "–" means the tool only observes — it performs no action, so there is nothing to restrict**. The exempt set is `READ_ONLY_TOOLS` (`src/tool-browser/index.ts`): `snapshot` / `a11y` / `content` / `scrape` / `screenshot` / `get_value` / `wait` / `challenge` / `list_tabs` / `session` / `history` / `visited` / `auth`, plus `restrict` (the restriction itself must stay liftable, or a task that restricted everything could never get out), `reset_session` and `reset`. `browser_close_tab` is **not** exempt: like `open`/`click`/`switch_tab` it can be restricted.
212
212
 
213
213
  ### Waiting for the page
214
214
 
@@ -292,7 +292,7 @@ node desktop-bridge/install.mjs --revert # roll back
292
292
  > - the agent can read the **cookies and login state** in that partition, and `browser_auth` can export them (controlled by a setting);
293
293
  > - your actions in the sidebar and the agent's actions act on **the same page** and can affect each other (the agent will not overwrite what you are typing, but navigation changes what you both see).
294
294
  >
295
- > That is the inherent cost of one shared page. We think it is worth it — it turns "the agent is doing something in a window you cannot see" into "you can watch it work and take over" — but you are entitled to know it exists, so there are switches: with **credential access** off the agent stops reading cookies and login state, and with the **vision strategy** set to non-visual any coordinate click that depends on a screenshot is refused. If you would rather not accept the boundary change at all, removing the plugin from the desktop profile returns you to the old separate-window shape.
295
+ > That is the inherent cost of one shared page. We think it is worth it — it turns "the agent is doing something in a window you cannot see" into "you can watch it work and take over" — but you are entitled to know it exists, so there are switches: with **credential access** off, `browser_auth` **refuses both export and restore** (`BROWSER_AUTH_DISABLED`) — not merely "stops reading", since restore needs the same switch; and with the **vision strategy** set to non-visual any coordinate click that depends on a screenshot is refused. If you would rather not accept the boundary change at all, removing the plugin from the desktop profile returns you to the old separate-window shape.
296
296
 
297
297
  **② Use the browser you already have (Chrome / Edge)**
298
298
 
@@ -312,7 +312,7 @@ The settings panel can point the plugin at an **installed Chrome or Edge** (`bro
312
312
 
313
313
  > The visible view and column layout always belong to the host shell; the plugin owns the seam, the provider and the tools. Across all carriers the **toolset, browsing history, settings panel, synthetic cursor and teardown rules are identical** — only the carrier of the page differs.
314
314
  >
315
- > **Precedence**: an explicitly chosen installed browser > the desktop sidebar > self-hosting. If the chosen browser is **missing or fails to start**, a warning is logged and the bundled browser is kept — it is **never silently swapped** for something else.
315
+ > **Precedence**: an explicitly chosen installed browser > the desktop sidebar > self-hosting. A missing carrier is handled two different ways: `automatic` means "any browser will do", so it **logs a warning and keeps the bundled one**; but when you **named a browser explicitly** and it is absent, the plugin no longer settles for a log line — it adopts a carrier whose only job is to explain, so **every command reports to the caller which browser was missing, the names and locations that were searched, and three ways out**. You are not left staring at an Electron error wondering what actually went wrong.
316
316
 
317
317
  ## Requirements
318
318
 
@@ -366,7 +366,7 @@ The plugin has one installation per host, and the two are updated separately —
366
366
 
367
367
  - JPEG screenshots work on the **self-hosted** carrier and with an **installed Chrome / Edge**. The **desktop sidebar** goes through the shell's `webContents.debugger`, whose Electron CDP JPEG encoder hangs, so a JPEG request on that carrier returns PNG. Downscaling (`maxWidth`/`maxHeight`) **works on all three** — through a CDP `clip.scale`, or the native `capturePage` when self-hosted.
368
368
  - Self-hosted captures prefer Electron's native `capturePage` (CDP `captureScreenshot` can hang with multiple views in the window); the target tab is raised before capturing.
369
- - `fullPage` capture is flaky under software compositing on some hosts — that affects the **self-hosted** carrier only (it captures through `capturePage`); the sidebar and an installed Chrome/Edge use CDP `captureBeyondViewport` and are unaffected.
369
+ - `fullPage` capture is flaky under software compositing on some hosts — with `fullPage` the native `capturePage` path is **skipped entirely** (`capturePage` cannot reach content beyond the viewport), so **all three carriers go through CDP `captureBeyondViewport`** and the flakiness is not carrier-specific: any carrier can hit it. Only viewport captures prefer the native `capturePage`.
370
370
  - CAPTCHA cannot be solved automatically: snapshots flag detected challenges; ask the human to complete it in the shared window instead of retrying.
371
371
  - Private mode (`privateMode`) is not implemented: it needs Electron session partitioning, which is host-layer territory; this plugin does not promise it.
372
372
  - `browser_download` fetches in the page context (keeps logins) and is subject to same-origin/CORS constraints; HTTP(S) targets only; `savePath` must be absolute and inside `downloadDir` (default: the system Downloads folder, auto-detecting `Downloads`/`下载`/`下載` and `XDG_DOWNLOAD_DIR`; override with `downloadDir`) and never replaces an existing file; `browser_screenshot`'s `savePath` goes through the same gate; single files are capped at 256 MB (streamed with a Content-Length early reject) and are written by the browser child itself (temp file + atomic rename).
@@ -374,7 +374,7 @@ The plugin has one installation per host, and the two are updated separately —
374
374
  - `browser_restrict` is a **soft guardrail** against accidental actions, not a security boundary: the model can lift it itself.
375
375
  - Popups (`window.open` / `target=_blank`) no longer overwrite the current view: HTTP(S) popups open as a **new tab** in the same session window, recorded in the session history, keeping the original page and its opener context alive. Non-HTTP(S) popups (empty-URL popup handoffs, `mailto:`, custom schemes) are still **allowed as native windows** and handed to the system — such windows are simply not part of the session model.
376
376
  - The `browser_auth` cookie round-trip does not preserve `hostOnly`/`sameSite` (host-only cookies come back as domain cookies). It **works on all three carriers**: self-hosted reads its own session, while the sidebar and an installed Chrome/Edge go through CDP's `Storage.getCookies` / `Storage.setCookies`.
377
- - After a self-hosted child crash (or a DSH restart that kills it) the browser host restarts automatically, and sessions opened before the crash **rebuild on their next use** — only page state is lost, no manual `browser_reset_session` needed (it still works for an explicit reset). A new view preloads `about:blank` (bounded 3 s) before creation so it always has a live renderer, host-side commands are bounded at 20 s, and the child's stderr plus exit code/signal are written to `$DSH_HOME/logs/dsh-builtin-browser-host.log` (2 MB self-truncating) so a plain `dsh web` self-hosted setup can diagnose a crash loop itself.
377
+ - After a self-hosted child crash (or a DSH restart that kills it) the browser host restarts automatically, and sessions opened before the crash **rebuild on their next use** — only page state is lost, no manual `browser_reset_session` needed (it still works for an explicit reset). A new view preloads `about:blank` (bounded 3 s) before creation so it always has a live renderer, and host-side commands are bounded at 20 s. The child's stderr plus exit code/signal go to `$DSH_HOME/logs/dsh-builtin-browser-host.log`; before appending, a file already over 2 MiB is **truncated: the old contents are discarded and replaced by a single timestamped rotation line** (`<ISO timestamp> log rotated: previous content exceeded 2097152 bytes and was discarded`). Nothing is kept from before the rotation — but that line records that it happened, so a plain `dsh web` self-hosted setup can still diagnose a crash loop itself.
378
378
  - The electron package ships with the plugin, but Electron 44+ no longer downloads its binary at install time (~100 MB, needs network) — the probe is filesystem-only and never triggers its lazy download, so a missing binary surfaces as a clear error on first use telling you to run `npx install-electron` first; alternatively pre-install a binary and point `ELECTRON_PATH` at it.
379
379
  - This plugin draws **no browser interface of its own** (no address bar, no tab strip, no side panel): on the desktop that interface is the **shell's own official sidebar**, and the plugin merely drives the page inside it. On the self-hosted carrier the window is drawn by the Electron child the plugin spawns — that is the carrier, not plugin UI. Do not treat "the sidebar" or "browser column" as a plugin feature.
380
380
  - **The sidebar carrier does not report user-action events**: a human clicking in that page is the shell's own event, and the bridge has no operation that reports it. Features relying on that event therefore do not fire on the desktop sidebar; they do on the self-hosted carrier.
@@ -409,7 +409,7 @@ Code layout:
409
409
  | 3 | 2026-08 | **browser-bridge parity + review fixes**: `browser_a11y` a11y tree; 6 form-control tools (`browser_set_value`/`check`/`select`/`clear`/`get_value`/`refresh`); semantic `target` (css/text/xpath); `browser_scrape` structured extraction; independent BrowserWindow + real toolbar (address bar, back/forward/reload, tab strip) routed back into the session model; tool count **20 → 33**; CI switched to npm (no lockfile → pnpm cache broken), README corrections |
410
410
  | 4 | 2026-08 | **DSH 0.1.1-rc.2 alignment + review fixes**: peer floor `^0.1.1-rc.2`; fixed `browser_type` dropping text with a target, `browser_key` Space missing CDP `text`, keyUp failure sticking a key, `browser_wait` same-origin URL mis-match, `.part` rename residue, `snapshotMaxElements`/`contentMaxChars` config wiring, missing type exports; 3 regression tests |
411
411
  | 5 | 2026-08 | **Electron 44 compatibility**: `available()` is now side-effect free (no more triggering Electron 44 lazy download); `flushAuth` cookie-domain build fix |
412
- | 6 | 2026-08 | **Windows handshake & tab lookup**: the Electron GUI process never receives piped stdin → RPC token now flows over **stdin + env var**; `browser_switch_tab`/`browser_close_tab` locate tabs across sessions (`locateTab`), `browser_close_tab` no longer fakes success, unknown ids error with the session's actual tab list |
412
+ | 6 | 2026-08 | **Windows handshake & tab lookup**: the Electron GUI process never receives piped stdin → RPC token now flows over **stdin + env var**; `browser_switch_tab`/`browser_close_tab` locate tabs (`locateTab`), `browser_close_tab` no longer fakes success, unknown ids error with the session's actual tab list. That round fell back to searching **every** session; it was tightened later — lookup is now **confined to the calling session** (a stale id must not close or switch another task's tab, or the human's), keeping only the convenience of accepting a bare uuid as well as `tab:<uuid>` |
413
413
  | 7 | 2026-08 | **Toolbar interaction (Windows focus routing)**: keyboard input only reaches the focused view and the page view grabbed it, so the address bar could not receive input → added `wireFocusRouting` (clicking a view focuses it) + window refocus restores the last-clicked view; verified with real OS input probes |
414
414
  | **0.1.16** | 2026-08-26 | **Release**: all seven rounds ship as **0.1.16** (build clean, 21/21 tests pass, `v0.1.16`) |
415
415
  | 8 | 2026-08-27 | **DSH Desktop host-Electron reuse**: running inside an Electron process reuses the host binary directly; when the host runs the plugin in a child Node process, walk the process ancestry to find the host's Electron (PowerShell CIM on Windows, last resort only) — **DSH Desktop works with zero install**; error now hints per active profile; electron shim completed to fix the CI typecheck; docs updated |
package/README.md CHANGED
@@ -133,7 +133,7 @@ node <本仓库路径>/desktop-bridge/install.mjs
133
133
  <tr>
134
134
  <td width="50%" valign="top">
135
135
  <h3>安全限制</h3>
136
- <p><code>browser_restrict</code> 限制允许的浏览器动作(白名单),防止 agent 误点、误导航;只读工具(snapshot/content/screenshot)不受限。</p>
136
+ <p><code>browser_restrict</code> 限制允许的浏览器动作(白名单),防止 agent 误点、误导航;只读工具(snapshot / a11y / content / scrape / screenshot / get_value / challenge / list_tabs / session / history / visited / auth)加上「解除限制 / 复位会话」几个永远豁免,不受白名单影响。</p>
137
137
  </td>
138
138
  <td width="50%" valign="top">
139
139
  <h3>截图即存即读</h3>
@@ -153,7 +153,7 @@ node <本仓库路径>/desktop-bridge/install.mjs
153
153
  <tr>
154
154
  <td width="50%" valign="top">
155
155
  <h3>设置页里的「浏览器」栏</h3>
156
- <p><b>用哪个浏览器</b>(内置 Electron / 本机 Chrome / 本机 Edge / 自动)、历史与 cookie 是否保留、侧栏是否自动展开、会话结束时是否关闭浏览器、是否显示光标、视觉策略、是否允许读取凭据 —— 开关<b>即时生效</b>,无需重启。</p>
156
+ <p><b>用哪个浏览器</b>(内置 Electron / 本机 Chrome / 本机 Edge / 自动)、历史是否保留、侧栏是否自动展开、会话结束时是否关闭浏览器、是否显示光标、视觉策略、是否允许读取凭据 —— 这些开关都是<b>每次使用时现读</b>,改完即时生效,无需重启。<b>只有两个例外</b>:<code>browser.channel</code>(用哪个浏览器)与 <code>cookies.persist</code>(是否保留 cookies)在插件挂载时读取一次,改完需要<b>重新加载插件</b>(重启 dsh / 刷新页面)才生效。</p>
157
157
  </td>
158
158
  <td width="50%" valign="top">
159
159
  <h3>收尾明确</h3>
@@ -192,12 +192,12 @@ node <本仓库路径>/desktop-bridge/install.mjs
192
192
  | `browser_check` | 勾选/取消勾选 checkbox 或 radio(按 `target` 定位) | ✅ |
193
193
  | `browser_select` | 选中 `<select>` 的某个选项(按值/文本/索引,按 `target` 定位) | ✅ |
194
194
  | `browser_clear` | 清空输入/文本域/contenteditable,或取消勾选(按 `target` 定位) | ✅ |
195
- | `browser_get_value` | 读取元素当前值(操作后验证用;按 `target` 定位) | – |
196
- | `browser_scrape` | 结构化提取:容器选择器 + 字段映射(`选择器[@属性]`),静态 CSS 查询、CSP 安全 | – |
195
+ | `browser_get_value` | 读取元素当前值(操作后验证用;按 `target` 定位) | – 豁免 |
196
+ | `browser_scrape` | 结构化提取:容器选择器 + 字段映射(`选择器[@属性]`),静态 CSS 查询、CSP 安全 | – 豁免 |
197
197
  | `browser_screenshot` | 截图,可选 `fullPage`、`savePath`、JPEG(`format`/`quality`)与缩放(`maxWidth`/`maxHeight`);`savePath` 与下载同一准入门(限定在 `downloadDir` 内、不覆盖已有文件) | – |
198
198
  | `browser_list_tabs` | 当前会话的标签列表 | – |
199
199
  | `browser_switch_tab` | 按 id 切换标签(自托管下同步切换可见视图) | ✅ |
200
- | `browser_close_tab` | 按 id 关闭标签;关闭活动标签后激活下一个 | – |
200
+ | `browser_close_tab` | 按 id 关闭标签;关闭活动标签后激活下一个 | ✅ |
201
201
  | `browser_reset` | 关闭本任务所有标签,回到一个空白标签 | ✅ |
202
202
  | `browser_session` | 查看本任务的浏览器会话与标签 | – |
203
203
  | `browser_reset_session` | 关闭并重建本任务的浏览器会话 | ✅ |
@@ -208,7 +208,7 @@ node <本仓库路径>/desktop-bridge/install.mjs
208
208
  | `browser_challenge` | 检测人机验证(CAPTCHA / Cloudflare / reCAPTCHA / hCaptcha / Turnstile) | – |
209
209
  | `browser_restrict` | 限制允许的浏览器动作(白名单;空列表解除)。**软护栏**,模型可自行解除,非安全边界 | – |
210
210
 
211
- > 「守卫」列:打 ✅ 的动作受 `browser_restrict` 白名单约束;只读工具(snapshot/content/screenshot/list_tabs/session/challenge/history)永不拦截。
211
+ > 「守卫」列:打 ✅ 的动作受 `browser_restrict` 白名单约束;**「– 豁免」的工具无论白名单怎么写都不拦截**;**「–」表示只观察、本来就不做动作,因此没有可拦的东西**。豁免集合来自 `READ_ONLY_TOOLS`(`src/tool-browser/index.ts`):`snapshot` / `a11y` / `content` / `scrape` / `screenshot` / `get_value` / `wait` / `challenge` / `list_tabs` / `session` / `history` / `visited` / `auth`,外加 `restrict`(白名单本身必须能解除,否则一旦限制到空就再也出不来)、`reset_session`、`reset`(从崩死状态恢复)。`browser_close_tab` **不在**豁免集合里,与 `open`/`click`/`switch_tab` 一样可被限制。
212
212
 
213
213
  ### 等待页面就绪
214
214
 
@@ -292,7 +292,7 @@ node desktop-bridge/install.mjs --revert # 回滚
292
292
  > - Agent 能读到该 partition 中的 **Cookie / 登录态**,`browser_auth` 可将其导出(由设置项控制);
293
293
  > - 反过来,你在侧栏里的操作与 Agent 的操作**作用于同一个页面**,可能互相影响(Agent 不会主动覆盖你的输入,但导航会改变双方看到的内容)。
294
294
  >
295
- > 这是"人机同页"的必然代价。我们认为值得(它把"Agent 在一个你看不见的窗口里操作"变成"你能看着它操作并随时接手"),但你有权知道它存在 —— 因此也提供了开关:**「凭据访问」关闭后 Agent 不再读取 Cookie/登录态**;**「视觉策略」设为纯非视觉后,任何依赖截图定位的坐标点击都会被拒绝**。不想接受这个边界变化时,把 desktop profile 里的插件移除即可回到"独立窗口"的旧形态。
295
+ > 这是"人机同页"的必然代价。我们认为值得(它把"Agent 在一个你看不见的窗口里操作"变成"你能看着它操作并随时接手"),但你有权知道它存在 —— 因此也提供了开关:**「凭据访问」关闭后 <code>browser_auth</code> 的导出与恢复都会被拒绝(<code>BROWSER_AUTH_DISABLED</code>)—— 不只是"不再读取":恢复走的是同一个开关**;**「视觉策略」设为纯非视觉后,任何依赖截图定位的坐标点击都会被拒绝**。不想接受这个边界变化时,把 desktop profile 里的插件移除即可回到"独立窗口"的旧形态。
296
296
 
297
297
  **② 用你自己的浏览器(Chrome / Edge)**
298
298
 
@@ -312,7 +312,7 @@ node desktop-bridge/install.mjs --revert # 回滚
312
312
 
313
313
  > 可见视图与列布局始终属于宿主外壳;插件只负责 seam、provider 与工具。各形态下**工具集、浏览历史、设置栏、可视化鼠标、收尾语义完全一致**,差别只在页面由谁承载。
314
314
  >
315
- > **优先级**:设置里显式选择的本机浏览器 > 桌面端侧栏 > 自托管。选定的浏览器**没装或起不来**时会记一条警告并继续用内置浏览器 —— **不会静默换成别的**。
315
+ > **优先级**:设置里显式选择的本机浏览器 > 桌面端侧栏 > 自托管。载体缺失时分两种处理:`自动` 的语义就是"哪个都行",找不到 Chrome/Edge 时**记一条警告并继续用内置**;**明确选了某个浏览器**而它没装时不再只写日志 —— 插件改挂一个只会解释原因的载体,**每次命令都向调用方报出找不到的是哪个浏览器、查过哪些名字与位置,以及三条出路**,不会让你对着一条关于 Electron 的错误去猜真正的原因。
316
316
 
317
317
  ## 环境要求
318
318
 
@@ -366,7 +366,7 @@ node desktop-bridge/install.mjs --revert # 回滚
366
366
 
367
367
  - JPEG 截图在**自托管**与**本机 Chrome / Edge** 上可用;**桌面端侧栏**走外壳的 `webContents.debugger`,其 Electron 的 CDP JPEG 编码会挂起,因此该载体下请求 JPEG 会返回 PNG。降采样(`maxWidth`/`maxHeight`)**三种载体都支持**(经 CDP `clip.scale`,或用自托管的原生 `capturePage`)。
368
368
  - 自托管截图优先走 Electron 原生 `capturePage`(CDP `captureScreenshot` 在多视图下会挂起);截图前自动把目标标签置顶。
369
- - `fullPage` 截图在部分主机的**软件合成**下不稳定 —— 这只影响**自托管**载体(它走 `capturePage`);侧栏与本机浏览器走 CDP 的 `captureBeyondViewport`,不受此影响。
369
+ - `fullPage` 截图在部分主机的**软件合成**下不稳定 —— 请求 `fullPage` 时**原生 `capturePage` 路径被整体跳过**(`capturePage` 没有捕获滚动区以外内容的能力),因此**三种载体都走 CDP 的 `captureBeyondViewport`**,`fullPage` 的不稳定性与载体无关,任何载体都可能碰到;只有视口截图才优先走原生的 `capturePage`。
370
370
  - 人机验证(CAPTCHA)无法自动解决:快照会标注检测到的挑战,此时应请用户在共享窗口中人工完成,而不是反复重试。
371
371
  - 无痕模式(`privateMode`)未实现:它需要 Electron 的 session 分区能力,属于宿主层,本插件不承诺。
372
372
  - `browser_download` 在页面上下文内 `fetch`(带登录态),受同源/CORS 约束;仅允许 HTTP(S) 目标;`savePath` 必须为绝对路径且位于 `downloadDir` 内(默认系统下载目录,自动识别 `Downloads`/`下载`/`下載` 与 `XDG_DOWNLOAD_DIR`,可用 `downloadDir` 覆盖),不覆盖已存在文件;`browser_screenshot` 的 `savePath` 走同一准入门;单文件上限 256MB(流式限流,按 Content-Length 提前拒绝),文件由浏览器子进程直接落盘(临时文件 + 原子改名)。
@@ -374,7 +374,7 @@ node desktop-bridge/install.mjs --revert # 回滚
374
374
  - `browser_restrict` 是防误操作的**软护栏**,不是安全边界:模型可以自行解除白名单。
375
375
  - 页面弹窗(`window.open` / `target=_blank`)不再覆盖当前视图:HTTP(S) 弹窗会在同一会话窗口**新开一个标签页**并计入历史,原页面与 opener 上下文保留;非 HTTP(S) 弹窗(空 URL 弹窗承接、`mailto:`、自定义协议)仍**放行原生窗口**,交给系统处理——这类弹窗不纳入会话模型。
376
376
  - `browser_auth` 的 cookie 往返不保留 `hostOnly`/`sameSite` 字段(host-only cookie 恢复后变成 domain cookie)。**三种载体都可用**:自托管走原生会话,侧栏与本机浏览器走 CDP 的 `Storage.getCookies` / `Storage.setCookies`。
377
- - 自托管浏览器子进程崩溃(或宿主 DSH 重启)后会自动重启;崩溃前已打开的会话在**下一次调用时自动重建**——仅页面状态丢失,无需手动 `browser_reset_session`。`browser_reset_session` 仍可用于主动重置。新视图创建前会先有界加载 `about:blank`(保证视图一存在就有可响应的渲染进程),宿主侧命令另有 20s 有界超时;子进程 stderr 与退出码/信号落到 `$DSH_HOME/logs/dsh-builtin-browser-host.log`,超过 2 MiB 时**轮转并保留一行时间戳标记**(不再整体清空 —— 那样会把几周的历史一起抹掉),纯 `dsh web` 自托管可据此自助排查崩溃循环。
377
+ - 自托管浏览器子进程崩溃(或宿主 DSH 重启)后会自动重启;崩溃前已打开的会话在**下一次调用时自动重建**——仅页面状态丢失,无需手动 `browser_reset_session`。`browser_reset_session` 仍可用于主动重置。新视图创建前会先有界加载 `about:blank`(保证视图一存在就有可响应的渲染进程),宿主侧命令另有 20s 有界超时;子进程 stderr 与退出码/信号落到 `$DSH_HOME/logs/dsh-builtin-browser-host.log`,写之前若该文件已超过 2 MiB,则**丢弃旧内容、只写入一行带时间戳的轮转记录**(形如 `<ISO 时间戳> log rotated: previous content exceeded 2097152 bytes and was discarded`)—— 旧内容就此消失,不留副本,但那一行证明"曾发生过轮转",纯 `dsh web` 自托管可据此自助排查崩溃循环。
378
378
  - electron 随插件安装;但 Electron 44+ 不再随安装下载二进制(约 100MB,需网络)——插件探测是纯文件系统、不触发其懒下载,二进制缺失时首次使用会报错并提示先 `npx install-electron`;也可预装 `ELECTRON_PATH` 指定的二进制。
379
379
  - 本插件不提供任何**浏览器界面**(地址栏、标签条、侧栏面板都不是插件画的):桌面端的浏览器界面是**外壳自带的官方侧栏**,我们只是借它的页面来驱动;自托管载体下画窗口的是插件拉起的那个 Electron 子进程,那是载体本身而非插件 UI。别把"侧栏"或"浏览器列"当成插件能力。
380
380
  - **侧栏载体不上报"用户操作"事件**:人在那个页面里点击是外壳自己的事件,而 bridge 没有用于回报它的操作。因此依赖该事件的功能(如自定义的接管提示)在桌面端侧栏下不会触发;换到自托管载体则可以。
@@ -411,7 +411,7 @@ npm run build
411
411
  | 第三轮 | 2026-08 | **对标 browser-bridge 的功能 + 审查修复**:`browser_a11y` 无障碍树;表单控件 6 件套(`browser_set_value`/`check`/`select`/`clear`/`get_value`/`refresh`);语义定位 `target`(css/text/xpath);`browser_scrape` 结构化提取;独立 BrowserWindow + 真实工具栏(地址栏/后退/前进/刷新/标签条),工具栏操作路由回会话模型;工具总数 **20 → 33**;CI 改 npm(无 lockfile 不兼容 pnpm cache)、README 修正等审查项 |
412
412
  | 第四轮 | 2026-08 | **DSH 0.1.1-rc.2 对齐 + 复查修复**:peer 下限对齐 `^0.1.1-rc.2`;修复 `browser_type` 带 target 丢文本、`browser_key` 空格缺 CDP `text`、keyUp 失败卡键、`browser_wait` URL 同源误匹配、download `.part` rename 残留、`snapshotMaxElements`/`contentMaxChars` 配置接线、导出类型补齐;新增 3 个回归测试 |
413
413
  | 第五轮 | 2026-08 | **Electron 44 兼容**:`available()` 改为无副作用探测(不再触发 Electron 44 懒下载);`flushAuth` cookie-domain 构建错误修复 |
414
- | 第六轮 | 2026-08 | **Windows 握手与标签定位**:Electron GUI 进程收不到 piped stdin → RPC token 改 **stdin + 环境变量双通道**;`browser_switch_tab`/`browser_close_tab` 跨会话按 id 定位(`locateTab`),`browser_close_tab` 不再静默假成功,未知 id 报错附带现有标签列表 |
414
+ | 第六轮 | 2026-08 | **Windows 握手与标签定位**:Electron GUI 进程收不到 piped stdin → RPC token 改 **stdin + 环境变量双通道**;`browser_switch_tab`/`browser_close_tab` 定位标签(`locateTab`),`browser_close_tab` 不再静默假成功,未知 id 报错附带现有标签列表。本轮当时的实现会**跨会话**兜底查找,后续已收紧:**查找范围只限调用方会话**(陈旧 id 不得关掉别的任务或人的标签页),仅保留"接受裸 uuid 与 `tab:<uuid>` 两种写法"这一便利 |
415
415
  | 第七轮 | 2026-08 | **工具栏交互(Windows 焦点路由)**:键盘输入只进有焦点的 view,页面 view 抢占焦点导致地址栏无法输入 → 新增 `wireFocusRouting`(点击即聚焦该 view)+ 窗口 refocus 恢复上次点击的 view;真机 OS 输入探针验证 |
416
416
  | **0.1.16** | 2026-08-26 | **发布**:以上七轮全部随 **0.1.16** 发布(构建零错误、21 项测试全绿,`v0.1.16`) |
417
417
  | 第八轮 | 2026-08-27 | **DSH Desktop 宿主 Electron 复用**:插件运行在 Electron 进程内直接复用宿主二进制;插件跑在宿主子 Node 进程时沿进程祖先树找到宿主 Electron 兜底(Windows 用 PowerShell CIM,仅最后手段)——DSH Desktop **零安装开箱可用**;报错按当前 profile 动态提示;补齐 electron shim 修复 CI 类型检查;文档同步 |
package/client.js CHANGED
@@ -62,7 +62,7 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
62
62
  "browser.edge": "本机 Edge",
63
63
  "browser.auto": "自动(优先 Chrome,其次 Edge)",
64
64
  "browser.hint": "选本机浏览器时会以**独立 profile** 启动它:不会打开、占用或修改你日常的窗口、书签与登录状态,关闭也不会关掉你自己的浏览器;代价是它看不到你日常浏览器里已登录的站点,需要时请在那个窗口里登录一次(登录态会留在插件自己的 profile 里)。桌面端默认由官方侧栏承载页面,此选项优先于侧栏。",
65
- "credentials.hint": "关闭后 browser_auth 会被拒绝。桌面端请注意:Agent 驱动的是侧栏里那一个页面(人机同页),因此它与该页面共用同一个 partition —— 这也正是它能读到登录态的原因,属于有意接受的沙箱边界变化;关闭本项即拒绝读取。",
65
+ "credentials.hint": "关闭后 browser_auth 的导出与恢复都会被拒绝(BROWSER_AUTH_DISABLED)—— 不只是导出:恢复走的是同一个开关。桌面端请注意:Agent 驱动的是侧栏里那一个页面(人机同页),因此它与该页面共用同一个 partition —— 这也正是它能读到登录态的原因,属于有意接受的沙箱边界变化;关闭本项即拒绝读取。",
66
66
  }
67
67
 
68
68
  const en = {
@@ -101,8 +101,7 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
101
101
  "browser.edge": "Installed Edge",
102
102
  "browser.auto": "Automatic (Chrome first, then Edge)",
103
103
  "browser.hint": "Choosing an installed browser launches it with a **separate profile**: your everyday windows, bookmarks and logins are never opened, locked or modified, and closing the plugin never closes your browser. The trade-off is that it does not see sites you are already signed into there — sign in once in that window and the session stays in the plugin's own profile. On the desktop the shell's sidebar normally carries the page; this setting outranks it.",
104
- "credentials.allowRead": "Allow the agent to read cookies / export login state",
105
- "credentials.hint": "Off makes browser_auth refuse. On the desktop, note that the agent drives the sidebar's own page (one page for both parties), so it shares that page's partition — which is exactly why it can reach the login state. That is a deliberate sandbox-boundary change; this switch is how you refuse it.",
104
+ "credentials.hint": "Off makes browser_auth refuse both export and restore (BROWSER_AUTH_DISABLED) — not just export: restore goes through the same switch. On the desktop, note that the agent drives the sidebar's own page (one page for both parties), so it shares that page's partition — which is exactly why it can reach the login state. That is a deliberate sandbox-boundary change; this switch is how you refuse it.",
106
105
  }
107
106
 
108
107
  /** Read or patch the host-owned settings document. */
@@ -61,7 +61,7 @@ RemoteElectronViewHost ──TCP JSON-RPC──▶ host-main.js
61
61
  - **协议**:本机 loopback TCP,每行一个 JSON(`{ id, op, ... }` ↔ `{ id, ok, result|err }`);
62
62
  - **认证**:每次 spawn 生成随机 token,经 **stdin 首行 + `DSH_BROWSER_RPC_TOKEN` 环境变量双通道**传给子进程(绝不进 argv,避免进程列表泄露;Windows GUI 进程收不到 piped stdin,环境变量兜底),子进程首条消息必须回传该 token(`hello`);服务端只接受**一个**连接,其余连接直接断开——本机其他进程无法伪冒子进程或注入回复;
63
63
  - **Electron 定位**:① `ELECTRON_PATH`(显式覆盖,优先于一切自动发现)→ ② 随插件安装的 electron 包(纯文件系统探测,含 pnpm store 布局,不触发 44+ 懒下载)→ ③ 锚点与 pnpm store 中**版本最新**者(33.x 有合成器缺陷,建议 ≥ 40)→ ④ 当前进程是**裸** Electron(dev 模式)时复用宿主二进制(`process.execPath`)→ ⑤ 进程祖先树中的裸 Electron 二进制(Windows 用 PowerShell CIM 查询,仅在其它路径全部落空时才执行)。**打包应用不参与复用**:旁有 `resources/app.asar` 的可执行文件(如 DSH Desktop.exe)不能按脚本参数拉起——spawn 会启动应用本体并秒退(单实例锁,即 "browser host exited (code=0)"),一律跳过;
64
- - **稳健性**:子进程/套接字都有 `error` 监听(否则未捕获事件会炸掉整个 DSH 进程);子进程退出自动重启,已物化的视图句柄在下次调用时**自动重建并重试一次**(会话跨崩溃存活,仅页面状态丢失,不再出现 "browser host is not running" 僵尸态);物化失败可重试;下载仅限 HTTP(S)、`savePath` 必须绝对路径(默认限定 `~/Downloads`,可配置 `downloadDir` 覆盖)、流式限流 + Content-Length 提前拒绝、256MB 上限与 60s 超时,文件由**子进程直接落盘**(临时文件 + rename,不再经 RPC 传 base64);cookie 导出/恢复有 30s 超时;
64
+ - **稳健性**:子进程/套接字都有 `error` 监听(否则未捕获事件会炸掉整个 DSH 进程);子进程退出自动重启,已物化的视图句柄在下次调用时**自动重建并重试一次**(会话跨崩溃存活,仅页面状态丢失,不再出现 "browser host is not running" 僵尸态);物化失败可重试;下载仅限 HTTP(S)、`savePath` 必须绝对路径(`downloadDir` 已配置时用它;未配置时按序探测 —— 存在的 `XDG_DOWNLOAD_DIR` → home 下第一个存在的 `Downloads`/`下载`/`下載` → 回退 `~/Downloads`,首次使用时创建)、流式限流 + Content-Length 提前拒绝、256MB 上限与 60s 超时,文件由**子进程直接落盘**(临时文件 + rename,不再经 RPC 传 base64);cookie 导出/恢复有 30s 超时,且两者都受设置里 `credentials.allowRead` 门控 —— 关闭时抛 `BROWSER_AUTH_DISABLED`(导出**与恢复**都拒绝),有原生方法时优先用它,否则走 CDP 的 `Storage.getCookies` / `Storage.setCookies`(三种载体都可用);
65
65
  - **视图可见性**:多标签/多会话时,`showView` 隐藏其他视图并把目标视图置顶(remove+re-add),确保用户看到的是活动标签;目标是当前可见视图时**跳过** remove/re-add(否则每次操作都闪烁);窗口标题实时显示当前可见会话的任务标识 + 页面标题/URL(`showView` 携带会话 label,子进程读 `getTitle`/`getURL`);窗口 resize 时视图 bounds 自动跟随;
66
66
  - **孤儿防护**:父进程断开时子进程自动退出,不留僵尸窗口;
67
67
  - **cookie 落盘**:子进程使用独立 userData 目录(`<DSH_HOME>/dsh-builtin-browser-host`),登录态跨重启保留(另有 `browser_auth` 手动导出/恢复)。
@@ -1,6 +1,6 @@
1
1
  # 工具参考
2
2
 
3
- 全部 34 个 `browser_*` 工具。守卫列:✅ 表示该动作受 `browser_restrict` 白名单约束;只读工具永不拦截。
3
+ 全部 34 个 `browser_*` 工具。守卫列:✅ 表示该动作受 `browser_restrict` 白名单约束;**「– 豁免」表示无论白名单怎么写都不拦截**(豁免集合为 `READ_ONLY_TOOLS`,`src/tool-browser/index.ts` 导出:**只观察的工具** —— `snapshot`/`a11y`/`content`/`scrape`/`screenshot`/`get_value`/`wait`/`challenge`/`list_tabs`/`session`/`history`/`visited`/`auth` —— 加上 **解除限制 / 从坏状态恢复的工具** `restrict`/`reset_session`/`reset`;`restrict` 在其中,否则限制到空的任务再也解不开);**「–」表示该工具只观察、不做动作**,故没有可拦的东西。两者之外的工具都受白名单约束 —— 例如 `browser_close_tab` 不属于豁免集合。
4
4
 
5
5
  ## 页面与导航
6
6
 
@@ -30,16 +30,16 @@
30
30
  | `browser_check` | `target`(必填), `checked?` | `{ checked }` | ✅ | 勾选/取消勾选 checkbox 或 radio(按 target 定位) |
31
31
  | `browser_select` | `target`(必填), `optionValue?`/`optionText?`/`optionIndex?`(三选一) | `{ value, text }` | ✅ | 选中 `<select>` 的某个选项(按 target 定位) |
32
32
  | `browser_clear` | `target`(必填) | `{ cleared }` | ✅ | 清空输入/文本域/contenteditable,或取消勾选 checkbox/radio |
33
- | `browser_get_value` | `target`(必填) | `{ value?, checked?, selectedText? }` | – | 读取元素当前值,用于操作后验证 |
34
- | `browser_scrape` | `item`(必填), `fields`(必填,映射), `timeoutMs?` | `{ count, items[] }` | – | 结构化提取:容器选择器 + 字段映射(如 `{"title": "h3", "url": "a@href"}`);静态 CSS 查询、不执行任意代码、CSP 安全;等待 item 出现(默认 5s) |
33
+ | `browser_get_value` | `target`(必填) | `{ value?, checked?, selectedText? }` | – 豁免 | 读取元素当前值,用于操作后验证 |
34
+ | `browser_scrape` | `item`(必填), `fields`(必填,映射), `timeoutMs?` | `{ count, items[] }` | – 豁免 | 结构化提取:容器选择器 + 字段映射(如 `{"title": "h3", "url": "a@href"}`);静态 CSS 查询、不执行任意代码、CSP 安全;等待 item 出现(默认 5s) |
35
35
 
36
36
  ## 标签与会话
37
37
 
38
38
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
39
39
  | --- | --- | --- | --- | --- |
40
- | `browser_list_tabs` | – | `{ session, tabs[] }` | – | 当前会话的标签列表 |
40
+ | `browser_list_tabs` | – | `{ tabs: [{ id, url, active }] }` | – | 当前会话的标签列表(输出 schema 只有 `tabs`,**没有 `session` 字段**;会话标识请用 `browser_session`) |
41
41
  | `browser_switch_tab` | `tabId`(必填) | `{ switched }` | ✅ | 按 id 切换标签;自托管下同步切换可见视图 |
42
- | `browser_close_tab` | `tabId`(必填) | `{ closed }` | – | 关闭标签;关闭活动标签后激活下一个 |
42
+ | `browser_close_tab` | `tabId`(必填) | `{ closed }` | ✅ | 关闭标签;关闭活动标签后激活下一个。**不属于只读豁免集合,受白名单约束**;`tabId` 只在本会话内查找(接受 `tab:<uuid>` 或裸 uuid),陈旧 id 不会关掉别的任务的标签页 |
43
43
  | `browser_reset` | – | `{ reset }` | ✅ | 关闭本任务所有标签,回到一个空白标签 |
44
44
  | `browser_session` | – | `{ session, tabs[] }` | – | 查看本任务的浏览器会话与标签 |
45
45
  | `browser_reset_session` | – | `{ reset }` | ✅ | 关闭并重建本任务的浏览器会话(崩溃/卡死后恢复) |
@@ -57,14 +57,14 @@
57
57
 
58
58
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
59
59
  | --- | --- | --- | --- | --- |
60
- | `browser_auth` | `action`(flush/restore,必填), `cookies?` | `{ cookies[]? / restored? }` | ✅ | 导出/恢复 cookie(自托管可用);flush 返回 cookie 列表,restore 带列表写回 |
61
- | `browser_restrict` | `allowed?` | `{ restrictedTo[] }` | – | 设置动作白名单;空列表解除;未知工具名报错。**软护栏**——模型可自行解除,非安全边界 |
60
+ | `browser_auth` | `action`(flush/restore,必填), `cookies?` | `{ cookies[]? / restored? }` | ✅ | 导出/恢复 cookie —— **三种载体都可用**:自托管走原生会话,桌面侧栏与本机 Chrome/Edge 走 CDP 的 `Storage.getCookies` / `Storage.setCookies`;flush 返回 cookie 列表,restore 带列表写回。设置里「允许读取 cookies / 导出登录状态」关闭时,flush 与 restore **都**抛 `BROWSER_AUTH_DISABLED` |
61
+ | `browser_restrict` | `allowed?` | `{ restrictedTo[] }` | – | 设置动作白名单;空列表解除;**只校验名字是否以 `browser_` 开头**(写错的前缀会报错,`browser_typo` 这种拼错但前缀合法的名字会被接受、等同于拦掉该名字,不额外报错);守卫按名字匹配,因此白名单里列什么名字就只放行什么名字。**软护栏**——模型可自行解除,非安全边界 |
62
62
 
63
63
  ## 截图
64
64
 
65
65
  | 工具 | 参数 | 输出 | 守卫 | 说明 |
66
66
  | --- | --- | --- | --- | --- |
67
- | `browser_screenshot` | `fullPage?`, `savePath?`, `format?`(png/jpeg), `quality?`, `maxWidth?`, `maxHeight?` | `{ dataUrl, path? }` | – | 截图;PNG 默认,JPEG 仅自托管原生路径;`maxWidth`/`maxHeight` 等比缩放降低视觉模型开销;`savePath` 落盘供视觉模型读取(与 `browser_download` 同一准入门:必须位于 `downloadDir` 内且不覆盖已有文件) |
67
+ | `browser_screenshot` | `fullPage?`, `savePath?`, `format?`(png/jpeg), `quality?`, `maxWidth?`, `maxHeight?` | `{ dataUrl, path? }` | – | 截图;PNG 默认,**JPEG 在自托管与本机 Chrome/Edge 上都可用**(自托管在原生 `capturePage` 路径编码,本机浏览器经 CDP 声明 `supportsCdpJpeg`;**桌面侧栏**的 Electron CDP JPEG 编码器会挂起,该载体下 JPEG 请求返回 PNG);`maxWidth`/`maxHeight` 等比缩放**三种载体都支持**;`fullPage` 会跳过原生 `capturePage`、三种载体统一走 CDP `captureBeyondViewport`;`savePath` 落盘供视觉模型读取(与 `browser_download` 同一准入门:必须位于 `downloadDir` 内且不覆盖已有文件) |
68
68
 
69
69
  ## 常用组合
70
70
 
@@ -17,10 +17,12 @@ dsh plugin --profile web add dsh-builtin-browser
17
17
  dsh plugin --profile web add <本仓库路径>
18
18
  ```
19
19
 
20
- 安装会链接插件、把 `dsh-builtin-browser` 加入 profile 的 bundle 层,并挂载三行:
20
+ 安装会链接插件、把 `dsh-builtin-browser` 加入 profile 的 bundle 层,并挂载四行(一个惰性的根行 + 三个功能行):
21
21
 
22
22
  | 行 | 子路径 | 角色 |
23
23
  | --- | --- | --- |
24
+ | `dsh-builtin-browser` | 包名本身 | **惰性根行**:不注册任何东西(空 `apply()`),只用来声明包名 —— 宿主的客户端插件扫描靠精确包名读到 `dsh.client`,少了这行设置栏不会出现 |
25
+ | --- | --- | --- |
24
26
  | `browser` | `dsh-builtin-browser/browser` | `ctx.browser` 能力 seam(始终挂载) |
25
27
  | `browser-electron` | `dsh-builtin-browser/browser-electron` | Electron CDP provider |
26
28
  | `tool-browser` | `dsh-builtin-browser/tool-browser` | `browser_*` 模型侧工具 |
@@ -31,7 +33,7 @@ dsh plugin --profile web add <本仓库路径>
31
33
 
32
34
  | 行 | 配置项 | 类型 | 默认 | 说明 |
33
35
  | --- | --- | --- | --- | --- |
34
- | `browser-electron` | `viewHost` | 对象 | 必填 | 宿主提供的 `ElectronBrowserViewHost`(通常 `!!js ctx.get('electronViewHost')`) |
36
+ | `browser-electron` | `viewHost` | 对象 | 可选 | 宿主提供的 `ElectronBrowserViewHost`(通常 `!!js ctx.get('electronViewHost')`)。**不传时插件自己选载体**:桌面端驱动官方侧栏、否则自托管;设置里显式选择的 Chrome/Edge 优先于两者 |
35
37
  | `browser-electron` | `httpOnly` | 布尔 | `true` | 仅允许 HTTP(S) 导航;`file:`/`data:` 等拒绝 |
36
38
  | `browser-electron` | `snapshotMaxElements` | 数字 | `60` | 快照最多收录的交互元素数 |
37
39
  | `browser-electron` | `contentMaxChars` | 数字 | `100000` | 内容抓取默认字符上限 |
@@ -33,7 +33,7 @@ LLM agent 需要"上网"。传统做法是给 agent 一个**无头浏览器**(Pl
33
33
  ## 设计取舍
34
34
 
35
35
  - **登录态共享**:所有任务共享同一份 cookie(符合"用户登录一次,agent 到处可用");需要隔离时用 `browser_auth` 手动导出/恢复。
36
- - **任务级会话隔离**:每个 DSH 会话(任务)拥有独立的浏览器会话(标签页与历史),并发任务互不抢页面;但窗口只有一个,当前活动的任务视图可见。
36
+ - **任务级会话隔离**:每个 DSH 会话(任务)拥有独立的浏览器会话(标签页与历史),并发任务互不抢页面;自托管载体下**每个会话连窗口都各自一份**(窗口按会话 id 分组,窗口标题带任务标识),侧栏与本机浏览器载体则是同一个宿主里切换当前可见视图。
37
37
  - **装好即用**:不依赖桌面外壳。有外壳时嵌入外壳视图;纯 `dsh web` 时插件自托管——自己拉起 Electron 窗口,通过本机 TCP JSON-RPC 驱动。
38
38
  - **边界清晰**:可见视图、浏览器列布局属于宿主外壳;插件只负责 seam、provider 与工具,不含任何 UI。
39
39
  - **人机验证不硬刚**:检测到 Cloudflare/reCAPTCHA/hCaptcha/Turnstile 时停下,请用户在共享窗口人工完成,而不是盲目重试。
@@ -206,6 +206,18 @@ class CdpClient {
206
206
  * own deadline is only checked between polls and this await happens inside one of them.
207
207
  * @returns when the handshake completed.
208
208
  */
209
+ /**
210
+ * Whether the socket is still usable.
211
+ *
212
+ * A dropped connection is not reported to the caller of send(); the pending command
213
+ * would simply never complete. Asking the socket directly is what lets a cached client
214
+ * be discarded instead of reused, which is the difference between a restart and a 30s
215
+ * hang on every subsequent call.
216
+ * @returns true while the socket is open.
217
+ */
218
+ isAlive() {
219
+ return this.socket.readyState === WebSocket.OPEN;
220
+ }
209
221
  async whenReady() {
210
222
  const timeoutMs = 30_000;
211
223
  let timer;
@@ -230,6 +242,11 @@ class CdpClient {
230
242
  * @returns the CDP result.
231
243
  */
232
244
  async send(method, params, sessionId) {
245
+ // Fail fast and say why. Waiting out the timeout on a closed socket produced
246
+ // only "timed out", which reads as a slow page and hides the real cause.
247
+ if (!this.isAlive()) {
248
+ throw new Error('dsh-builtin-browser: the browser connection is closed (the browser window was probably closed); the next command will start it again');
249
+ }
233
250
  await this.ready;
234
251
  const id = this.nextId++;
235
252
  const message = { id, method, ...params !== undefined ? { params } : {}, ...sessionId !== undefined ? { sessionId } : {} };
@@ -326,8 +343,15 @@ export class SystemBrowserViewHost {
326
343
  // nobody owns and nobody will kill.
327
344
  if (this.disposed)
328
345
  throw new Error('dsh-builtin-browser: this browser host was released');
329
- if (this.client !== undefined)
330
- return this.client;
346
+ // The exit listener clears this, but a socket can also die without the process doing so
347
+ // (the browser closing its debugging endpoint, a dropped connection). Checking the
348
+ // socket's own state costs nothing and turns a 30s hang into a restart.
349
+ if (this.client !== undefined) {
350
+ if (this.client.isAlive())
351
+ return this.client;
352
+ this.client.close();
353
+ this.client = undefined;
354
+ }
331
355
  // Concurrent first calls share one startup rather than racing two browsers.
332
356
  this.starting ??= this.start().finally(() => { this.starting = undefined; });
333
357
  return await this.starting;
@@ -364,6 +388,17 @@ export class SystemBrowserViewHost {
364
388
  */
365
389
  let launchError;
366
390
  child.on('error', error => { launchError = error; });
391
+ // A browser that exits later is as unusable as one that never started, and the failure
392
+ // has to reach the NEXT call rather than being swallowed: clearing the client here is
393
+ // what makes ensureClient() start a fresh browser instead of returning a socket to a
394
+ // dead process and letting every command time out.
395
+ child.on('exit', () => {
396
+ if (this.child !== child)
397
+ return;
398
+ this.client?.close();
399
+ this.client = undefined;
400
+ this.child = undefined;
401
+ });
367
402
  const portFile = join(this.profileDir, 'DevToolsActivePort');
368
403
  const deadline = Date.now() + 30_000;
369
404
  /**
@@ -462,10 +497,21 @@ export class SystemBrowserViewHost {
462
497
  // provider may pass format and quality through instead of falling back to PNG.
463
498
  supportsCdpJpeg: true,
464
499
  sendCommand: async (method, params) => {
465
- const client = await this.ensureClient();
466
- const session = this.views.get(viewId) ?? await this.ensureSession(viewId);
467
- const result = await client.send(method, params, session);
468
- return (result ?? {});
500
+ // A command that never answered is the only signal that a client has wedged — the
501
+ // socket can stay OPEN while nothing comes back, which is the form the reporter saw with
502
+ // a live, healthy-looking Edge. Dropping the client here means the next call starts a new
503
+ // browser rather than hanging again, and the error names the cause instead of a timeout.
504
+ try {
505
+ const client = await this.ensureClient();
506
+ const session = this.views.get(viewId) ?? await this.ensureSession(viewId);
507
+ const result = await client.send(method, params, session);
508
+ return (result ?? {});
509
+ }
510
+ catch (error) {
511
+ this.client?.close();
512
+ this.client = undefined;
513
+ throw error;
514
+ }
469
515
  },
470
516
  };
471
517
  }
@@ -1619,7 +1619,7 @@ export function apply(ctx, config = {}) {
1619
1619
  }));
1620
1620
  ctx.tools.register(defineTool({
1621
1621
  name: 'browser_auth',
1622
- description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on the self-hosted browser. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
1622
+ description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on all three carriers: the self-hosted browser reads its own session, while the desktop sidebar and an installed Chrome/Edge go through CDP. Refused entirely when the credential switch is off. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
1623
1623
  parameters: {
1624
1624
  action: { type: 'string', required: true, enum: ['flush', 'restore'], description: 'flush = export cookies; restore = import cookies.' },
1625
1625
  cookies: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Cookie list to restore (required when action=restore).' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.4.0",
3
+ "version": "0.4.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",
@@ -227,6 +227,19 @@ class CdpClient {
227
227
  * own deadline is only checked between polls and this await happens inside one of them.
228
228
  * @returns when the handshake completed.
229
229
  */
230
+ /**
231
+ * Whether the socket is still usable.
232
+ *
233
+ * A dropped connection is not reported to the caller of send(); the pending command
234
+ * would simply never complete. Asking the socket directly is what lets a cached client
235
+ * be discarded instead of reused, which is the difference between a restart and a 30s
236
+ * hang on every subsequent call.
237
+ * @returns true while the socket is open.
238
+ */
239
+ isAlive(): boolean {
240
+ return this.socket.readyState === WebSocket.OPEN
241
+ }
242
+
230
243
  async whenReady(): Promise<void> {
231
244
  const timeoutMs = 30_000
232
245
  let timer: NodeJS.Timeout | undefined
@@ -253,6 +266,11 @@ class CdpClient {
253
266
  * @returns the CDP result.
254
267
  */
255
268
  async send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<unknown> {
269
+ // Fail fast and say why. Waiting out the timeout on a closed socket produced
270
+ // only "timed out", which reads as a slow page and hides the real cause.
271
+ if (!this.isAlive()) {
272
+ throw new Error('dsh-builtin-browser: the browser connection is closed (the browser window was probably closed); the next command will start it again')
273
+ }
256
274
  await this.ready
257
275
  const id = this.nextId++
258
276
  const message = { id, method, ...params !== undefined ? { params } : {}, ...sessionId !== undefined ? { sessionId } : {} }
@@ -338,7 +356,14 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
338
356
  // A disposed host must never start a browser: doing so would spawn a process
339
357
  // nobody owns and nobody will kill.
340
358
  if (this.disposed) throw new Error('dsh-builtin-browser: this browser host was released')
341
- if (this.client !== undefined) return this.client
359
+ // The exit listener clears this, but a socket can also die without the process doing so
360
+ // (the browser closing its debugging endpoint, a dropped connection). Checking the
361
+ // socket's own state costs nothing and turns a 30s hang into a restart.
362
+ if (this.client !== undefined) {
363
+ if (this.client.isAlive()) return this.client
364
+ this.client.close()
365
+ this.client = undefined
366
+ }
342
367
  // Concurrent first calls share one startup rather than racing two browsers.
343
368
  this.starting ??= this.start().finally(() => { this.starting = undefined })
344
369
  return await this.starting
@@ -376,6 +401,16 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
376
401
  */
377
402
  let launchError: Error | undefined
378
403
  child.on('error', error => { launchError = error })
404
+ // A browser that exits later is as unusable as one that never started, and the failure
405
+ // has to reach the NEXT call rather than being swallowed: clearing the client here is
406
+ // what makes ensureClient() start a fresh browser instead of returning a socket to a
407
+ // dead process and letting every command time out.
408
+ child.on('exit', () => {
409
+ if (this.child !== child) return
410
+ this.client?.close()
411
+ this.client = undefined
412
+ this.child = undefined
413
+ })
379
414
  const portFile = join(this.profileDir, 'DevToolsActivePort')
380
415
  const deadline = Date.now() + 30_000
381
416
  /**
@@ -450,10 +485,20 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
450
485
  // provider may pass format and quality through instead of falling back to PNG.
451
486
  supportsCdpJpeg: true,
452
487
  sendCommand: async (method: string, params?: Record<string, unknown>) => {
488
+ // A command that never answered is the only signal that a client has wedged — the
489
+ // socket can stay OPEN while nothing comes back, which is the form the reporter saw with
490
+ // a live, healthy-looking Edge. Dropping the client here means the next call starts a new
491
+ // browser rather than hanging again, and the error names the cause instead of a timeout.
492
+ try {
453
493
  const client = await this.ensureClient()
454
494
  const session = this.views.get(viewId) ?? await this.ensureSession(viewId)
455
495
  const result = await client.send(method, params, session)
456
496
  return (result ?? {}) as Record<string, unknown>
497
+ } catch (error) {
498
+ this.client?.close()
499
+ this.client = undefined
500
+ throw error
501
+ }
457
502
  },
458
503
  }
459
504
  }
@@ -1662,7 +1662,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1662
1662
 
1663
1663
  ctx.tools.register(defineTool({
1664
1664
  name: 'browser_auth',
1665
- description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on the self-hosted browser. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
1665
+ description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on all three carriers: the self-hosted browser reads its own session, while the desktop sidebar and an installed Chrome/Edge go through CDP. Refused entirely when the credential switch is off. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
1666
1666
  parameters: {
1667
1667
  action: { type: 'string', required: true, enum: ['flush', 'restore'], description: 'flush = export cookies; restore = import cookies.' },
1668
1668
  cookies: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Cookie list to restore (required when action=restore).' },