DSH Browser
English | 中文
Embedded browser for the DSH Web GUI: browse the web and your workspace files inside the chat interface — multi-tab, address bar, per-workspace tab persistence — plus agent tools (
browser_open,browser_read). Pages are rendered by a headless Chromium (Puppeteer) on the host, so sites that sendX-Frame-Optionsload correctly.
An external plugin package for DeepSeek Harness (DSH). It is a single
dual-face cordis bundle: the host half owns the agent tools, the
/api/dsh-browser route family (Puppeteer page proxy + SSE open-event stream
- workspace file listing/serving), the settings namespace, and the
system-prompt announcement; the browser half renders the sidebar entry, the
multi-tab panel, and the plugin settings card. Hot-pluggable — mounted via
dsh plugin --profile <name> add link:<repo>.
Platform support. Works with both DSH Web and Desktop. On Web the settings card lives under Settings → Plugins and requires a one-line whitelist patch in DSH source (see Option A below). On Desktop it appears as a standalone "Embedded browser" entry in the left nav — no source changes needed.
Prerequisites
A Chromium-based browser must be installed on the host machine (Chrome,
Edge, or Chromium). The plugin auto-detects the executable on Windows, macOS,
and Linux; you can also set an explicit path in the settings card. The plugin
uses puppeteer-core (not puppeteer), so it never downloads its own
Chromium.
What it does
- Entry: a "Browser" row in the sidebar, below the New Session button.
- Panel: takes over the center column with a tab strip, a toolbar
(back / forward / reload / home / open-in-system-browser), an address bar
(URL or search, Enter opens), and an iframe content area. Each page is
rendered by a shared headless Chromium on the host — the proxy route waits
for
networkidle, reads the fully-executed DOM, injects a<base>and a link-interception script, and returns it to the iframe. Inactive tabs stay mounted and stateful; iframes lazy-load on first activation. - Link interception: clicks on
http(s)links inside a proxied page are caught and posted to the panel —target="_blank"/window.openopens a new tab, ordinary links navigate the current tab. Nothing ever pops the system browser. - Tabs per workspace: the tab set is persisted per project root
(localStorage, debounced + flushed on page hide). Switching sessions swaps
the whole tab set; switching back restores it. A configurable cap (default
- trims the oldest inactive tab.
- Workspace browsing: the new-tab page lists the current workspace
directory (folders navigate, breadcrumbs, up button); clicking a file opens
it in the panel through the host's file route. HTML previews get a
<base>injection so relative images/styles resolve, and a CSPsandboxheader so a previewed file can never run scripts in the GUI origin. - Agent tools:
browser_openpushes a URL into the panel (a new tab opens and the panel gains focus);browser_readfetches a page from the host and returns extracted readable text (static-HTML approximation, no JavaScript). - Settings card: On Web, an "Embedded browser" card appears under Settings → Plugins; on Desktop, a standalone "Embedded browser" page appears in the left navigation. Both support staged edits, save/discard, and inherit/reset semantics. Fields: enable, agent announcement, home page, tab cap, private-address override, browser executable path, proxy server.
- Agent announcement: a system-prompt section tells every agent the plugin exists, what its tools do, and its limits (same mechanism dsh-ssh uses).
Install
# from a local checkout (development)
dsh plugin --profile <name> add link:<repo>
# from npm (once published)
dsh plugin --profile <name> add @nono-neko/dsh-browser
Restart dsh web; the sidebar entry appears. The web profile needs the
@deepseek-ai/* client packages the bundle injects (any rc.6 web deployment
has them). Make sure a Chromium-based browser is installed on the host.
Configuration
The plugin reads its settings from a layered source: schema defaults, then the
plugin's cordis.yml entry (composition base), then the user settings
document. All fields are optional.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Mount the sidebar entry, tools, and proxy routes. |
announceToAgent |
boolean | true |
Inject a system-prompt section telling agents about browser_open / browser_read. |
defaultHome |
string | https://www.bing.com |
URL loaded by the new-tab / home button. |
maxTabs |
number | 10 |
Per-workspace tab cap; oldest inactive tab is trimmed. |
allowPrivateAccess |
boolean | false |
Let browser_read fetch private / loopback addresses. |
browserExecutable |
string | auto-detect | Absolute path to a Chromium-based browser (Chrome / Edge / Chromium). |
proxyServer |
string | empty | Route Puppeteer traffic through a proxy, e.g. http://127.0.0.1:7890. |
Option A — Settings card in the DSH GUI
The plugin provides an interactive settings form:
- Web: Settings → Plugins → Embedded browser — requires the DSH whitelist patch (see below)
- Desktop: standalone Embedded browser entry in the left nav — works out of the box, no source changes needed
| Web settings card | Desktop settings page |
|---|---|
![]() |
![]() |
Web requires a DSH whitelist patch. As of DSH rc.6, the settings API only exposes namespaces in a hard-coded allowlist inside
packages/host/apiproxy/src/api-proxy.ts(WEB_SETTINGS_NAMESPACES). An external plugin's namespace is filtered out even after it registers correctly, so the card renders "not exposed" until you add'dsh-browser'to that array and restartdsh web. The DSH team has noted that moving this declaration tosettings.register()so plugins can self-expose is deferred work.
Edit packages/host/apiproxy/src/api-proxy.ts in your DSH checkout:
const WEB_SETTINGS_NAMESPACES = [
'agent-loop', 'shell', 'locale', 'permission', 'ui-conversation',
'ui-theme', 'web-search-deepseek', 'dsh-browser', // <-- add this
] as const
DSH runs through tsx, so no rebuild is needed — restart dsh web and the
card becomes editable.
Option B — Config file only (no DSH source changes)
If you do not want to patch DSH, set the same fields directly. Two layers are available:
Plugin entry config (cordis.yml or your profile's plugin config) — the
composition base, applies to every user of that profile:
plugins:
dsh-browser:
defaultHome: https://www.google.com
maxTabs: 20
proxyServer: http://127.0.0.1:7890
User settings document (~/.dsh/settings.yaml) — per-user overrides that
layer on top of the entry config:
dsh-browser:
browserExecutable: C:\Program Files\Google\Chrome\Application\chrome.exe
allowPrivateAccess: true
The settings card stays read-only ("not exposed") in this mode, but every field is honored from the files above.
Development
pnpm install # @deepseek-ai/* SDK packages are public on npm (or a mirror)
pnpm build # tsc types + tsdown dual-half bundle (lib/index.js + lib/client.js)
pnpm typecheck # tsc --noEmit
pnpm test # vitest
The build emits two artifacts from one config: the node half (lib/index.js,
esm) and the browser half (lib/client.js, a window.__ModuleLoader__
closure-factory served at /plugins/dsh-browser/client.js). CSS Modules are
compiled into the client bundle by lightningcss; the client bundle enforces a
purity gate — value imports from @deepseek-ai/* are only allowed for the
platform seed modules, everything else must inline or go through cordis
services.
Security model
- Loopback fence: every
/api/dsh-browserroute (proxy, SSE, file) refuses non-loopback clients (socket address + Host header + same-origin markers). A LAN-exposed dsh web cannot serve workspace files or the proxy to unpaired devices. - Workspace gate: file listing and serving canonicalize the requested root (realpath) and require it to be a registered workspace or inside one; every requested path is re-checked after resolution, so symlinks cannot escape the root.
- Served HTML sandbox: workspace-previewed HTML is served with
Content-Security-Policy: sandbox— scripts never execute in the GUI origin (which holds the session's loopback API access). - Proxied pages are not sandboxed: the Puppeteer-rendered HTML is returned without CSP / X-Frame-Options so it can render in the panel iframe. The loopback fence is the security boundary — only local clients can reach the proxy route. Proxied pages cannot access the GUI origin's APIs because they are served from a different path and the browser's same-origin policy applies to the iframe content.
- SSRF guard on
browser_read: the target hostname is resolved through DNS before the request leaves the process and every address must be public (private/loopback/link-local/reserved ranges are refused). Redirects are followed manually and each hop is re-checked. TheallowPrivateAccesssetting is an explicit override; the risk is yours. - Proxy route uses Puppeteer: the headless Chromium fetches the page, so
the SSRF guard from
browser_readdoes not apply to the panel proxy. TheproxyServersetting lets you route browsing traffic through a local VPN / proxy. - Size/time caps:
browser_readbodies over 2 MB answer an error before being read; served workspace files over 64 MB are refused; each Puppeteer render times out after 30 seconds.
Limitations
- No persistent login: each proxied page opens a fresh Puppeteer page and closes it after rendering. Cookies and login state are not retained between requests, so sites that require authentication will show a logged-out view.
- GET only: the panel proxy supports GET requests. Form submissions (POST)
and file uploads are not proxied — they will execute inside the iframe and
may be blocked by the target site's
X-Frame-Options. - JavaScript-rendered navigation: the initial page is fully rendered by
Puppeteer, but subsequent in-page navigation (SPA routing, form posts)
happens inside the iframe and may hit
X-Frame-Optionson the new URL. Ordinary<a>links are intercepted and re-proxied. browser_readsees only static HTML: JavaScript-rendered pages come back without their client-side content, and it cannot use your logins.- Browsing consumes real network traffic on the host machine.
License
Apache-2.0


No comments yet. Be the first to write one.