LAN/remote access for the Web UI: injects a crypto.randomUUID polyfill on plain-HTTP origins so the frontend survives LAN or Tailscale IP direct links.
Install
# from npm (prebuilt)
dsh plugin --profile web add dsh-web-lan-access
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:AcidGr/dsh-web-lan-access
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).
README
English | 简体中文
LAN / remote access support for the DeepSeek Harness Web UI.
Bind every interface with one command, reach the GUI from a LAN IP, a Tailscale IP, or a VPN address, and keep Host-backed settings working on the remote page.
Compatibility
| dsh | plugin | notes |
|---|---|---|
| 0.2.2-rc.1 | 1.4.x | current; verified end to end |
| 0.2.1-rc.x | 1.4.x | verified end to end |
| 0.2.0-rc.x | 1.3.x | shipped webserver still accepted 0.0.0.0 |
| ≤ 0.1.7 | 1.3.x | no wildcard ban yet |
Version 1.4.0 is the release that tracks dsh 0.2.1's stricter webserver. See Why 1.4.0 had to change if you are upgrading from 1.3.x.
The problem
Three separate things stand between a remote browser and a working GUI:
1. The listener binds loopback only. dsh web defaults to 127.0.0.1, and from 0.2.1 the CLI rejects a wildcard host outright:
error: --host 0.0.0.0 is an unspecified (wildcard) address, which is not supported:
binding every interface would expose remote code execution to the network
2. The /api fence rejects unknown authorities. Every request must name a loopback host, the listener's own bind literal, or a declared trustedHosts entry. A wildcard bind has no literal of its own — 0.0.0.0 matches no request Host — so the page shell loads but every /api call returns 403 and sessions never appear.
3. Host-backed settings are gated on isLoopback. client-connection derives isLoopback: transport?.ownsHost === true, and client plugins consult that fact before reaching Host surfaces. A page served from a LAN or Tailscale address is not loopback by hostname, so the Models and plugin settings pages stay empty even after authentication succeeds.
The fix
Two small pieces, both reversible.
A replacement webserver row
lib/webserver.js subclasses @deepseek-ai/dsh-host-webserver and changes exactly two things:
Configdelegates to the shipped schema and rescues only the wildcard rejection. A non-literal host, a port out of range, or a missing port still fail exactly as before — the relaxation is one error message wide.getOrigin()never returns a wildcard origin. The parent builds its URL from the bind address, which would yieldhttp://0.0.0.0:3080— an address no browser can dial. A wildcard listener reports loopback instead, and--public-urlstill overrides the advertised root upstream.
Everything else — the service name, routes, index taps, TLS handling, gzip, listener lifecycle — is inherited untouched.
A /api fence widened from the live interface list
The bundle patch derives trustedHosts from every non-internal IPv4 the host currently has, so LAN, Tailscale (100.x), and VPN addresses are all accepted without per-IP configuration.
A bootstrap script for Host ownership
Injected as the first script in <head>, before the boot manifest and shell entry, through the webserver's official index-tap extension point (webServer.tapIndex). It declares ownsHost: true while keeping the ordinary HTTP fetch transport, and leaves a transport supplied by another shell untouched.
What 1.4.0 removed: the
crypto.randomUUIDpolyfill. From 0.2.1 DSH mints UUIDs through@deepseek-ai/dsh-util-crypto, which is built oncrypto.getRandomValues— available on insecure origins — so a plain-HTTP LAN page needs no patch to render. The old polyfill is gone because the bug it worked around no longer exists.
Why 1.4.0 had to change
Through 1.3.x this plugin worked by writing host: '0.0.0.0' into the shipped webserver row's config. That stopped working in 0.2.1 for two independent reasons:
- The webserver's own schema now rejects wildcard addresses, so the row fails validation and the boot aborts with
3 required plugins did not activate. - A patch cannot retarget a row's
name— app-boot skips any patch whosenamediffers from the target's — so the row could not be swapped for a custom module in place.
1.4.0 therefore disables the shipped row (disabled: true, which startup auditing skips) and mounts the subclass under a new id. A second breakage came from the same upgrade: web-app no longer provides the webRuntime service, so the old ctx.webRuntime?.trustedHosts expression threw cannot get property "webRuntime" without inject. The fence list is now derived directly from os.networkInterfaces().
Install
Method 1: Ask your DSH Agent (Easiest 🤖)
Send this repo URL directly to your DSH chat with the instruction:
"Install this plugin for me: https://github.com/AcidGr/dsh-web-lan-access"
Your DSH Agent will automatically install the package and configure it in the background.
Method 2: Bundle install via CLI (Recommended)
From npm:
dsh plugin --profile web add dsh-web-lan-access
From the repo (no npm publish / local development):
dsh plugin --profile web add github:AcidGr/dsh-web-lan-access
Restart dsh web, then hard-refresh the browser.
Method 3: Manual install (no pnpm / offline)
PROFILE="$DSH_HOME/profiles/web" # adjust DSH_HOME and profile name
mkdir -p "$PROFILE/plugins"
cp -r dsh-web-lan-access "$PROFILE/plugins/lan-access"
ln -sfn ../../plugins/lan-access "$PROFILE/node_modules/dsh-web-lan-access"
Then add dsh-web-lan-access to the profile's dsh.profile.bundles in package.json. The bundle patch registers its own rows, so no manual cordis.patch.yml edit is needed.
Usage
The plugin is self-contained: the bundle patch supplies the bind host, so you do not pass --host (and must not — the CLI still rejects 0.0.0.0). --port, --tls-cert, --tls-key, and --public-url all still work.
Install the plugin, then start normally:
dsh --profile web --port 3080The patch derives the
/apifence from every non-internal IPv4 the host currently has — LAN (192.168.x), Tailscale (100.x), and VPN interfaces. As long as the remote interface is up whendsh webstarts, LAN and Tailscale IP access need zero extra config: openhttp://<server-ip>:3080orhttp://<tailscale-ip>:3080and sessions and models load.If you would rather not let the plugin take over the bind host (for example you want loopback plus a port forward), remove the
dsh-web-lan-accessbundle and forward a port (socat / rinetd /tailscale serve) from127.0.0.1:3080, adding the forwarded address totrustedHostsmanually.MagicDNS hostnames (e.g.
xxx.tailXXXX.ts.net) — the fence discovers IP literals only, never hostnames. Add your own names when you browse by name rather than by IP, using the repeatable CLI flag:dsh --profile web --trusted-host myhost --trusted-host myhost.tailXXXX.ts.netOr replace the
connectionrow'strustedHostswith a literal list that still includes the interface addresses:- id: connection config: trustedHosts: - <short-name> # e.g. myhost — MUST be listed separately! - <name>.tailXXXX.ts.net # full domain - 192.168.1.10 # keep your IPs; a literal list replaces the - 100.64.0.5 # derived expression entirely⚠️ The fence compares the
Hostheader literally: a MagicDNS short name (http://myhost:3080) is not the full domain — list the short name on its own line, or every/apicall returns 403 (page shell loads, sessions and models absent).⚠️ A literal list on
connection.config.trustedHostsreplaces the bundle's derived expression rather than merging with it. Either keep the derived expression and add names with--trusted-host, or spell out every IP you need. Do not mix the two.
Host ownership scope
The transport signal enables every client surface DSH associates with owning the Host, not only Models. That includes Host-backed settings and native Host actions such as opening a produced file. Use this plugin only when the authenticated remote browser is meant to operate the agent machine. DSH's Host/Origin fence and browser authentication remain in force; this signal changes the client's capability projection, not request authentication.
Older harness builds that pin privileged methods to loopback on the server continue returning 403 for those methods. This client bootstrap does not weaken that server-side fence.
Verify
# 1. the bootstrap is injected
curl -s http://127.0.0.1:3080/ -b <cookie> | grep lan-access-bootstrap
# 2. every interface is listening (0.0.0.0 in the address column)
ss -ltnp | grep 3080
# 3. the fence admits your interface IPs and refuses anything else
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: 100.64.0.5:3080" http://127.0.0.1:3080/api # 401
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: evil.example:3080" http://127.0.0.1:3080/api # 403
401 means the fence admitted the authority and only the session cookie is missing — that is the expected result for a trusted IP without a browser session. 403 means the authority was rejected. Then open http://<server-ip>:3080 from another device: sessions and models must load.
Security warning
Binding 0.0.0.0 exposes the DSH authentication surface to every reachable interface, including addresses you may not have considered — container bridges, VPN tunnels, and any other network the host is attached to. trustedHosts is an Origin/Host fence, not identity; DSH separately authenticates the browser.
This plugin deliberately relaxes a restriction the DSH maintainers added on purpose:
binding every interface would expose remote code execution to the network
Use it only on trusted networks, restrict the port with a firewall (e.g. ufw allow from 192.168.0.0/16), or front it with Tailscale or an authenticated reverse proxy. If a single interface is enough, prefer plain dsh web --host <ip> — it needs no plugin, and the fence auto-trusts the bind literal.
Because this plugin depends on the webserver's schema and URL-building internals, a future DSH release may break it again. Check the compatibility table before upgrading.
Rollback
- Bundle install:
dsh plugin --profile web remove dsh-web-lan-access - Manual install: remove
dsh-web-lan-accessfromdsh.profile.bundles; the bundle patch's rows disappear with it and the shipped loopback-onlywebservertakes over again.
Development
node --test # 9 tests, no dependencies beyond the linked dsh packages
lib/webserver.js needs @deepseek-ai/dsh-host-webserver resolvable from the package root. When developing outside a profile, link it once:
mkdir -p node_modules/@deepseek-ai
ln -sfn "$(npm root -g)/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-webserver" \
node_modules/@deepseek-ai/dsh-host-webserver
License
MIT
Links
More in this category
zhu1090093659/dsh-web#packages/dsh-remote-web-ui★ 9959
Remote control of a dsh web workspace from phone or PC: QR-code pairing through a token-gated channel, SSE real-time sync, and separate mobile and full desktop GUI modes.
zhu1090093659/dsh-web#packages/dsh-ssh★ 9959
SSH ops panel for DSH: web terminal, SFTP transfer with progress, local port forwarding, and one-command cluster execution across hosts; agents share the same host config.
liguobao/ds-harness-remote★ 1319
Multi-device remote access for DeepSeek Harness and supported coding workspaces through an end-to-end encrypted channel, with adaptive Relay/WebRTC transport, device authorization, workspace files, read-only previews, terminals, and optional Codex Remote.
saya-ch/dsh-mobile★ 728
Access DeepSeek Harness from the Android app or a mobile browser with secure LAN and remote connections, persistent device pairing, and a customizable mobile interface.
uumit-ai/dsh-uumit-remote★ 381
Open dsh web from your phone or another computer through a UUMit account, without running your own server or installing another program: a built-in tunnel client connects to UUMit's hosted tunnel, and a panel in the sidebar handles pairing and who can use it (only you, people you invite by link or phone number, or everyone), with revocation. Requires dsh web on DSH >=0.2.1-rc.2.
ZSeven-W/dsh-ios★ 355
A live iOS Simulator or USB-connected iPhone inside the conversation: 22 agent tools for booting, building, driving the UI by accessibility identity or OCR text, list-row actions and SwiftUI preview hot reload, plus a streaming sidebar panel you can tap and drag on.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.