dsh-remote-access
Serve the DeepSeek Harness (dsh) Web UI through a non-localhost authority — a reverse proxy, SSH tunnel, or LAN address — without patching the installed dsh.
What this fixes
Stock dsh web is built for a browser on the same machine. Three things break when the browser reaches it through any other authority:
| Problem | Stock dsh | This plugin |
|---|---|---|
/api Host/Origin fence |
accepts loopback, the listener's bind literal, and --trusted-host only; every RPC answers 403 for a proxy authority |
appends DSH_REMOTE_ACCESS_TRUSTED_HOSTS authorities to the same list (--trusted-host keeps working) |
Page view (connection.isLoopback) |
derived from location.hostname; a proxied page is treated as non-loopback, so on dsh 0.2.0 settings stay in process-local memory (settings are unavailable in this browser on the Models page) and on every version the host-document action is hidden |
injects __DSH_TRANSPORT__ = { ownsHost: true }, the host-owned transport hook dsh already uses for its desktop shell, so authenticated pages report the loopback view |
| Launch token | random per process; the only way to find the login URL after a restart is the service log | pins DSH_WEB_TOKEN (or plugin config) across restarts |
Authentication is unchanged: the browser still has to exchange the launch token for the signed, authority-bound session cookie, and the Host/Origin fence still runs before every /api request. This plugin removes deployment friction; it does not bypass access control.
Install
# from GitHub (public repository, no build step)
dsh plugin --profile web add git+https://github.com/0xshawn/dsh-remote-access.git
# from a local checkout
dsh plugin --profile web add /path/to/dsh-remote-access
Restart dsh web afterwards — dsh loads bundles at boot. Verify the row is active:
dsh --profile web --dump-config | grep -A6 'dsh-remote-access'
Remove with dsh plugin --profile web remove dsh-remote-access; the stock behaviour returns on the next restart.
Migrating from a source patch
If a deployment currently patches the installed dsh bundles (fixed launch token plus a forced loopback classification), the switch is:
- Stop applying the patch in the installer;
npm install -g @deepseek-ai/dsh@<version>no longer needs a follow-up patch step. - Install this bundle into the
webprofile (above) and restartdsh web. - Keep the deployment's
DSH_WEB_TOKEN; it is read by the same name. Keep--trusted-hostor move the authorities toDSH_REMOTE_ACCESS_TRUSTED_HOSTS. - Confirm the startup line still prints the pinned token:
dsh web: ...?token=<same token>.
Configure
Environment
| Variable | Value | Effect |
|---|---|---|
DSH_REMOTE_ACCESS_TRUSTED_HOSTS |
comma-separated bare authorities, e.g. dsh.example.com,dsh.example.com:8443 |
appended to --trusted-host; a port-less entry matches any port |
DSH_WEB_TOKEN |
non-empty launch token | pins the login token; empty/unset keeps the random per-process token |
Both work in systemd Environment=/EnvironmentFile=, launchd EnvironmentVariables, Docker env, or a shell profile. Keep --trusted-host if you prefer flags; the expression appends the environment authorities to the invocation list. Note that a patch layer replaces the whole connection.config: if another layer (or a later profile patch) overrides connection, restate trustedHosts there — otherwise the environment authorities are dropped — and restate any other key you still need, such as cookieMaxAgeDays or recovery.
DSH_WEB_TOKEN="$(cat /etc/dsh/web-token)" \
DSH_REMOTE_ACCESS_TRUSTED_HOSTS="dsh.example.com" \
dsh web --host 127.0.0.1 --port 3080 --no-open
Malformed authorities (a scheme, path, userinfo, or zero-padded port) abort boot with the upstream validation error instead of being silently ignored.
Plugin config
Override the dsh-remote-access row in the profile patch ($DSH_HOME/profiles/web/cordis.patch.yml) for per-profile values:
- id: dsh-remote-access
config:
# false keeps the stock per-authority page view.
loopback: true
# Fixed token; empty falls back to DSH_WEB_TOKEN, then the random token.
# Prefer DSH_WEB_TOKEN: profile configuration is plain text.
launchToken: ''
Behind a reverse proxy
--public-url https://dsh.example.com/ advertises the address browsers use (printed URL, DSH_WEB_URL, runtime context); this plugin does not change that. The proxy must still:
- preserve the browser-facing
Hostheader (the fence compares it verbatim); - strip any mount prefix before forwarding to the listener;
- forward WebSocket upgrades with
Upgrade/Connectionintact; - rewrite cookie
Pathto the mount, and addSecureon the HTTPS leg.
See upstream Publish the Web UI behind a reverse proxy for the full checklist. TLS termination, network isolation, and who may reach the port remain the operator's job.
How it works
The package is a dsh bundle: cordis.patch.yml is a patch layer over @deepseek-ai/dsh-base + @deepseek-ai/dsh-web-app, and two seams are used.
Trusted hosts — patch layer. The
connectionrow'strustedHostsgets the environment list concatenated onto the invocation list. The expression readsctx.get('webRuntime')(dsh 0.2.0, which already carries the bind-derived LAN literals and CLI extras) orctx.get('webStartup')(dsh 0.2.1); 0.2.0 provides both services, so runtime must win to keep a wildcard bind's LAN authorities. One patch covers both layouts. The Host/Origin fence and theallowsRemoteAuthoritiesclassification are then upstream's own.Loopback view — index injection. The host plugin listens to
webserver/index-injectand adds one inline classic script to the served index:if (globalThis.__DSH_TRANSPORT__ === void 0) globalThis.__DSH_TRANSPORT__ = { ownsHost: true };ClientTransportHooks.ownsHostis upstream's flag for "this page owns the Host", documented as reserved for a shell that assembles its own transport; upstream also states that served pages never carry the global. This bundle sets it deliberately to obtain owner parity:dsh-client-connectionreports the loopback view without changing the page's HTTP + WebSocket carrier. The guard leaves any real shell-provided transport untouched, and the script runs before the shell entry and every client plugin.Launch token — service field. The plugin waits for
ctx.connectionand setsconnection.browserAuth.launchTokenbefore the loader settles, so both the printed authenticated URL and token validation use the pinned value. If a future dsh version hides that field, the plugin fails loudly instead of silently printing a random URL.
Security
- The launch token is the credential. Anyone holding the printed
?token=URL can obtain a session cookie and full owner access. Share it deliberately. A pinned token is a long-lived credential — never commit it. ChangingDSH_WEB_TOKENand restarting invalidates the old URL but does not revoke cookies already issued (they live forcookieMaxAgeDays, 30 days by default); clear the site's cookies, or remove the browser-session credential record and restart, to revoke them. - Trusted hosts are not authentication. They only admit the authority to the Host/Origin fence; the signed session cookie still gates every RPC. Never expose the listener port to an untrusted network without TLS and a proxy you control.
- Loopback parity is a privilege decision. On dsh 0.2.0 it makes settings host-backed for authenticated remote browsers; on every version it exposes local-only UI affordances such as Open configuration file, which opens the settings document in the host's editor. Remote browsers already hold owner credentials, but operators who want the stock least-privilege view can set
loopback: false. - The loopback view goes beyond a documented client contract. Upstream documents
ownsHostas reserved for a shell that assembles its own transport and says served pages never carry the global. This bundle sets it anyway; every current dsh consumer reads it only forisLoopback, but a future or third-party client plugin could treat it as proof of host ownership.loopback: falseremoves that exposure. - Plain HTTP leaks the token. Terminate TLS at the proxy or with
--tls-cert/--tls-key; the upstream guide covers cookieSecurebehaviour. - Do not add a CSP that blocks inline scripts. The loopback row is an inline classic script; dsh's own boot globals already require inline script permission, but a proxy-injected CSP without
'unsafe-inline'(or a nonce covering it) silently blocks the row while the page still loads.
Compatibility
- Tested against dsh
0.2.1-rc.2,0.2.1-rc.3, and0.2.0-rc.2(Web profile). - Declared range:
engines.dsh >=0.2.0-0 <0.3.0-0. dsh 0.1.x is not supported or tested. - On dsh 0.2.0 a profile-level wildcard bind (
webserver.host: 0.0.0.0) derives LAN authorities; the patch preserves them, so the printed LAN URL works without extra trusted-host entries. - The plugin row is Web-profile only; in a profile without a
connectionservice the row stays inactive, so installing it there achieves nothing. npm install -g @deepseek-ai/dsh@<version>replaces the dsh install but not the profile: the plugin keeps working after a dsh upgrade. Restartdsh webto load a new dsh version.- The token seam reads
connection.browserAuth.launchToken. If dsh changes that shape, the plugin throws at boot when a token is configured; unset the token or update the plugin. - The pin runs once at startup. A host-plugin reload (HMR) can rebuild the Connection service from dsh's cached per-process token and will not reprint the startup URL; restart
dsh webafter changing the token or the dsh version.
Development
npm install
npm test # node:test unit tests for config, token resolution, index injection
End-to-end check on an isolated home (no effect on your real profile):
export DSH_HOME=$(mktemp -d)
dsh plugin --profile web add "$PWD"
DSH_WEB_TOKEN=dev-token DSH_REMOTE_ACCESS_TRUSTED_HOSTS=dsh.test \
dsh --profile web --host 127.0.0.1 --port 3199 --no-open
# then open http://<non-localhost>:3199/?token=dev-token through your proxy
License
MIT © 2026 shawn
No comments yet. Be the first to write one.