packages/dsh-ssh
zhu1090093659/dsh-webSSH 远程运维面板:Web 终端、SFTP 传输、本地端口转发与一条命令并发集群执行,Agent 与面板共用同一份主机配置。
English | 中文
Mobile & Cross-Device Remote Web Control for DeepSeek Harness (DSH) Instant QR Pairing · Cross-Device PC Access · Mobile Touch Gestures · Cloudflare Tunnels · Secure Device Session
Seamless remote access for DeepSeek Harness (DSH) Web GUI and desktop client sharing the exact same interface: pair an iPhone, Android smartphone, tablet, or another computer from the QR code next to settings. Both ends run the authentic official Web GUI — phones receive an injected portrait touch layer while remote computers enjoy the complete desktop workbench. Access is guarded by expiring pairing tokens and revocable sessions. Built-in LAN binding and optional Cloudflare quick tunnels enable effortless external access with persistent hostnames.
This package is an official external plugin for DeepSeek Harness (DSH), structured as a cordis bundle: the host half manages pairing tokens, device sessions, /api/pair routes, the gated /remote channel, and LAN binding; the browser half renders the remote access trigger, QR pairing modal, live device roster, settings card, and mobile portrait touch adaptation.
/pair-accept → /pair-app): the plugin itself serves the official shell, so the phone needs no harness browser-auth cookie at all. A reopen service worker (https origins) then owns later navigations to / from history, bookmarks, or tab restore, so the phone reopens straight into the app instead of a 401 dead end (see Security model)./remote channel; unpaired PCs see a guided blocking page (with a manual pair-token input) and no workspace data behind it./remote channel cuts them off on their next request. Pairing is this plugin's access control for the /remote channel; the web server keeps its loopback bind, so a LAN browser reaches the app through this plugin's gated /remote gateway (the client moves its transport there) rather than the native /api, which the harness Host fence answers with 403 for a LAN authority (see Security model). Loopback (127.0.0.1) keeps using /api directly, and so does a page an application on this machine delivered — the desktop shell's dsh-app://app/ or a file: page — because a pairing link is reachable only over the network. A paired device is a full-control credential (see Security model).127.0.0.1:. --host/--port keep their ordinary meaning, so an explicit dsh web --host still works and the forwarder then yields the port. The toggle applies while the process runs: no restart, no profile edit. When this machine has no LAN IPv4 address the forwarder stays off and the card says so. It maintains the matching host firewall rule (Windows Defender via netsh; Linux firewalld/ufw/iptables; other platforms report the firewall as unmanaged) and shows the live bind, the reachable LAN URLs, the forwarder state (forward { listening, host, port, reason, error }) beside the host's own localBindHost, and the firewall state./api posture probe reports any host whose /api fence the SDK leaves open, and the auto-tunnel state appears on the panel while the tunnel is starting.dsh-update, the same sidebar foot seat), so turning remote access off — or disabling this plugin — never removes the update trigger.The official desktop layout already auto-collapses the sidebar below 1024px. On top of that, while the viewport is portrait + coarse pointer + narrower than 1100px the plugin injects:
[class$="_composerSeat"]) so the selectors survive official rebuilds that only change hashes: 44px touch targets on the collapsed rail, 16px inputs (prevents iOS focus zoom), safe-area padding for the composer, compact type for the message list and sidebar, a column layout for the settings modal, and a full-width PlanReview card;ctx.layout.toggleSidebar()), hidden while the sidebar is expanded;data-dsh-plugin), so ownership stays with the declaring plugin and class churn cannot resurrect them. These are render suppressions; the client bundles still load. Activation also closes the details panel through the official ctx.layout.closeDetails().sessionStorage.dsh-remote-force-desktop = 1 disables the whole layer; landscape, desktop, and wide viewports are never touched.The paired remote desktop also runs in host mode: on this harness line the "configuration plane is local" behavior is a client-side branch on connection.isLoopback, and the channel boot script installs the transport hook (__DSH_TRANSPORT__.ownsHost = true) before any boot entry. Host mode is server-granted: the hook is installed only when the device-gated app landing (/pair-app) published the grant marker ahead of the boot patch, so a shell served to an unpaired browser — a fence-open deployment — never poses as the machine owner. Settings, credentials, agent presets, and deliverables therefore work on the phone exactly as on the desktop — every call still rides the gated /remote channel. Three control planes stay physically local: /api/pair/*, /api/update/*, and /api/plugin-manager/*.
dsh CLI supports profiles (dsh --profile, dsh plugin) — the profile/bundle mechanism this package rides on.dsh web --host . A wildcard --host 0.0.0.0 works only on a host that still accepts it; DSH 0.2.1-rc.2 and later refuses it outright. 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, after which a phone on the same network is moved off the tunnel onto the LAN origin by the landing page. The panel's mint/stop endpoints are loopback-only by design: a desktop browser opened at the LAN URL sees a "配对面板仅限本机使用" banner.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.tunnelToken): the public hostname never changes, so a paired phone keeps its bookmark and its pairing cookie across restarts and never re-pairs.Install the family aggregate package @linxin666/dsh-web-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@latest
# Or from the repository (development loop)
git clone https://github.com/zhu1090093659/dsh-web.git
cd dsh-web
pnpm install && pnpm -r build
dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-web-uiRestart 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:/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.
localBindHost, the firewall state, and the reachable LAN URLs, and it says why the LAN entry is off when this machine has no LAN address. The forwarder starts and stops at once, with no restart: after a network change, flip the toggle off and on again so it follows the machine's new address.dsh web, click the phone icon, and the panel mints a fresh time-limited QR./pair-accept → /pair-app), which reloads into /. When the link names the public base, the landing page also probes the advertised LAN origins (one short no-cors request to /pair-ping per candidate) and moves the phone to the first one that answers, remembering the decision for the session: the public page it was served from stays usable throughout, so the probe never blocks pairing, and a phone that is not on that network simply stays where it is. On a phone, the portrait adaptation layer is already active — same layout as the desktop, same live state. Later reopens from history or a bookmark go straight back into the app (https origins; see Security model)./remote channel; unpaired PCs see the guided blocking page and no data. One active token pairs one device; mint a fresh QR for the next device.requirePairingForLan), the public base URL, the auto tunnel, the stable-hostname relay, and the named-tunnel token in the same settings card. Which network the pair traverses needs no choice: the QR link names the public base and the landing page moves a same-network phone to a LAN origin by itself. If that probe ever misjudges, pin it with the patch-only pairingSource key (auto | public | lan); the card carries no control for it, so a save from the card leaves it alone.A tunnel process restart — most often a mobile network dropping and reconnecting — used to drop the public host from the pairing fence the moment the tunnel left running, so the address still printed in the QR code answered 403, including on the deliberately public /api/pair/status (issue #1547). A named tunnel's fixed hostname and a registered relay origin never change, so both now stay trusted throughout the reconnect; a quick tunnel's previous host stays trusted for a 60 s grace window, because the edge may still be delivering a connection the phone already opened, and is dropped only if the tunnel has not come back by then.
Set 自动公网隧道 (autoTunnel) in the settings card. The plugin runs its own Cloudflare quick tunnel (cloudflared ships with the package), feeds the minted public URL into the QR base and the pairing fence dynamically, and keeps the posture probe informed — a phone anywhere can pair at any time. The manual public address and the named-tunnel token below are ignored while this is on. The minted hostname is ephemeral and changes on every dsh web restart — the stable-hostname relay below keeps the phone's origin fixed across those restarts. A host that dies without tearing its tunnel down leaves the cloudflared child running, so each start reaps the child it recorded (pid plus an exactly matching command line) and never a process it did not start itself.
While the quick tunnel runs, the plugin additionally registers its current tunnel URL with the dsh-market relay registry and rebuilds the QR on a fixed origin — https://.dsh-market.com — minted once per profile and stored under $DSH_HOME/remote-web-ui-registry/. The hostname never changes, so the phone's bookmark and its pairing cookie keep working across dsh web restarts with no Cloudflare account, no dashboard, and no domain of your own.
https://dsh-market.com/api/relay/claim — the worker finds or creates the tunnel named dsh-relay-, writes its ingress (catch-all rule included) and an exact proxied CNAME, and hands back that instance's own tunnel token. A lease an operator flipped to the bridge transport converges nothing in the account: that claim creates no tunnel, no ingress and no CNAME, sends no Cloudflare API call at all, keeps only the hostname and the bridge grant, and answers no tunnel block — the bridge's data plane is the instance's own Durable Object, so there is nothing to build and nothing to count against the account's 1 000-tunnel quota. The plugin then runs a named connector on the same hostname beside the quick connector; the mode the control plane answers names the one the phone is served from, so neither the switch nor its rollback needs a plugin restart. A failed claim is not fatal: the quick tunnel and the origin the registry already gave the phone keep serving, and the claim retries with capped backoff (502 carries retryAfterMs). Turning the toggle off only releases the lease (the tunnel and the DNS record stay, so re-enabling is instant); only an explicit retire or the 90-day idle cron recovers the source.wss://dsh-market.com/api/relay/bridge?id= with the lease secret in the Authorization header and serves every bridged request and phone WebSocket from that one socket, forwarding the public origin as Host and never the loopback authority it dials. It idles while the control plane answers mode=quick; with no connector attached the Worker falls back to the quick path inside the same request and marks the answer x-relay-fallback: no-connector. Because the claim is the only answer that carries the bridge grant, it does not wait for a quick tunnel: an instance served over the bridge reaches its object with autoTunnel off. The connector keeps its state in $DSH_HOME/remote-web-ui-relay-bridge.json — state, instance, endpoint, local upstream and the last dial error — so a connector that never attached is readable without the host log.The relay above already fixes the phone's origin on a shared dsh-market subdomain. A Cloudflare named tunnel is the alternative for deployments that want a hostname on their own domain: its public hostname is permanent, so the phone's bookmark and its pairing cookie keep working across dsh web restarts — pair once, never again (until the session idles past idleExpireMs).
dsh.example.com — to http://127.0.0.1:3080 (the port dsh web listens on).The plugin then runs cloudflared tunnel run --token with the same lifecycle management as the quick tunnel: the binary ships with the package, unexpected exits restart with backoff, a running tunnel's public URL is probed every 60 s and two consecutive failures mint a fresh tunnel through that same restart path, and the posture probe audits the /api fence for the fixed host. The token is stored as a settings secret and read back redacted. The auto quick tunnel takes precedence while it is on.
A running tunnel is not a terminal state: a connector that lost its Cloudflare edge registration keeps its process and its metrics port alive while the minted hostname stops resolving, so the plugin used to report running indefinitely and the relay kept forwarding the phone to the dead address (Cloudflare 530 / Error 1016) instead of the relay's offline page (issue #1723). The readiness watchdog now treats a public URL that stops answering — DNS failure, refused connection, or a Cloudflare 5xx such as 530 — as a dead tunnel after two consecutive probes, and the ordinary backoff restart mints a new URL and re-registers the relay. An unauthenticated 401/403 answer still counts as alive, because that is a healthy round trip to a harness that has not been paired yet.
Expose the local port with any tunnel and set 公网地址 (publicBaseUrl), e.g.:
# 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 as usual. Do not add --trusted-host for the tunnel
# domain unless you intentionally want the SDK to trust that host for
# /api. Keep the LAN bind off if only tunnel access is wanted.Then set the printed URL as 公网地址. The QR link is built from it, and the plugin's pairing fence accepts the tunneled authority. The posture probe keeps auditing which /api authorities the SDK fence leaves open.
Work from this repository (no sibling checkout needed):
cd dsh-web
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 typecheckThe peer APIs come from the official NPM SDK: every @deepseek-ai/* package used here is declared in devDependencies (0.2.1-rc.2 cohort), 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 buildPinned to the 0.2.1-rc.2 line; the seams this build relies on:
src/inner-auth.ts), so re-issued /api calls satisfy the harness browser-auth check.__DSH_REMOTE_HOST_GRANT__ marker ahead of the boot patch, so the flip is server-granted rather than asserted from the origin. There is no host-side per-method privilege pin on this line — the configuration plane branches on connection.isLoopback in the client — and no api/gate waterfall either (the gate listener stays mounted for deployments that gain the seam; pairing enforcement lives on this plugin's own /remote channel).