dsh-builtin-browser 0.4.2 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +1753 -0
  2. package/README.en.md +63 -20
  3. package/README.md +60 -20
  4. package/client.js +433 -24
  5. package/desktop-bridge/install.mjs +66 -6
  6. package/desktop-bridge/plugin-browser-bridge.js +425 -163
  7. package/docs/architecture.md +4 -4
  8. package/docs/tool-reference.md +3 -3
  9. package/docs/user-guide.md +50 -2
  10. package/lib/browser/types.d.ts +9 -2
  11. package/lib/browser-electron/bridge-connection.d.ts +18 -21
  12. package/lib/browser-electron/bridge-connection.js +42 -1
  13. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  14. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  15. package/lib/browser-electron/entry.d.ts +20 -0
  16. package/lib/browser-electron/entry.js +110 -7
  17. package/lib/browser-electron/history-store.d.ts +11 -2
  18. package/lib/browser-electron/history-store.js +27 -5
  19. package/lib/browser-electron/host-main.js +11 -1
  20. package/lib/browser-electron/provider.d.ts +62 -2
  21. package/lib/browser-electron/provider.js +28 -12
  22. package/lib/browser-electron/remote-host.js +19 -6
  23. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  24. package/lib/browser-electron/rpc-ops.js +36 -0
  25. package/lib/browser-electron/settings-route.d.ts +6 -6
  26. package/lib/browser-electron/settings-route.js +48 -8
  27. package/lib/browser-electron/settings-store.js +57 -16
  28. package/lib/browser-electron/system-browser.d.ts +31 -9
  29. package/lib/browser-electron/system-browser.js +116 -27
  30. package/lib/index.js +0 -1
  31. package/lib/tool-browser/element-fields.d.ts +145 -0
  32. package/lib/tool-browser/element-fields.js +152 -0
  33. package/lib/tool-browser/index.js +26 -60
  34. package/package.json +6 -2
  35. package/src/browser/types.ts +9 -2
  36. package/src/browser-electron/bridge-connection.ts +37 -1
  37. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  38. package/src/browser-electron/entry.ts +113 -8
  39. package/src/browser-electron/history-store.ts +32 -7
  40. package/src/browser-electron/host-main.ts +10 -1
  41. package/src/browser-electron/provider.ts +91 -18
  42. package/src/browser-electron/remote-host.ts +19 -6
  43. package/src/browser-electron/rpc-ops.ts +41 -0
  44. package/src/browser-electron/settings-route.ts +54 -13
  45. package/src/browser-electron/settings-store.ts +56 -17
  46. package/src/browser-electron/system-browser.ts +121 -28
  47. package/src/index.ts +3 -2
  48. package/src/tool-browser/element-fields.ts +167 -0
  49. package/src/tool-browser/index.ts +31 -60
package/README.en.md CHANGED
@@ -93,7 +93,7 @@ See the full list in [Tool reference](#tool-reference).
93
93
  <tr>
94
94
  <td width="50%" valign="top">
95
95
  <h3>Multi-tab sessions</h3>
96
- <p>Open URLs in parallel tabs; list/switch/close/reset tabs while each session keeps its own state. On the desktop <b>each session gets its own tab in the sidebar</b>, so ending one session does not disturb another.</p>
96
+ <p>Open URLs in parallel tabs; list/switch/close/reset tabs while each session keeps its own state. On the desktop <b>each session gets its own tab in the sidebar, and can open further tabs within it</b>, so ending one session does not disturb another.</p>
97
97
  </td>
98
98
  <td width="50%" valign="top">
99
99
  <h3>Multi-format content</h3>
@@ -103,7 +103,7 @@ See the full list in [Tool reference](#tool-reference).
103
103
  <tr>
104
104
  <td width="50%" valign="top">
105
105
  <h3>Per-task session isolation</h3>
106
- <p>Each DSH task (session) gets its own browser session (own tabs and history); concurrent tasks never fight over the page or pollute each other. Calls within one task reuse the same session.</p>
106
+ <p>Each DSH task (session) drives <b>its own sidebar panel and its own page</b>: which conversation the shell happens to be displaying makes no difference, so a page requested in one conversation can never turn up in another's. Concurrent tasks never fight over the page or pollute each other (cookies, history and logins are shared across the session by design).</p>
107
107
  </td>
108
108
  <td width="50%" valign="top">
109
109
  <h3>Login persistence</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). 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>
156
+ <p><b>Which browser</b> (sidebar browser / 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>
@@ -173,7 +173,7 @@ See the full list in [Tool reference](#tool-reference).
173
173
 
174
174
  | Tool | Purpose | Guard |
175
175
  | --- | --- | --- |
176
- | `browser_open` | Open a URL (optionally in a new tab); returns a page snapshot | ✅ |
176
+ | `browser_open` | Open a URL; returns a page snapshot. **A further call in the same session opens a new tab** (the first call opens the side panel automatically) | ✅ |
177
177
  | `browser_wait` | Wait for page load (optional expected URL / CSS selector), returns readiness | – |
178
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
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) | – |
@@ -202,7 +202,7 @@ See the full list in [Tool reference](#tool-reference).
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 | – exempt |
204
204
  | `browser_history` | Operation log (newest last), with per-step success/error and result summary | – |
205
- | `browser_replay` | Replay one step by sequence number (navigate/execute/click/type) | ✅ |
205
+ | `browser_replay` | Replay one step by sequence number (navigate/click/type/scroll/key/execute) | ✅ |
206
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
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) | – |
@@ -217,17 +217,50 @@ 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 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`.
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 the literal `enabled` is omitted from `states` (`unchecked`, `collapsed` and others are still 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
 
224
- ### Operating discipline (click/fill)
224
+ ### Operating discipline (locating/clicking/filling)
225
225
 
226
+ - **Semantics first, coordinates last**: `browser_a11y` gives roles and accessible names, `browser_snapshot` gives refs and coordinates, and **both carry a selector** (see the next section) — **use it when it exists**, because scrolling and layout changes do not invalidate it.
227
+ - **`browser_click`'s `target` (css/text) IS semantic location**; use it instead of computing coordinates yourself. Scrolling into view, clicking the centre, iframe offsets and device pixel ratio are all handled for you.
226
228
  - **Prefer DOM semantics over coordinates**: submit forms with `form.requestSubmit()`; click with `element.click()`; coordinate clicks are the last resort.
227
229
  - **Target the right element**: pages often have hidden duplicates (e.g. mobile buttons); filter visible elements with `browser_execute` (`getBoundingClientRect()` w/h > 0, `getComputedStyle` not `display:none`), then take coordinates.
228
230
  - **Click right after taking coordinates**: do not insert other operations in between (filling/scrolling moves elements and invalidates old coordinates).
229
231
  - **Verify before clicking**: use `document.elementFromPoint(x, y)` to confirm the coordinate hits the intended element (button/link), then perform the real click.
230
232
  - **DPR awareness**: CDP input uses CSS pixels; on high-DPI screens calibrate with `elementFromPoint` instead of guessing coordinates.
233
+ - **Two failures of the same action means change the approach**: a different selector, a semantic target, or a fresh snapshot — not the same coordinate a third time.
234
+
235
+ ### Vision strategy: know which one you are in first
236
+
237
+ The **`vision.strategy`** setting decides whether coordinate clicks are usable, and **that is the only thing it affects**:
238
+
239
+ | Strategy | Coordinate clicks | Suited to |
240
+ | --- | --- | --- |
241
+ | `auto` (default) | allowed | **A model that can read images** — screenshots are a valid way to check layout |
242
+ | `nonVisual` | **refused** | **A model with no image input** |
243
+
244
+ **Under `nonVisual` a coordinate click is refused outright**, reported as `BROWSER_NON_VISUAL_COORDINATES`, and the error names the fix: pass a semantic target, e.g. `target { by: "text", value: "Sign in" }`, which locates the element from the DOM and clicks its centre. **That is a configuration, not a failure — do not retry the same coordinate.**
245
+
246
+ **DOM queries, the accessibility tree, text extraction and structured scraping are identical under both strategies** — that is, **the page can be operated completely without image input**. So the "when not to screenshot" advice below is the default for **every** model, not a concession to the ones that cannot see.
247
+
248
+ ### When NOT to take a screenshot
249
+
250
+ Tool output is spent out of the caller's context, and **a screenshot is the most expensive kind**:
251
+
252
+ - **If text will do, use text.** `browser_snapshot` / `browser_a11y` / `browser_content` carry the same page as text; **a model without image input gains nothing from a screenshot** and only pays for it. Keep screenshots for what genuinely needs pixels: layout, charts, design checks.
253
+ - **A downscaled capture returns `width`/`height`** (the real pixel size). When you use `maxWidth`/`maxHeight` to control cost, read those to confirm it took effect rather than decoding the data URL yourself.
254
+
255
+ ### The operator's three switches, which no tool can change
256
+
257
+ **Running page scripts**, **downloading to disk** and **writing login state** are controlled by the operator in the settings page, and **no `browser_*` tool can change them**. When one is off the error code is unambiguous — **do not retry and do not route around it**; say that the human needs to enable it:
258
+
259
+ `BROWSER_EXECUTE_DISABLED` (scripts), `BROWSER_DOWNLOAD_DISABLED` (downloads), `BROWSER_AUTH_WRITE_DISABLED` (writing login state), `BROWSER_AUTH_DISABLED` (reading cookies).
260
+
261
+ Do not confuse those with `BROWSER_DOWNLOAD_BLOCKED` / `BROWSER_SCREENSHOT_BLOCKED`, which mean **the save path was refused by the sandbox** (change the path, not a setting), or `BROWSER_DOWNLOAD_UNSUPPORTED`, which means **this carrier has no such capability**.
262
+
263
+ **`browser_restrict` is different** — it is a temporary allow-list the model sets and lifts itself, and **it is not a way around the three switches above**.
231
264
 
232
265
  ### Output size and truncation
233
266
 
@@ -243,17 +276,18 @@ Tool output is spent out of the caller's context, so several results have explic
243
276
 
244
277
  ## Configuration
245
278
 
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:
279
+ 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 reads a row's `dsh.client` only when that row resolves to an **exact package name** — a row named after a subpath is skipped entirely, and then the settings panel would never appear) plus three functional rows (`browser` / `browser-electron` / `tool-browser`). Per-row config:
247
280
 
248
281
  | Row | Key | Type | Default | Description |
249
282
  | --- | --- | --- | --- | --- |
250
283
  | `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 |
251
284
  | `browser-electron` | `httpOnly` | boolean | `true` | Allow HTTP(S) navigation only; other protocols (e.g. `file:`/`data:`) rejected (`BROWSER_NAVIGATION_BLOCKED`) |
252
285
  | `browser-electron` | `snapshotMaxElements` | number | `60` | Max snapshot elements before truncation |
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 |
286
+ | `browser-electron` | `contentMaxChars` | number | unset (per-format defaults) | The default content character cap. **Unset**, the caps are per format (html 50 000, json 50 000, txt 20 000, markdown 20 000); **set**, it is the operator's value and overrides them. A call's own `maxChars` outranks both |
254
287
  | `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 |
255
288
  | `tool-browser` | `timeoutMs` | number | `60000` | Cooperative tool timeout (ms) |
256
289
  | `tool-browser` | `tabTools` | boolean | `true` | Register tab-management tools (`browser_list_tabs` etc.) |
290
+ | `tool-browser` | `allowedActions` | string[] | unrestricted | **Deployment-level** action allow-list, applied to every task in the deployment (a task can still narrow it further with `browser_restrict`); `READ_ONLY_TOOLS` and `browser_restrict` are never blocked, whatever the list says. `cordis.patch.yml` ships an empty `config: {}` (i.e. unrestricted); its comments show how to tighten it |
257
291
 
258
292
  ## How it works
259
293
 
@@ -287,6 +321,10 @@ DSH Desktop is two layers: an Electron shell plus an `--expose-internals` **Node
287
321
 
288
322
  The result: **the page the agent works on is the page the human looks at**. The plugin no longer spawns its own Electron and no second window appears.
289
323
 
324
+ **Which conversation a page lands in is decided by the conversation that asked, never by what is on screen.** The shell mounts one sidebar per conversation, and the plugin's client half opens that conversation's own panel **by session id** (DSH's `openTabIn`) without reading which conversation is displayed — so "ask in A, then switch to B" cannot deliver the page to B, and there is no path from one conversation's request to another's sidebar.
325
+
326
+ > **One behaviour inherited from the shell**: it renders only the **displayed** conversation's sidebar. When the asking conversation is not the one on screen, its panel is still created (the tab really does land in that conversation), but the page attaches only once that conversation is shown. In practice: after switching away, a further request from A reports "a panel was placed, but the page can only attach while the conversation is displayed" — a notice, not a cross-conversation write.
327
+
290
328
  Install that bridge (it modifies an **installed** desktop app, so it is replayable):
291
329
 
292
330
  ```bash
@@ -331,9 +369,10 @@ The settings panel can point the plugin at an **installed Chrome or Edge** (`bro
331
369
  ## Requirements
332
370
 
333
371
  - DeepSeek Harness (dsh) with the matching profile (`web` / `desktop`, etc.)
334
- - **Electron runtime** (required dependency, installed automatically with the plugin, ≥ 40 recommended; the 44+ binary is not downloaded at install time — if missing, follow the error and run `npx install-electron` first, needs network):
372
+ - **Electron runtime** (required dependency, installed automatically with the plugin, ≥ 40 recommended; the 44+ binary is not downloaded at install time — if missing, follow the error and run `npx install-electron` first, needs network) — **only the self-hosted carrier needs it**:
335
373
  - `ELECTRON_PATH` can point at another binary explicitly (highest priority);
336
- - **DSH Desktop**: the packaged host exe (`DSH Desktop.exe`) is **never reused** — packaged apps cannot be spawned with a script argument and misuse exits instantly (issue #6); the bundled electron is used directly, and dev-mode **bare** Electron hosts are still reusable;
374
+ - **DSH Desktop**: the official sidebar by default, so **no Electron is needed**; the bundled electron is used only when it falls back to self-hosting. The packaged host exe (`DSH Desktop.exe`) is **never reused** — packaged apps cannot be spawned with a script argument and misuse exits instantly (issue #6); dev-mode **bare** Electron hosts are still reusable;
375
+ - **an installed Chrome / Edge needs no Electron either**;
337
376
  - **plain `dsh web` self-hosted**: uses the bundled electron package directly
338
377
 
339
378
  ### Verified versions
@@ -344,7 +383,7 @@ The settings panel can point the plugin at an **installed Chrome or Edge** (`bro
344
383
  | Electron | `44.0.0` (≥ 40 recommended; 33.x has a compositor defect) |
345
384
  | Node.js | `22.20.0` |
346
385
  | Installed Chrome / Edge (optional carriers) | `154.0.8037.58` / `154.0.4258.37` |
347
- | dsh-builtin-browser | `0.4.1` (the development branch is past that release) |
386
+ | dsh-builtin-browser | `0.4.4` (unreleased, on the development branch) |
348
387
  | OS | Windows 10 (10.0.26200) |
349
388
 
350
389
  > 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.
@@ -397,6 +436,7 @@ The plugin has one installation per host, and the two are updated separately —
397
436
  - `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`.
398
437
  - CAPTCHA cannot be solved automatically: snapshots flag detected challenges; ask the human to complete it in the shared window instead of retrying.
399
438
  - Private mode (`privateMode`) is not implemented: it needs Electron session partitioning, which is host-layer territory; this plugin does not promise it.
439
+ - A settings document that **exists but cannot be read** (corrupt JSON, a half-written file, unreadable permissions) resolves the four **gate** switches (`credentials.allowRead` plus the three action switches) to **off**, with every other setting at its default — "the operator's intent cannot be read" is not "the operator permits everything". A file that is simply **absent** (first run) still takes the defaults. A gate field of the wrong type (`"false"` as a string, `null`, a number) reads as **off** as well, and so does a **gate-holding section** written as a non-object (`actions: false`, `null`, a stray string): such a section is read as unreadable rather than as absent, so every gate inside it is **off** instead of taking its permissive default.
400
440
  - `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).
401
441
  - 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.
402
442
  - `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.
@@ -422,9 +462,11 @@ Code layout:
422
462
  | Directory | Responsibility |
423
463
  | --- | --- |
424
464
  | `src/browser/` | The `ctx.browser` seam and all request/result types |
425
- | `src/browser-electron/` | Electron CDP provider, self-hosted child (`host-main.ts`), RPC layer |
426
- | `src/tool-browser/` | Model-facing `browser_*` tools |
465
+ | `src/browser-electron/` | The provider and its three carriers — the desktop sidebar bridge (`desktop-bridge-host.ts`), an installed browser (`system-browser.ts`), the self-hosted child (`host-main.ts`); plus the transport (`bridge-connection.ts`) and the RPC operation vocabulary (`rpc-ops.ts`), settings, history and the synthetic cursor |
466
+ | `src/tool-browser/` | Model-facing `browser_*` tools; the element/node field set (`element-fields.ts`), where the item schema and the projection come from one declaration |
427
467
  | `src/types/` | Electron ambient types (shim; no hard electron type dependency) |
468
+ | `desktop-bridge/` | The bridge installed into the desktop app, with its idempotent installer (`install.mjs`) |
469
+ | `tools/` | Operational scripts (e.g. `install-web-plugin.mjs`: pin → install → repair the profile → verify) |
428
470
 
429
471
  ## Update history
430
472
 
@@ -479,14 +521,15 @@ Code layout:
479
521
  | **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
522
  | 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
523
  | **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. |
524
+ | Round 32 | 2026-10-03 | **Output-cost audit + independent verification** (shipped in **0.4.2**): ① **`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** |
525
+ | Round 33 | 2026-10-03 | **The system browser carrier: actually usable after it dies** (shipped in **0.4.2**): ① **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** |
526
+ | Round 34 | 2026-10-03 | **Operator-level action switches (the real entry point)** (shipped in **0.4.2**): 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** |
527
+ | Round 35 | 2026-10-03 | **DSH 0.2.1 adaption** (shipped in **0.4.2**): 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 |
528
+ | Round 36 | 2026-10-04 | **An external audit, worked through** (shipped in **0.4.2**): 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
529
  | **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` |
530
+ | Round 37 | 2026-10-04 | **Architecture refactor + an external review worked through** (no version bump; these changes ship with the next release): the theme is **one fact, no longer hand-copied in four places** — both published bugs (`browser_a11y`'s `selector`, `browser_screenshot`'s `width`/`height`) came out of the same mechanism, "the provider computed it, the schema declared it, and the tool layer's hand-written allow-list dropped it", and two full test runs stayed green because **no test ever ran a tool**. (1) **One field set, declared once**: new `src/tool-browser/element-fields.ts` (the element/node field tables, plus the item schema and the projections derived from them); `browser_snapshot`'s schema joined `browser_open`'s, which the earlier pass had done only half of; and new `tests/element-fields.test.mjs` (7 cases: schema ⟷ tables equal in both directions, the projection carries every declared field, the projection drops undeclared fields, and all 34 registered tools are walked to assert that what the schema requires the tool really produces). (2) **Page-script parsing goes from 4/16 to full coverage**: new `tests/page-scripts-parse.test.mjs` scans the source directly (the provider, `host-main`'s toolbar and download, the virtual cursor, the sidebar bridge) instead of relying on driving a behaviour to capture a script — the cursor, download and bridge scripts had never been parsed at all. (3) **Two real self-hosted bugs**: `materializeOnce`'s "reset on failure" was dead code (it stored a derived promise and compared the original, so the branch could never be taken), letting a child that is alive but not answering **poison that handle permanently** while the retry its comment promised was unreachable; and `fail()` cleared `kill()`'s 500 ms backstop — a dead socket is not a dead child — so it now clears only on an observed exit or the release path. (4) **The seam stops lying**: the handle's `download`/`flushAuth`/`restoreAuth`/`capture` and the host's `notifyUserActionError` were promoted from structural casts to interface declarations (so a new host written against the interface no longer gets a silent no-op), `capture`'s contract gained the size the `browser_screenshot` schema had always declared, and 8 empty methods that existed only to satisfy the interface plus 2 host-level `focus()` methods with no caller anywhere were deleted. (5) **Screenshot size is no longer CDP-carrier-only**: the earlier fix did only the CDP half — the native `capturePage` path never passed its size back, so `width`/`height` were **always missing on the default self-hosted carrier** — and the child now measures and reports them after any downscale, wired through `capture()`'s type, `screenshot()`'s return type and the native branch (reported when the capture was measured; `width?`/`height?` were optional all along). (6) **Settings and storage**: a **gate-holding section** that is present but not an object (`actions: false`, `null`, a string) is no longer treated as absent (which took each gate's permissive default) — every gate inside it now reads as **off**; and `history.maxEntries`/`maxAgeDays` stopped being dead knobs (parsed and persisted, but never handed to the `HistoryStore`), the reader now passing the live values to `updateLimits()`. (7) **System-browser carrier**: the `'exited'` error no longer reads every exit code as "handed the request to an instance holding the profile" — that story holds only for code 0, while a crash code sent people looking for a leftover process that was not there; the launch poll **checks `signalCode` as well** (a browser killed by a signal never sets `exitCode`, so checking only that let an instant death run out the full 30 seconds and report "did not expose CDP"); and the port probe's `fetch` gained an `AbortSignal`. (8) **RPC vocabulary**: new `src/browser-electron/rpc-ops.ts` gives the child's switch and the parent's senders one list of op names, with `tests/rpc-ops.test.mjs` reading both sources and asserting they agree (it caught two ops named from memory that the child does not handle). **183/183 passing** (**184** total, 1 skipped: creating a file symlink on Windows needs elevation) |
488
531
 
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).
532
+ > The npm badge at the top is the authority on the registry's latest version (currently `0.4.2`). 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).
490
533
 
491
534
  ## Acknowledgements
492
535
 
package/README.md CHANGED
@@ -93,7 +93,7 @@ node <本仓库路径>/desktop-bridge/install.mjs
93
93
  <tr>
94
94
  <td width="50%" valign="top">
95
95
  <h3>多标签会话</h3>
96
- <p>并行打开 URL,查看/切换/关闭/重置标签,每个会话的状态独立保持。桌面端下<b>每个会话独占侧栏里自己的一个标签页</b>,结束一个会话不会影响另一个。</p>
96
+ <p>并行打开 URL,查看/切换/关闭/重置标签,每个会话的状态独立保持。桌面端下<b>每个会话独占侧栏里自己的标签页,并可在自己的会话里继续开新标签</b>,结束一个会话不会影响另一个。</p>
97
97
  </td>
98
98
  <td width="50%" valign="top">
99
99
  <h3>多格式内容</h3>
@@ -103,7 +103,7 @@ node <本仓库路径>/desktop-bridge/install.mjs
103
103
  <tr>
104
104
  <td width="50%" valign="top">
105
105
  <h3>任务级会话隔离</h3>
106
- <p>每个 DSH 任务(会话)拥有独立的浏览器会话(独立标签页与历史),并发任务互不抢页面、互不污染;同一任务内多次调用复用同一会话。</p>
106
+ <p>每个 DSH 任务(会话)拥有独立的浏览器会话(独立标签页与历史),并发任务互不抢页面、互不污染;同一任务内**每次调用各开一个标签**(cookie / 历史 / 登录态在同一会话内共享)。</p>
107
107
  </td>
108
108
  <td width="50%" valign="top">
109
109
  <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 / 刷新页面)才生效。栏里另有一组「Agent 能做什么」的动作开关 —— <b>执行页面脚本</b>、<b>下载文件到磁盘</b>、<b>写入登录状态</b> —— 它们与 <code>browser_restrict</code> 不是一回事:后者是模型自己的软护栏,模型随时可以解除;这三项写在设置文档里,<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 / 刷新页面)才生效。栏里另有一组「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>
@@ -173,7 +173,7 @@ node <本仓库路径>/desktop-bridge/install.mjs
173
173
 
174
174
  | 工具 | 用途 | 守卫 |
175
175
  | --- | --- | --- |
176
- | `browser_open` | 打开 URL(可选新标签),返回页面快照 | ✅ |
176
+ | `browser_open` | 打开 URL,返回页面快照;**每个会话只使用自己的侧栏面板,外壳此刻显示哪个会话完全无关**(首次调用自动为发起会话打开面板) | ✅ |
177
177
  | `browser_wait` | 等待页面加载完成(可选期望 URL / CSS 选择器),返回是否就绪 | – |
178
178
  | `browser_snapshot` | 交互元素(输入框/按钮/链接)带编号清单,元素可引用时附 `{#id}` / `{[name=x]}` 定位选择器(穿透同源 iframe 与 Shadow DOM) | – |
179
179
  | `browser_a11y` | 无障碍树:每个交互节点的语义角色/名称/值/状态,可引用时附 `{#id}` / `{[name=x]}`;坐标按需(`coords: true`,默认不含;穿透同源 iframe 与 Shadow DOM) | – |
@@ -202,7 +202,7 @@ node <本仓库路径>/desktop-bridge/install.mjs
202
202
  | `browser_session` | 查看本任务的浏览器会话与标签 | – |
203
203
  | `browser_reset_session` | 关闭并重建本任务的浏览器会话 | – 豁免 |
204
204
  | `browser_history` | 操作日志(最新在后),含成功/失败与结果摘要 | – |
205
- | `browser_replay` | 按序号回放某一步(navigate/execute/click/type) | ✅ |
205
+ | `browser_replay` | 按序号回放某一步(navigate/click/type/scroll/key/execute) | ✅ |
206
206
  | `browser_download` | 带会话 cookie 下载 HTTP(S) URL 到本地文件(`savePath` 必须绝对路径且位于 `downloadDir` 内,不覆盖已有文件,上限 256MB)。受设置「允许下载文件到磁盘」门控(`BROWSER_DOWNLOAD_DISABLED`) | ✅ |
207
207
  | `browser_auth` | 导出/恢复 cookie(登录态持久化;**三种载体都可用** —— 自托管走原生会话,侧栏与本机浏览器走 CDP)。导出受设置「凭据」门控(`BROWSER_AUTH_DISABLED`),写入受「允许写入登录状态」门控(`BROWSER_AUTH_WRITE_DISABLED`);两者独立 | ✅ |
208
208
  | `browser_challenge` | 检测人机验证(CAPTCHA / Cloudflare / reCAPTCHA / hCaptcha / Turnstile) | – |
@@ -217,17 +217,50 @@ node <本仓库路径>/desktop-bridge/install.mjs
217
217
 
218
218
  ### 语义定位(`target`)与无障碍树
219
219
 
220
- - **`browser_a11y` 是理解页面的首选**:它返回每个交互节点的语义角色(button/textbox/checkbox…)、可访问名称、当前值与状态(checked/expanded/required…),比编号快照更能说明“这是什么、能做什么”。**默认 150 个节点**(`maxNodes` 10-5000,要更多请显式传),**不再默认带坐标**(需要像素落点时传 `coords: true`);状态只列**非默认**项 —— 没有 `states=` 就是 enabled。每个节点的 `name` 就是 `browser_click`/`browser_type` 按 `text` 定位时匹配的东西。
220
+ - **`browser_a11y` 是理解页面的首选**:它返回每个交互节点的语义角色(button/textbox/checkbox…)、可访问名称、当前值与状态(checked/expanded/required…),比编号快照更能说明“这是什么、能做什么”。**默认 150 个节点**(`maxNodes` 10-5000,要更多请显式传),**不再默认带坐标**(需要像素落点时传 `coords: true`);`states` **只省略字面量 `enabled`**(`unchecked`、`collapsed` 等仍会打印)—— 没有 `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
 
224
- ### 操作纪律(点击/填表)
224
+ ### 操作纪律(定位/点击/填表)
225
225
 
226
+ - **先语义、后坐标**:`browser_a11y` 给角色与可访问名称,`browser_snapshot` 给引用与坐标;**两者都带选择器**(见下一节),**有选择器就优先用它** —— 它不会被滚动和布局变化作废。
227
+ - **`browser_click` 的 `target`(css/text)本身就是语义定位**,能用就别自己算坐标:滚进视野、中心点击、iframe 偏移、像素比都由它处理。
226
228
  - **优先用 DOM 语义而非坐标**:表单提交优先 `form.requestSubmit()`;点击优先 `element.click()`;坐标点击是最后手段。
227
229
  - **选中正确的元素**:页面常有隐藏副本(如移动端按钮),用 `browser_execute` 过滤可见元素(`getBoundingClientRect()` 宽高 > 0、`getComputedStyle` 非 `display:none`),再取坐标。
228
230
  - **取坐标后立即点击**:中间不要插入其他操作(填表、滚动会移动元素,旧坐标立即失效)。
229
231
  - **点击前验证命中**:`document.elementFromPoint(x, y)` 确认该坐标确实是目标元素(按钮/链接),再执行真实点击。
230
232
  - **DPR 注意**:CDP 输入使用 CSS 像素;高 DPI 屏上若点击落空,用 `elementFromPoint` 校准,不要盲试坐标。
233
+ - **同一动作连败两次就换策略**:换选择器、换语义定位、或重新快照 —— 不要第三次用同样的坐标。
234
+
235
+ ### 视觉策略:先确认你在哪一种
236
+
237
+ 设置里的 **`vision.strategy`** 决定坐标点击是否可行,而**它只影响坐标一件事**:
238
+
239
+ | 策略 | 坐标点击 | 适合谁 |
240
+ | --- | --- | --- |
241
+ | `auto`(默认) | 允许 | **能读图的模型** —— 可以靠截图核对布局 |
242
+ | `nonVisual` | **被拒绝** | **没有图像输入能力的模型** |
243
+
244
+ **`nonVisual` 下用坐标点击会被明确拒绝**,报 `BROWSER_NON_VISUAL_COORDINATES`,错误信息里直接给出改法:传语义 target,例如 `target { by: "text", value: "Sign in" }`,它从 DOM 定位并点击元素中心。**这不是故障,是配置 —— 不要重试同一次坐标点击。**
245
+
246
+ **两种策略下,DOM 查询、无障碍树、文本提取、结构化抓取的能力完全一致** —— 也就是说**不需要图像输入也能完整操作页面**。下面这节的"不该用截图"因此是**给所有模型的默认建议**,不是因为谁看不了图。
247
+
248
+ ### 什么时候不该用截图
249
+
250
+ 工具输出占用的是调用方的上下文,**而截图是最贵的一种**:
251
+
252
+ - **能用文本就用文本。** `browser_snapshot` / `browser_a11y` / `browser_content` 给出同一页面的文本表示;**没有图像输入能力的模型看截图什么也得不到**,只是白花。截图留给布局、图表、设计核对这类**必须看像素**的事。
253
+ - **降采样后结果里会返回 `width`/`height`**(实际像素尺寸)。用 `maxWidth`/`maxHeight` 控制成本时,靠这两个数字确认它生效了,而不是自己解码 data URL。
254
+
255
+ ### 人的三个开关,模型绕不过
256
+
257
+ 设置页里的**执行页面脚本**、**下载到磁盘**、**写入登录态**由操作者控制,**任何 `browser_*` 工具都改不了**。关掉时报的错误码是明确的,看到它**不要重试、不要绕路** —— 那是人的决定,直接说明"需要他在设置里打开":
258
+
259
+ `BROWSER_EXECUTE_DISABLED`(执行脚本)、`BROWSER_DOWNLOAD_DISABLED`(下载)、`BROWSER_AUTH_WRITE_DISABLED`(写入登录态)、`BROWSER_AUTH_DISABLED`(读 cookie)。
260
+
261
+ 别和这两种混淆:`BROWSER_DOWNLOAD_BLOCKED` / `BROWSER_SCREENSHOT_BLOCKED` 是**保存路径被沙箱拒绝**(换路径,不是找开关);`BROWSER_DOWNLOAD_UNSUPPORTED` 是**该载体没这个能力**。
262
+
263
+ **`browser_restrict` 不同** —— 那是模型自己设的临时白名单,可设可解除,但**不该用来绕过上面三个门**。
231
264
 
232
265
  ### 输出尺寸与截断
233
266
 
@@ -243,17 +276,18 @@ node <本仓库路径>/desktop-bridge/install.mjs
243
276
 
244
277
  ## 配置
245
278
 
246
- 插件通过 `cordis.patch.yml` 挂载**四行**:一个**惰性的根行**(只用来声明包名,宿主的客户端插件扫描靠它读到 `dsh.client`,否则设置栏不会出现)+ 三个功能行(`browser` / `browser-electron` / `tool-browser`)。各行配置:
279
+ 插件通过 `cordis.patch.yml` 挂载**四行**:一个**惰性的根行**(只用来声明包名:宿主的客户端插件扫描按**精确包名**读该行的 `dsh.client`,以子路径命名的行会被整行跳过,少了这行设置栏不会出现)+ 三个功能行(`browser` / `browser-electron` / `tool-browser`)。各行配置:
247
280
 
248
281
  | 行 | 配置项 | 类型 | 默认 | 说明 |
249
282
  | --- | --- | --- | --- | --- |
250
283
  | `browser-electron` | `viewHost` | 对象 | 可选 | 宿主提供的 `ElectronBrowserViewHost` 实例(如 `!!js ctx.get('electronViewHost')`)。**不传时插件自己选载体** —— 桌面端驱动官方侧栏、否则自托管;若在设置里指定了本机 Chrome / Edge,该选择优先于两者 |
251
284
  | `browser-electron` | `httpOnly` | 布尔 | `true` | 仅允许 HTTP(S) 导航;其余协议(如 `file:`/`data:`)拒绝(`BROWSER_NAVIGATION_BLOCKED`) |
252
285
  | `browser-electron` | `snapshotMaxElements` | 数字 | `60` | 快照最多收录的交互元素数,超出截断 |
253
- | `browser-electron` | `contentMaxChars` | 数字 | **不再被读取(已废弃)** | 曾经的"内容抓取默认字符上限"。**现在没有任何代码读它,改它不会改变任何行为** —— 上限按格式固定:html 50 000、json 50 000、txt 20 000、markdown 20 000,单次调用用 `maxChars` 覆盖。保留该键只是为了不让既有配置文件因为未知键而报错 |
286
+ | `browser-electron` | `contentMaxChars` | 数字 | 未设置(按格式分档) | 内容抓取的字符上限。**不设**时按格式取分档默认(html 50 000、json 50 000、txt 20 000、markdown 20 000);**设了**就是操作者的值,覆盖分档默认。单次调用的 `maxChars` 优先级最高,覆盖前两者 |
254
287
  | `browser-electron` | `downloadDir` | 字符串 | 系统下载目录(自动识别 `Downloads`/`下载`/`下載`,或 `XDG_DOWNLOAD_DIR`) | 限定 `browser_download` 与 `browser_screenshot` 的保存路径必须位于该目录内,且不覆盖已有文件(防 agent 写任意路径或替换现有文件);默认收敛到系统下载目录,可改为沙箱目录 |
255
288
  | `tool-browser` | `timeoutMs` | 数字 | `60000` | 工具协作超时(ms) |
256
289
  | `tool-browser` | `tabTools` | 布尔 | `true` | 是否注册标签管理工具(`browser_list_tabs` 等) |
290
+ | `tool-browser` | `allowedActions` | 字符串数组 | 不限制 | **部署级**动作白名单,对该部署里的每个任务生效(单个任务仍可用 `browser_restrict` 再收窄);`READ_ONLY_TOOLS` 与 `browser_restrict` 无论列表怎么写都不拦。`cordis.patch.yml` 默认给的是空 `config: {}`(等于不限制),收紧的写法在它的注释里 |
257
291
 
258
292
  ## 工作原理
259
293
 
@@ -287,6 +321,10 @@ DSH Desktop 是两层结构:Electron 外壳 + 一个 `--expose-internals` 的 **
287
321
 
288
322
  结果是:**agent 操作的页面就是人看的页面**,插件不再 spawn 自己的 Electron,也不再出现第二个窗口。
289
323
 
324
+ **页面落在哪个会话,由发起的会话决定,与屏幕上显示谁无关。** 外壳为每个会话各挂一个侧栏,而插件的渲染侧半边按**会话 id** 打开那一会话自己的面板(DSH 的 `openTabIn`),不读"当前显示的是谁"——所以"在 A 里让 agent 开页面、随后切到 B"不会把页面开到 B 去,结构上也没有通往别人侧栏的路径。
325
+
326
+ > **一条来自外壳的行为**:外壳只渲染**当前显示会话**的侧栏。因此当发起会话不是屏幕上那个时,面板会被建立(它的标签确实进了那个会话),但页面要等该会话被显示时才附着上来。表现是:切走后在 A 里再让 agent 开页面,会得到一句"面板已放置,页面只能在被显示时附着",而 A 的侧栏此后一直是它自己的样子——**那是提示,不是串扰**。
327
+
290
328
  安装这条桥(改的是**已安装的桌面端**,所以做成可重放):
291
329
 
292
330
  ```bash
@@ -345,7 +383,7 @@ node desktop-bridge/install.mjs --revert # 回滚
345
383
  | Electron | `44.0.0`(推荐 ≥ 40;33.x 存在合成器缺陷) |
346
384
  | Node.js | `22.20.0` |
347
385
  | 本机 Chrome / Edge(可选载体) | `154.0.8037.58` / `154.0.4258.37` |
348
- | dsh-builtin-browser | `0.4.1`(开发分支已超出该发布版本) |
386
+ | dsh-builtin-browser | `0.4.4`(未发布,开发分支) |
349
387
  | 操作系统 | Windows 10 (10.0.26200) |
350
388
 
351
389
  > 插件声明 `electron >= 30`。**核心链路在 Windows 上完整实测**;系统浏览器的查找已适配 Linux 与 macOS(先查 `PATH`,再查各平台的惯例安装位置,均可用 `DSH_BROWSER_CHROME_PATH` / `DSH_BROWSER_EDGE_PATH` 覆盖),但这两个平台上的**端到端链路尚未实测**,暂不承诺。
@@ -375,6 +413,7 @@ node desktop-bridge/install.mjs --revert # 回滚
375
413
  bridge 不在时插件自动退回自托管(多出一个独立窗口),功能不中断。
376
414
  - 浏览器内核默认由桌面端自带的 Electron 提供,插件不会再下载一份 Electron;也可以在设置里改用它自己装的 Chrome / Edge。
377
415
  - **选了某个浏览器但它没装时会怎样**:`内置` 用自己的 Electron;`自动` 挑一个已装的、**都没有就安静地退回内置**;**明确选了 Chrome / Edge** 而它不在时则**直接报错说明找不到它**(报错里写明查了哪些名字与位置,并给出三条出路:装上它 / 用 `DSH_BROWSER_CHROME_PATH`、`DSH_BROWSER_EDGE_PATH` 指定路径 / 把载体改回 `内置` 或 `自动`)—— 不会让你对着一条关于 Electron 的错误去猜真正的原因。
416
+ - **升级桌面端本身不会带上插件**,按上面的方法单独更新它。
378
417
 
379
418
  **Web 端(`dsh web`)**
380
419
  - 插件是 web profile 里的依赖(`$DSH_HOME/profiles/web`),更新后**重启 `dsh web`** 生效。
@@ -394,13 +433,13 @@ node desktop-bridge/install.mjs --revert # 回滚
394
433
  - `fullPage` 截图在部分主机的**软件合成**下不稳定 —— 请求 `fullPage` 时**原生 `capturePage` 路径被整体跳过**(`capturePage` 没有捕获滚动区以外内容的能力),因此**三种载体都走 CDP 的 `captureBeyondViewport`**,`fullPage` 的不稳定性与载体无关,任何载体都可能碰到;只有视口截图才优先走原生的 `capturePage`。
395
434
  - 人机验证(CAPTCHA)无法自动解决:快照会标注检测到的挑战,此时应请用户在共享窗口中人工完成,而不是反复重试。
396
435
  - 无痕模式(`privateMode`)未实现:它需要 Electron 的 session 分区能力,属于宿主层,本插件不承诺。
397
- - 设置文档**存在但读不懂**(JSON 损坏、写到一半、权限读不到)时,四个**门控**开关(`credentials.allowRead` 与三个动作开关)一律按**关**处理,其余设置取默认值 —— "读不透操作者的意图"不等于"操作者什么都允许"。文件**不存在**(首次运行)仍按默认值。单个门控字段类型不对(`"false"` 字符串、`null`、数字)同样按**关**。
436
+ - 设置文档**存在但读不懂**(JSON 损坏、写到一半、权限读不到)时,四个**门控**开关(`credentials.allowRead` 与三个动作开关)一律按**关**处理,其余设置取默认值 —— "读不透操作者的意图"不等于"操作者什么都允许"。文件**不存在**(首次运行)仍按默认值。单个门控字段类型不对(`"false"` 字符串、`null`、数字)同样按**关**;门控所在的**小节**整个写成非对象(如 `actions: false`、`null`、一个字符串)也按**关** —— 而不是把里面的每个开关当成"缺席"、各取放行的默认值。
398
437
  - `browser_download` 在页面上下文内 `fetch`(带登录态),受同源/CORS 约束;仅允许 HTTP(S) 目标;`savePath` 必须为绝对路径且位于 `downloadDir` 内(默认系统下载目录,自动识别 `Downloads`/`下载`/`下載` 与 `XDG_DOWNLOAD_DIR`,可用 `downloadDir` 覆盖),不覆盖已存在文件;`browser_screenshot` 的 `savePath` 走同一准入门;单文件上限 256MB(流式限流,按 Content-Length 提前拒绝),文件由浏览器子进程直接落盘(临时文件 + 原子改名)。准入还会解析路径的**真实归属**:`downloadDir` 里的符号链接无法把写入带出目录,悬空链接同样被拒(它读作"名字已被占用",而不是"路径空闲")。
399
438
  - 自托管浏览器的 cookie 在磁盘上以明文存储(Electron 默认行为);需要加密落盘的部署应在宿主层接入系统钥匙串 / DPAPI。
400
439
  - `browser_restrict` 是防误操作的**软护栏**,不是安全边界:模型可以自行解除白名单。要一个**模型拿工具改不掉**的限制,用设置页「Agent 能做什么」的三个动作开关(它们写在设置文档里,`browser_*` 工具没有一个会写它),或部署级的 `tool-browser` 配置 `allowedActions`(见 `cordis.patch.yml` 的注释)。**这条边界要说准**:设置走的是本机 HTTP 端点,写入要求带同源 `Origin`(裸 HTTP 客户端因此写不进去),但**同源页面仍然可以写** —— 这是"插件暴露了一个 HTTP 端点"的固有代价,它**不是凭据系统**,面板上的说明也是这么写的。
401
440
  - 页面弹窗(`window.open` / `target=_blank`)不再覆盖当前视图:HTTP(S) 弹窗会在同一会话窗口**新开一个标签页**并计入历史,原页面与 opener 上下文保留;非 HTTP(S) 弹窗(空 URL 弹窗承接、`mailto:`、自定义协议)仍**放行原生窗口**,交给系统处理——这类弹窗不纳入会话模型。
402
441
  - `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 的动作。
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` 自托管可据此自助排查崩溃循环。
442
+ - 自托管浏览器子进程崩溃(或宿主 DSH 重启)后会自动重启;崩溃前已打开的会话在**下一次调用时自动重建**——仅页面状态丢失,无需手动 `browser_reset_session`。`browser_reset_session` 仍可用于主动重置。新视图创建前会先有界加载 `about:blank`(3 秒上限,保证视图一存在就有可响应的渲染进程),宿主侧命令另有 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` 自托管可据此自助排查崩溃循环。
404
443
  - electron 随插件安装;但 Electron 44+ 不再随安装下载二进制(约 100MB,需网络)——插件探测是纯文件系统、不触发其懒下载,二进制缺失时首次使用会报错并提示先 `npx install-electron`;也可预装 `ELECTRON_PATH` 指定的二进制。
405
444
  - 本插件不提供任何**浏览器界面**(地址栏、标签条、侧栏面板都不是插件画的):桌面端的浏览器界面是**外壳自带的官方侧栏**,我们只是借它的页面来驱动;自托管载体下画窗口的是插件拉起的那个 Electron 子进程,那是载体本身而非插件 UI。别把"侧栏"或"浏览器列"当成插件能力。
406
445
  - **侧栏载体不上报"用户操作"事件**:人在那个页面里点击是外壳自己的事件,而 bridge 没有用于回报它的操作。因此依赖该事件的功能(如自定义的接管提示)在桌面端侧栏下不会触发;换到自托管载体则可以。
@@ -420,8 +459,8 @@ npm run build
420
459
  | 目录 | 职责 |
421
460
  | --- | --- |
422
461
  | `src/browser/` | `ctx.browser` seam 与全部请求/结果类型 |
423
- | `src/browser-electron/` | provider 与三种载体实现 —— 桌面侧栏桥(`desktop-bridge-host.ts`)、本机浏览器(`system-browser.ts`)、自托管子进程(`host-main.ts`);传输层 `bridge-connection.ts`;以及设置、历史、虚拟光标 |
424
- | `src/tool-browser/` | 模型侧 `browser_*` 工具 |
462
+ | `src/browser-electron/` | provider 与三种载体实现 —— 桌面侧栏桥(`desktop-bridge-host.ts`)、本机浏览器(`system-browser.ts`)、自托管子进程(`host-main.ts`);传输层 `bridge-connection.ts` 与 RPC 操作词汇表 `rpc-ops.ts`;以及设置、历史、虚拟光标 |
463
+ | `src/tool-browser/` | 模型侧 `browser_*` 工具;元素/节点字段集 `element-fields.ts`(item schema 与投影同源,一处声明) |
425
464
  | `src/types/` | electron 环境类型(shim,避免强制依赖 electron 类型) |
426
465
  | `desktop-bridge/` | 装进桌面端的那条 bridge 与幂等安装脚本(`install.mjs`) |
427
466
  | `tools/` | 运维脚本(如 `install-web-plugin.mjs`:钉版本 → 安装 → 修复 profile → 复验) |
@@ -479,14 +518,15 @@ npm run build
479
518
  | **0.4.0** | 2026-10-02 | **发布**:第二十七~三十一轮合并发布 —— 主题是**把"限制"分成真限制与实现限制**:`browser_auth` 三种载体全部可用、本机 Chrome/Edge 支持 JPEG、降采样三种载体都可用;同时修掉会崩宿主、会静默做错事、会永久挂住的缺陷。**130/130**,tag `v0.4.0` |
480
519
  | 第三十一轮 | 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
520
  | **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` 会破坏正常重启而非修好孤儿。 |
521
+ | 第三十二轮 | 2026-10-03 | **输出成本审计 + 独立验证**(随 **0.4.2** 发布):① **`browser_a11y`**:默认节点数 **500 → 150**、缩进两空格 → 一空格、`states=[…]` 省略字面量 `enabled`(`unchecked`、`collapsed` 等仍会打印;没有 `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** |
522
+ | 第三十三轮 | 2026-10-03 | **系统浏览器载体:崩溃之后真的能用**(随 **0.4.2** 发布):① **一个命令失败不再拆掉整条连接** —— CDP 协议错误意味着浏览器答了并拒绝这一条,连接是健康的;② **重启后清掉的是会话映射**(不只是缓存里的连接与子进程),否则新浏览器收到的每条命令都带着上个进程发放的 session id —— 这就是"重启之后每条调用都失败、永不恢复"的成因;③ **慢页面不是死浏览器**:以错误文本判死活会把 5s/15s 的外层超时当成浏览器死亡,进而杀掉你正在用的窗口(标签与填到一半的表单一起丢),现在状态分三态 —— socket 已关闭 → 丢弃、有应答但拒绝 → 保留、**没有应答 → 标记存疑**,下次调用先用 `Browser.getVersion` 探一次;④ **kill 之后最多等 3 秒它真的退出**(Windows 上 `kill` 是异步的,不等就会把新进程交给旧实例,还把由此产生的秒退误报成"路径不是可运行的浏览器");⑤ **只认本次启动写的 `DevToolsActivePort`**(被杀掉的浏览器没机会清理它,旧文件会让宿主连到别的 Chromium 或 `node --inspect` 上);⑥ **端口发现的回归测试换成真能说 CDP 的夹具**(旧的 `.cmd` 桩在 Windows 上被 Node 拒绝 spawn,三个断言恰好都被一次同步失败满足,CI 一直是绿的而退出处理一次都没跑)。**134/134** |
523
+ | 第三十四轮 | 2026-10-03 | **操作者级动作开关(真正的入口)**(随 **0.4.2** 发布):设置页新增「Agent 能做什么」—— **执行页面脚本** / **下载文件到磁盘** / **写入登录状态** 三个开关,关闭即拒绝对应能力(`BROWSER_EXECUTE_DISABLED` / `BROWSER_DOWNLOAD_DISABLED` / `BROWSER_AUTH_WRITE_DISABLED`)。与 `browser_restrict` 的区别是**谁能改**:后者由模型设定、也由模型解除;这三项写在设置文档里,没有任何工具能写它。顺带修正一处读/写混淆:恢复登录态(`browser_auth` restore)原先受**读**开关管、报错还说 "reading cookies is switched off" —— 允许读取的操作者因此无法拒绝写入;现在它归写入开关,读与写可以分别设置。**148 → 157** |
524
+ | 第三十五轮 | 2026-10-03 | **DSH 0.2.1 适配(随 **0.4.2** 发布)**:宿主升到 `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` 判定,故这些宿主实际都在范围内 |
525
+ | 第三十六轮 | 2026-10-04 | **一轮外部审计的落实(随 **0.4.2** 发布)**:审计给了 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
526
  | **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` |
527
+ | 第三十七轮 | 2026-10-04 | **架构重构 + 一份外部审查的落实**(未 bump 版本,随下一次发布生效):主题是**同一份事实不再四处手抄** —— 两个已发布的 bug(`browser_a11y` 的 `selector`、`browser_screenshot` 的 `width`/`height`)都出自同一个机制"provider 算了、schema 声明了、而工具层的手写白名单把它丢了",两次全套测试都是绿的,因为**没有一个测试真的跑过一个工具**。① **字段集一处声明、两端同源**:新增 `src/tool-browser/element-fields.ts`(元素/节点字段表,以及由它导出的 item schema 与投影),`browser_snapshot` 的 schema 也切过来了(此前只做了 `browser_open` 那一半);新增 `tests/element-fields.test.mjs`(7 条:schema ⟷ 表**双向相等**、投影携带全部声明字段、投影丢弃未声明字段,并遍历全部 34 个注册工具断言"schema 要求的字段工具确实产出");② **页面脚本解析从 4/16 变成全覆盖**:新增 `tests/page-scripts-parse.test.mjs` 直接扫描源码(provider、`host-main` 的工具栏与下载、虚拟光标、侧栏 bridge),不再依赖"驱动某个行为"才能捕获脚本 —— 虚拟光标、下载与 bridge 的脚本此前从未被解析过;③ **自托管两个真 bug**:`materializeOnce` 的"失败即重置"是死代码(存进去的是派生 promise,比较的是原始 promise,判定永假),于是"子进程活着但不应答"这类失败会**永久毒死该句柄**、注释承诺的重试不可达;`fail()` 误清 `kill()` 的 500ms 兜底刀 —— socket 死 ≠ 子进程死,现在只在观察到的退出与释放路径上清;④ **seam 不再说谎**:handle 的 `download`/`flushAuth`/`restoreAuth`/`capture` 与宿主的 `notifyUserActionError` 从**结构性强转**提升为接口声明(照着接口实现的新宿主不再得到静默 no-op),`capture` 的契约补上尺寸,并删掉 8 个只为满足接口而存在的空方法与 2 个全仓无调用点的宿主级 `focus()`;⑤ **截图尺寸不再只有 CDP 载体报**:更早那次修复只做了 CDP 载体那一半,**原生 `capturePage` 路径没把尺寸传回**,于是**默认的自托管载体上 `width`/`height` 永远缺席** —— 现在 child 在下采样之后记录尺寸并回报,`capture()` 的类型、`screenshot()` 的返回类型与 native 分支全部接通(测量到尺寸时才回报,`width?`/`height?` 本就是可选的);⑥ **设置与存储**:门控所在的**小节整个存在但不是对象**(`actions: false`、`null`、一个字符串)不再被当成"缺席"(那会让里面每个门控各取放行的默认值),现在其中每个门控都按**关**;`history.maxEntries`/`maxAgeDays` 不再是死旋钮(被解析、被持久化,却从不交给 `HistoryStore`),现在读取器把当前值传给 `updateLimits()`;⑦ **系统浏览器载体**:`'exited'` 的报错不再把任意退出码都解释成"移交给了占着 profile 的实例"—— 那句只对 code 0 成立,崩溃码会把人引向一个并不存在的残留进程;启动轮询**同时看 `signalCode`**(被信号杀死的浏览器从不设置 `exitCode`,只查它会让一次秒死拖满 30 秒、最后报成"did not expose CDP");端口探活的 `fetch` 加上 `AbortSignal`;⑧ **RPC 词汇表**:新增 `src/browser-electron/rpc-ops.ts`,子端 switch 与父端发送的 op 名从此有清单,并由 `tests/rpc-ops.test.mjs` 读两侧源码断言一致(当场抓到两个凭印象写下的 op 子端并不处理)。**183/183 通过**(共 **184** 项,1 项跳过:Windows 上创建文件符号链接需要提权) |
488
528
 
489
- > registry 上的最新版本以顶部 npm 徽章为准(当前 `0.4.1`)。桌面端升级后需重跑一次 `node desktop-bridge/install.mjs`;Web 端无此步骤 —— 详见[更新方式](#更新方式两端不同)。
529
+ > registry 上的最新版本以顶部 npm 徽章为准(当前 `0.4.2`)。桌面端升级后需重跑一次 `node desktop-bridge/install.mjs`;Web 端无此步骤 —— 详见[更新方式](#更新方式两端不同)。
490
530
 
491
531
  ## 特别感谢
492
532