dsh-remote-web-ui · Mobile & Cross-Device Remote Web Control for DeepSeek Harness (DSH)
English | 中文
Deprecation notice: @linxin666/dsh-remote-web-ui is deprecated in favor of @linxin666/dsh-remote-bridge. It has been retired from the @linxin666/dsh-web-all aggregate bundle (where legacy profile rows mount silently as no-ops via tombstone exports and the retiredPlugins registry) and will be removed in the next release. Standalone installation (dsh plugin --profile <name> add @linxin666/dsh-remote-web-ui) remains functional during this transitional period. Browser-based remote control has been replaced by the native phone app (@linxin666/dsh-remote-bridge), and this package no longer provides remote access capabilities.
- Capabilities migrated to
@linxin666/dsh-remote-bridge: Ingress stack (relay registry identity, lease control claim/release, quick and named tunnel connectors, Durable Object relay bridge connector, cloudflared lifecycle management and orphan process reaping, Cloudflare named tunnel ingress sync, LAN forwarder, host bind capability probe, and host firewall management).
- Capabilities retired with this package: Browser-facing remote web control (pairing cookie gate,
/api/pair endpoints, /remote proxy channel, and the mobile browser touch adaptation layer).
- Known coexistence risk: if installed in the same profile alongside
@linxin666/dsh-remote-bridge, do not enable relay on both plugins simultaneously (both share the same on-disk relay identity per profile; running both causes Durable Object connection contention, and mounting the bridge with relay=false withdraws the shared registration row and releases the lease).
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.
What it does
- Entry: a phone icon beside the settings button in both the expanded sidebar and the narrow rail; its tooltip and accessible label say "Remote access".
- Panel: "Remote access" title, a "Pair a device" card with the status area ("Waiting for a device" + status badge), a large QR code, the pairing link with its copy button, Stop / Refresh QR actions, and the authorized-device roster (a saved device name that falls back to the User-Agent-derived label, online/offline, last active time, per-device rename and unpair, and a merge action for rows sharing one User-Agent). Credential-bearing device ids and raw User-Agent values are never rendered. Both the phone and the PC use the same time-limited link: until it expires or the QR is refreshed, that link can pair several devices, each with its own device session.
- Phone side: scanning the QR binds the phone with a time-limited token and reloads into the official Web GUI — there is no second interface to drift from. While the phone is in portrait, the plugin injects a touch-adaptation layer over the running UI (see "The mobile adaptation layer" below). The accept chain is cookieless (
/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).
- PC side: the same link opens the full desktop Web GUI on another computer over the gated
/remote channel; unpaired PCs see a guided blocking page (with a manual pair-token input) and no workspace data behind it.
- Security: one active token at a time (a refresh invalidates the old link; the link stays re-usable within its expiry window so a scan handed between browsers can complete pairing; it expires). 停止 revokes every paired device and the current token — the
/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).
The mobile adaptation layer
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:
- a CSS sheet keyed on CSS-Modules semantic suffixes (
[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;
- a draggable whale button as the entry to the collapsed sidebar (position persisted; wired to the official
ctx.layout.toggleSidebar()), hidden while the sidebar is expanded;
- gestures: left-swipe collapses the sidebar, right-swipe in the conversation opens it; long-pressing a session row opens the same action menu as the desktop ellipsis; tapping a session row or anywhere outside the sidebar collapses it again;
- input behavior for touch: Enter only inserts a newline (send goes through the send button), programmatic composer focus is suppressed (no spurious keyboards), and official tooltip bubbles are hidden because taps leave them stuck;
- mobile plugin scope: while the layer is active, the right-hand details column and the desktop-oriented tool surfaces (SSH terminal, skill explorer, task board, git graph, pet, usage) are hidden - keyed on the L2 semantic roots (
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().
- a manual opt-out:
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/*.
Requirements
- A DSH installation whose
dsh CLI supports profiles (dsh --profile, dsh plugin) — the profile/bundle mechanism this package rides on.
- For LAN use, either flip the 局域网访问 toggle in the settings card — it listens at once on this machine's current LAN IPv4 address at the web server's port, so after a network change flip it off and on again — or start with
dsh web --host <a LAN address of this machine>. 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.
- For the one-click public tunnel (
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.
- The same binary also powers the fixed-hostname named-tunnel mode (
tunnelToken): the public hostname never changes, so a paired phone keeps its bookmark and its pairing cookie across restarts and never re-pairs.
Install
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-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 (the prepare script builds lib/ during install; pnpm ≥10 blocks that until you copy the printed key into the profile's pnpm-workspace.yaml allowBuilds and re-run). Monorepo subpackages use the link: form above.
Use
- Open the settings card (设置 → Web 插件 → 远程访问) and flip 局域网访问 on; the card shows the live bind, the forwarder state beside the host's own
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.
- Start
dsh web, click the phone icon, and the panel mints a fresh time-limited QR.
- Scan with the phone (or open the copied link): the device pairs and boots the official Web GUI served cookieless by the plugin (
/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).
- To pair a PC instead: copy the same link and open it in a browser on the other computer. After the same round trip the full Web GUI runs there over the gated
/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.
- The desktop badge flips to 已连接 in real time; the device roster lists the paired devices with per-device 取消配对, and 停止 revokes everything.
- Configure pairing lifetime, device limits, the LAN fence policy (
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.
Remote access over the internet (tunnels)
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.
One-click public tunnel (recommended)
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.
Stable-hostname relay (default on; pair once across restarts)
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://<id>.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.
- Registration is secret-authenticated: the plugin mints a 256-bit secret next to the identity and re-syncs the mapping on every tunnel start (crash restarts included) with capped backoff. If the registry is unreachable, the QR falls back to the raw quick URL for that session and the panel says so.
- A phone opening the stable origin while the instance is offline sees an explicit "instance offline — refresh in a moment, no re-pairing needed" page instead of a dead link.
- Turn 固定域名中继 (relay) off in the settings card to keep the deployment entirely on the ephemeral origin; the registry row is removed at once. Trust note: relayed traffic transits the dsh-market edge (a Cloudflare Worker operated by the package author) — see Security model.
- The relay control plane is additive: whenever the relay is on and the web server port is bound, the plugin claims a named-tunnel lease from
https://dsh-market.com/api/relay/claim — the worker finds or creates the tunnel named dsh-relay-<id>, 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.
- The bridge transport rides the same lease: the plugin dials
wss://dsh-market.com/api/relay/bridge?id=<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 off. The connector keeps its state in — state, instance, endpoint, local upstream and the last dial error — so a connector that never attached is readable without the host log.
Fixed-hostname named tunnel (bring your own domain)
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).
- In the Cloudflare dashboard create a Tunnel and map a public hostname — e.g.
dsh.example.com — to http://127.0.0.1:3080 (the port dsh web listens on).
- Copy the tunnel's token and paste it into 固定域名隧道令牌 (tunnelToken) in the settings card.
- Set the same hostname as 公网地址 (publicBaseUrl). The token does not carry the hostname, so without this step the tunnel stays off and a warning names the missing piece.
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.
Keeping the ingress port in sync (optional, update-only)
A named tunnel's hostname is permanent, but its ingress rule names a fixed local port (http://127.0.0.1:3080 above). When dsh web later listens on another port — an operator --port, another profile, a second instance on the old one — the hostname still resolves and answers 502 while everything in this plugin still believes the address is published. Turn on Cloudflare config sync (ingressSync) with an account-level API token (cfApiToken), the cfAccountId and the cfTunnelId, and the plugin keeps that one number following the port it actually bound:
- one read on start; the document is written only when the matched rule's service has drifted from the bound port;
- only the
service of the rule whose hostname exactly equals publicBaseUrl's host is touched — one full-document PUT, because the API has no single-rule patch, with every other rule and every top-level key proven identical first;
- a document it cannot prove that about is reported and never written: a missing or duplicated hostname, a wildcard anywhere, an
ingress that is not an array, a missing catch-all, a service that is not an http(s) origin, or a service hosted anywhere but on this machine — only loopback literals (127.0.0.0/8, ::1, localhost) and this host's own LAN addresses are retargeted, so a neighbour's address or a DNS name is never touched. No rule is added and no catch-all is invented;
- the write is read back once. A disagreement is a conflict and stops there: one tunnel document has one writer, so a deployment whose dashboard or another sync owns it is reconciled by hand;
- the bound port is the target: before the listener binds nothing is requested (the same settle re-plan that starts the quick tunnel delivers it), and the tunnel token's own format is never parsed.
The state frame reports off/waiting/applied/failed/conflict with the hostname, the port, the two service strings and the reason, and the settings card renders it beside the switch. The quick tunnel takes precedence: with autoTunnel on there is no named tunnel to update and the sync reads as off.
Manual tunnels (bring your own)
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.
Development
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 typecheck
The peer APIs come from the official NPM SDK: every @deepseek-ai/* package used here is declared in devDependencies (0.2.1-rc.3 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.
Checks
pnpm run typecheck
pnpm test
pnpm run build
Harness contract dependencies
Pinned to the 0.2.1-rc.3 line; the seams this build relies on:
sidebar.footer.action foot seat (the 0.1.7 shell composition): the sidebar declares and renders the seat the remote entry occupies.
ctx.layout.toggleSidebar() (packages/client/ui-layout): the whale button expands the collapsed sidebar through the official panel-action face.
ctx.connection.authenticatedUrl() (packages/client/connection): the sanctioned launch-token seam the proxy redeems once for its inner credential (src/inner-auth.ts), so re-issued /api calls satisfy the harness browser-auth check.
__DSH_FILE_UPLOAD__ (file-upload client hooks): the optional pre-Cordis transport the upload service reads once at construction. The remote boot patch publishes it so background uploads stay on the rewritten main-thread fetch instead of a Web Worker that escapes the channel (issue #1580).
__DSH_TRANSPORT__.ownsHost (client-connection transport hooks): the host-mode flip for paired remote desktops. It is installed only when the device-gated app landing published the __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).
- Web server bind ownership: the profile's
- id: webserver row belongs to the official bundle and this plugin never writes it — same-id patch rows replace a row's config wholesale, and the user patch layer cannot evaluate webStartup-dependent !!js expressions reliably, so the row keeps host: !!js ctx.webStartup.host ?? '127.0.0.1' and --host/--port reach it. LAN reachability is runtime state instead: the forwarder in src/lan-forward.ts listens on the LAN interface at the web server's actual port and re-issues to 127.0.0.1 on the same port.
The fence helpers (isTrustedApiRequest / isLoopbackHostname) are reimplemented locally in src/gate.ts / src/routes.ts: the connection plugin stopped exporting them, so the pairing routes carry their own copy scoped to the literals the QR links advertise. The loopback question is split in two there: isLoopbackUpstream (loopback socket and Host, forwarding headers ignored) only decides whether a Host can be answered at all, while isLoopbackClient (no forwarding header) decides who receives a credential or the desktop payload. The shared copy in shared/host/loopback.ts keeps isLoopbackRequest refusing every forwarded request and adds isForwardedLoopbackRequest for the narrower shape a route family may admit behind its own default-off switch.
Manual E2E: LAN pairing round trip
The unit/component specs cover the route family, the gate, the channel, the LAN forwarder with its status frame, and the adaptation layer, 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:
- Start an isolated instance:
DSH_HOME=/tmp/dsh-qa dsh --profile web --no-open --port 3191; open the card at http://127.0.0.1:3191, flip 局域网访问 on, and confirm the card reports the forwarder listening on the machine's LAN IPv4 at 3191.
- Open the loopback URL (
http://127.0.0.1:3191) in a browser: the phone icon sits in the sidebar foot; the panel mints a QR whose link is <lan-url>/pair-accept?pair=<token>. With a public base configured the link names that origin instead — reachable from anywhere — and the landing does the network choice described under Use.
- In a second tab under mobile emulation (390x844, touch) open that link: the chain
/pair-accept → /pair-app?grant=<one-time> → / on an https origin, ending at /pair-app instead over plain-HTTP LAN, sets the device cookie, spends the grant, serves the patched official shell, and boots the UI — document.body.classList carries dsh-remote-portrait, the adaptation stylesheet and the whale button exist, and __DSH_TRANSPORT__.ownsHost is true. The settings surface renders host data (host mode), not a memory mirror. In an https deployment the shell also registers the reopen service worker (/pair-app.sw.js); reloading / in that tab boots the app instead of the harness 401. Over plain-HTTP LAN the same tab reloads /pair-app and boots the app from the pairing cookie instead.
- The desktop badge flips to 已连接 in real time; a LAN-origin desktop page instead shows the 配对面板仅限本机使用 banner and opens no status stream.
- 停止 on the desktop cuts the device off: its next request 403s with
unpaired (the fence page offers the manual pair-token input).
The public path is the same round trip through a tunnel (see "Remote access over the internet"): loopback mint → device opens the public QR URL → accept → official UI, and when the advertised LAN origin actually answers (check with curl -s -o /dev/null -w '%{http_code}' http://<lan-ip>:<port>/pair-ping, expecting 204) the landing replaces itself with http://<lan-ip>:<port>/pair-app?grant=<one-time> and the UI boots from the LAN origin. Re-scanning the public QR within the same session lands on the public source again, because the decision records the failed switch. Only publicBaseUrl (plugin config) names the tunneled host; --trusted-host is not part of this pairing flow. The desktop panel still opens at http://127.0.0.1.
Security model
-
Pairing is the access control for the /remote channel, unconditionally: every request must carry a live paired-device cookie, enforced before any bytes are forwarded. A missing or revoked session receives HTTP 403 with a JSON rejection carrying error.code: "unpaired"; the browser's EventSource API exposes only the stream failure, not that response body. This gate does not follow requirePairingForLan: the channel re-issues every call with the process's own browser-auth credential, so admitting an unpaired caller would hand out that authority. The LAN policy governs the plain /api surface only and can never widen the channel (issue #1665).
-
The channel carries the process's own inner credential. The harness browser-auth cookie is authority-bound (minted for the exact host:port a browser visited) and has no loopback exemption, so a proxied re-issue to 127.0.0.1 cannot reuse a device's cookie. The plugin therefore redeems its own launch token once — the same exchange a first browser visit performs — and attaches that cookie to re-issued requests. The credential is only ever exercised behind the pairing gate above; 停止/取消配对 immediately stop exercising it.
-
The channel forwards the caller's own request headers, and keeps only what it owns (issue #1925). A plugin may authenticate its own calls with a header of its own — x-dsh-im-connect-client on @michengai/dsh-im-connect's /sessions/ensure is the reported case — so the re-issue no longer rebuilds the request from a fixed allowlist: every header is forwarded except the ones this channel owns. It rewrites host to the loopback authority, replaces the caller's cookie with the process's inner credential, synthesizes sec-fetch-site: same-origin, and drops origin/referer, the whole browser fetch-metadata family (sec-*), hop-by-hop headers (including the ones the caller nominates in its own Connection header), the forwarding marks (x-forwarded-*, cf-connecting-ip, forwarded, x-real-ip — a re-issued request is loopback, and an edge's stamp would fence it as remote), and the cookieless device credential x-dsh-remote-device, which the gate consumes and never hands further. accept-encoding, range and if-range stay out as well, because the response headers the proxy copies carry neither content-encoding nor content-range.
Known Limitations and Deferred Work
- The LAN entry follows the forwarder, not a profile write: the toggle starts and stops it while the process runs, so nothing here needs a restart; after a network change, flip it off and on again. A LAN address that has gone away, or a port already taken, costs the LAN entry only — the loopback listener is untouched and the status frame reports
forward.reason (off, host-bind, or no-address) with forward.error.
- A legacy managed block is removed once, and one of its shapes still needs a hand: a block written by this plugin up to 0.4.8 is stripped on the first activation of each process — atomically, only when it is actually there, with the file's permissions preserved and nothing written back — and
legacyBlockRemoved reports it. The cleanup runs when the plugin activates, so an updated build takes it on the next dsh web start. The exception is a block that pinned 0.0.0.0: DSH 0.2.1-rc.2 and later refuse that value before this plugin ever loads, so that host cannot reach the cleanup. Edit the profile's cordis.patch.yml once by hand: delete the block's two marker comments (the # --- remote-web-ui lan-bind block ... line and the # --- end remote-web-ui lan-bind block --- line) and the - id: webserver item between them — not the whole marked region, because other plugin rows may sit inside it.
- The ingress sync assumes one writer per tunnel document: it reads, proves, writes and reads back, but the Cloudflare API offers no conditional write, so a concurrent writer (the dashboard open in a browser, another machine running the same sync) can land between the read and the PUT. That write is then one full document that may include the other writer's state, and a read-back that disagrees is reported as
conflict and never rewritten. Run the sync from one machine per tunnel; a second one shows up in the frame instead of being retried silently. The whole path is covered by offline tests only — the transport is an injected seam, and neither the tests nor this release have driven a real Cloudflare account.
- A refused worker registration falls back to the harness gate: the reopen worker registers only where service workers exist. When an otherwise secure origin refuses it (deployment policy, a hardened browser profile), the address bar has already moved to
/ and a reopen needs a fresh QR; the plain-HTTP LAN path has no such gap, because its shell stays on /pair-app.
- The adaptation selectors track the official build: the semantic-suffix strategy survives hash churn but not semantic renames; each official GUI update needs a visual QA pass (per the dsh-LAN reference, these suffixes have been stable across many official releases).
Dependency rationale
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.
Telemetry
The browser half sends one anonymous install heartbeat per UTC day to dsh-market.com: a random localStorage id plus this package's name, nothing else. The server stores only a salted hash of that id, never IP addresses, and exposes aggregate counts only. See docs/telemetry.md for the full contract.