kexuejin/dsh-tunnelmux-remote

dsh-tunnelmux-remote

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.

Uncategorized

dsh-tunnelmux-remote

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.

Features

  • Scan-to-pair QR beside the official sidebar footer: one-time token, first accept consumes it, refresh invalidates the old QR immediately.
  • Device sessions: HttpOnly cookie gate, presence tracking with offline detection, max 4 devices (oldest evicted), revoke all with one click.
  • Mobile surface at /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.
  • TunnelMux backend: 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.
  • Live updates: SSE /api/pair/events for the desktop panel and /m/api/events.mux for the phone.
  • Desktop browser pairing: opens the real GUI on the public URL, not just the phone page — see Desktop bridge.

Install

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

Configuration (settings namespace tunnelmux-remote)

keydefaultmeaning
enabledtruemaster switch
tunnelmuxBaseUrlhttp://127.0.0.1:4765TunnelMux control API
tunnelmuxApiToken'' (secret)optional Bearer token
targetUrlhttp://127.0.0.1:3080local GUI the tunnel exposes
tunnelProvidercloudflaredcloudflared or ngrok
autoTunnelfalsestart the tunnel on plugin load
publicBaseUrl''existing public entry (skips auto-tunnel)
tokenTtlMs / offlineAfterMs / maxDevices10min / 25s / 4pairing tuning
cookieNamedsh_pairdevice cookie
mobileEnterToSendtrueEnter sends in the phone chat box
desktopBridgetruestart the loopback pairing bridge
bridgePort3988bridge listen port (127.0.0.1 only)
bridgeRedirect/where the browser goes after pairing
bridgeDays30minted cookie lifetime in days (≤ DSH cookieMaxAgeDays)
registerRoutestrueupsert the two TunnelMux routes on load
pairRouteId / pairRoutePrefixdeepseek / /deepseekgated entry that mints the cookie
rootRouteId / rootRoutePrefixdsh-root / /the GUI itself, reachable at the root
gateCookie''explicit NAME=VALUE to install; empty = read the daemon's default access code
dshHome~/.dshwhere .credentials.yaml lives
tunnelmuxStateFile~/.tunnelmux/state.jsonsource of the default access code

Phone surface

/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:

  • Notches and home indicators — the shell declares viewport-fit=cover, and the header and composer consume env(safe-area-inset-*).
  • The on-screen keyboard — the app frame is sized in 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.
  • Both colour schemes — every colour is a custom property, redefined under prefers-color-scheme: dark, with the browser chrome following through two theme-color metas. No colour literal exists outside styles.ts.
  • Touch — controls are at least 44px, hover styling is gated behind (hover: hover), pressed feedback is a :active change, and prefers-reduced-motion drops the animations.
  • Zoom stays available. An earlier user-scalable=no is gone.
  • Width — nothing sets a fixed pixel width, one column and 16px insets carry a 360px phone, and from 900px the list and the conversation sit side by side.

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.

Desktop bridge (browser sessions over the public URL)

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)
  • Cookies are minted offline. The HMAC secret under 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.
  • Two routes, both upserted idempotently. /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.
  • The gate code is carried across. On pairing the bridge also installs tunnelmux_access_<routeId> for both routes (from the daemon's state file), so passing the gate once unlocks /deepseek and / together.
  • A locked control plane is not fatal. If the daemon answers 401, route sync is deferred and logged; routes persist in the daemon state file and apply once it is unlocked.
  • If the secret cannot be read (missing record, malformed value) the bridge stays down and logs once. Everything else — QR pairing, phone page, tunnel observation — keeps working.

Security model

  • One active token; issue() replaces it, so a fresh QR invalidates the previous link immediately.
  • One-time accept; reuse returns used (409). Tokens expire (default 10 min). stop() revokes every session and clears the token — the phone's next gated request gets 403.
  • Fences: control endpoints (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).
  • Mobile allowlist: only 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.
  • The bridge is a bearer-token dispenser. Anyone who can reach it gets a valid DSH session, so it binds 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.

Development

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.

License

Apache-2.0.