packages/dsh-remote-web-ui
zhu1090093659/dsh-web手机/PC 远程操控 dsh web 工作区:扫码配对、令牌门控通道、SSE 实时同步,提供移动端与完整桌面 GUI 两种远程形态。
English | 中文
移动端远程控制 + 一键远程更新:扫码配对后用手机远程使用当前 dsh web 工作区; 点击侧边栏更新按钮自动检查并更新 dsh-web-ui 全家桶。
This repository is an external plugin package for DeepSeek Harness (DSH):
scan-to-pair mobile remote control for the dsh web GUI, plus a one-click
self-update for the dsh-web-ui family. It is a single dual-face package — the
host half owns pairing tokens, device sessions, the /api/pair route family,
and the /api/update surface; the browser half renders the sidebar-foot
entries (the download trigger and the phone icon beside the settings button),
the pairing panel with a QR code, live device status, and stop/refresh/copy
actions, and the update panel that probes and runs the update.
/m — a thin client purpose-built for a small screen (see
Screenshots), not the desktop UI squeezed into a phone.
The link carries a workspace parameter so the phone lands in the same
workspace the desktop was looking at.requirePairingForLan gate is on
(default), every non-loopback /api request must carry a live paired
device cookie, so the QR is the only way into a LAN-exposed dsh web.@linxin666/dsh-* family releases. When a newer release exists
the panel runs the update automatically (pnpm update --latest inside the
owning dsh profile; when pnpm is missing it falls back to corepack pnpm
and then npx --yes pnpm, and on Windows the command runs through
cmd.exe so npm-installed .cmd shims resolve; the loopback-only
/api/update/status + /api/update/run endpoints drive it) and asks for
a dsh web restart to pick it up. After a green pnpm exit the installed
versions are re-checked against the registry: a green exit that left every
version in place (e.g. the pnpm minimumReleaseAge gate silently skipping
same-day releases) is reported as a stale update with configuration
guidance instead of a false success. Local
link installs (development mode) are detected and report the npm state
without updating.The phone surface on a 390pt viewport. Light is the default theme; a sun/moon toggle in every header flips to the dark palette at any time.
session.models directory the
desktop uses):
dsh CLI supports profiles (dsh --profile,
dsh plugin) — the profile/bundle mechanism this package rides on.dsh web --host 0.0.0.0. With the default 127.0.0.1 bind the panel
shows an explicit explanation instead of a dead QR code — unless a public
base URL is configured (see "Remote access over the internet" below),
which makes the QR reachable from anywhere without rebinding. The panel's
mint/stop endpoints are loopback-only by design: a desktop browser
opened at the LAN URL sees a "配对面板仅限本机使用" banner instead —
open the panel at http://127.0.0.1 and let the phone use the paired
link.autoTunnel), the cloudflared
platform binary ships with the package (its postinstall downloads it; a
runtime download covers installers that skip postinstall scripts). No
user-side tooling, account, or domain is needed — a Cloudflare quick
tunnel is free and anonymous.Install the family aggregate package @linxin666/dsh-web-ui-all (all plugins and skins in one) or this plugin alone:
# Recommended: install directly from npm
dsh plugin --profile web add @linxin666/dsh-remote-web-ui
# Or from the repository (development loop)
git clone https://github.com/zhu1090093659/dsh-web-ui.git
cd dsh-web-ui
pnpm install && pnpm -r build
dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-web-ui
Restart the profile (dsh web), then open the phone icon in the sidebar
foot. The plugin's cordis.patch.yml inserts the single plugin row that
mounts both halves.
github:<org>/<repo>installs work for a standalone repo whose package sits at the root (thepreparescript buildslib/during install; pnpm ≥10 blocks that until you copy the printed key into the profile'spnpm-workspace.yamlallowBuildsand re-run). Monorepo subpackages use thelink:form above.
dsh web --host 0.0.0.0 (the printed LAN URL confirms reachability)./m — no desktop UI on a
small screen. The surface is deliberately thin:
session.create and opens the new chat immediately),mobileEnterToSend: false to make Enter insert a
newline and reserve sending for the 发送 button),session.models /
session.selectModel RPCs and the /permission command — the phone
changes the same session settings the desktop would.The mobile surface is fully self-contained in this plugin: the /m page
and its data channel (/m/api) are served by the plugin's own routes and
need no harness source changes — the phone's RPC calls ride the
plugin's /m/api proxy (which delegates to the host ApiProxy service and
pages session.list itself), so the tunneled Host never has to enter the
connection plugin's trust fence. The phone is gated by its paired-device
cookie and an explicit method allowlist (settings/credentials/host-action
domains are never reachable from the phone; model reads/writes are limited
to the advisory session.models / session.selectModel pair, creation to
session.create (workspace id only — the phone never names a working
directory of its own), and the permission picker only ever sends the
mode-agnostic /permission command
through the already-allowlisted session.prompt); the live stream arrives
over Server-Sent Events on /m/api/events.mux.
mobileEnterToSend: false in the plugin settings card (or
the profile patch) to make plain Enter insert a newline instead; sending
then happens only through the 发送 button. The phone reads the flag
through its own /m/api preferences method when a chat opens. On
browsers that support field-sizing: content, the input grows with the
draft up to its 120px cap in either mode./api access behind pairing
(see requirePairingForLan in src/index.ts). A desktop browser opened
via the LAN URL must pair like any remote device; loopback (127.0.0.1)
is unaffected. Set requirePairingForLan: false in the profile patch to
restore the open-LAN behavior while keeping tokens/status/revocation.publicBaseUrl is set, the
picker adds a 公网地址 option on top — the default QR then uses the
public base, and picking a LAN literal re-mints an in-network link.publicBaseUrl satisfies the reachable-bind requirement on
its own: dsh web bound to 127.0.0.1 (no --host 0.0.0.0) still mints
working public QR links through the tunnel.Turn on autoTunnel in the plugin settings card (or set
autoTunnel: true in the profile patch). The plugin then runs its own
Cloudflare quick tunnel — the cloudflared binary ships with the package,
no install, account, or domain needed — and wires everything itself:
https://xxx.trycloudflare.com URL becomes the QR base, so a
phone anywhere can pair. The panel shows the tunnel status (starting /
running / failed with the reason), and a crash is restarted
automatically with backoff.The QR stays LAN-only until the tunnel reports its URL, and a tunnel
restart mints a NEW hostname — the plugin clears the old link and mints a
fresh one, so users never touch configuration. Note that a quick tunnel is
public: anyone with the URL can load the static page; the pairing gate is
the real fence, and the phone's data channel (/m/api) is protected by
its own paired-device gate plus a method allowlist — the tunneled Host
never needs to enter the connection plugin's trust fence, so no profile
or harness customization is required for the auto tunnel to work.
The QR link is normally a LAN URL, so a phone outside the house cannot use it. Point a tunnel at the dsh web port and tell the plugin its public address — the QR is then built from the tunnel URL and the phone-facing pairing fence trusts the tunneled host. Two knobs are involved:
publicBaseUrl (plugin config, in the profile patch or the settings
card): the public origin, e.g. https://foo.trycloudflare.com. The QR
link is built from it, and accept/heartbeat/status accept its host.
Malformed values are ignored with a warning (LAN-only behavior kept).--trusted-host <authority> (dsh web flag): the transport-level
/api fence of the connection plugin must accept the public host too —
without it every /api request through the tunnel 403s before the
pairing layer (the plugin's own fence only covers the /api/pair
routes). Pass the public host (or host:port) exactly as the tunnel
forwards it.Install the client once (macOS: brew install cloudflared; other systems:
grab the cloudflared-darwin-{arm64,amd64} binary from the official GitHub
releases). Then:
# 1. Expose the local port (whatever dsh web is listening on):
cloudflared tunnel --url http://127.0.0.1:3080
# prints something like: https://xxxx-xxxx-xxxx.trycloudflare.com
# 2. Start dsh web with that host trusted (use --host 0.0.0.0 too when LAN
# access should stay available):
dsh web --trusted-host xxxx-xxxx-xxxx.trycloudflare.com
Then set publicBaseUrl: https://xxxx-xxxx-xxxx.trycloudflare.com in the
profile patch (or the plugin settings card — it hot-reloads). Open the
phone icon at http://127.0.0.1, scan the QR from anywhere: the phone
binds, reloads into the mobile surface, and heartbeats keep it online.
Notes:
cloudflared restart changes it, so update --trusted-host
and publicBaseUrl together. Cloudflare documents no uptime guarantees;
in-flight-request concurrency is capped (HTTP 429 past it), and Quick
Tunnels do not forward Server-Sent Events. Tailscale Serve (and
tailscale serve on a single port) behaves the same way. SSE is how the
phone receives live messages in real time, so over a quick tunnel or
Tailscale Serve the mobile chat falls back to polling: the phone still
sends and receives messages (everything else rides plain HTTP, which does
forward), only a new message may arrive a few seconds late instead of
instantly. The plugin polls session.history on a short interval once the
SSE channel goes silent, and resumes streaming as soon as SSE works again.
For true real-time push, point the QR at a tunnel that forwards SSE — a
Cloudflare named tunnel (domain hosted on Cloudflare, see below), or a
plain TCP port forward (LAN address, the tailscale up virtual-interface
address, or a manual ssh -L / cloudflared TCP tunnel to the port)./api call — so keep requirePairingForLan on.100.x.y.z) shows up in
the QR's address picker automatically, and a phone on the same tailnet
reaches it like a LAN host.Work from this repository (no sibling checkout needed):
cd ~/code/dsh-web-ui
export NPM_TOKEN='<token>' # only if private @deepseek-ai auth is still required
pnpm install
pnpm --filter @linxin666/dsh-remote-web-ui run build
pnpm --filter @linxin666/dsh-remote-web-ui test
pnpm --filter @linxin666/dsh-remote-web-ui run typecheck
The peer APIs come from the official NPM SDK: every @deepseek-ai/* package
used here is declared in devDependencies (rc.6), and TypeScript/Vitest resolve
types straight from node_modules — no DSH source checkout is required. The
consumer-side prepare build (tsdown.prepare.config.ts) transpiles without
type checking, so git installs work without any harness checkout either.
pnpm run typecheck
pnpm test
pnpm run build
This plugin rides three harness seams that may not exist in older checkouts:
api/gate waterfall (packages/client/connection): the /api route and
event WebSocket upgrades emit this event after the trust fence so plugins
can enforce application-level access control. Without it, revocation has
no server-side teeth.sidebar.remote foot seat (packages/client/ui-sidebar): the sidebar
declares and renders the seat the phone entry occupies.mintRpcId fallback for
insecure-context origins; the 20260808-branch connection loop opening the
host stream after the mux stream): without them the browser runtime cannot
run on a plain-HTTP LAN page at all (the mobile side of this feature).The fence helpers (isTrustedApiRequest / isLoopbackHostname) are
reimplemented locally in src/gate.ts / src/routes.ts: the 20260810
upstream moved the trust fence inside the connection plugin and stopped
exporting them, so the pairing routes carry their own copy scoped to the
literals the QR links advertise.
See the Agent Notes api-gate-and-sidebar-remote-seat and
lan-runtime-connection-fixes in the harness checkout.
The unit/component specs cover the route family, the gate, and the panel, but the pairing loop involves a real browser on a non-loopback origin. Repeat this after any change to the wire contract or the connection loop:
dsh web --host 0.0.0.0 --port 3190 --workspace-root /tmp/remote-e2e.http://127.0.0.1:3190) in a browser: the
phone icon sits in the sidebar foot; the panel mints a QR instantly.http://192.168.1.7:3190/?pair=<token>): the page accepts, sets
the HttpOnly dsh_pair cookie, reloads, and boots the full UI — no
console errors, and a generation round trip completes./api request 403s
(reconnect loops retry until a fresh QR re-pairs).The public path is the same round trip through a tunnel (see "Remote access
over the internet"): loopback mint → phone opens the public QR URL →
accept → full UI. Both publicBaseUrl (plugin config) and
--trusted-host (dsh web flag) must name the tunneled host; the desktop
panel still opens at http://127.0.0.1.
dsh web process.trycloudflare.com URL is
random on every cloudflared start, so --trusted-host and
publicBaseUrl must be updated together whenever the tunnel restarts.
A named tunnel (fixed hostname) avoids the churn.dsh web --dev polls every roster bundle by path, so
rebuilding this package (its own tsdown --watch) hot-reloads the client
bundle; no harness-side watcher is involved.qrcode.react (MIT, actively maintained, React 16–19 support) renders the
QR as a dependency-free SVG component — no canvas, no server-side image
generation. It is inlined into the client bundle at build time (like the
official skin/turtle-ui plugins inline their non-shared deps), so profile
installations need no extra runtime dependency beyond the dsh peer closure.
schemastery is the DSH-standard config schema validator.