dsh-chrome-control
Verifieddsh-chrome-control Β· v0.3.14 Β· MIT
DeepSeek Harness (DSH) bundle: spawns the Rust chrome-daemon process exposing the user's real Chrome to the agent as mcp__chrome__* tools.
Install
dsh plugin add dsh-chrome-control Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Creators
Readme
dsh-chrome
A DeepSeek Harness bundle that gives the agent control of the user's real
Chrome β their profile, their logins, their open tabs β as mcp__chrome__*
tools. The Chrome bridge runs in a small standalone Rust process,
chrome-daemon, which this plugin starts and adopts.
Requires the Chrome extension. This plugin is only the server half. It must be used together with the Deepseek Harness APP Chrome extension from the Chrome Web Store: https://chromewebstore.google.com/detail/deepseek-harness-app/kgjjicancjnedmappjhefngdjaommpop
Without the extension installed and connected, every
mcp__chrome__*tool reports that no Chrome extension is attached.
How it fits together
agent βββΆ dsh web :3080 ββ in-box mcp-client (Node) ββHTTPβββ
ββ GET /chrome/status (proxied to the daemon) β
βΌ
chrome-daemon 127.0.0.1:37086 (Rust, detached, outlives dsh web)
ββ POST /chrome/mcp MCP Streamable HTTP endpoint β dsh web only
ββ GET /chrome/status liveness / build probe β dsh web only
ββ POST /chrome/shutdown retire a stale build β dsh web only
ββ GET /chrome/ws βββWebSocketββ extension ββCDPβββΆ page
ββ POST /host/* settings-UI host operations β extension only
The bundle's patch contributes two rows:
| Row | Job |
|---|---|
chrome-server |
Starts chrome-daemon on 127.0.0.1:37086 (or reuses a running one with the same build hash, retiring a stale one), proxies /chrome/status on the harness's own webServer, and loads the in-box @deepseek-ai/dsh-mcp-client bridge against the daemon's /chrome/mcp. The bridge publishes the catalog as mcp__chrome__*. |
chrome-skills |
Registers the bundled skill that teaches the agent how to drive those tools. |
Install
dsh plugin --profile web add dsh-chrome-control # or a link: dependency for local dev
Then install the browser extension yourself from the
Chrome Web Store β Deepseek Harness APP
(click Add to Chrome). Installing an extension is a decision only the
browser's owner can make, so nothing does it for you. Point the extension's DSH server address at the same dsh web URL (default
http://127.0.0.1:3080); it derives the bridge socket ws(s)://β¦/chrome/ws
from it. Until it is connected, every tool returns a message saying exactly
that.
Native Messaging host (installed on every boot)
The store extension cannot start processes, so it relies on a Native Messaging
host, com.dsh.chrome, for exactly two things: starting dsh web when it
is down, and signing the login cookie with the browser-session secret in
$DSH_HOME/.credentials.yaml. This plugin registers it every time dsh boots β
there is no separate installer to run:
$DSH_HOME/native-host/dsh-native-host.mjs the host (copied from native-host/ in this package)
$DSH_HOME/native-host/dsh-native-host launcher Chrome executes; absolute node, forwards argv
$DSH_HOME/native-host/config.json node + entry of the dsh that booted, used to start it again
<browser>/NativeMessagingHosts/com.dsh.chrome.json for each Chromium-family browser present
<browser> is ~/Library/Application Support/<vendor> on macOS and
$XDG_CONFIG_HOME/<browser> (default ~/.config) on Linux: google-chrome
(+ -beta, -unstable), chromium, microsoft-edge (+ -beta, -dev),
BraveSoftware/Brave-Browser, vivaldi (+ -snapshot). Snap/Flatpak browsers
are confined and cannot execute a host outside their sandbox, so they are not
targeted. The launcher is POSIX sh (no bash assumption), and tries the
recorded node, then Homebrew/Linuxbrew, /usr/local, /usr/bin, Volta, snap,
and finally node on PATH. On Linux filePaths reads text/uri-list from the
clipboard with wl-paste (Wayland, package wl-clipboard) or xclip (X11);
without either it answers clipboard-tool-missing, and like Windows there is no
drag pasteboard (unsupported-board).
On Windows 10+ Chrome never looks for host manifests in a directory β it only reads the registry, which is why a manifest file alone reports "Specified native messaging host not found". The layout there is:
%DSH_HOME%\native-host\dsh-native-host.mjs the host
%DSH_HOME%\native-host\dsh-native-host.cmd launcher (Chrome runs it through cmd.exe); forwards %*
%DSH_HOME%\native-host\config.json as above
%DSH_HOME%\native-host\com.dsh.chrome.json the manifest, pointing at the .cmd launcher
HKCU\Software\Google\Chrome\NativeMessagingHosts\com.dsh.chrome (Default) = that manifest; always
HKCU\Software\{Chromium | Microsoft\Edge | BraveSoftware\Brave-Browser}\β¦ same, when that browser's User Data exists
The keys live under HKCU, so no elevation is needed; they are written with the
system reg.exe and only when their value differs. (A machine policy that sets
NativeMessagingUserLevelHosts to false makes Chrome ignore HKCU hosts.) The
launcher finds the host and DSH_HOME from its own directory (%~dp0), so a
non-ASCII profile path survives cmd's OEM code page; node is tried as recorded,
then %ProgramFiles%\nodejs\node.exe, then PATH. dsh web is started
without a console window. filePaths reads the Windows clipboard (copy/paste of
folders); Windows keeps no drag pasteboard, so board: "drag" answers
unsupported-board.
Writes are compare-then-rename, so an unchanged boot touches nothing; failures are logged and never stop the plugin. Supported on macOS, Linux and Windows 10+.
Only chrome-extension://kgjjicancjnedmappjhefngdjaommpop may call it, checked
twice: Chrome enforces the manifest's allowed_origins, and the host itself
compares the caller origin Chrome passes as its first argument, refusing
anything else before it reads a message. It will also only start dsh on a
loopback host.
Behaviour worth knowing
- Fixed daemon port. The daemon always listens on
127.0.0.1:37086, which is also the extension's default bridge address (ws://127.0.0.1:37086/chrome/ws). It is detached, so it survivesdsh webrestarts; stop it withkill -TERM <pid>. - Daemon log:
$DSH_HOME/logs/chrome-daemon.log. The daemon's stdout and stderr go to this file (appended; rotated to.1past 5 MiB at the next spawn), never to pipes intodsh web: a pipe dies with thedsh webthat spawned it, while the daemon lives on. The daemon itself also treats a dead log destination as harmless. It once was not β a failed log write panicked the extension's socket task, after which the extension showed as disconnected and every tool call timed out. - Startup order does not matter. The bridge is loaded with
failOnStartupError: falseand its own reconnect policy; the extension may attach at any time. - This plugin needs the
webServerservice (the Web composition). In a profile without it, the row stays pending and nothing breaks. - Response size guards. Text tool results are capped at 256 KiB (with a
trailing
[truncated]marker) and top-level arrays past 4000 elements are sliced; the extension WebSocket frame limit is 8 MiB. These keep a giantsnapshotfull outline orget_textdump from stalling the shareddsh webevent loop. For very large pages, narrowsnapshotto a subtree (or pass a container ref), or preferget_text.
Tools
navigate, find_tab, list_tabs, close_tab, close_session,
snapshot, click, fill, upload, evaluate, screenshot, save_as_pdf,
mouse_click, key_type, send_keys, hover, focus, select,
scroll, scroll_into_view, find, wait, wait_for_selector,
network, network_detail, dialog, get_text β each prefixed
mcp__chrome__.
upload forwards local paths to Chrome. Its selector may be a file input or a visible upload trigger; the extension resolves standard label, ARIA and unique-container relationships without framework-specific selectors.
These are distinct from the harness's built-in browser_* tools, which drive a
separate WebKit panel sharing no cookies or logins with Chrome. The bundled
skill tells the agent when to use which.
Security
While the extension is connected the agent acts with your logged-in
sessions, upload may expose explicitly named local files to the page, and mouse_click/key_type send trusted input that pages cannot
distinguish from your own. The extension popup has an Allow agent control
toggle that severs this immediately. The daemon binds to the loopback only
and sends no telemetry.
Who may call the daemon
Every request passes one gate (daemon/src/request_guard.rs) before any route
runs, 404s included. It relies only on headers a browser sets and a web page
can neither drop nor forge:
| Check | Rule |
|---|---|
Host |
when present, must be 127.0.0.1, localhost or [::1] β defeats DNS rebinding, whose requests still carry the attacker's name |
/chrome/ws, /host/* |
Origin must equal chrome-extension://kgjjicancjnedmappjhefngdjaommpop exactly β refuses pages, every other extension, and a missing Origin |
everything else (/chrome/mcp, /chrome/status, /chrome/shutdown, future routes) |
no Origin at all β the caller is dsh web's Node client; any browser request (fetch, no-cors, form post) carries one and is refused |
The extension id is the Chrome Web Store id, defined once in
daemon/src/host_guard.rs. The extension repository ships the store's public
key as key in manifest.json, so an unpacked development build has the same
id; strip key when packaging for the store.
Not covered: another local process running as the same user, which can send
any header. That is accepted β such a process can already drive Chrome's
profile, git and pnpm directly, and a token file would be readable by it
just the same.
Host operations (/host/*)
Alongside the Chrome bridge, the daemon exposes a small REST surface the extension's settings UI uses for things an extension cannot do itself, because it cannot start a process:
POST /host/capabilities { }
POST /host/git { cwd, args[] }
POST /host/plugin { action, name? }
POST /host/server/version { }
POST /host/server/restart { }
POST /host/server/update { }
POST /host/disable { }
These are not MCP and share no codec with /chrome/mcp; nothing here goes
near the control WebSocket, since a second connection to /chrome/ws makes the
hub fail the extension's in-flight calls and drop it for tens of seconds.
Every route is a POST, including the capability probe, and that is load-bearing
rather than stylistic: Chrome sends no Origin header on a simple GET
from an extension page, so an Origin-only guard cannot authenticate one. An
earlier revision exposed GET /host/capabilities and the side panel's own probe
was refused, which made the whole Git tab report "this deployment does not offer
host operations". POST with application/json is a preflighted request, so
the header is always present. GET now answers 405 naming the right method.
A non-zero subprocess exit is returned as HTTP 200 with an exitCode, because
the UI needs git's own stderr rather than a generic 500.
dsh is resolved through pnpm, by shim path
/host/server/* runs $PNPM_HOME/dsh, never whatever dsh is first on
PATH, and never the path that shim resolves to. Both rules exist because of
measured failure modes:
- A machine can carry two installs (say an npm-global copy earlier on
PATHplus the pnpm one). Updating with pnpm while launching fromPATHupdates one copy and keeps running the other, so the version never changes and the update looks like it worked. - The pnpm shim
execs a content-addressed directory whose hash changes on every reinstall. A resolved inner path is therefore valid only until the next update β exactly when a restart tends to be requested.
When dsh is not a pnpm install, these routes return 400 with
reason: "dsh-not-installed-via-pnpm" rather than falling back.
/host/server/version reports the version of the dsh web actually running,
so an update that has not been restarted into still shows the old version. A
server this daemon restarted reports the dsh --version recorded just before
launching it (an in-place install rewrites the package under a running
process); any other is read from the package its command line runs
(β¦/@deepseek-ai/dsh/lib/bin.js, or the one a dsh bin link resolves to). Only when no server can be read does it fall
back to $PNPM_HOME/dsh --version.
The guard, and what it does not cover
/host/* requires Origin to equal the one authorized extension exactly, and
is served only when the daemon is bound to loopback. That stops web pages
(browsers will not let a page forge an extension origin) and other extensions
(compared with ==, never starts_with, which would admit any extension). The
router-wide gate above enforces this first; each /host/* handler checks it
again, so dropping the middleware in a refactor cannot open these routes.
It does not stop another local process running as the same user: such a
process can set any Origin, and loopback TCP exposes no peer pid or uid. This
is an accepted trade-off, on the grounds that the same process can already run
git and pnpm directly. What limits it instead, and must not be relaxed:
- subcommand and option allowlists (
-c,--upload-packand friends are refused, since they make git run arbitrary programs); - registry-only package specs (
file:,link:,git+,github:refused); - the server operations taking no caller-supplied command, pid or package name
β restart proves the process on the port really is
dsh webbefore signalling it; POST /host/disable, which withdraws the whole surface until restart while leaving/chrome/*untouched;- refusing to serve
/host/*at all when bound off the loopback.
Tests
pnpm run check # typecheck + build + unit and end-to-end tests
cd daemon && cargo test
The end-to-end suite boots a real webServer on an ephemeral port, lets the
in-box bridge discover the catalog through /chrome/mcp, attaches a fake
extension over the real WebSocket, and completes a whole tool call round trip.