Skip to content
dsh-market Browse plugins GitHub 中文

wqty123/dsh-browser

Shared real browser for DSH: a native Electron window the human can watch and take over, driven by the agent over CDP with 20 browser_* tools (open/snapshot/execute/fill/screenshot/download/auth), per-task session isolation, cookie persistence, CAPTCHA detection; self-hosts on plain dsh web without a desktop shell.

Stars ★ 100 Category Browser & Web Listed 2026-08-16 npm dsh-builtin-browser

Install

Inside DeepSeek Harness, with dsh-market

dsh plugin --profile web add dshmarket

Or from the command line

dsh plugin --profile web add dsh-builtin-browser

Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.

README

Documentation

Goal Entry
Why a shared real browser, and how it differs from headless approaches Why a shared real browser
Installation, configuration, day-to-day use User guide
All 34 tools: parameters, output, examples Tool reference
How the seam / provider / tools layers and self-hosting work Architecture
Documentation index and README split Docs index

What is this

dsh-builtin-browser adds browser capability to DeepSeek Harness:

  • A real page, not a relay: the page is carried by a real browser — on the desktop it is the one in the official sidebar, on plain dsh web it is a window the plugin launches, and either can be switched to an installed Chrome / Edge in settings. The human sees exactly what the agent is doing and can take over at any time;
  • One page for both parties (desktop): the page the agent works on is the same page the human sees — no longer a window you cannot see;
  • Works out of the box: the carrier is chosen automatically (the desktop sidebar first, otherwise self-hosting), and plain dsh web needs no extra configuration at all;
  • One plugin, one toolset: after install the agent automatically gets 34 browser_* tools (open, a11y tree, wait, semantic/coordinate interaction, scroll, back/forward, batch and single-control form filling, keys, structured scraping, screenshot, download, auth management…).

In one sentence: installing the plugin gives you a real browser that is shared with the user and drivable by the agent.

Quick start

Install the plugin into the profile of the host you want to use. Installing it on both is fine — one codebase, one copy per profile.

Web (plain dsh web)

# install from npm
dsh plugin --profile web add dsh-builtin-browser
# or from a checkout (one plugin, one repository)
dsh plugin --profile web add <path-to-this-repo>

Desktop (DSH Desktop)

# 1. install into the desktop profile
dsh plugin --profile desktop add dsh-builtin-browser

# 2. desktop-only step: let the plugin drive the official sidebar's page
node <path-to-this-repo>/desktop-bridge/install.mjs

Step 2 is not optional, and it comes back every time: a desktop upgrade replaces resources/app/, and the bridge goes with it; a plugin update needs the same re-run. With no bridge the plugin falls back to opening its own separate window — nothing breaks, you just lose "one page for both parties". The web side has no such step.

Updating (the two hosts differ — see Updating (the two hosts differ))

Host Steps
Web update the profile dependency → restart dsh web
Desktop update the dependency → re-run node desktop-bridge/install.mjs → restart DSH Desktop

After install the agent can use the browser tools, e.g.:

What you want Tool Notes
Open a page browser_open Opens a URL and returns a numbered snapshot
Understand a page browser_snapshot Numbered inventory of inputs/buttons/links to target
Operate a page browser_execute Runs JS in the page (native setters, framework-friendly)
Fill a form browser_fill Fills many fields in one call, optional submit
See the page browser_screenshot PNG capture, optionally saved for a vision model

See the full list in Tool reference.

Main features

Why this plugin

  • Install-and-use, zero config: no desktop shell or extra startup step required; on plain dsh web it self-hosts an Electron window and the browser_* tools just work.
  • Human-in-the-loop, non-interfering: the user sees and can take over every agent action; per-task isolation gives each parallel task its own tabs and history.
  • Built for the real world: CAPTCHA detection, login persistence, batch form filling, authenticated downloads, operation replay, action restriction — real-browser automation that is actually reliable.
  • Testable, replaceable architecture: the provider talks to Electron through the ElectronBrowserViewHost seam, so a future headless relay provider can serve remote deployments without touching the tool layer.

Tool reference

Tool Purpose Guard
browser_open Open a URL (optionally in a new tab); returns a page snapshot ✅
browser_wait Wait for page load (optional expected URL / CSS selector), returns readiness –
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) –
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) –
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) ✅
browser_visited Read the persistent browsing history (visited pages, filterable by domain / capped); reopen with browser_open –
browser_content Fetch the page as html / markdown / txt / json (selector, maxChars, timeoutMs) –
browser_click Click a semantic target (target: css/text/xpath, scrolls into view and clicks center) or viewport coordinates (vision-located) ✅
browser_type Type text (optionally focusing a target element first; CDP Input.insertText) ✅
browser_key Press a named key (Enter/Tab/arrows/Home/End…) ✅
browser_scroll Scroll the page (pixel deltas / selector / top-bottom) ✅
browser_back One step back in page history (no-op at the start) ✅
browser_forward One step forward in page history (no-op at the end) ✅
browser_refresh Reload the current page (like a browser refresh button) ✅
browser_fill Batch form fill (selector/name/label matching, controlled inputs, selects, checkbox/radio, optional submit) ✅
browser_set_value Set one control's value (target-located; native setter + input/change, React-controlled friendly) ✅
browser_check Check/uncheck a checkbox or radio (target-located) ✅
browser_select Select an option of a <select> by value/text/index (target-located) ✅
browser_clear Clear an input/textarea/contenteditable, or uncheck (target-located) ✅
browser_get_value Read an element's current value for verification (target-located) – exempt
browser_scrape Structured extraction: container selector + field map (selector@attr), static CSS only, CSP-safe – exempt
browser_screenshot Capture, optional fullPage, savePath, JPEG (format/quality) and scaling (maxWidth/maxHeight); savePath shares the download gate (confined to downloadDir, never overwrites) –
browser_list_tabs List the session's tabs –
browser_switch_tab Switch to a tab by id (also switches the visible view when self-hosted) ✅
browser_close_tab Close a tab by id; closing the active tab activates the next ✅
browser_reset Close all tabs of this task, back to one blank tab – exempt
browser_session Show this task's browser session and tabs –
browser_reset_session Close and rebuild this task's browser session – exempt
browser_history Operation log (newest last), with per-step success/error and result summary –
browser_replay Replay one step by sequence number (navigate/click/type/scroll/key/execute) ✅
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) ✅
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 ✅
browser_challenge Detect a human-verification challenge (CAPTCHA / Cloudflare / reCAPTCHA / hCaptcha / Turnstile) –
browser_restrict Restrict allowed browser actions (allow-list; empty list lifts it). Soft guardrail — the model can lift it itself; not a security boundary –

"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.

Waiting for the page

  • browser_open and navigation already wait (bounded) for the new document to parse (readyState plus a document fingerprint, so a same-URL reload or an A→B→A redirect is not mistaken for the old document), but they do not wait for async content: on slow sites or XHR-rendered pages, call browser_wait before browser_snapshot — pass url (what you opened) and an optional selector, and wait for ready: true — otherwise you snapshot the old document, a white screen, or an empty element list.
  • Content you cannot see may live in an iframe / Shadow DOM: snapshots and the a11y tree pierce same-origin iframes and shadow roots and mark them (iframe); coordinates are always top-document, so browser_click works directly. DOM selectors are frame-scoped — reach them via iframe.contentDocument in browser_execute.

Semantic targets and the a11y tree

  • 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.
  • 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.
  • 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.

Operating discipline (locating/clicking/filling)

  • 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.
  • 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.
  • Prefer DOM semantics over coordinates: submit forms with form.requestSubmit(); click with element.click(); coordinate clicks are the last resort.
  • 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.
  • Click right after taking coordinates: do not insert other operations in between (filling/scrolling moves elements and invalidates old coordinates).
  • Verify before clicking: use document.elementFromPoint(x, y) to confirm the coordinate hits the intended element (button/link), then perform the real click.
  • DPR awareness: CDP input uses CSS pixels; on high-DPI screens calibrate with elementFromPoint instead of guessing coordinates.
  • 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.

Vision strategy: know which one you are in first

The vision.strategy setting decides whether coordinate clicks are usable, and that is the only thing it affects:

Strategy Coordinate clicks Suited to
auto (default) allowed A model that can read images — screenshots are a valid way to check layout
nonVisual refused A model with no image input

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.

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.

When NOT to take a screenshot

Tool output is spent out of the caller's context, and a screenshot is the most expensive kind:

  • 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.
  • 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.

The operator's three switches, which no tool can change

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:

BROWSER_EXECUTE_DISABLED (scripts), BROWSER_DOWNLOAD_DISABLED (downloads), BROWSER_AUTH_WRITE_DISABLED (writing login state), BROWSER_AUTH_DISABLED (reading cookies).

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.

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.

Output size and truncation

Tool output is spent out of the caller's context, so several results have explicit bounds — and a truncation is always stated:

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

Configuration

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:

Row Key Type Default Description
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
browser-electron httpOnly boolean true Allow HTTP(S) navigation only; other protocols (e.g. file:/data:) rejected (BROWSER_NAVIGATION_BLOCKED)
browser-electron snapshotMaxElements number 60 Max snapshot elements before truncation
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
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
tool-browser timeoutMs number 60000 Cooperative tool timeout (ms)
tool-browser tabTools boolean true Register tab-management tools (browser_list_tabs etc.)
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

How it works

agent (browser_* tools)
  → ctx.browser (seam, dsh-builtin-browser/browser)
  → dsh-builtin-browser/browser-electron (provider)
  → ElectronBrowserViewHost  ← the same seam, implemented once per carrier
      ① desktop sidebar       via bridge → shell main process → webContents.debugger (CDP)
      ② installed Chrome/Edge via WebSocket → CDP
      ③ self-hosted Electron  via loopback TCP JSON-RPC → child process → CDP
  • Seam (browser row): provides the ctx.browser service — provider registration, session lifecycle, error codes — decoupled from any implementation.
  • Provider (browser-electron row): knows only the one ElectronBrowserViewHost seam (create/destroy/show, sendCommand), so switching carrier touches neither the tools, nor the history, nor the cursor, nor the teardown logic.
  • Tools (tool-browser row): the 34 model-facing browser_* tools, maintaining one browser session per calling task (DSH session).

Self-hosted mode: without a desktop shell, the plugin spawns its own Electron child process (host-main.js) and drives it over loopback TCP JSON-RPC. The RPC is authenticated with a random per-spawn token delivered over both stdin and an environment variable — on Windows the Electron GUI process never receives piped stdin, so the env fallback keeps the handshake reliable. The child auto-restarts after a crash; the plugin prefers its own bundled electron package — packaged app executables (e.g. DSH Desktop.exe) are never reused as the spawnable binary, which would launch the app itself and exit immediately; screenshots prefer Electron's native capturePage (CDP capture can hang with multiple views in the window); the Electron lookup order follows below (33.x has a compositor defect; ≥ 40 recommended; the electron 44+ package no longer downloads its binary at install time — if it is missing on first use, the tool errors and tells you to run npx install-electron first, needs network).

The self-hosted browser IS a real browser: every task (DSH session) gets its own browser window with a full toolbar — address bar, back/forward/reload buttons, and a tab strip (new/switch/close tabs). A human can use it exactly like Chrome: type a URL in the address bar (https:// is added automatically), click tabs, open new ones. Keyboard focus follows your clicks — click the address bar to type, click the page to interact (Windows focus routing; fixes the case where clicks did not move focus and the address bar could not receive typed URLs). Human and agent actions feed the same session model (same tabs, history, and navigation); the window title always shows the task label plus the page title/URL, and views follow the window size on resize. A window closes automatically with its session when the task ends.

Electron lookup order: ① ELECTRON_PATH (explicit override, wins first) → ② the electron package bundled with the plugin (filesystem-only probe, never triggers the 44+ lazy download; covers both node_modules and pnpm-store layouts) → ③ the newest among DSH install anchors and pnpm virtual stores → ④ reuse the host binary when the current process is a bare Electron (dev mode) → ⑤ walk the process ancestry for a bare Electron host (PowerShell CIM on Windows, last resort only). Packaged apps (e.g. DSH Desktop.exe) are never reused — they cannot be spawned with a script argument, and misusing them exits instantly (issue #6); when nothing is found a clear error tells you what to do (including the npx install-electron hint).

Division of labor with the desktop shell

The plugin picks a carrier automatically, and the setting can override it (four in total):

① Desktop: drive the official sidebar's page (one page for both parties)

DSH Desktop is two layers: an Electron shell plus an --expose-internals Node-mode host (the plugin runs there, with no Electron API). 0.2 removed electronViewHost, and the host/shell event set carries nothing view-related either — so the plugin borrows a small bridge: a loopback + token service inside the shell's main process hands the plugin CDP access to the sidebar browser's guest, i.e. the very page you see on screen.

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.

Install that bridge (it modifies an installed desktop app, so it is replayable):

node desktop-bridge/install.mjs            # idempotent; backs up main.js.before-bridge on first run
node desktop-bridge/install.mjs --revert   # roll back

Re-run install.mjs after every desktop upgrade — the upgrade replaces resources/app/ and takes the bridge with it. With no bridge the plugin falls back to self-hosting: nothing breaks, you just get a separate window again.

⚠️ What the sandbox boundary change means

The official sidebar browser is built on the premise that its pages are not readable from outside — it uses a separate partition and the shell refuses cross-site content access. Letting the agent drive that guest deliberately breaks that premise:

  • the agent can read the content of any page you open in the sidebar (that is precisely what "one page for both parties" means);
  • the agent can read the cookies and login state in that partition, and browser_auth can export them (controlled by a setting);
  • 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).

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.

② Use the browser you already have (Chrome / Edge)

The settings panel can point the plugin at an installed Chrome or Edge (browser.channel: bundled / auto / chrome / edge). The approach is the same one Codex Browser Use takes: launch it with --remote-debugging-port=0, read the port it writes into DevToolsActivePort, and drive it entirely over CDP (through Node 22's built-in WebSocket — no new dependency).

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.

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.

What happens to login state:

  • 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.
  • 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.
  • 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.

③ A shell that provides electronViewHost (older desktop shells): that view is used directly.

④ No shell at all (plain dsh web): self-hosted — the plugin spawns the Electron it ships.

The visible view and column layout always belong to the host shell; the plugin owns the seam, the provider and the tools. Across all carriers the toolset, browsing history, settings panel, synthetic cursor and teardown rules are identical — only the carrier of the page differs.

Precedence: an explicitly chosen installed browser > the desktop sidebar > self-hosting. A missing carrier is handled two different ways: automatic means "any browser will do", so it logs a warning and keeps the bundled one; but when you named a browser explicitly and it is absent, the plugin no longer settles for a log line — it adopts a carrier whose only job is to explain, so every command reports to the caller which browser was missing, the names and locations that were searched, and three ways out. You are not left staring at an Electron error wondering what actually went wrong.

Requirements

  • DeepSeek Harness (dsh) with the matching profile (web / desktop, etc.)
  • 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:
    • ELECTRON_PATH can point at another binary explicitly (highest priority);
    • 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;
    • an installed Chrome / Edge needs no Electron either;
    • plain dsh web self-hosted: uses the bundled electron package directly

Verified versions

Component Version
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)
Electron 44.0.0 (≥ 40 recommended; 33.x has a compositor defect)
Node.js 22.20.0
Installed Chrome / Edge (optional carriers) 154.0.8037.58 / 154.0.4258.37
dsh-builtin-browser 0.4.3 (unreleased, on the development branch)
OS Windows 10 (10.0.26200)

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.

Re-verify host compatibility at any time: node scripts/verify-host-compat.mjs assembles a host out of the @deepseek-ai/* packages actually installed in a profile, loads this plugin's compiled output, registers its tools through the host's own defineTool, and calls two of them over a fake provider (browser_session / browser_a11y). It answers "did a DSH upgrade break us?" without starting a browser. It reads the profiles under $DSH_HOME/profiles; DSH_PROFILE / DSH_HOME override that.

On semver and prereleases: the range >=0.1.1-rc.2 <0.3.0 matches no prerelease-tagged version (such as 0.2.1-alpha.1) under default semver semantics — that is by specification, where a prerelease is only admitted by a comparator carrying the same major.minor.patch tuple. DSH evaluates plugin compatibility with includePrerelease: true (packages/boot/app-boot/src/plugin-compatibility.ts), so those hosts are in range in practice; if some external tool reads "incompatible" under default semantics, that is its rule differing from DSH's, not a wrong claim here.

Updating (the two hosts differ)

The plugin has one installation per host, and the two are updated separately — updating one does not update the other.

Desktop (DSH Desktop)

  • The plugin is a dependency of the desktop profile (usually $DSH_HOME/profiles/desktop). Updating means moving that dependency to the new version and then restarting DSH Desktop, which is when the settings panel and the tools pick up the new code.
  • The desktop has one extra step, which the web does not: the bridge that lets the plugin drive the sidebar lives in the desktop app's own install directory (resources/app/), and a plugin update does not carry it along. A desktop upgrade replaces that directory and takes the bridge with it, so re-run it once:
    node desktop-bridge/install.mjs            # idempotent; refreshes the module if already installed
    node desktop-bridge/install.mjs --revert   # roll back
    
    With no bridge the plugin falls back to self-hosting (one extra separate window) — nothing breaks, the tools stay available.
  • The browser engine comes from the desktop app's own Electron by default, and the plugin never downloads a second copy; you can also point it at an installed Chrome / Edge in settings.
  • What happens when the browser you picked is not installed: bundled uses its own Electron; automatic picks an installed one and quietly falls back to bundled if there is none; but an explicit chrome / edge choice that is missing fails with a message saying so — it lists the names and locations that were checked and offers three ways out (install it / point DSH_BROWSER_CHROME_PATH or DSH_BROWSER_EDGE_PATH at it / set the carrier back to bundled or automatic). You are never left guessing from an error about Electron.
  • Upgrading the desktop app itself does not carry the plugin along; update it as described above.

Web (dsh web)

  • The plugin is a dependency of the web profile ($DSH_HOME/profiles/web); restart dsh web after updating.
  • There is no desktop shell, so the shared browser is self-hosted by the plugin: the first install may need an Electron binary. If the package manager's build allow-list blocked it (pnpm v10+ blocks electron's postinstall), run npx install-electron once to fetch it.
  • It can equally be pointed at an installed Chrome / Edge in settings — an option that behaves the same on both hosts.
  • Update it the same way you installed it (npm package dsh-builtin-browser, the GitHub repo wqty123/dsh-browser, or a local directory).

What is the same on both

  • The toolset (34 browser_* tools), the "Browser" section in Settings, and how browsing history and cookies persist are identical; only the carrier of the page differs (the official sidebar on the desktop, the plugin's own self-hosted window on the web, or an installed browser you picked in settings).
  • Upgrading loses no data: history and settings live in $DSH_HOME/dsh-builtin-browser-host/ (history.jsonl, settings.json), and login state sits in the same profile directory — including the <chrome|edge>-profile used for an installed browser.
  • If history behaves unexpectedly after an upgrade, check Settings → Browser: history defaults to on, one-time auto-expand defaults to on, closing the browser when a session ends defaults to off, and the carrier defaults to bundled.

Known limitations

  • JPEG screenshots work on the self-hosted carrier and with an installed Chrome / Edge. The desktop sidebar goes through the shell's webContents.debugger, whose Electron CDP JPEG encoder hangs, so a JPEG request on that carrier returns PNG. Downscaling (maxWidth/maxHeight) works on all three — through a CDP clip.scale, or the native capturePage when self-hosted.
  • Self-hosted captures prefer Electron's native capturePage (CDP captureScreenshot can hang with multiple views in the window); the target tab is raised before capturing.
  • 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.
  • CAPTCHA cannot be solved automatically: snapshots flag detected challenges; ask the human to complete it in the shared window instead of retrying.
  • Private mode (privateMode) is not implemented: it needs Electron session partitioning, which is host-layer territory; this plugin does not promise it.
  • 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.
  • 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).
  • 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.
  • 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.
  • 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.
  • 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.
  • After a self-hosted child crash (or a DSH

…

Content from the project README on GitHub ↗

Comments

Comments live in GitHub Discussions. Sign in with GitHub to post or react.