LAN-only Remote Web Control for DeepSeek Harness (DSH) Instant QR Pairing · The Same Official UI on Phone and PC · Mobile Touch Gestures · Revocable Device Sessions
Remote control of the DeepSeek Harness (DSH) Web GUI over the local area network, with both ends running the exact same official interface: pair an iPhone, Android smartphone, tablet, or another computer from the QR code next to settings. Phones receive an injected portrait touch layer while remote computers enjoy the complete desktop workbench, and access is guarded by expiring pairing tokens plus revocable device sessions. Reachability is LAN only: the host keeps its loopback bind, a runtime forwarder serves this machine's LAN address, and there is no public-access path.
Origin and scope
This plugin began as one package inside the dsh-web monorepo and was split into a
standalone repository in October 2026 (the shared build presets were inlined into
build/), then taken from "the phone can open it" to "it works on a real iPhone".
Origin: copied out of the monorepo as its own repository, renamed to tell it apart
from the original (@echo-125/deh-remote).
Scope: LAN-only remote control — no tunnel, no relay, no public base URL, and
the master switch enabled is off by default. Pairing grants full control, but
/api/update and /api/plugin-manager stay on loopback and never ride the
/remote channel.
The phone runs the same official Web GUI — there is no second interface to drift.
This round of changes (see PROJECT_HISTORY.md): the tunnel
answers WebSocket Pongs on the phone's behalf, the browser APIs Safari only gained in
17.4 are polyfilled, the channel reconnects as soon as the page resumes, and the
host traces the channel so a phone can read the diagnosis without DevTools.
Known open issue: after "Add workspace" on the phone, the folder must be chosen on the
computer; the phone shows no feedback until then (the channel and the reply are
both fine — see PROJECT_HISTORY.md §4.1).
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, the LAN forwarder and the matching host firewall rule; 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 and token each with a copy button, Stop / Refresh QR actions, and the authorized-device roster (a device name inferred from User-Agent, online/offline, last active time, per-device unpair). 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; a plain-HTTP LAN origin is not a secure context, so its shell keeps /pair-app in the address bar and re-validates the device cookie on every reload (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 (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. The gated channel keeps three path families 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 and the toggle off the panel shows an explicit explanation instead of a dead QR code. The panel's mint/stop endpoints are loopback-only by design: a desktop browser opened at the LAN URL sees a "配对面板仅限本机使用" banner.
The host firewall rule needs administrator rights: Windows and Linux are managed by the plugin (netsh, firewalld, ufw, iptables), while macOS and platforms with no supported manager report the firewall as unmanaged and normally need no rule. A refused rule is reported and leaves the loopback listener untouched.
Install
This plugin is a standalone bundle. Install it alone:
# From npm (once published)
dsh plugin --profile web add @echo-125/deh-remote@latest
# Or from this checkout (development loop)
dsh plugin --profile web add link:$(pwd)
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 because the package sits at this repo's root (the prepare script builds lib/ during install; pnpm ≥10 blocks that until you copy the printed key into the profile's pnpm-workspace.yamlallowBuilds and re-run).
The bundle must also be listed in the profile's dsh.profile.bundles. A dependency alone is not enough: the plugin-manager composes the bundle's patch only for bundles that list declares, and without it the plugin never loads at all (its routes answer 404 and no settings surface appears).
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?grant=...), which reloads into / on an https origin, or keeps /pair-app in the address bar over plain-HTTP LAN. On a multi-homed machine the landing may switch the phone to another advertised LAN candidate that answers /pair-ping, carrying a fresh one-time grant; a phone that reaches none simply stays on the page it was served. 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 at a time; the same link can still pair several devices inside its window, and 刷新二维码 mints a fresh one.
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 LAN access toggle and the device cookie name in the same settings card. Two deployment-level keys stay in the profile patch: devicesFile (the paired-session store path) and profile (the profile whose host this plugin manages), so the card carries no control for them.
Development
Work from this repository (no sibling checkout needed):
cd deh-remote
export NPM_TOKEN='<token>' # only if private @deepseek-ai auth is still required
pnpm install
pnpm run build
pnpm test
pnpm 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.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.
Checks
pnpm run typecheck
pnpm test
pnpm run build
Harness contract dependencies
Pinned to the 0.2.1-rc.2 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>.
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).
On a multi-homed machine, repeat step 3 from a QR minted for a different LAN literal and confirm the landing either stays put (the advertised address answers on http://<lan-ip>:<port>/pair-ping, expecting 204) or hops once to that origin, and that a failed hop leaves the original page paired and usable.
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.
Runtime reality: a LAN browser rides the /remote gateway, not the native /api. This plugin never rebinds the web server, so the host stays on 127.0.0.1 and the harness's /api Host fence answers a LAN authority with 403. The forwarder makes the host reachable from the LAN, but only this plugin's own surfaces answer there: the pairing entry pages, the status view, and the gated /remote channel. Restoring native /api over a LAN authority needs the harness web server's trustedHosts, which is outside this package; an operator who binds the host to the network with --host gets that surface under the harness fence alone, because nothing in the official runtime emits the api/gate seam (verified against the 0.2.1-rc.2 packages installed by this repository). On such a deployment a browser credential a device has already redeemed survives 停止/取消配对 until its natural expiry (30 days) — revocation binds the /remote channel and the pairing cookie, not that credential. Treat a network-exposed host bind as a deliberate decision, and prefer keeping the host on loopback with the LAN toggle confined to a network you trust.
A paired device is a full-control credential. With host mode active it reaches the complete host API — chat, sessions, settings, credentials, agent presets, deliverables — mirroring the SDK's own stance that a loopback desktop is trusted. The gated channel nevertheless refuses three path families: the pairing control plane, the self-update installer and plugin install/manage. Those belong to other packages and stay physically local on the loopback path; only pair devices you control, and 停止 or per-device 取消配对 revokes immediately.
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. The port follows the web server's own port, so restarting the desktop can move it. 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.
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).
Dev HMR: dsh web --dev polls every roster bundle by path, so rebuilding this package (its own tsdown --watch) hot-reloads the client bundle; the host half needs a restart.
A mounted @deepseek-ai/dsh-hmr row blocks every settings save: the official config editor serializes its writes through hmr.runExclusive whenever the hmr service is mounted, and on this cohort such a write is rejected as a nested transaction - the Host answers and each family settings card (and the official settings pages) reports a failed save. The row is user-added (no bundle ships it); a Desktop profile carries it to hot-reload a local plugin's host half. Remove the row from the profile patch to restore saves, and re-add it only while iterating on that plugin. Observed on the Desktop client 0.2.1-rc.2 (2026-09-30).
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.
full-control credential
LAN access toggle (runtime forwarder): the settings card starts a forwarder instead of touching any file. The web server keeps its loopback bind, and the forwarder listens on this machine's first LAN IPv4 address at the same port, re-issuing every request — HTTP and WebSocket upgrades alike — to 127.0.0.1:<port>. --host/--port keep their ordinary meaning, so an explicit dsh web --host <a LAN address> 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; 198.18.x-style fake-IP/TUN addresses and other unreachable ranges or adapters are excluded from the candidate list. 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. The port follows the web server's own port, so a desktop restart can change it; after a network change, flip the toggle off and on again.
Settings card: the card registers into the official keyed slot plugins.bundle.config, keyed by the exact bundle package name @echo-125/deh-remote, on Settings → Plugins → that plugin's page; it also registers into the family list seat web-ui.plugin.item when the family group dsh-web-settings is loaded (the aggregate install), and moves between the two seats if the group applies later. Registration waits for the slot's declaration (slots.inject) instead of firing a bare register that would throw on an undeclared seat. The form edits enabled, lanBind, requirePairingForLan, maxDevices, tokenTtlMs, idleExpireMs, offlineAfterMs and cookieName; profile and devicesFile stay patch-only, so the card offers no control a live write could not honor.
Live status: the desktop badge flips to 已连接 in real time; the device roster, 停止 and 刷新二维码 mirror the pairing service over the loopback status stream.
Remote control only: the one-click family update is its own plugin (dsh-update, the same sidebar foot seat), so turning remote access off — or disabling this plugin — never removes the update trigger. The paired-device channel refuses the self-update installer (/api/update) and plugin install/manage (/api/plugin-manager): both belong to other packages, stay on the plain loopback path, and are not part of remote control.
Desktop foot row: on the wide sidebar the footer-action occupants share the official Settings trigger's line instead of stacking above it, so no single control owns a full-width row; any other foot block (the usage glance card, another plugin's) keeps a full row above them, and the 56px rail keeps the shell's stacked foot.
Control endpoints stay loopback-only; the status view answers a LAN visitor minimally: mint/stop/revoke, the device roster, the events stream, and the lan-bind status answer only to a desktop request (loopback socket and loopback Host, no forwarding header). /api/pair/status is reachable from a LAN origin, but a forwarded or unpaired caller receives only { ok, paired, requirePairingForLan, phase, lanAvailable }; the full snapshot — token expiry, counts — and the per-device roster stay with the desktop. A LAN-origin browser sees the "配对面板仅限本机使用" banner.
A forwarding header makes a request remote (issue #1906), and only the operator may narrow that. A reverse proxy in front of the host — frp, an nginx reverse proxy, anything a user puts there — dials 127.0.0.1:<port> and rewrites the Host header to that upstream literal, so the origin sees a loopback socket and a loopback Host for a remote request: both halves of the loopback classification hold and the pairing gate would wave it through. The fence therefore treats a request that carries CF-Connecting-IP or X-Forwarded-For as forwarded and never as local. The rule lives once in shared/host/loopback.ts (isProxiedRequest, consumed by isLoopbackRequest) and is applied by every synced copy. This package splits the question in two (issue #1919): isLoopbackUpstream (loopback socket and Host, forwarding headers deliberately ignored) only decides whether a Host can be answered at all, while isLoopbackClient (no forwarding header) still guards the pairing gate, the /api/pair control planes and the desktop payload. No switch in this package admits a forwarded request to a credential-bearing surface; the sibling task-board plugin ships that narrow counter-case as its own default-off switch (trustForwardedProxy). A real desktop request (loopback socket, loopback Host, no forwarding header) still classifies as local; the browser same-origin markers are unchanged and still refuse a cross-site fetch. Residual: a proxy that strips both headers puts the request back in the same shape as the desktop, which no header-level rule can separate — configure the proxy to forward X-Forwarded-For (nginx does by default), and note that a locally running process could always forge any header.
Background file uploads ride the channel too. The official upload service prefers a Web Worker carrier whose own globals no main-thread patch reaches, so the boot patch (and the browser patch as a fallback) publishes the official pre-Cordis hook __DSH_FILE_UPLOAD__ and hands it the patched fetch: the raw /api/session/uploadFileBinary POST is rewritten onto /remote and carries the device credential exactly like every other gated call. Without the hook a paired browser's uploads bypass the channel and the harness browser-auth fence answers 401 (issue #1580). The hook is only published on non-loopback origins, only while the channel is installed, and never overwrites a hook the page already owns.
The landing URL carries a one-time grant, never the device id./pair-accept mints a 256-bit, single-consume grant with a 60 s TTL and redirects to /pair-app?grant=<g>; the landing spends it — delete-on-attempt, so a replayed or expired grant resolves to nothing — and hands the device id to the shell in its own response. A device id is a live session credential, so it never enters a URL, the address bar, or any log on the redirect path. The pairing cookie is Secure whenever the request arrived over TLS (x-forwarded-proto: https) and plain over LAN HTTP, where a Secure cookie would simply be dropped by the browser. The 303 names the authority the visitor actually reached.
The app landing is cookieless-optional. After pairing, the QR lands the device on /pair-app, served by this plugin: the official shell is delivered without passing the harness index gate, and the device credential rides a x-dsh-remote-device header (fetch) / device query (WebSocket upgrades) that the boot patch attaches from sessionStorage. The flow therefore works when the device browser blocks all cookies; the pairing cookie remains the primary credential where the browser stores it, and the harness browser-auth cookie is no longer required on the phone path at all.
Reopens are owned by a service worker (https origins). A paired phone returning from history, a bookmark, or tab restore navigates to bare / — a path the plugin does not own, where the harness fallback seat answers with its browser-auth 401 (the cookieless flow never holds that credential). The app shell therefore registers /pair-app.sw.js (fenced like /pair-app; the script is inert logic with no secrets): it intercepts navigations to / only, re-serves the shell network-first through /pair-app — which re-validates the device cookie and refreshes its presence, so every reopen also keeps the session alive — falls back to the cached shell offline, and passes the navigation through when the plugin no longer answers (a revoked device then sees the harness response or the bilingual re-scan page). Plain-HTTP LAN origins are never secure contexts and never register the worker; there the shell keeps /pair-app in the address bar instead, so a refresh, a bookmark, or a tab restore re-validates the device cookie through the same device-gated landing (and reaches the bilingual re-pair page once that pairing is gone).
Revocation is per-request: a paired device whose request is already in flight when 停止 lands completes that request; the next one 403s.
Paired device sessions persist by default: device sessions (not the time-limited QR token) are written to $DSH_HOME/remote-web-ui-devices.json (0600, temp file + atomic rename). A paired cookie still works after a dsh web restart. Refreshing the QR still mints a new token; restarting does not restore the current QR. Sessions idle for idleExpireMs (default 30 days; the reopen service worker refreshes the window on every navigation it serves) are deleted and must pair again. Device ids are session credentials. Override devicesFile with another absolute path when needed. Changing cookieName invalidates existing devices (expected). A revocation that cannot be written to disk (read-only DSH_HOME, a full disk) removes the stale store instead of leaving a revoked session on disk, so the next start cannot restore it.
The LAN edge never makes a request look local: the forwarder discards the client's Host and every forwarding header it sends, then stamps the LAN authority it was reached at plus X-Forwarded-For and x-forwarded-proto, so the loopback upstream leg still arrives as a forwarded request and every fence that decides "is this the desktop" refuses it. Failing to take the LAN socket — the address went away after a network change, the port is taken — costs the LAN entry only: the loopback listener is never touched, the failure is logged and reported as forward.error, and nothing is written to a file, so a network change needs neither a profile edit nor a restart. A firewall rule that cannot be written (missing administrator rights) is reported by the card and in the log; it never touches the loopback listener either.
The desktop gate policy is public, and it is not an access control: /api/pair/status exposes only the boolean requirePairingForLan policy so a remote desktop can choose the correct transport before its settings scope is available. The switch governs the bare /api surface only: turning it off lets an unpaired client use that surface under the harness's own fence, and it never loosens the /remote channel, which demands a paired credential unconditionally. This field is not a credential and does not expose tokens, devices or counts; the minimal payload a LAN caller receives is described under the control-endpoint rule above.
The device credential still rides the WebSocket query. A WS handshake cannot carry headers from the Web API, so the cookieless credential travels as ?device=<id>; the plugin's own /remote handler consumes it and never forwards it to the inner loopback request, but the origin access log and any proxy the operator placed in front of the host still see a URL that authenticates as that device. Cookie-capable browsers authenticate the handshake by cookie instead. A short-lived single-use WS ticket is the planned replacement.
There is no public-access path, by construction. This package ships no tunnel, no relay registration and no public base URL setting; the forwarder is the only reachability mechanism it adds, and every authority it advertises is one of this machine's LAN IPv4 literals. A request can therefore only arrive with a forwarding header when an operator deliberately puts a proxy in front of the host, which is the exact case the rule above refuses to treat as local.