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 webit 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 webneeds 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 webit self-hosts an Electron window and thebrowser_*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
ElectronBrowserViewHostseam, 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_restrictallow-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 isREAD_ONLY_TOOLS(src/tool-browser/index.ts):snapshot/a11y/content/scrape/screenshot/get_value/wait/challenge/list_tabs/session/history/visited, plusrestrict(the restriction itself must stay liftable, or a task that restricted everything could never get out),reset_sessionandreset.browser_close_tabis not exempt: likeopen/click/switch_tabit can be restricted; neither isbrowser_auth, whoserestorewrites cookies to arbitrary domains — an action, not an observation.
Waiting for the page
browser_openand navigation already wait (bounded) for the new document to parse (readyStateplus 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, callbrowser_waitbeforebrowser_snapshot— passurl(what you opened) and an optionalselector, and wait forready: 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, sobrowser_clickworks directly. DOM selectors are frame-scoped — reach them viaiframe.contentDocumentinbrowser_execute.
Semantic targets and the a11y tree
browser_a11yis 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 (maxNodes10-5000; ask for more explicitly), and coordinates are no longer included by default (passcoords: truewhen you actually need a pixel to click). Only the literalenabledis omitted fromstates(unchecked,collapsedand others are still printed) — nostates=means the node is enabled. A node'snameis exactly whatbrowser_click/browser_typematch when you target bytext.browser_click/browser_typeaccept atarget:{by: css|text|xpath, value, index?}—textmatches 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_fillfor batches, andbrowser_scrapefor structured list extraction.
Operating discipline (locating/clicking/filling)
- Semantics first, coordinates last:
browser_a11ygives roles and accessible names,browser_snapshotgives 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'starget(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 withelement.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,getComputedStylenotdisplay: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
elementFromPointinstead 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_contentcarry 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 usemaxWidth/maxHeightto 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
idor aname, its line ends with{#id}/{[name=x]}, usable directly asbrowser_click/browser_type'starget {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_contentcaps are per format (defaults): html 50 000, json 50 000, txt 20 000, markdown 20 000 characters; override per call withmaxChars. When the result is truncated, the notice says what to do about it;selectornarrows the fetch to one region first.browser_contentwithjsonreturns the element's markup:{"html": "<element outerHTML>", "tag": "<tag name>"}, not an empty object — a DOM node has no own enumerable properties, soJSON.stringifyon one yields{}for every element.browser_executecaps 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_scrapereturns 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_historyshows 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; passverbose: truewhen you actually want the parameters.browser_visitedtimestamps 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 (
browserrow): provides thectx.browserservice — provider registration, session lifecycle, error codes — decoupled from any implementation. - Provider (
browser-electronrow): knows only the oneElectronBrowserViewHostseam (create/destroy/show,sendCommand), so switching carrier touches neither the tools, nor the history, nor the cursor, nor the teardown logic. - Tools (
tool-browserrow): the 34 model-facingbrowser_*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.mjsafter every desktop upgrade — the upgrade replacesresources/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_authcan 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_authrefuses the export (BROWSER_AUTH_DISABLED) — it governs reading only, while writing login state (restore) answers to its own switch and reportsBROWSER_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.persiston (default) → that fixed profile is kept, so you stay signed in across DSH restarts, andbrowser_authcan still export/restore its cookies.cookies.persistoff → 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:
automaticmeans "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-electronfirst, needs network) — only the self-hosted carrier needs it:ELECTRON_PATHcan 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 webself-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 withDSH_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.mjsassembles 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 owndefineTool, 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_HOMEoverride that.On semver and prereleases: the range
>=0.1.1-rc.2 <0.3.0matches no prerelease-tagged version (such as0.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 withincludePrerelease: 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:
With no bridge the plugin falls back to self-hosting (one extra separate window) — nothing breaks, the tools stay available.node desktop-bridge/install.mjs # idempotent; refreshes the module if already installed node desktop-bridge/install.mjs --revert # roll back - 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:
bundleduses its own Electron;automaticpicks an installed one and quietly falls back to bundled if there is none; but an explicitchrome/edgechoice 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 / pointDSH_BROWSER_CHROME_PATHorDSH_BROWSER_EDGE_PATHat it / set the carrier back tobundledorautomatic). 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); restartdsh webafter 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), runnpx install-electrononce 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 repowqty123/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>-profileused 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 CDPclip.scale, or the nativecapturePagewhen self-hosted. - Self-hosted captures prefer Electron's native
capturePage(CDPcaptureScreenshotcan hang with multiple views in the window); the target tab is raised before capturing. fullPagecapture is flaky under software compositing on some hosts — withfullPagethe nativecapturePagepath is skipped entirely (capturePagecannot reach content beyond the viewport), so all three carriers go through CDPcaptureBeyondViewportand the flakiness is not carrier-specific: any carrier can hit it. Only viewport captures prefer the nativecapturePage.- 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.allowReadplus 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_downloadfetches in the page context (keeps logins) and is subject to same-origin/CORS constraints; HTTP(S) targets only;savePathmust be absolute and insidedownloadDir(default: the system Downloads folder, auto-detectingDownloads/下载/下載andXDG_DOWNLOAD_DIR; override withdownloadDir) and never replaces an existing file;browser_screenshot'ssavePathgoes 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 insidedownloadDircannot 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_restrictis 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 nobrowser_*tool writes — or the deployment-leveltool-browserconfigallowedActions(see the comments incordis.patch.yml). Be precise about that boundary: settings travel over a local HTTP endpoint whose writes require a same-originOrigin(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_authcookie round-trip does not preservehostOnly/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'sNetwork.getCookies/Storage.setCookies.flushexports 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 raisesBROWSER_AUTH_SCOPE_UNKNOWN).browser_authis subject to thebrowser_restrictallow-list too, becauserestoreis an action that writes cookies to arbitrary domains. - After a self-hosted child crash (or a DSH
…
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.