dsh-builtin-browser 0.4.1 → 0.4.2

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.
Files changed (44) hide show
  1. package/README.en.md +61 -23
  2. package/README.md +59 -23
  3. package/client.js +60 -5
  4. package/cordis.patch.yml +21 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +12 -4
  6. package/docs/architecture.md +12 -2
  7. package/docs/tool-reference.md +12 -12
  8. package/docs/user-guide.md +10 -3
  9. package/docs/why-browser.md +1 -1
  10. package/lib/browser/types.d.ts +25 -1
  11. package/lib/browser-electron/bridge-connection.d.ts +31 -5
  12. package/lib/browser-electron/bridge-connection.js +143 -21
  13. package/lib/browser-electron/entry.d.ts +1 -1
  14. package/lib/browser-electron/entry.js +61 -35
  15. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  16. package/lib/browser-electron/ephemeral-profile.js +146 -0
  17. package/lib/browser-electron/history-store.d.ts +25 -0
  18. package/lib/browser-electron/history-store.js +51 -4
  19. package/lib/browser-electron/host-main.js +75 -27
  20. package/lib/browser-electron/provider.d.ts +44 -1
  21. package/lib/browser-electron/provider.js +379 -48
  22. package/lib/browser-electron/remote-host.d.ts +52 -3
  23. package/lib/browser-electron/remote-host.js +244 -23
  24. package/lib/browser-electron/settings-route.d.ts +28 -0
  25. package/lib/browser-electron/settings-route.js +28 -0
  26. package/lib/browser-electron/settings-store.d.ts +40 -0
  27. package/lib/browser-electron/settings-store.js +107 -11
  28. package/lib/browser-electron/system-browser.d.ts +71 -3
  29. package/lib/browser-electron/system-browser.js +311 -40
  30. package/lib/tool-browser/index.js +142 -33
  31. package/package.json +5 -3
  32. package/scripts/verify-host-compat.mjs +195 -0
  33. package/src/browser/types.ts +25 -1
  34. package/src/browser-electron/bridge-connection.ts +142 -21
  35. package/src/browser-electron/entry.ts +58 -33
  36. package/src/browser-electron/ephemeral-profile.ts +152 -0
  37. package/src/browser-electron/history-store.ts +51 -4
  38. package/src/browser-electron/host-main.ts +105 -35
  39. package/src/browser-electron/provider.ts +401 -51
  40. package/src/browser-electron/remote-host.ts +261 -36
  41. package/src/browser-electron/settings-route.ts +46 -0
  42. package/src/browser-electron/settings-store.ts +121 -11
  43. package/src/browser-electron/system-browser.ts +302 -26
  44. package/src/tool-browser/index.ts +143 -35
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 / 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>
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 / wait / challenge / list_tabs / session / history / visited) 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, 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>
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). The section also carries a group of <b>"What the agent may do"</b> switches — <b>run scripts in the page</b>, <b>download files to disk</b>, <b>write login state</b>. They are not the same thing as <code>browser_restrict</code>: that one is the model's own soft guardrail and the model can lift it whenever it likes, while these live in the settings document, which <b>no tool can write</b> — the model can only be refused.</p>
157
157
  </td>
158
158
  <td width="50%" valign="top">
159
159
  <h3>Unambiguous teardown</h3>
@@ -175,9 +175,9 @@ See the full list in [Tool reference](#tool-reference).
175
175
  | --- | --- | --- |
176
176
  | `browser_open` | Open a URL (optionally in a new tab); returns a page snapshot | ✅ |
177
177
  | `browser_wait` | Wait for page load (optional expected URL / CSS selector), returns readiness | – |
178
- | `browser_snapshot` | Numbered inventory of interactive elements (inputs/buttons/links; pierces same-origin iframes and Shadow DOM) | – |
179
- | `browser_a11y` | Accessibility tree: semantic role/name/value/states + coordinates per interactive node (pierces same-origin iframes and Shadow DOM) | – |
180
- | `browser_execute` | Run JS in the page; args arrive as `arguments[0..n]` | ✅ |
178
+ | `browser_snapshot` | Numbered inventory of interactive elements (inputs/buttons/links), each line carrying a `{#id}` / `{[name=x]}` selector when one is derivable (pierces same-origin iframes and Shadow DOM) | – |
179
+ | `browser_a11y` | Accessibility tree: semantic role/name/value/states per interactive node, plus a `{#id}` / `{[name=x]}` selector when derivable; coordinates only on request (`coords: true`, off by default, pierces same-origin iframes and Shadow DOM) | – |
180
+ | `browser_execute` | Run JS in the page; args arrive as `arguments[0..n]`. Governed by the "Run scripts in the page" switch — off refuses it outright (`BROWSER_EXECUTE_DISABLED`) | ✅ |
181
181
  | `browser_visited` | Read the persistent browsing history (visited pages, filterable by domain / capped); reopen with `browser_open` | – |
182
182
  | `browser_content` | Fetch the page as html / markdown / txt / json (selector, maxChars, timeoutMs) | – |
183
183
  | `browser_click` | Click a semantic target (`target`: css/text/xpath, scrolls into view and clicks center) or viewport coordinates (vision-located) | ✅ |
@@ -198,17 +198,17 @@ See the full list in [Tool reference](#tool-reference).
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
200
  | `browser_close_tab` | Close a tab by id; closing the active tab activates the next | ✅ |
201
- | `browser_reset` | Close all tabs of this task, back to one blank tab | ✅ |
201
+ | `browser_reset` | Close all tabs of this task, back to one blank tab | – exempt |
202
202
  | `browser_session` | Show this task's browser session and tabs | – |
203
- | `browser_reset_session` | Close and rebuild this task's browser session | ✅ |
203
+ | `browser_reset_session` | Close and rebuild this task's browser session | – exempt |
204
204
  | `browser_history` | Operation log (newest last), with per-step success/error and result summary | – |
205
205
  | `browser_replay` | Replay one step by sequence number (navigate/execute/click/type) | ✅ |
206
- | `browser_download` | Download an HTTP(S) URL with session cookies to a local file (absolute `savePath` inside `downloadDir`, never overwrites, 256 MB cap) | ✅ |
207
- | `browser_auth` | Export/restore cookies (login persistence; **works on all three carriers** — self-hosted reads its own session, the sidebar and an installed browser go through CDP) | ✅ |
206
+ | `browser_download` | Download an HTTP(S) URL with session cookies to a local file (absolute `savePath` inside `downloadDir`, never overwrites, 256 MB cap). Governed by the "Download files to disk" switch (`BROWSER_DOWNLOAD_DISABLED`) | ✅ |
207
+ | `browser_auth` | Export/restore cookies (login persistence; **works on all three carriers** — self-hosted reads its own session, the sidebar and an installed browser go through CDP). Export is governed by the Credentials switch (`BROWSER_AUTH_DISABLED`) and writing by "Write login state" (`BROWSER_AUTH_WRITE_DISABLED`); the two are independent | ✅ |
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; **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.
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`, 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; neither is `browser_auth`, whose `restore` writes cookies to arbitrary domains — an action, not an observation.
212
212
 
213
213
  ### Waiting for the page
214
214
 
@@ -217,7 +217,7 @@ See the full list in [Tool reference](#tool-reference).
217
217
 
218
218
  ### Semantic targets and the a11y tree
219
219
 
220
- - **`browser_a11y` is the best way to understand a page**: every interactive node carries its semantic role (button/textbox/checkbox…), accessible name, current value, states (enabled/checked/expanded…) and coordinates — click/type them directly.
220
+ - **`browser_a11y` is the best way to understand a page**: every interactive node carries its semantic role (button/textbox/checkbox…), accessible name, current value and states (checked/expanded/required…) — it explains *what this is and what it does* better than a numbered snapshot. **150 nodes by default** (`maxNodes` 10-5000; ask for more explicitly), and **coordinates are no longer included by default** (pass `coords: true` when you actually need a pixel to click). Only **non-default** states are printed — no `states=` means the node is enabled. A node's `name` is exactly what `browser_click`/`browser_type` match when you target by `text`.
221
221
  - **`browser_click`/`browser_type` accept a `target`**: `{by: css|text|xpath, value, index?}` — `text` matches an element's own visible text (exact first, then contains, deepest preferred); clicks scroll the element to the viewport center first; typing focuses it first.
222
222
  - **Use the single-control tools for one field** (`browser_set_value`/`browser_check`/`browser_select`/`browser_clear`/`browser_get_value`), `browser_fill` for batches, and `browser_scrape` for structured list extraction.
223
223
 
@@ -229,6 +229,18 @@ See the full list in [Tool reference](#tool-reference).
229
229
  - **Verify before clicking**: use `document.elementFromPoint(x, y)` to confirm the coordinate hits the intended element (button/link), then perform the real click.
230
230
  - **DPR awareness**: CDP input uses CSS pixels; on high-DPI screens calibrate with `elementFromPoint` instead of guessing coordinates.
231
231
 
232
+ ### Output size and truncation
233
+
234
+ Tool output is spent out of the caller's context, so several results have explicit bounds — and a truncation is always stated:
235
+
236
+ - **Snapshots and the a11y tree hand you a selector**: when an element has an `id` or a `name`, its line ends with `{#id}` / `{[name=x]}`, usable directly as `browser_click`/`browser_type`'s `target {by: "css", value: "#id"}` — no guessing at text from an accessible name. Elements with neither carry no selector segment: the tool does not invent one it cannot derive.
237
+ - **`browser_content` caps are per format (defaults)**: **html 50 000, json 50 000, txt 20 000, markdown 20 000 characters**; override per call with `maxChars`. When the result is truncated, the notice says what to do about it; `selector` narrows the fetch to one region first.
238
+ - **`browser_content` with `json` returns the element's markup**: `{"html": "<element outerHTML>", "tag": "<tag name>"}`, not an empty object — a DOM node has no own enumerable properties, so `JSON.stringify` on one yields `{}` for every element.
239
+ - **`browser_execute` caps its return value at 50 000 characters**, and states the cut in the result ("truncated at 50 000 of N") so a shortened value is never silently short.
240
+ - **`browser_scrape` returns compact JSON** (no indentation) with the item count in front — indentation carries nothing a model can use and measured over a tenth of the output.
241
+ - **`browser_history` shows the last 20 operations and omits each operation's parameters** (they are what you just sent), leading with the total so a tail is not mistaken for the whole; pass `verbose: true` when you actually want the parameters.
242
+ - **`browser_visited` timestamps are minute-granular**, and the session tag prints only when it changes, shortened to 8 characters — not a full session id per row.
243
+
232
244
  ## Configuration
233
245
 
234
246
  The plugin mounts through `cordis.patch.yml` (**four rows**): one **inert root row** (it exists only to name the package, because the host's client-module scan resolves a row's specifier to a package root — a row named after a subpath is skipped, and then the settings panel would never appear) plus three functional rows (`browser` / `browser-electron` / `tool-browser`). Per-row config:
@@ -238,7 +250,7 @@ The plugin mounts through `cordis.patch.yml` (**four rows**): one **inert root r
238
250
  | `browser-electron` | `viewHost` | object | optional | `ElectronBrowserViewHost` instance supplied by the host (typically `!!js ctx.get('electronViewHost')`). **When it is absent the plugin picks the carrier itself** — the desktop sidebar if that is where it runs, otherwise self-hosting; a browser chosen in settings outranks both |
239
251
  | `browser-electron` | `httpOnly` | boolean | `true` | Allow HTTP(S) navigation only; other protocols (e.g. `file:`/`data:`) rejected (`BROWSER_NAVIGATION_BLOCKED`) |
240
252
  | `browser-electron` | `snapshotMaxElements` | number | `60` | Max snapshot elements before truncation |
241
- | `browser-electron` | `contentMaxChars` | number | `100000` | Default content character cap |
253
+ | `browser-electron` | `contentMaxChars` | number | **no longer read (dead)** | The old "default content character cap". **Nothing reads it now, so changing it changes nothing** — the caps are fixed per format: html 50 000, json 50 000, txt 20 000, markdown 20 000, overridable per call with `maxChars`. The key is still accepted so an existing config file is not rejected for an unknown key |
242
254
  | `browser-electron` | `downloadDir` | string | system Downloads folder (`Downloads`/`下载`/`下載`, or `XDG_DOWNLOAD_DIR`, auto-detected) | Confine `browser_download` AND `browser_screenshot` save paths to this directory, never overwriting an existing file (stops a prompt-injected agent writing or replacing arbitrary paths); override for a sandbox dir |
243
255
  | `tool-browser` | `timeoutMs` | number | `60000` | Cooperative tool timeout (ms) |
244
256
  | `tool-browser` | `tabTools` | boolean | `true` | Register tab-management tools (`browser_list_tabs` etc.) |
@@ -292,7 +304,7 @@ node desktop-bridge/install.mjs --revert # roll back
292
304
  > - the agent can read the **cookies and login state** in that partition, and `browser_auth` can export them (controlled by a setting);
293
305
  > - 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
306
  >
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.
307
+ > 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 the export** (`BROWSER_AUTH_DISABLED`) — it governs reading only, while writing login state (restore) answers to its own switch and reports `BROWSER_AUTH_WRITE_DISABLED`; 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
308
 
297
309
  **② Use the browser you already have (Chrome / Edge)**
298
310
 
@@ -300,10 +312,12 @@ The settings panel can point the plugin at an **installed Chrome or Edge** (`bro
300
312
 
301
313
  **Your data is not touched**: the plugin launches it with a **separate profile** (`$DSH_HOME/dsh-builtin-browser-host/<chrome|edge>-profile`). Your everyday windows, bookmarks and logins are never opened, locked or modified, and closing the plugin never closes your browser.
302
314
 
315
+ **It comes back usable after it dies**: this carrier watches its child's exit and drops the **stale session mappings** along with the cached connection, so the restarted browser can actually be driven instead of answering every command with "Session not found". Before relaunching it kills the old process and **waits up to 3 seconds** for it to really exit (on Windows `kill` is asynchronous, and spawning immediately let the replacement hand its command line to the old instance and exit — which was then reported as "check that the path is a runnable browser"), and it **deletes the previous launch's `DevToolsActivePort` and trusts only a file written after this spawn** (otherwise it connects to whatever else holds that port — another Chromium, or a `node --inspect` — and publishes a client it does not own and can never kill). Conversely, **a slow page is not a dead browser**: a command **timeout** only marks the connection suspect, and the next call probes it once with `Browser.getVersion` before deciding to drop it; only a **closed socket** restarts the browser. One slow page therefore cannot make the plugin kill the window you are using, tabs and half-filled forms included.
316
+
303
317
  **What happens to login state**:
304
318
 
305
319
  - `cookies.persist` **on** (default) → that fixed profile is kept, so **you stay signed in across DSH restarts**, and `browser_auth` can still export/restore its cookies.
306
- - `cookies.persist` **off** → a **throwaway profile** each time, deleted when the browser is released; no login trace is left behind.
320
+ - `cookies.persist` **off** → a **throwaway profile** each time, deleted when the browser is released; no login trace is left behind. A process killed outright never reaches that release path, so the next start **sweeps** the throwaway profiles an earlier run left behind — only those whose owner is gone AND which have not been written to for an hour, so the profile an orphaned browser is still working in is left alone.
307
321
  - The trade-off, stated plainly: a separate profile **does not see** the sites you are signed into in your everyday browser. Sign in once in the window the plugin opens and the session stays in its own profile.
308
322
 
309
323
  **③ A shell that provides `electronViewHost`** (older desktop shells): that view is used directly.
@@ -326,15 +340,29 @@ The settings panel can point the plugin at an **installed Chrome or Edge** (`bro
326
340
 
327
341
  | Component | Version |
328
342
  | --- | --- |
329
- | DeepSeek Harness (dsh) | `0.2.0-rc.2` (peer range `>=0.1.1-rc.2 <0.3.0`) |
343
+ | DeepSeek Harness (dsh) | `0.2.1-alpha.1` (peer range `>=0.1.1-rc.2 <0.3.0`; `0.2.0-rc.2` and `0.1.x` were verified earlier) |
330
344
  | Electron | `44.0.0` (≥ 40 recommended; 33.x has a compositor defect) |
331
345
  | Node.js | `22.20.0` |
332
346
  | Installed Chrome / Edge (optional carriers) | `154.0.8037.58` / `154.0.4258.37` |
333
- | dsh-builtin-browser | `0.4.0` |
347
+ | dsh-builtin-browser | `0.4.1` (the development branch is past that release) |
334
348
  | OS | Windows 10 (10.0.26200) |
335
349
 
336
350
  > The plugin declares `electron >= 30`. The **core path is fully verified on Windows**; system-browser detection is now adapted for Linux and macOS (PATH first, then each platform's conventional install locations, all overridable with `DSH_BROWSER_CHROME_PATH` / `DSH_BROWSER_EDGE_PATH`), but the end-to-end path on those platforms has not been measured, so no promise is made yet.
337
351
 
352
+ > **Re-verify host compatibility at any time**: `node scripts/verify-host-compat.mjs` assembles a host out
353
+ > of the `@deepseek-ai/*` packages **actually installed** in a profile, loads this plugin's compiled
354
+ > output, registers its tools through the host's own `defineTool`, and calls two of them over a fake
355
+ > provider (`browser_session` / `browser_a11y`). It answers "did a DSH upgrade break us?" without starting
356
+ > a browser. It reads the profiles under `$DSH_HOME/profiles`; `DSH_PROFILE` / `DSH_HOME` override that.
357
+ >
358
+ > **On semver and prereleases**: the range `>=0.1.1-rc.2 <0.3.0` matches no prerelease-tagged version
359
+ > (such as `0.2.1-alpha.1`) under **default semver semantics** — that is by specification, where a
360
+ > prerelease is only admitted by a comparator carrying the same major.minor.patch tuple. DSH evaluates
361
+ > plugin compatibility with `includePrerelease: true`
362
+ > (`packages/boot/app-boot/src/plugin-compatibility.ts`), so those hosts are in range in practice; if
363
+ > some external tool reads "incompatible" under default semantics, that is its rule differing from DSH's,
364
+ > not a wrong claim here.
365
+
338
366
  ## Updating (the two hosts differ)
339
367
 
340
368
  The plugin has one installation per host, and the two are updated separately — **updating one does not update the other**.
@@ -369,11 +397,11 @@ The plugin has one installation per host, and the two are updated separately —
369
397
  - `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
398
  - CAPTCHA cannot be solved automatically: snapshots flag detected challenges; ask the human to complete it in the shared window instead of retrying.
371
399
  - Private mode (`privateMode`) is not implemented: it needs Electron session partitioning, which is host-layer territory; this plugin does not promise it.
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).
400
+ - `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). Admission also resolves where a path really lands: a symlink inside `downloadDir` cannot carry the write out of it, and a dangling link is refused too (it reads as a name already taken, not as a free path).
373
401
  - The self-hosted browser's cookies are stored in plaintext on disk (Electron default); deployments that need encrypted-at-rest should integrate a system keychain / DPAPI at the host layer.
374
- - `browser_restrict` is a **soft guardrail** against accidental actions, not a security boundary: the model can lift it itself.
402
+ - `browser_restrict` is a **soft guardrail** against accidental actions, not a security boundary: the model can lift it itself. For a limit the model cannot remove with a tool, use the three action switches in Settings ("What the agent may do") — they live in the settings document, which no `browser_*` tool writes — or the deployment-level `tool-browser` config `allowedActions` (see the comments in `cordis.patch.yml`). **Be precise about that boundary**: settings travel over a local HTTP endpoint whose writes require a same-origin `Origin` (so a bare HTTP client cannot write them), but a **same-origin page still can** — that is inherent to exposing an endpoint at all, and it is **not a credential system**. The panel's own text says exactly this, and it is worth reading as what it is rather than as a lock.
375
403
  - 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
- - 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`.
404
+ - 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 `Network.getCookies` / `Storage.setCookies`. **`flush` exports only the cookies of the site the current page is on**, not the machine-wide shared jar — the export is scoped to the URL of the page this session is sitting on, and when that URL is unknown the tool **refuses rather than widening** (the self-hosted sidebar path reports that the cookie scope is unknown; the CDP path raises `BROWSER_AUTH_SCOPE_UNKNOWN`). `browser_auth` **is subject to the `browser_restrict` allow-list** too, because `restore` is an action that writes cookies to arbitrary domains.
377
405
  - 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
406
  - 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
407
  - 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.
@@ -421,10 +449,10 @@ Code layout:
421
449
  | 11 | 2026-08-27 | **Self-hosted session self-healing after host death** (issue #5): after the host dies (DSH restart / checkpoint restore / crash), already-open sessions rebuild the host and retry on their **next call** — no more "browser host is not running", no more half-dead state; host-gone logging + a fake-child regression test added |
422
450
  | 12 | 2026-08-27 | **resolveElectronPath excludes packaged apps** (issue #6): added `isBareElectron` (a sibling `app.asar` means packaged — never reused, all platforms incl. macOS bundle layout); bundled-electron filesystem probe goes first; `ELECTRON_PATH` override wins first; missing dist errors clearly (`npx install-electron` hint) |
423
451
  | Hardening | 2026-08-27 | **Three review passes hardened**: concurrent-recovery double-rebuild race (child `createView` made idempotent), macOS bundle-path detection, `dispose()` vs `start()` zombie-child race triple-guard, pendingSocket leak, fully unit-tested lookup order (24→25 tests) |
424
- | **0.1.20** | 2026-08-27 | **Release**: rounds 11/12 + hardening ship as **0.1.20** (build clean, 25/25 tests pass) |
452
+ | **0.1.20** | 2026-08-28 | **Release**: rounds 11/12 + hardening ship as **0.1.20** (build clean, 25/25 tests pass) |
425
453
  | 13 | 2026-08-28 | **macOS/Linux untypeable inputs fix** (issue #7): the Windows focus routing from 0.1.16 (mousedown force-focus + window-refocus restore) shipped without a platform guard and fought macOS native click-to-focus, leaving login inputs untypeable → both handlers are now gated to `win32`; non-Windows restores native behavior |
426
454
  | 14 | 2026-08-28 | **window.open/target=_blank opens a new tab** (issue #8): HTTP(S) popups no longer loadURL over the current view; they are handed to the parent and open as a **new tab in the same session window** — the opener page and its context survive (portal "workspace" jumps no longer 403) and the jump lands in session history; ungrouped views keep the fallback; non-HTTP popups still go to the system |
427
- | **0.1.21** | 2026-08-28 | **Release**: rounds 13/14 ship as **0.1.21** (build clean, 25/25 tests pass) |
455
+ | **0.1.21** | 2026-09-02 | **Release**: rounds 13/14 ship as **0.1.21** (build clean, 25/25 tests pass) |
428
456
  | macOS binary probe | 2026-09-09 | **Electron.app layout detection** (issues #9 / #14): `electronDistExe()` only probed `dist/electron(.exe)`, so macOS's `dist/Electron.app/Contents/MacOS/Electron` was never found → darwin candidate added to the shared platform probe (bundled + profile/anchor layers both benefit); regression test added |
429
457
  | 15 | 2026-09-16 | **Toolbar parse-time SyntaxError** (issue #11): the inline script's `const bridge = window.bridge` collided with the non-configurable global installed by `contextBridge.exposeInMainWorld` (`HasRestrictedGlobalProperty`) → a **parse-time** early error, so not one line ran and the address bar, the four nav buttons, the tab strip and the error bar were all dead → the whole script is wrapped in an IIFE and the handle renamed `tb`, making the class of collision structurally impossible; 3 toolbar regression tests parse the snippets out of the **published artifact** and execute them in a `vm` under contextBridge semantics |
430
458
  | 16 | 2026-09-16 | **Self-hosted trio fixes** (issue #10): (1) `browser_open` waits (bounded 5 s) for the new document to settle via a `performance.timeOrigin` fingerprint + `readyState`, so it no longer returns a titled-but-empty snapshot; (2) a new waiting `presentView` barrier (materialize the view, then showView, then a ping barrier; child dispatch is strictly serial) is required before `click`/`type`/`key` dispatch `Input.*`, which now fail loudly with `BROWSER_VIEW_NOT_PRESENTED` instead of faking success; (3) `createView` preloads `about:blank` (bounded 3 s) so a fresh view always has a live renderer (the step that wedged after a host restart); (4) `did-navigate` forces re-presentation, host commands are bounded at 20 s, child stderr plus exit code/signal go to `$DSH_HOME/logs/dsh-builtin-browser-host.log` (2 MB self-truncating), and `locateTab` accepts a bare uuid as well as `tab:<uuid>` |
@@ -447,8 +475,18 @@ Code layout:
447
475
  | **0.3.1** | 2026-10-01 | **Release**: round 27 (lazy startup + refusing to start after release) ships as **0.3.1**. **100/100 tests pass**, tag `v0.3.1` |
448
476
  | Round 28 | 2026-10-01 | **Two "limits" were implementation limits**: reviewing the documented limitations showed they were never about the carriers. ① `browser_auth` called a native method only the self-hosted handle implements and refused otherwise — but **cookies are part of CDP** (`Storage.getCookies` / `Storage.setCookies`) and **all three carriers speak CDP**, so export and restore now go through it when no native method exists, **working on all three** (domain+path vs URL, the leading-dot spelling and seconds vs milliseconds are handled, and cookies that cannot form a usable URL are dropped). ② JPEG was denied to every CDP path because Electron's CDP JPEG encoder hangs — true for Electron, irrelevant to an installed Chrome/Edge — so a view now advertises `supportsCdpJpeg` and only then is the format passed through; the sidebar keeps PNG. ③ The tool description promised downscaling that existed only on the native path, so the CDP path now scales through `clip.scale` (reading `Page.getLayoutMetrics` first, and capturing unscaled rather than failing when the size is unavailable). ④ Two carrier differences that were never documented are now written down: **the sidebar does not report user-action events**, and **an installed Chrome/Edge uses a plugin-owned profile**. 11 new tests over the pure parts, **124/124** |
449
477
  | Round 29 | 2026-10-01 | **Independent code review**: a separate reviewer read the whole tree and reproduced the findings. ① **`spawn` had no `error` listener** — a launch failure arrives as an asynchronous event, and without a listener Node takes **the entire DSH host process down**; it now fails the command instead. ② **`dispose()` during `start()`'s 250 ms poll** left the launched browser alive with nobody to kill it; the poll now kills it before stopping. ③ Three different failures all reported "did not expose CDP within 30s", in half a second; each now says what happened. ④ **An explicit `chrome`/`edge` choice was still overridden by the desktop sidebar**, because discovery never checked the channel — which also meant the "your browser is not installed" explanation could never appear, cancelling the previous round's fix. ⑤ `CdpClient.whenReady()` awaited an unbounded promise, so a dropped connection could hang a tool call forever. ⑥ `focus()` used `kill('SIGCONT')`, a no-op on Windows, while its comment promised to raise the window. ⑦ Removed an unreachable `brave` branch. **125/125** |
450
-
451
- > The npm badge at the top is the authority on the registry's latest version (currently `0.4.0`). After a desktop upgrade, re-run `node desktop-bridge/install.mjs` once; the web side has no such step — see [Updating (the two hosts differ)](#updating-the-two-hosts-differ).
478
+ | Round 30 | 2026-10-02 | **The second independent review's 4 HIGH + 2 MEDIUM**: ① **read-only tools were in fact blocked by the allow-list** — `assertAllowed` exempted only `browser_restrict` itself, so a narrow restriction also locked away `browser_history` / `browser_reset_session` / `browser_auth` with no way back → an explicit read-only set (plus the tools that lift a restriction) now exists. ② **A cancelled navigation completed as success** — `navigate()` checked the signal once at the top while `settleDocument` returned quietly on abort, so the call finished with no exception, was recorded as success, and wrote the page into the persistent browsing history. ③ **The settings switch nothing read** — `credentials.allowRead` existed only in the settings store and the panel, so turning it off still let `browser_auth` export every cookie; both auth paths are genuinely gated now. ④ **A stale tab id could close someone else's tab** — `locateTab` searched every other session when the id was not local and reported success, so isolation was broken; it is confined to the calling session now (a bare uuid and `tab:<uuid>` are still both accepted). ⑤ With an empty `target.value`, `browser_type` fell back to the focused element (possibly a password field) and `browser_click` dropped the semantic target for coordinates, both reporting success → an empty value is an error. ⑥ `scrape`'s outer budget equalled the page-side deadline, so a page-side "no elements matched" verdict could never travel back → the outer call now leaves room for the transfer. **130/130** |
479
+ | **0.4.0** | 2026-10-02 | **Release**: rounds 27–31 ship together — the theme is **separating real limits from implementation limits**: `browser_auth` works on all three carriers, an installed Chrome/Edge can encode JPEG, and downscaling works everywhere; alongside fixes for defects that crashed the host, silently did the wrong thing, or hung forever. **130/130 tests pass**, tag `v0.4.0` |
480
+ | Round 31 | 2026-10-02 | **Third review round + atomic settings writes**: ① **settings writes go through a temporary file and a same-directory rename** (the rename is the commit point, so a crash or a full disk cannot leave half a JSON document that the reader then silently replaces with defaults), and a failed write cleans up only the temporary and **no longer publishes the new value as cached**; ② fixed an `el` never rebound after a filter (a selector matching several elements wrote the value onto the first one while reporting success); ③ `handle.focus()` got a 5 s budget (the only unbounded await in the file, and it runs before typing); ④ `browser_wait`'s poll leaked an abort listener every 200 ms (~150 for a 30 s wait) and now reuses `delay()`; ⑤ with `autoExpandOnce` off, `showActive` treated "show the view" and "fold the carrier" as exclusive branches and returned after the second — on carriers with no collapse capability the view was **never shown at all**, so the agent drove a view the human could not see and every screenshot failed with "view not painted" (an error that blamed the page for a setting); ⑥ assorted `bridge-connection` / `scrape` timeout clean-up. **130/130** |
481
+ | **0.4.1** | 2026-10-02 | **Release**: issue #21 — **the system browser carrier (chrome/edge) neither reconnected nor restarted after its process died**, so every `browser_*` call hung for 30 seconds and only restarting DSH helped. Launch-time failures were handled in the previous round, but death **after a successful launch** was never observed: there was no `exit` listener, `this.client` was never cleared once assigned, and `ensureClient()` kept returning that dead connection forever. Fixed along the two failure shapes reported: process death → the child is observed throughout, its exit clears the cache and the next call relaunches; socket death with the process alive → the cached client's liveness is checked before use; process and socket both alive but the command never answered → **a failed command drops the client**. Commands sent on an already-closed socket now **fail immediately and say why**, instead of waiting out the budget and reporting a "timeout" that points nowhere near the real cause. **132/132 tests pass**, tag `v0.4.1` |
482
+ | Round 32 | 2026-10-03 | **Output-cost audit + independent verification** (unreleased): ① **`browser_a11y`**: the default node count went **500 → 150**, indentation two spaces → one, `states=[…]` prints omitting the literal `enabled` (unchecked, collapsed and others are still printed) (**no `states=` means enabled**) and the trailing hint is short; ② **snapshots and the a11y tree now hand you a citable selector**: `{#id}` / `{[name=x]}` — the page-side script had always computed it and the tool layer threw it away, while the targeting tools accept only css/text/xpath, leaving the model to guess at text or fall back to a screenshot — and the field is **declared in the output schema**, without which `additionalProperties: false` rejects the whole result, which is exactly how the gap was found on the first real call; ③ **`browser_content` caps are per format** (html/json 50 000, the rest 20 000, overridable with `maxChars`), **`json` no longer returns `{}`** (a DOM node serialises to nothing) but `{"html":…,"tag":…}`, and the truncation notice says how to narrow; ④ `browser_scrape` returns compact JSON; ⑤ `browser_execute`'s return value is capped at 50 000 characters with the cut stated; ⑥ `browser_history` shows the last 20 entries without parameters and leads with the total — `verbose: true` brings the parameters (the parameter had been declared and never read); ⑦ `browser_visited` timestamps are minute-granular and the session tag prints once per change, shortened to 8 characters; ⑧ **`browser_auth`'s `flush` exports only the cookies of the site the current page is on** (it used to be the machine-wide shared jar) and **refuses rather than widening** when the scope is unknown, while `restore`'s count now reflects what was really submitted (`Storage.setCookies` returns nothing, so it was always 0 and read as failure) and **`browser_auth` left the read-only set** — its `restore` writes cookies to arbitrary domains, an action rather than an observation; ⑨ **atomic settings writes**, a settings cache that returns before reading the file, a browsing history that no longer rewrites the whole file on every navigation (648 KB per navigation once past the cap, measured at 17.5–20.6 ms), the `browser_content` page-script syntax error that broke all four formats, `browser_visited` printing blank rows, bridge replies correlated by request id, and `tools/install-web-plugin.mjs` refusing to damage a profile it cannot repair. Two unbounded host awaits (self-hosted capture/download, and cookie export/restore) got timeouts. **132 → 134** |
483
+ | Round 33 | 2026-10-03 | **The system browser carrier: actually usable after it dies** (unreleased): ① **one failed command no longer tears down the connection** — a CDP protocol error means the browser answered and refused that one command, so the connection is healthy; ② **a restart now drops the stale session mappings**, not just the cached connection and child, because every command the replacement received carried a session id issued by the dead process — that is the reported "it restarts, and every call fails forever"; ③ **a slow page is not a dead browser** — deciding liveness by error text turned the 5 s/15 s outer timeouts into "the browser is dead" and killed the window you were using, tabs and half-filled forms with it; the state is now three-way — socket closed → discard, answered and refused → keep, **no answer → mark suspect** and probe once with `Browser.getVersion` on the next call; ④ **after the kill it waits up to 3 seconds for a real exit** (on Windows the kill is asynchronous, and not waiting handed the new process's command line to the old instance, whose resulting exit was misreported as "check that the path is a runnable browser"); ⑤ **only the launch's own `DevToolsActivePort` counts** (a killed browser never cleans it up, and the stale file made the host connect to another Chromium or a `node --inspect`); ⑥ **the port-discovery regression test now uses a fixture that really speaks CDP** (the old `.cmd` stub was refused by Node's spawn on Windows, and all three assertions were satisfied by that single synchronous failure — CI was green while the exit handling never ran once). **134/134** |
484
+ | Round 34 | 2026-10-03 | **Operator-level action switches (the real entry point)**: the settings panel gains "What the agent may do" — **run scripts in the page** / **download files to disk** / **write login state**. Switching one off refuses that capability outright (`BROWSER_EXECUTE_DISABLED` / `BROWSER_DOWNLOAD_DISABLED` / `BROWSER_AUTH_WRITE_DISABLED`). What separates them from `browser_restrict` is **who can change them**: that one is set by the model and can be lifted by the model, while these live in the settings document, which no tool can write. Also fixes a read/write mix-up: restoring login state (`browser_auth restore`) answered to the READ switch and reported "reading cookies is switched off", so an operator who allowed reading had no way to refuse writes — it now answers to the write switch. **148 → 157** |
485
+ | Round 35 | 2026-10-03 | **DSH 0.2.1 adaption (unreleased)**: the host moved to `0.2.1-alpha.1` (266 commits past rc.2), so every host-facing dependency was checked item by item — cordis `Context`/`Service` (vendor 4.0.5-alpha.1), `dsh-tools` `defineTool` and every `DefineToolOptions` field, `dsh-system-prompt` `section()`, `dsh-llm` `HarnessError`, the schemastery default export, and on the client side `slots.inject('settings.section')` with `locale.register`/`bind`. **None of the shapes changed** (the client calls match DSH's own plugin word for word). Added `scripts/verify-host-compat.mjs`: it assembles a host from the `@deepseek-ai/*` packages **actually installed** in a profile, loads this build, registers 34 tools through the host's own `defineTool`, and calls `browser_session` plus `browser_a11y` over a fake provider — **all green**. `dsh.compatibility.dshReleases` now records `0.2.1-alpha.1 = compatible`. One semantic trap recorded too: that range matches no prerelease under **default semver semantics**, while DSH evaluates with `includePrerelease: true`, so these hosts are in range in practice |
486
+ | Round 36 | 2026-10-04 | **An external audit, worked through (unreleased)**: eight findings with line numbers and evidence, each verified against the code and fixed in three batches — (1) **storage and settings**: a settings document that EXISTS but cannot be parsed no longer resolves to "everything on" (a torn write used to reopen every capability the operator had switched off), a gate field of the wrong type now reads as OFF, `history.jsonl` uses the settings store's atomic write for its whole-file rewrite, and `contentMaxChars` — assigned but never read since the caps became per-format — works again; (2) **lifecycle**: the browser child gets the socket close first and time to `app.quit()` its profile out, and only then is killed (the old comment claiming `app.exit(0)` "flushes the profile" was false, and on Windows `kill()` is TerminateProcess, which lands before any handler runs), and the child no longer calls `app.exit(0)` either; (3) **usability**: click/type/fill resolved targets against the top document only while the snapshot and `waitFor` walked shadow roots and iframes — **visible, waitable, unclickable** — so the resolve scripts now collect every root; on Windows the per-user `%LOCALAPPDATA%` install locations are searched, and the "exited immediately (exit code 0)" message no longer points at a broken path when it means the browser handed the request to an instance already holding its profile. Two places that silently wreck someone else's work are fixed as well: `install-web-plugin.mjs` refuses to overwrite a `link:` pin with a registry version, and a failed adopt disposes its half-built host. **Two findings I do not agree with**: a zero-hit grep for `window-all-closed`/`before-quit` says nothing about behaviour (Electron's default is already right), and a `requestSingleInstanceLock` would break normal restarts rather than fix orphans. |
487
+ | **0.4.2** | 2026-10-04 | **Release**: rounds 32-36 in one release — the theme is **making invisible errors visible**. (1) **Output cost**: what every call pays dropped to a fraction (a11y defaults to **500 → 150** nodes, indentation and `states` carry only information, `content` caps are per format, `history` shows 20 entries by default). (2) **Action switches**: three capability gates held by the **operator** that no `browser_*` tool can change (run page scripts / download to disk / write login state), kept clearly separate from `browser_restrict`, which the model sets and lifts itself. (3) **Recovery semantics completed**: a dead system-browser process now takes its sessions down with it (previously the browser could restart while the view still held the dead one's session id — "fails fast and keeps failing"), a slow page is no longer mistaken for a dead browser, and `kill` waits for the process to actually exit. (4) **An external audit, worked through**: a settings document that EXISTS but cannot be parsed is now fail-closed (a torn write used to reopen **every** capability the operator had switched off, and the next save wrote those defaults to disk), `history.jsonl` is written atomically, the client panel — read at request time — no longer fails to open because the host lacks a field, click/type/fill reach shadow-DOM and iframe elements the snapshot already shows, and Windows user-level installs (`%LOCALAPPDATA%`) are searched. **171/171 passing** (172 total, 1 skipped by platform), tag `v0.4.2` |
488
+
489
+ > The npm badge at the top is the authority on the registry's latest version (currently `0.4.1`). After a desktop upgrade, re-run `node desktop-bridge/install.mjs` once; the web side has no such step — see [Updating (the two hosts differ)](#updating-the-two-hosts-differ).
452
490
 
453
491
  ## Acknowledgements
454
492
 
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 / a11y / content / scrape / screenshot / get_value / challenge / list_tabs / session / history / visited / auth)加上「解除限制 / 复位会话」几个永远豁免,不受白名单影响。</p>
136
+ <p><code>browser_restrict</code> 限制允许的浏览器动作(白名单),防止 agent 误点、误导航;只读工具(snapshot / a11y / content / scrape / screenshot / get_value / wait / challenge / list_tabs / session / history / visited)加上「解除限制 / 复位会话」几个永远豁免,不受白名单影响。</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 / 自动)、历史是否保留、侧栏是否自动展开、会话结束时是否关闭浏览器、是否显示光标、视觉策略、是否允许读取凭据 —— 这些开关都是<b>每次使用时现读</b>,改完即时生效,无需重启。<b>只有两个例外</b>:<code>browser.channel</code>(用哪个浏览器)与 <code>cookies.persist</code>(是否保留 cookies)在插件挂载时读取一次,改完需要<b>重新加载插件</b>(重启 dsh / 刷新页面)才生效。</p>
156
+ <p><b>用哪个浏览器</b>(内置 Electron / 本机 Chrome / 本机 Edge / 自动)、历史是否保留、侧栏是否自动展开、会话结束时是否关闭浏览器、是否显示光标、视觉策略、是否允许读取凭据 —— 这些开关都是<b>每次使用时现读</b>,改完即时生效,无需重启。<b>只有两个例外</b>:<code>browser.channel</code>(用哪个浏览器)与 <code>cookies.persist</code>(是否保留 cookies)在插件挂载时读取一次,改完需要<b>重新加载插件</b>(重启 dsh / 刷新页面)才生效。栏里另有一组「Agent 能做什么」的动作开关 —— <b>执行页面脚本</b>、<b>下载文件到磁盘</b>、<b>写入登录状态</b> —— 它们与 <code>browser_restrict</code> 不是一回事:后者是模型自己的软护栏,模型随时可以解除;这三项写在设置文档里,<b>没有任何工具能改它</b>,模型只能被拒。</p>
157
157
  </td>
158
158
  <td width="50%" valign="top">
159
159
  <h3>收尾明确</h3>
@@ -175,9 +175,9 @@ node <本仓库路径>/desktop-bridge/install.mjs
175
175
  | --- | --- | --- |
176
176
  | `browser_open` | 打开 URL(可选新标签),返回页面快照 | ✅ |
177
177
  | `browser_wait` | 等待页面加载完成(可选期望 URL / CSS 选择器),返回是否就绪 | – |
178
- | `browser_snapshot` | 交互元素(输入框/按钮/链接)带编号清单(穿透同源 iframe 与 Shadow DOM) | – |
179
- | `browser_a11y` | 无障碍树:每个交互节点的语义角色/名称/值/状态 + 坐标(穿透同源 iframe 与 Shadow DOM) | – |
180
- | `browser_execute` | 在页面执行 JS;参数以 `arguments[0..n]` 传入 | ✅ |
178
+ | `browser_snapshot` | 交互元素(输入框/按钮/链接)带编号清单,元素可引用时附 `{#id}` / `{[name=x]}` 定位选择器(穿透同源 iframe 与 Shadow DOM) | – |
179
+ | `browser_a11y` | 无障碍树:每个交互节点的语义角色/名称/值/状态,可引用时附 `{#id}` / `{[name=x]}`;坐标按需(`coords: true`,默认不含;穿透同源 iframe 与 Shadow DOM) | – |
180
+ | `browser_execute` | 在页面执行 JS;参数以 `arguments[0..n]` 传入。受设置「允许在页面里执行脚本」门控,关闭后一律被拒(`BROWSER_EXECUTE_DISABLED`) | ✅ |
181
181
  | `browser_visited` | 读取持久化浏览历史(访问过的页面,可按域名过滤/限量);重开用 `browser_open` | – |
182
182
  | `browser_content` | 以 html / markdown / txt / json 抓取页面(selector、maxChars、timeoutMs) | – |
183
183
  | `browser_click` | 点击:语义目标(`target`: css/text/xpath,滚动到元素并点中心)或视口坐标(配合截图视觉定位) | ✅ |
@@ -198,17 +198,17 @@ node <本仓库路径>/desktop-bridge/install.mjs
198
198
  | `browser_list_tabs` | 当前会话的标签列表 | – |
199
199
  | `browser_switch_tab` | 按 id 切换标签(自托管下同步切换可见视图) | ✅ |
200
200
  | `browser_close_tab` | 按 id 关闭标签;关闭活动标签后激活下一个 | ✅ |
201
- | `browser_reset` | 关闭本任务所有标签,回到一个空白标签 | ✅ |
201
+ | `browser_reset` | 关闭本任务所有标签,回到一个空白标签 | – 豁免 |
202
202
  | `browser_session` | 查看本任务的浏览器会话与标签 | – |
203
- | `browser_reset_session` | 关闭并重建本任务的浏览器会话 | ✅ |
203
+ | `browser_reset_session` | 关闭并重建本任务的浏览器会话 | – 豁免 |
204
204
  | `browser_history` | 操作日志(最新在后),含成功/失败与结果摘要 | – |
205
205
  | `browser_replay` | 按序号回放某一步(navigate/execute/click/type) | ✅ |
206
- | `browser_download` | 带会话 cookie 下载 HTTP(S) URL 到本地文件(`savePath` 必须绝对路径且位于 `downloadDir` 内,不覆盖已有文件,上限 256MB) | ✅ |
207
- | `browser_auth` | 导出/恢复 cookie(登录态持久化;**三种载体都可用** —— 自托管走原生会话,侧栏与本机浏览器走 CDP) | ✅ |
206
+ | `browser_download` | 带会话 cookie 下载 HTTP(S) URL 到本地文件(`savePath` 必须绝对路径且位于 `downloadDir` 内,不覆盖已有文件,上限 256MB)。受设置「允许下载文件到磁盘」门控(`BROWSER_DOWNLOAD_DISABLED`) | ✅ |
207
+ | `browser_auth` | 导出/恢复 cookie(登录态持久化;**三种载体都可用** —— 自托管走原生会话,侧栏与本机浏览器走 CDP)。导出受设置「凭据」门控(`BROWSER_AUTH_DISABLED`),写入受「允许写入登录状态」门控(`BROWSER_AUTH_WRITE_DISABLED`);两者独立 | ✅ |
208
208
  | `browser_challenge` | 检测人机验证(CAPTCHA / Cloudflare / reCAPTCHA / hCaptcha / Turnstile) | – |
209
209
  | `browser_restrict` | 限制允许的浏览器动作(白名单;空列表解除)。**软护栏**,模型可自行解除,非安全边界 | – |
210
210
 
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` 一样可被限制。
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`,外加 `restrict`(白名单本身必须能解除,否则一旦限制到空就再也出不来)、`reset_session`、`reset`(从崩死状态恢复)。`browser_close_tab` **不在**豁免集合里,与 `open`/`click`/`switch_tab` 一样可被限制;`browser_auth` 同样不在(它的 `restore` 会向任意域写 cookie,是动作而非观察)。
212
212
 
213
213
  ### 等待页面就绪
214
214
 
@@ -217,7 +217,7 @@ node <本仓库路径>/desktop-bridge/install.mjs
217
217
 
218
218
  ### 语义定位(`target`)与无障碍树
219
219
 
220
- - **`browser_a11y` 是理解页面的首选**:它返回每个交互节点的语义角色(button/textbox/checkbox…)、可访问名称、当前值、状态(enabled/checked/expanded…)与坐标,比编号快照更能说明“这是什么、能做什么”;拿到坐标后可直接 `browser_click`/`browser_type`。
220
+ - **`browser_a11y` 是理解页面的首选**:它返回每个交互节点的语义角色(button/textbox/checkbox…)、可访问名称、当前值与状态(checked/expanded/required…),比编号快照更能说明“这是什么、能做什么”。**默认 150 个节点**(`maxNodes` 10-5000,要更多请显式传),**不再默认带坐标**(需要像素落点时传 `coords: true`);状态只列**非默认**项 —— 没有 `states=` 就是 enabled。每个节点的 `name` 就是 `browser_click`/`browser_type` 按 `text` 定位时匹配的东西。
221
221
  - **`browser_click`/`browser_type` 支持 `target` 定位**:`{by: css|text|xpath, value, index?}`——`text` 按元素自身可见文本匹配(精确优先、退化包含、最深元素优先);点击会把元素滚动到视口中央再点;输入会先聚焦该元素。
222
222
  - **单控件操作用 `browser_set_value`/`browser_check`/`browser_select`/`browser_clear`/`browser_get_value`**,批量用 `browser_fill`,列表页结构化抓取用 `browser_scrape`。
223
223
 
@@ -229,6 +229,18 @@ node <本仓库路径>/desktop-bridge/install.mjs
229
229
  - **点击前验证命中**:`document.elementFromPoint(x, y)` 确认该坐标确实是目标元素(按钮/链接),再执行真实点击。
230
230
  - **DPR 注意**:CDP 输入使用 CSS 像素;高 DPI 屏上若点击落空,用 `elementFromPoint` 校准,不要盲试坐标。
231
231
 
232
+ ### 输出尺寸与截断
233
+
234
+ 工具输出占用的是调用方的上下文,因此几处返回都有明确上限,并且**截断时一定说出来**:
235
+
236
+ - **快照与无障碍树带引用选择器**:元素有 `id` 或 `name` 时,每行末尾附 `{#id}` / `{[name=x]}`,可直接作为 `browser_click`/`browser_type` 的 `target {by: "css", value: "#id"}` —— 不用对着可访问名称猜文本;没有 `id`/`name` 的元素不带这一段(选择器推导不出来,工具不会编一个)。
237
+ - **`browser_content` 按格式设上限(默认值)**:**html 50 000 字符、json 50 000 字符、txt 20 000 字符、markdown 20 000 字符**;单次调用用 `maxChars` 覆盖。被截断时结果末尾提示用什么手段收窄;`selector` 可先把范围限到一个区域。
238
+ - **`browser_content` 的 `json` 返回的是元素标记**:`{"html": "<元素 outerHTML>", "tag": "标签名"}`,不是空对象(DOM 节点自身没有可枚举属性,`JSON.stringify` 一个 DOM 元素只会得到 `{}`)。
239
+ - **`browser_execute` 的返回值上限 50 000 字符**,超出即在结果里写明"截断于 50 000 / 实际 N 字符",并提示改用更小的返回值。
240
+ - **`browser_scrape` 的结果是紧凑 JSON**(不缩进),前面带条目数 —— 缩进对模型没有信息量,却占实测输出的一成以上。
241
+ - **`browser_history` 默认只展示最近 20 条操作、不重复回显参数**(参数就是你刚发出去的东西),首行先给总条数,免得把尾部当成全部;确实要看参数时传 `verbose: true`。
242
+ - **`browser_visited` 的时间戳精确到分钟**,同一会话的标记只在切换时打印一次并取 8 位前缀,不是每行一份完整会话 id。
243
+
232
244
  ## 配置
233
245
 
234
246
  插件通过 `cordis.patch.yml` 挂载**四行**:一个**惰性的根行**(只用来声明包名,宿主的客户端插件扫描靠它读到 `dsh.client`,否则设置栏不会出现)+ 三个功能行(`browser` / `browser-electron` / `tool-browser`)。各行配置:
@@ -238,7 +250,7 @@ node <本仓库路径>/desktop-bridge/install.mjs
238
250
  | `browser-electron` | `viewHost` | 对象 | 可选 | 宿主提供的 `ElectronBrowserViewHost` 实例(如 `!!js ctx.get('electronViewHost')`)。**不传时插件自己选载体** —— 桌面端驱动官方侧栏、否则自托管;若在设置里指定了本机 Chrome / Edge,该选择优先于两者 |
239
251
  | `browser-electron` | `httpOnly` | 布尔 | `true` | 仅允许 HTTP(S) 导航;其余协议(如 `file:`/`data:`)拒绝(`BROWSER_NAVIGATION_BLOCKED`) |
240
252
  | `browser-electron` | `snapshotMaxElements` | 数字 | `60` | 快照最多收录的交互元素数,超出截断 |
241
- | `browser-electron` | `contentMaxChars` | 数字 | `100000` | 内容抓取默认字符上限 |
253
+ | `browser-electron` | `contentMaxChars` | 数字 | **不再被读取(已废弃)** | 曾经的"内容抓取默认字符上限"。**现在没有任何代码读它,改它不会改变任何行为** —— 上限按格式固定:html 50 000、json 50 000、txt 20 000、markdown 20 000,单次调用用 `maxChars` 覆盖。保留该键只是为了不让既有配置文件因为未知键而报错 |
242
254
  | `browser-electron` | `downloadDir` | 字符串 | 系统下载目录(自动识别 `Downloads`/`下载`/`下載`,或 `XDG_DOWNLOAD_DIR`) | 限定 `browser_download` 与 `browser_screenshot` 的保存路径必须位于该目录内,且不覆盖已有文件(防 agent 写任意路径或替换现有文件);默认收敛到系统下载目录,可改为沙箱目录 |
243
255
  | `tool-browser` | `timeoutMs` | 数字 | `60000` | 工具协作超时(ms) |
244
256
  | `tool-browser` | `tabTools` | 布尔 | `true` | 是否注册标签管理工具(`browser_list_tabs` 等) |
@@ -292,7 +304,7 @@ node desktop-bridge/install.mjs --revert # 回滚
292
304
  > - Agent 能读到该 partition 中的 **Cookie / 登录态**,`browser_auth` 可将其导出(由设置项控制);
293
305
  > - 反过来,你在侧栏里的操作与 Agent 的操作**作用于同一个页面**,可能互相影响(Agent 不会主动覆盖你的输入,但导航会改变双方看到的内容)。
294
306
  >
295
- > 这是"人机同页"的必然代价。我们认为值得(它把"Agent 在一个你看不见的窗口里操作"变成"你能看着它操作并随时接手"),但你有权知道它存在 —— 因此也提供了开关:**「凭据访问」关闭后 <code>browser_auth</code> 的导出与恢复都会被拒绝(<code>BROWSER_AUTH_DISABLED</code>)—— 不只是"不再读取":恢复走的是同一个开关**;**「视觉策略」设为纯非视觉后,任何依赖截图定位的坐标点击都会被拒绝**。不想接受这个边界变化时,把 desktop profile 里的插件移除即可回到"独立窗口"的旧形态。
307
+ > 这是"人机同页"的必然代价。我们认为值得(它把"Agent 在一个你看不见的窗口里操作"变成"你能看着它操作并随时接手"),但你有权知道它存在 —— 因此也提供了开关:**「凭据访问」关闭后 <code>browser_auth</code> 的导出会被拒绝(<code>BROWSER_AUTH_DISABLED</code>)—— 它只管读;写入登录态(<code>restore</code>)另有「允许写入登录状态」开关,报 <code>BROWSER_AUTH_WRITE_DISABLED</code>,两者独立**;**「视觉策略」设为纯非视觉后,任何依赖截图定位的坐标点击都会被拒绝**。不想接受这个边界变化时,把 desktop profile 里的插件移除即可回到"独立窗口"的旧形态。
296
308
 
297
309
  **② 用你自己的浏览器(Chrome / Edge)**
298
310
 
@@ -300,10 +312,12 @@ node desktop-bridge/install.mjs --revert # 回滚
300
312
 
301
313
  **你自己的数据不会被碰**:插件用的是**独立 profile**(`$DSH_HOME/dsh-builtin-browser-host/<chrome|edge>-profile`),不会打开、占用或修改你日常的窗口、书签与登录状态;插件退出也不会关掉你的浏览器。
302
314
 
315
+ **浏览器死了之后会自己回来**:这个载体会观察子进程的退出并清掉**已失效的会话映射**(不只是缓存里的连接),所以重启后的浏览器是**可用的**,而不是"活着但每条命令都报 Session not found"。重新拉起前会先 kill 旧进程并**最多等 3 秒**它真正退出(Windows 上 `kill` 是异步的,不等就会把新进程交给旧实例、然后被误报成"路径不是可运行的浏览器"),并**删掉上一轮写下的 `DevToolsActivePort`、只认本次启动之后写入的那份**(否则会连到别的 Chromium 或 `node --inspect` 上,发布一个自己既不属于、也杀不掉的客户端)。反过来,**页面慢不等于浏览器死了**:命令**超时**只把连接标记为"存疑",下一次调用先用 `Browser.getVersion` 探一次再决定是否丢弃;只有**socket 已关闭**才直接重启 —— 因此一次慢页面不会让插件杀掉你正在用的浏览器窗口、连你打开的标签和填到一半的表单一起丢掉。
316
+
303
317
  **登录态怎么办**:
304
318
 
305
319
  - `cookies.persist` **开**(默认)→ 上面那个固定 profile 会保留,**重启 DSH 后仍是登录状态**;`browser_auth` 也照常可导出/恢复该 profile 的 Cookie。
306
- - `cookies.persist` **关** → 每次用**临时 profile**,释放浏览器时整个目录被删除,不留登录痕迹。
320
+ - `cookies.persist` **关** → 每次用**临时 profile**,释放浏览器时整个目录被删除,不留登录痕迹。**进程被强杀时释放路径跑不到**,所以下一次启动会**先清扫**上一轮残留的临时 profile —— 只清"属主进程已不在 **且** 一小时没有写入"的那些,免得删掉仍在运行的孤儿浏览器正在用的目录。
307
321
  - 代价要说清:独立 profile **看不到**你日常浏览器里已登录的站点 —— 在插件打开的窗口里登录一次即可,之后登录态就存在它自己的 profile 里。
308
322
 
309
323
  **③ 有 `electronViewHost` 的宿主(旧版桌面外壳)**:直接使用外壳提供的视图。
@@ -327,15 +341,26 @@ node desktop-bridge/install.mjs --revert # 回滚
327
341
 
328
342
  | 组件 | 版本 |
329
343
  | --- | --- |
330
- | DeepSeek Harness(dsh) | `0.2.0-rc.2`(peer 声明 `>=0.1.1-rc.2 <0.3.0`) |
344
+ | DeepSeek Harness(dsh) | `0.2.1-alpha.1`(peer 声明 `>=0.1.1-rc.2 <0.3.0`;更早的 `0.2.0-rc.2`、`0.1.x` 亦有实测记录) |
331
345
  | Electron | `44.0.0`(推荐 ≥ 40;33.x 存在合成器缺陷) |
332
346
  | Node.js | `22.20.0` |
333
347
  | 本机 Chrome / Edge(可选载体) | `154.0.8037.58` / `154.0.4258.37` |
334
- | dsh-builtin-browser | `0.4.0` |
348
+ | dsh-builtin-browser | `0.4.1`(开发分支已超出该发布版本) |
335
349
  | 操作系统 | Windows 10 (10.0.26200) |
336
350
 
337
351
  > 插件声明 `electron >= 30`。**核心链路在 Windows 上完整实测**;系统浏览器的查找已适配 Linux 与 macOS(先查 `PATH`,再查各平台的惯例安装位置,均可用 `DSH_BROWSER_CHROME_PATH` / `DSH_BROWSER_EDGE_PATH` 覆盖),但这两个平台上的**端到端链路尚未实测**,暂不承诺。
338
352
 
353
+ > **宿主兼容性可以随时复验**:`node scripts/verify-host-compat.mjs` 会用某个 profile 里**真实安装**的
354
+ > `@deepseek-ai/*` 包组装一个宿主,加载本插件的编译产物、经宿主自己的 `defineTool` 注册工具、再用一个
355
+ > 假 provider 真调两个工具(`browser_session` / `browser_a11y`)。它回答的是"DSH 升级有没有把我们弄坏",
356
+ > 不需要启动浏览器。默认读 `$DSH_HOME/profiles` 下的 profile,可用 `DSH_PROFILE` / `DSH_HOME` 指定。
357
+ >
358
+ > **关于预发布版本的 semver 语义**:范围 `>=0.1.1-rc.2 <0.3.0` 在 **semver 默认语义**下不匹配任何带
359
+ > 预发布标签的版本(如 `0.2.1-alpha.1`)—— 这是规范行为,预发布只被"同一 major.minor.patch 元组"的
360
+ > 比较器允许。DSH 判定插件兼容性时用的是 `includePrerelease: true`
361
+ > (`packages/boot/app-boot/src/plugin-compatibility.ts`),因此这些预发布宿主**实际都在范围内**;
362
+ > 若某个外部工具用默认语义得出"不兼容",那是它的语义与 DSH 不同,不是这里的声明写错了。
363
+
339
364
  ## 更新方式(两端不同)
340
365
 
341
366
  插件在两种宿主里各有一份安装,更新路径也不同 —— **更新其中一端不会连带更新另一端**。
@@ -369,11 +394,12 @@ node desktop-bridge/install.mjs --revert # 回滚
369
394
  - `fullPage` 截图在部分主机的**软件合成**下不稳定 —— 请求 `fullPage` 时**原生 `capturePage` 路径被整体跳过**(`capturePage` 没有捕获滚动区以外内容的能力),因此**三种载体都走 CDP 的 `captureBeyondViewport`**,`fullPage` 的不稳定性与载体无关,任何载体都可能碰到;只有视口截图才优先走原生的 `capturePage`。
370
395
  - 人机验证(CAPTCHA)无法自动解决:快照会标注检测到的挑战,此时应请用户在共享窗口中人工完成,而不是反复重试。
371
396
  - 无痕模式(`privateMode`)未实现:它需要 Electron 的 session 分区能力,属于宿主层,本插件不承诺。
372
- - `browser_download` 在页面上下文内 `fetch`(带登录态),受同源/CORS 约束;仅允许 HTTP(S) 目标;`savePath` 必须为绝对路径且位于 `downloadDir` 内(默认系统下载目录,自动识别 `Downloads`/`下载`/`下載` 与 `XDG_DOWNLOAD_DIR`,可用 `downloadDir` 覆盖),不覆盖已存在文件;`browser_screenshot` 的 `savePath` 走同一准入门;单文件上限 256MB(流式限流,按 Content-Length 提前拒绝),文件由浏览器子进程直接落盘(临时文件 + 原子改名)。
397
+ - 设置文档**存在但读不懂**(JSON 损坏、写到一半、权限读不到)时,四个**门控**开关(`credentials.allowRead` 与三个动作开关)一律按**关**处理,其余设置取默认值 —— "读不透操作者的意图"不等于"操作者什么都允许"。文件**不存在**(首次运行)仍按默认值。单个门控字段类型不对(`"false"` 字符串、`null`、数字)同样按**关**。
398
+ - `browser_download` 在页面上下文内 `fetch`(带登录态),受同源/CORS 约束;仅允许 HTTP(S) 目标;`savePath` 必须为绝对路径且位于 `downloadDir` 内(默认系统下载目录,自动识别 `Downloads`/`下载`/`下載` 与 `XDG_DOWNLOAD_DIR`,可用 `downloadDir` 覆盖),不覆盖已存在文件;`browser_screenshot` 的 `savePath` 走同一准入门;单文件上限 256MB(流式限流,按 Content-Length 提前拒绝),文件由浏览器子进程直接落盘(临时文件 + 原子改名)。准入还会解析路径的**真实归属**:`downloadDir` 里的符号链接无法把写入带出目录,悬空链接同样被拒(它读作"名字已被占用",而不是"路径空闲")。
373
399
  - 自托管浏览器的 cookie 在磁盘上以明文存储(Electron 默认行为);需要加密落盘的部署应在宿主层接入系统钥匙串 / DPAPI。
374
- - `browser_restrict` 是防误操作的**软护栏**,不是安全边界:模型可以自行解除白名单。
400
+ - `browser_restrict` 是防误操作的**软护栏**,不是安全边界:模型可以自行解除白名单。要一个**模型拿工具改不掉**的限制,用设置页「Agent 能做什么」的三个动作开关(它们写在设置文档里,`browser_*` 工具没有一个会写它),或部署级的 `tool-browser` 配置 `allowedActions`(见 `cordis.patch.yml` 的注释)。**这条边界要说准**:设置走的是本机 HTTP 端点,写入要求带同源 `Origin`(裸 HTTP 客户端因此写不进去),但**同源页面仍然可以写** —— 这是"插件暴露了一个 HTTP 端点"的固有代价,它**不是凭据系统**,面板上的说明也是这么写的。
375
401
  - 页面弹窗(`window.open` / `target=_blank`)不再覆盖当前视图:HTTP(S) 弹窗会在同一会话窗口**新开一个标签页**并计入历史,原页面与 opener 上下文保留;非 HTTP(S) 弹窗(空 URL 弹窗承接、`mailto:`、自定义协议)仍**放行原生窗口**,交给系统处理——这类弹窗不纳入会话模型。
376
- - `browser_auth` 的 cookie 往返不保留 `hostOnly`/`sameSite` 字段(host-only cookie 恢复后变成 domain cookie)。**三种载体都可用**:自托管走原生会话,侧栏与本机浏览器走 CDP 的 `Storage.getCookies` / `Storage.setCookies`。
402
+ - `browser_auth` 的 cookie 往返不保留 `hostOnly`/`sameSite` 字段(host-only cookie 恢复后变成 domain cookie)。**三种载体都可用**:自托管走原生会话,侧栏与本机浏览器走 CDP 的 `Network.getCookies` / `Storage.setCookies`。**`flush` 只导出当前页所属站点的 cookie**,不是整台机器的共享 cookie 罐 —— 导出范围按本会话所在页面的 URL 收敛;页面上没有可用的 URL(未知范围)时**直接拒绝**,而不是扩大范围去读全部域(自托管侧栏载体报"无法确定 cookie 范围",CDP 载体报 `BROWSER_AUTH_SCOPE_UNKNOWN`);`browser_restrict` 的白名单**也管着 `browser_auth`**,因为 `restore` 是向任意域写 cookie 的动作。
377
403
  - 自托管浏览器子进程崩溃(或宿主 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
404
  - electron 随插件安装;但 Electron 44+ 不再随安装下载二进制(约 100MB,需网络)——插件探测是纯文件系统、不触发其懒下载,二进制缺失时首次使用会报错并提示先 `npx install-electron`;也可预装 `ELECTRON_PATH` 指定的二进制。
379
405
  - 本插件不提供任何**浏览器界面**(地址栏、标签条、侧栏面板都不是插件画的):桌面端的浏览器界面是**外壳自带的官方侧栏**,我们只是借它的页面来驱动;自托管载体下画窗口的是插件拉起的那个 Electron 子进程,那是载体本身而非插件 UI。别把"侧栏"或"浏览器列"当成插件能力。
@@ -423,10 +449,10 @@ npm run build
423
449
  | 第十一轮 | 2026-08-27 | **自托管宿主崩溃后的会话自愈**(issue #5):宿主死亡(DSH 重启 / checkpoint 恢复 / 崩溃)后,已打开的会话在**下一次调用时自动重建宿主并重试**——不再报 "browser host is not running",半死态消除;新增 host-gone 日志与假子进程回归测试 |
424
450
  | 第十二轮 | 2026-08-27 | **resolveElectronPath 排除打包应用**(issue #6):新增 `isBareElectron`(旁有 `app.asar` 即打包应用,一律不复用,全平台含 macOS bundle 布局);bundled electron 纯文件系统探测置最优先;`ELECTRON_PATH` 显式覆盖最优先;dist 缺失时明确报错(`npx install-electron` 指引) |
425
451
  | 全面复审加固 | 2026-08-27 | **三轮审查加固**:并发恢复双重建竞态(child `createView` 幂等化)、macOS bundle 路径判定、`dispose()` vs `start()` 僵尸 child 竞态三道闸、pendingSocket 泄漏、选路顺序全量单测(24→25 项测试) |
426
- | **0.1.20** | 2026-08-27 | **发布**:第十一/十二轮 + 全面复审加固随 **0.1.20** 发布(构建零错误、25 项测试全绿) |
452
+ | **0.1.20** | 2026-08-28 | **发布**:第十一/十二轮 + 全面复审加固随 **0.1.20** 发布(构建零错误、25 项测试全绿) |
427
453
  | 第十三轮 | 2026-08-28 | **macOS/Linux 输入框无法键入修复**(issue #7):0.1.16 引入的 Windows 焦点路由(mousedown 强制 focus + 窗口 refocus 恢复)未做平台判断,与 macOS 原生 click-to-focus 冲突导致登录框收不到键入 → 两处焦点逻辑加 `win32` 平台门,非 Windows 恢复原生行为 |
428
454
  | 第十四轮 | 2026-08-28 | **window.open/target=_blank 新开标签**(issue #8):HTTP(S) 弹窗不再 loadURL 覆盖当前视图,转交父进程在**同一会话窗口新开标签**——原页面与 opener 上下文保留(门户「工作台」类跳转不再 403),跳转计入会话历史;未分组视图保留回退;非 HTTP 弹窗仍放行系统 |
429
- | **0.1.21** | 2026-08-28 | **发布**:第十三/十四轮随 **0.1.21** 发布(构建零错误、25 项测试全绿) |
455
+ | **0.1.21** | 2026-09-02 | **发布**:第十三/十四轮随 **0.1.21** 发布(构建零错误、25 项测试全绿) |
430
456
  | macOS 二进制探测 | 2026-09-09 | **Electron.app 布局探测**(issue #9 / #14):`electronDistExe()` 只认 `dist/electron(.exe)`,macOS 的 `dist/Electron.app/Contents/MacOS/Electron` 永远找不到 → 共用平台探测补 darwin 候选路径(bundled 与 profile/anchor 两层同时受益);新增回归测试 |
431
457
  | 第十五轮 | 2026-09-16 | **工具栏脚本解析期 SyntaxError**(issue #11):内联脚本 `const bridge = window.bridge` 与 `contextBridge.exposeInMainWorld` 装上的不可配置全局冲突(`HasRestrictedGlobalProperty`)→ **解析期** early error,整段脚本一行都不执行(地址栏回车 / 四个导航按钮 / 标签条 / 错误条全失效)→ 整段包进 IIFE 并把句柄改名 `tb`,从结构上杜绝同类冲突;新增 3 个工具栏回归测试(从**发布产物**解析出脚本、在 `vm` 里按 contextBridge 语义真执行) |
432
458
  | 第十六轮 | 2026-09-16 | **自托管三连修复**(issue #10):① `browser_open` 用 `performance.timeOrigin` 指纹 + `readyState` 有界(5s)等新文档 settle,不再返回"有标题、0 元素"的空快照;② 新增等待式 `presentView` 屏障(先 materialize 视图再 showView,再发 ping 屏障;子进程消息严格串行),`click`/`type`/`key` 派发 `Input.*` 前必须 present,失败明确报 `BROWSER_VIEW_NOT_PRESENTED` 而非假报成功;③ `createView` 前有界加载 `about:blank`,新视图必有渲染进程(宿主重启后卡死的根因);④ `did-navigate` 标记强制重呈现、命令 20s 有界超时、子进程 stderr 与退出码落 `$DSH_HOME/logs/dsh-builtin-browser-host.log`(2MB 自截断)、`locateTab` 兼容裸 uuid 与 `tab:<uuid>` |
@@ -449,8 +475,18 @@ npm run build
449
475
  | **0.3.1** | 2026-10-01 | **发布**:第二十七轮(惰性启动 + 释放后拒绝启动)随 **0.3.1** 发布。**100/100 测试全绿**,tag `v0.3.1` |
450
476
  | 第二十八轮 | 2026-10-01 | **两条"限制"其实是实现限制**:审查文档时被指出,查证后确认它们与载体无关 —— ①`browser_auth` 原本直接调用只有自托管才实现的原生方法,没有就报 `BROWSER_AUTH_UNSUPPORTED`;但 **cookie 本来就在 CDP 里**(`Storage.getCookies` / `Storage.setCookies`),而**三种载体全都走 CDP** → 改为有原生方法时优先用它、否则走 CDP,**三种载体全部可用**,并处理 domain+path 与 URL 的差异、前导点、秒与毫秒,丢弃无法构成有效 URL 的 cookie。②JPEG 被对所有 CDP 路径禁用,理由是"CDP JPEG 在 Electron 上挂起" —— **对 Electron 成立,对本机 Chrome/Edge 无关**(那是真浏览器)→ 新增 `supportsCdpJpeg` 能力声明,只有本机浏览器声明,provider 据此透传 `format`/`quality`;侧栏保持 PNG。③顺带发现**降采样在工具描述里承诺了却只在原生路径实现** → CDP 路径改用 `clip.scale`(先读 `Page.getLayoutMetrics` 取文档尺寸,读不到就**不缩放地照常截图**),三种载体都能缩放。④文档补齐两条从未写过的载体差异:**侧栏不上报用户操作事件**(那个页面属于外壳)、**本机浏览器使用插件自己的 profile**(个人登录态不继承)。新增 11 条纯函数测试,**124/124** |
451
477
  | 第二十九轮 | 2026-10-01 | **独立代码审查**:另派一个审查者通读全树并**亲手复现**了发现。① **`spawn` 没有 `error` 监听** —— 启动失败(ENOENT、无执行权限、二进制损坏)是**异步事件**,无监听时 Node 会**把整个 DSH 宿主进程带走**;现在改为让该次命令失败。② **`dispose()` 落在 `start()` 的 250ms 轮询期间**时,已拉起的浏览器无人可杀而永久存活;轮询现在会在退出前 kill 它。③ 三种不同失败(子进程退出 / 被释放 / 真超时)共用"30 秒内没暴露 CDP",而且**半秒就报出来**;现在各自说明真实原因。④ **明确选择 `chrome`/`edge` 时仍被桌面侧栏顶掉**(发现流程从不检查 channel)—— 这也让"你选的浏览器没装"那条说明永远无法出现,等于抵消了上一轮的修复;现在明确选择会跳过侧栏发现。⑤ `CdpClient.whenReady()` 等待的 promise 无界,连接被丢弃时工具调用**永不返回**;已加界。⑥ `focus()` 用 `kill('SIGCONT')`,在 Windows 上是空操作,而注释承诺"把窗口前置";注释已改为实情。⑦ 删除不可达的 `brave` 分支。**125/125** |
452
-
453
- > registry 上的最新版本以顶部 npm 徽章为准(当前 `0.4.0`)。桌面端升级后需重跑一次 `node desktop-bridge/install.mjs`;Web 端无此步骤 —— 详见[更新方式](#更新方式两端不同)。
478
+ | 第三十轮 | 2026-10-02 | **第二轮独立审查的 4 个 HIGH + 2 个 MEDIUM**:① **只读工具真的会被白名单拦** —— `assertAllowed` 只豁免 `browser_restrict` 自己,过窄的白名单会把 `browser_history`/`browser_reset_session`/`browser_auth` 一起锁死且无路可退 → 新增显式只读集合(加上能解除限制/复位的工具);② **被取消的导航全程报成功** —— `navigate()` 只在开头查一次 signal,而 `settleDocument` 遇到 abort 静默 return,于是那条调用**不抛异常**地完成,还把这一页写进持久浏览历史;③ **设置栏的开关没人读** —— `credentials.allowRead` 只存在于设置存储与面板,关掉后 `browser_auth` 照常导出全部 cookie;两条 auth 路径现在真的受它门控;④ **过期的 tab id 能关掉别人的标签** —— `locateTab` 在本会话找不到就遍历所有会话,会关掉别的任务或人类的标签页还报成功,现在只查本会话(仍接受裸 uuid 与 `tab:<uuid>`);⑤ 空 `target.value` 时 `browser_type` 退化成往当前焦点(可能是密码框)输入、`browser_click` 丢弃语义目标改用坐标,两者都报成功 → 空值现在直接报错;⑥ scrape 的外层预算与页内截止用同一个数字,页内已判明的"no elements matched"永远传不回来 → 外层留出传输余量。**130/130** |
479
+ | **0.4.0** | 2026-10-02 | **发布**:第二十七~三十一轮合并发布 —— 主题是**把"限制"分成真限制与实现限制**:`browser_auth` 三种载体全部可用、本机 Chrome/Edge 支持 JPEG、降采样三种载体都可用;同时修掉会崩宿主、会静默做错事、会永久挂住的缺陷。**130/130**,tag `v0.4.0` |
480
+ | 第三十一轮 | 2026-10-02 | **第三轮复核 + 设置写入原子化**:① **设置写入改为临时文件 + 同目录 rename**(rename 是提交点,崩溃/磁盘满不会留下半个 JSON 再被读者按默认值取代),失败时只清理临时文件并**不再把新值发布为缓存**;② 修复上一轮引入的 `el` 未重新绑定(选择器匹配到多个元素时,值写到了第一个元素上却报成功);③ `handle.focus()` 补上 5s 预算(它是全文件唯一没有预算的 await,而它在输入之前);④ `browser_wait` 的轮询每 200ms 泄漏一个 abort 监听器(30s 等待约 150 个)已改为复用 `delay()`;⑤ `autoExpandOnce` 关闭时 `showActive` 把"显示视图"和"收起载体"当成互斥分支并提前 return,在没有收起能力的自我托管载体上**视图从不显示**,表现为"agent 驱动的不是人看的那个视图 + 截图报 view not painted"(错误信息怪页面,其实是设置);⑥ 若干项与 `bridge-connection`/`scrape` 超时的收尾修复。**130/130** |
481
+ | **0.4.1** | 2026-10-02 | **发布**:issue #21 —— **系统浏览器载体(chrome/edge)进程死亡后不重连、不重启**,每条 `browser_*` 挂起 30 秒、只能重启 DSH。启动阶段失败上一轮已处理,但**启动成功之后**的死亡完全没被观察:没有 `exit` 监听,`this.client` 赋值后从不清空,`ensureClient()` 永远返回那条已断开的连接。按两种失败形态分别修:进程死亡 → 全程观察子进程,退出即清缓存、下次调用重新拉起;socket 死亡而进程存活 → 用缓存前先查存活;进程与 socket 都在但命令不再回答 → **命令失败即丢弃客户端**。另外在已关闭的 socket 上发命令改为**立刻失败并说明原因**,而不是等满预算再报一条指向不到真正原因的"超时"。**132/132**,tag `v0.4.1` |
482
+ | 第三十二轮 | 2026-10-03 | **输出成本审计 + 独立验证**(未发布):① **`browser_a11y`**:默认节点数 **500 → 150**、缩进两空格 → 一空格、`states=[…]` 只列非默认项(**没有 `states=` 就是 enabled**)、结尾提示缩短;② **快照与无障碍树给出可引用选择器**:`{#id}` / `{[name=x]}`(此前页面脚本算出来了却被工具层丢掉,而定位工具只接受 css/text/xpath,模型只能猜文本或退回截图),并在输出 schema 里声明该字段(否则 `additionalProperties: false` 会让整个结果被拒 —— 这正是它第一次真实调用时暴露的方式);③ **`browser_content` 上限改为按格式**(html/json 50 000,其余 20 000,`maxChars` 可覆盖),**`json` 不再返回 `{}`**(DOM 节点序列化不出东西)而返回 `{"html":…,"tag":…}`,截断提示说明如何收窄;④ `browser_scrape` 结果改紧凑 JSON;⑤ `browser_execute` 返回值上限 50 000 字符并写明截断;⑥ `browser_history` 默认只展示最近 20 条、不回显参数、首行给总数,`verbose: true` 才带参数(该参数此前声明了没人读);⑦ `browser_visited` 时间戳精确到分钟、会话标记只在变化时打印一次并取 8 位前缀;⑧ **`browser_auth` 的 `flush` 只导出当前页所属站点的 cookie**(此前是整台机器共享的 cookie 罐),**范围未知时拒绝而不是放宽**;`restore` 的条数反映真的提交了多少(`Storage.setCookies` 没有返回值,此前恒为 0,读起来像失败);**`browser_auth` 移出只读集合**——它的 `restore` 向任意域写 cookie,是动作不是观察;⑨ **设置写入原子化**、设置缓存先返回再读文件、浏览历史不再每次导航重写整个文件(到上限后每条导航重写 648 KB,实测 17.5~20.6ms 一次)、`browser_content` 页内脚本的语法错误(四种格式全坏)、`browser_visited` 输出空行、`bridge-connection` 按请求 id 关联应答、`tools/install-web-plugin.mjs` 先确认能修再动 profile。另有**两条无上限的页内等待**(历史:自托管截图/下载,以及 cookie 导出与恢复)补齐超时。**132 → 134** |
483
+ | 第三十三轮 | 2026-10-03 | **系统浏览器载体:崩溃之后真的能用**(未发布):① **一个命令失败不再拆掉整条连接** —— CDP 协议错误意味着浏览器答了并拒绝这一条,连接是健康的;② **重启后清掉的是会话映射**(不只是缓存里的连接与子进程),否则新浏览器收到的每条命令都带着上个进程发放的 session id —— 这就是"重启之后每条调用都失败、永不恢复"的成因;③ **慢页面不是死浏览器**:以错误文本判死活会把 5s/15s 的外层超时当成浏览器死亡,进而杀掉你正在用的窗口(标签与填到一半的表单一起丢),现在状态分三态 —— socket 已关闭 → 丢弃、有应答但拒绝 → 保留、**没有应答 → 标记存疑**,下次调用先用 `Browser.getVersion` 探一次;④ **kill 之后最多等 3 秒它真的退出**(Windows 上 `kill` 是异步的,不等就会把新进程交给旧实例,还把由此产生的秒退误报成"路径不是可运行的浏览器");⑤ **只认本次启动写的 `DevToolsActivePort`**(被杀掉的浏览器没机会清理它,旧文件会让宿主连到别的 Chromium 或 `node --inspect` 上);⑥ **端口发现的回归测试换成真能说 CDP 的夹具**(旧的 `.cmd` 桩在 Windows 上被 Node 拒绝 spawn,三个断言恰好都被一次同步失败满足,CI 一直是绿的而退出处理一次都没跑)。**134/134** |
484
+ | 第三十四轮 | 2026-10-03 | **操作者级动作开关(真正的入口)**:设置页新增「Agent 能做什么」—— **执行页面脚本** / **下载文件到磁盘** / **写入登录状态** 三个开关,关闭即拒绝对应能力(`BROWSER_EXECUTE_DISABLED` / `BROWSER_DOWNLOAD_DISABLED` / `BROWSER_AUTH_WRITE_DISABLED`)。与 `browser_restrict` 的区别是**谁能改**:后者由模型设定、也由模型解除;这三项写在设置文档里,没有任何工具能写它。顺带修正一处读/写混淆:恢复登录态(`browser_auth` restore)原先受**读**开关管、报错还说 "reading cookies is switched off" —— 允许读取的操作者因此无法拒绝写入;现在它归写入开关,读与写可以分别设置。**148 → 157** |
485
+ | 第三十五轮 | 2026-10-03 | **DSH 0.2.1 适配(未发布)**:宿主升到 `0.2.1-alpha.1`(相对 rc.2 有 266 个提交),逐项核对插件的宿主依赖面 —— `cordis` 的 `Context`/`Service`(vendor 4.0.5-alpha.1)、`dsh-tools` 的 `defineTool` 与 `DefineToolOptions` 全部字段、`dsh-system-prompt` 的 `section()`、`dsh-llm` 的 `HarnessError`、`schemastery` 的默认导出,以及客户端的 `slots.inject('settings.section')` 与 `locale.register/bind` —— **形状全部未变**(客户端侧与 DSH 官方插件的用法逐字对照)。新增 `scripts/verify-host-compat.mjs`:用 profile 里**真实安装**的 `@deepseek-ai/*` 组装宿主、加载插件编译产物、经宿主的 `defineTool` 注册 34 个工具、再以假 provider 真调 `browser_session` 与 `browser_a11y`,实测**全绿**;`dsh.compatibility.dshReleases` 记入 `0.2.1-alpha.1 = compatible`。另记一处语义坑:该范围在 **semver 默认语义**下不匹配任何预发布版本,而 DSH 用 `includePrerelease: true` 判定,故这些宿主实际都在范围内 |
486
+ | 第三十六轮 | 2026-10-04 | **一轮外部审计的落实(未发布)**:审计给了 8 条带行号与证据链的指控,逐条用代码核实后分三批修完 —— ①**存储与设置**:设置文件**存在但读不懂**时不再回落"全开"(那让一次撕裂写入重新打开操作者关掉的全部能力),门控字段类型不对也按 OFF 处理,`history.jsonl` 的整文件重写改用设置那套原子写,`contentMaxChars` 这个被"按格式分档"取代后**赋值却无人读取**的配置重新生效;②**生命周期**:浏览器子进程先收 socket 关闭、给足时间自己 `app.quit()` 落盘 profile,之后才强杀(旧注释声称的"`app.exit(0)` 会 flush profile"是假的,而 Windows 上 `kill()` 是 TerminateProcess,子进程根本来不及跑 handler),子进程侧也不再 `app.exit(0)`;③**可用性**:click/type/fill 的目标解析此前只匹配顶层 `document`,而快照与 `waitFor` 走 shadow root 与 iframe —— **看得见、等得到、却点不到**,现在解析脚本也收集全部根;Windows 上补上 `%LOCALAPPDATA%` 的用户级安装路径,并把 "exited immediately (exit code 0)" 的文案从"路径不是可运行浏览器"改成它真正的含义(把请求交给已占用该 profile 的实例后退出)。另修两处会静默破坏他人工作的地方:`install-web-plugin.mjs` 不再把 registry 版本号写进 profile 覆盖 `link:` 钉版,失败的 adopt 会先 dispose 那个半成品 host。**审计里两条我不同意**:`window-all-closed`/`before-quit` 的 grep 为 0 并不说明问题(Electron 默认行为已正确),而 `requestSingleInstanceLock` 会破坏正常重启而非修好孤儿。 |
487
+ | **0.4.2** | 2026-10-04 | **发布**:第三十二~三十六轮合并发布 —— 主题是**把看不见的错变成看得见的**。① **输出成本**:每次调用的 token 降到原来的零头(a11y 默认 **500 → 150** 节点、缩进与 `states` 只留信息量、`content` 按格式分档、`history` 默认 20 条)。② **动作开关**:三个由**操作者**持有、`browser_*` 工具一个都改不掉的权限闸(执行页面脚本 / 下载到磁盘 / 写入登录状态),与模型自设自解的 `browser_restrict` 明确分开。③ **恢复语义补完**:系统浏览器载体进程死亡后**连会话一起重建**(此前浏览器能重启,视图却还拿着死浏览器的 session id,于是"快速失败、持续失败")、慢页面不再被误判成死浏览器、`kill` 之后先等它真的退出。④ **一轮外部审计的落实**:设置文件**存在但读不懂**时改为 fail-closed(此前一次撕裂写入会重新打开操作者关掉的**全部**能力,且下一次保存把默认值写回磁盘)、`history.jsonl` 原子写、**请求时读取**的客户端面板不再因宿主缺字段而整体打不开、click/type/fill 现在能点到快照里看得见的 shadow DOM 与 iframe 元素、Windows 补上 `%LOCALAPPDATA%` 用户级安装路径。**171/171 通过**(共 172 项,1 项因平台跳过),tag `v0.4.2` |
488
+
489
+ > registry 上的最新版本以顶部 npm 徽章为准(当前 `0.4.1`)。桌面端升级后需重跑一次 `node desktop-bridge/install.mjs`;Web 端无此步骤 —— 详见[更新方式](#更新方式两端不同)。
454
490
 
455
491
  ## 特别感谢
456
492