dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
Mobile remote control for the DeepSeek Harness web GUI with TunnelMux as the tunnel backend: scan-to-pair QR, one-time tokens, revocable device sessions, mobile /m page.
Mobile remote control for the DeepSeek Harness web GUI, with TunnelMux as the tunnel backend. Scan a QR code beside the sidebar to pair your phone, chat with your sessions from /m, and revoke any device at any time. The public URL comes from the local TunnelMux control API (cloudflared/ngrok) — no embedded tunnel binary in the plugin.
Built from scratch (2026-08-16) following the design in TunnelMux docs/plans/2026-08-16-dsh-tunnelmux-remote-design.md and referencing @linxin666/dsh-remote-web-ui (Apache-2.0) for the pairing model.
/m: a real phone client — session list with cursor
pagination and content search, conversation view rebuilt from the session log
(user / assistant / tool activity, streaming tail, compaction replacements),
send, rename, and model switching — bridged through the host apiProxy with a
strict method allowlist. See Phone surface.POST /v1/tunnel/start returns the public URL
synchronously (daemon waits for provider startup); the plugin observes
status for the panel and never restarts the tunnel itself — the daemon owns
auto_restart./api/pair/events for the desktop panel and
/m/api/events.mux for the phone.Requires a running TunnelMux daemon (control API on 127.0.0.1:4765) and the DSH web profile:
cd ~/.dsh/profiles/web
dsh plugin add github:kexuejin/dsh-tunnelmux-remote # or link:/path/to/this/repo
Or add to cordis.patch.yml manually:
- insert:
- id: tunnelmux-remote
name: dsh-tunnelmux-remote
tunnelmux-remote)| key | default | meaning |
|---|---|---|
enabled | true | master switch |
tunnelmuxBaseUrl | http://127.0.0.1:4765 | TunnelMux control API |
tunnelmuxApiToken | '' (secret) | optional Bearer token |
targetUrl | http://127.0.0.1:3080 | local GUI the tunnel exposes |
tunnelProvider | cloudflared | cloudflared or ngrok |
autoTunnel | false | start the tunnel on plugin load |
publicBaseUrl | '' | existing public entry (skips auto-tunnel) |
tokenTtlMs / offlineAfterMs / maxDevices | 10min / 25s / 4 | pairing tuning |
cookieName | dsh_pair | device cookie |
mobileEnterToSend | true | Enter sends in the phone chat box |
desktopBridge | true | start the loopback pairing bridge |
bridgePort | 3988 | bridge listen port (127.0.0.1 only) |
bridgeRedirect | / | where the browser goes after pairing |
bridgeDays | 30 | minted cookie lifetime in days (≤ DSH cookieMaxAgeDays) |
registerRoutes | true | upsert the two TunnelMux routes on load |
pairRouteId / pairRoutePrefix | deepseek / /deepseek | gated entry that mints the cookie |
rootRouteId / rootRoutePrefix | dsh-root / / | the GUI itself, reachable at the root |
gateCookie | '' | explicit NAME=VALUE to install; empty = read the daemon's default access code |
dshHome | ~/.dsh | where .credentials.yaml lives |
tunnelmuxStateFile | ~/.tunnelmux/state.json | source of the default access code |
/m is three static routes and one RPC channel, all behind the paired-device
cookie: the shell (/m), its stylesheet (/m/mobile.css), and the bundle
(/m/mobile.js, react inlined so the phone needs no module loader).
The conversation is rebuilt from the session log, the same way the desktop
client does it, in src/mobile-app/transcript.ts: session.history seeds the
surface, the mirrored mux appends to it, and surfaceOp replacements (a
compaction summary) collapse the nodes they shadow. Streaming text is shown from
assistant/chunk deltas and retired when the assembled assistant/message
lands; a tool call shows as running until its result pairs by callId. Replayed
events are dropped by seq, and a sequence gap reloads the window instead of
rendering a hole. A sent message is echoed optimistically and reconciled against
the durable user/message event through the rpcId the host passes back.
Display adaptation is the point of the surface, so the decisions are recorded rather than left in the stylesheet:
viewport-fit=cover, and
the header and composer consume env(safe-area-inset-*).dvh and the shell asks
for interactive-widget=resizes-content, so the composer stays on screen
instead of being covered while the transcript scrolls independently.prefers-color-scheme: dark, with the browser chrome following through two
theme-color metas. No colour literal exists outside styles.ts.(hover: hover), pressed feedback is a :active change, and
prefers-reduced-motion drops the animations.user-scalable=no is gone.Known limits of this surface: the host event stream is not proxied, so a session
created on the desktop appears after the list's 刷新 button rather than on its
own; session.cancel and the queue mutations are outside the allowlist, so a
running turn cannot be stopped from the phone; images are announced but not
shown; and the 回车发送/回车换行 toggle applies to the current visit, because
mobile.preferences is read-only over /m/api.
The QR flow pairs a phone with /m. Opening the full GUI in a real browser
over the public URL needs something else: a DSH browser-session cookie. The
bridge mints one, so a visitor who passes the access gate lands in the app
already signed in. It replaces the standalone ~/.dsh/bin/dsh-web-cookie tool
— you no longer run a second process by hand.
browser ──► https://host/deepseek (TunnelMux asks for the access code)
└─► 127.0.0.1:3988 (bridge: sets cookies, JS redirect)
└─► https://host/ (the GUI, already authenticated)
client-connection/browser-session in $DSH_HOME/.credentials.yaml is
persistent, unlike the per-launch ?token=, so a minted cookie survives
restarts of both the app and the tunnel./deepseek points at the bridge
(strip prefix), / points at the GUI (no strip). The root entry is not
decoration: the DSH client builds root-absolute URLs from location.origin,
so under a path mount every RPC asked the tunnel host for /api/... and came
back empty — session list included. Routing around it is the point, because
the client is not modifiable.tunnelmux_access_<routeId> for both routes (from the daemon's state file),
so passing the gate once unlocks /deepseek and / together.issue() replaces it, so a fresh QR invalidates the
previous link immediately.used (409). Tokens expire (default
10 min). stop() revokes every session and clears the token — the phone's
next gated request gets 403.issue/stop/events) are loopback-only;
phone endpoints (accept/heartbeat/status) allow loopback, LAN
literals, or the public tunnel Host. Accept is rate-limited per IP
(10 attempts / 30 s).workspace.list, session.* and
mobile.preferences are exposed over /m/api; everything else 403s.
settings.* / credentials.* remain loopback-only host methods and are
never reachable from a phone.127.0.0.1 only and must sit behind an
access-gated route (registerRoutes puts the access code in front of
/deepseek for exactly this reason). Exposing port 3988 directly — by
disabling the gate, or by adding an ungated route to it — hands out full
access to the GUI. Cookies are minted fresh per request, only for the current
authorities, and only for GET/HEAD: the session cookie is
HttpOnly; SameSite=Strict, the gate cookie HttpOnly; SameSite=Lax.
GET /health on the bridge answers ok for local diagnostics.npm install
npm run typecheck # tsc client (+ jsdom tests) + host
npm test # vitest (99 tests)
npm run build # tsdown: lib/index.js (host) + lib/mobile.js + client/client.js
node test/smoke-live.mjs # read-only probe of a live daemon at 127.0.0.1:4765
The phone surface is gated without a browser: test/mobile-display.test.ts
pins the head and the stylesheet's display contract, test/mobile-styles.test.ts
fails when the markup uses a class the stylesheet does not define or writes a
colour literal outside styles.ts, test/transcript.test.ts covers the surface
fold, and test/dom/mobile-app.test.tsx mounts the page in jsdom and asserts the
prompt payload the host actually receives.
Apache-2.0.