# dsh-zen-remote > One DeepSeek Harness plugin, two roles, shipped as ONE npm package. The **host** role: app-shell mobile UI (two-level page stack, session-list home, reworked composer, gestures, phone-local attachment upload), pairing-code/device-token gateway for public-internet access behind your own reverse proxy, installable PWA, real Web Push, and per-session sharing to paired desktops. The **client** role: runs inside another DSH desktop app and surfaces the host's remote-enabled sessions in its own sidebar — history, live progress, messages, approvals, terminals; everything executes on the host over zen-remote's relay protocol. ## Key facts - One npm package `dsh-zen-remote`, MIT. Install: DSH Plugins page → Add plugin → `dsh-zen-remote` → Enable now (desktop app and web UI), or `dsh plugin --profile web add dsh-zen-remote` on a headless web service (the CLI requires `--profile` and refuses `--profile desktop`); by hand: one dependency line in `~/.dsh/profiles/desktop|web/package.json` + `pnpm install` + `dsh-zen-remote` appended to that file's `dsh.profile.bundles`. Then restart DSH. The composition layer is a single row `dsh-zen-remote`; the main entry loads the gateway and push sub-plugins itself according to the `role` config. Old `dsh-zen-remote-gateway` / `dsh-zen-remote-push` rows are warned and skipped — do not re-add them (a second gateway would fight over port 3088). - Roles: `role: host` (default) runs mobile UI + gateway child + push + session sharing + the relay server; `role: client` runs the relay client and the sidebar/session-page remote parts only — no gateway, no push, no phone UI. Anything that is not exactly `client` degrades to host. - Config lives in the DSH Plugins page settings block; per field the first legal value wins: environment variable > plugin row settings > `~/.dsh/lan-gate.config.json` > default. Restart-required fields (`role`, `port`, `host`, `targetPort`, `rateLimit`, `trustedProxies`, `vapidSubject`, `lang`, push fields) reload the plugin row automatically; everything else is volatile and read live. - Host sharing: per-session remote toggle from four places (sidebar session "…" menu, title-row icon with confirm, settings list, the phone session info card's remote access switch); idle sleep after 48h without session activity (running/waiting sessions exempt, viewers don't count); auto-share new sessions (opt-in; relay-created sessions always auto-share); subagent/fork sessions follow the parent. - Pairing codes are role-bound: web app devices (phones/browsers, see all sessions) redeem on the pairing page; desktop app devices pair from the client's settings page via `/lan-gate/pair/claim-desktop` and only ever reach the relay prefix (everything else 403 `relay-only`). - Relay protocol (prefix `/_dsh/zen-remote/relay`): `GET ping`, `POST v1/handshake` (relay protocol version + DSH interface fingerprints — version mismatch refuses; fingerprint mismatch annotates and degrades; the answer carries `serverName`/`deviceName`), `POST v1/invoke`, `POST v1/stream` (NDJSON, ping heartbeats that carry the current `serverName`/`deviceName`, `unshared` error line on close), `POST v1/event-result`, `POST v1/http` (the plain-HTTP long tail: only `changes.summary` / `changes.diff`, GET only, query normalized to `sessionId`/`seq`/`index`, dispatched in-process through the host's shared `/api` handler), `POST v1/upload` (a remote session's non-image attachments: raw byte stream, share-checked, query normalized to `sessionId`/`name`, 100 MiB cap answers 413, 8 concurrent per device answers 429, dispatched in-process through the same shared handler), `POST v1/unshare`, `POST v1/device/name` (gateway-handled rename of the CALLING device's own record — paired desktop clients only, never forwarded to DSH; 1–40 chars). Access control is a per-method allowlist (`src/relay-access.ts` RELAY_METHODS, mirrored by intercept.ts CLIENT_METHOD_FIELDS, pinned identical by test); global reads (`session/control`, `session/list`, `workspace/follow`) and the @-mention candidates are output-filtered (candidates list only shared sessions); `session/modelCatalog` is a parameter-less global read whose `failures` array the server empties; `dsh-session:` references are scanned wherever DSH would inject them — prompt content, queue-edit content, every string of a `commands/execute` call — and a reference naming an unshared session refuses the call. Approval/question events forward first come, first served; the relay never answers on the client's behalf (an unshared session's approval waits for the server UI or the next subscription), and unshared sessions' notification-type events are never forwarded. - Plugin bridge (2.1): another plugin registers a bridge declaration (`ctx.inject(['zenRemote'], …)`, types from `dsh-zen-remote`); when the plugin name, exact version and declaration digest agree on BOTH ends, the sub-client reroutes the declared GET routes to the server — `POST relay/v1/plugin` (coordinates in x-zr-* headers, no body; validation: registry → version+digest → exact path → parse-once query with the route's sessionParam → share table → per-plugin consent for scope:'server' routes → 8 per device), the server re-issues it over loopback HTTP with the operator's cookie and streams the answer back; unbridged answers are a uniform 503 (`x-zen-remote: unbridged`, reason: not-on-server / version-mismatch / declaration-mismatch / consent-off / offline / server-too-old / server-changed / bad-query / method-not-bridged, or the relay's own codes). 2.1 forwards GET on exact paths only; plugin WebSockets will ride streamed responses in 2.2 (docs/adr/0002). Docs: docs/plugin-bridge.md - Remote extras: a remote session can attach non-image files — the sub-client's own backend wraps the host's `fetchRoutes` upload entry and streams the bytes through `v1/upload` (the 100 MiB cap is the relay's own, an over-limit pick is refused locally on the client, slow links are bounded by a 300 s client-side timeout; export is disabled remotely instead — menu item hidden, backend 403 for virtual ids). The model picker in a remote session lists the SERVER's catalog (groups named "server name · group name"); local sessions list only local models, the last server groups survive relay offline, and virtual groups never appear in local settings such as the vision router (boundary: a server-created session that never sent a message shows the local default model). - Gateway auth chain: device token (Bearer) → gateway marker headers `x-zen-remote-via/-role/-device` + `x-zen-remote-secret` (per-load shared secret, `LAN_GATE_RELAY_SECRET`, not externally settable, re-verified with timingSafeEqual) → per-method share-table check. Admin API stays loopback-only; forwarded requests may read status but never mutate. - Admin surface: the dsh-zen-remote settings block (pairing with role picker, device list with rename/set-role/revoke, push test, shares list). `/lan-gate/admin` is now just a notice pointing there. - Trust model: a paired desktop client is a TRUSTED DEVICE — server-user terminal without sandbox/approval gates, file previews not contained to the session dir, session creation in any workspace. "Shared only" constrains session data (lists, history, events are refused/filtered server-side; @ candidates list only shared sessions; references in messages, queue edits and slash commands naming an unshared session refuse the call), not the machine. - Env: `LAN_GATE_PORT` (3088), `LAN_GATE_HOST`, `LAN_GATE_TARGET_PORT` (auto-discovered), `LAN_GATE_RATE_LIMIT` (120), `LAN_GATE_TRUSTED_PROXIES`, `LAN_GATE_VAPID_SUBJECT`, `LAN_GATE_LANG`, `LAN_GATE_RELAY_SECRET` (auto), `DSH_PUSH_*`, `DSH_PUSH_APPROVAL_GRACE_MS`. Same keys (lowerCamelCase) in `lan-gate.config.json`. - Security: 8-char one-time role-bound pairing code (10 min TTL), 5-wrong-code lockout, revocable per-device tokens (kills connections + push subscription), 120 req/min per client IP for unpaired requests. - Known limits: remote sessions disable export (menu item hidden, backend 403 for virtual ids); changes summary/diff work remotely but the "open on the server machine" entries (open / reveal in Finder / open in app) are hidden there; with dsh-better-sidebar installed, UNBRIDGED versions have their file surfaces (file browser, editor, git, tasks, bottom workbench, floating windows) hidden in remote sessions because they act on the CLIENT machine's own files — 0.25.0 and 0.26.0 are bridged by the built-in adapter and keep working against the server, so only an UNBRIDGED remote session has no sidebar file browser (it takes over the host's "Files" tab) and unbridged file links in chat or deliverables open an empty right column; @ candidates are truncated to 50 by the host BEFORE share filtering (a shared session can be missing from the list on a session-heavy server); third-party plugins' non-standard endpoints are not forwarded — their tools run on the server and are unaffected, what breaks is UI calling the plugin's own backend with a session id (better-sidebar, handled as above); both ends need the same zen-remote version; server switch-back or workspace-id reuse needs a page reload; several DSH windows at once (including the desktop backend opened in a browser) can misjudge which session is currently open — closed-remote hiding can fire at the wrong moment and worst case dismiss an open session to the home page. Dev only (see AGENTS.md hard constraints): same-machine testing must reach the gateway over a LAN IP (loopback = local-direct, skips token checks) — normal two-machine use is unaffected. ## Docs - README.md (English, install + config + migration + security model), README.zh-CN.md (same in Chinese), AGENTS.md (repo guide), LICENSE. - docs/remote-access.md(.en.md) — gateway detail: reverse proxy, roles and pairing, env, admin API, relay protocol, security boundary. - docs/interface.md — interface detail: breakpoints, settings block and remote parts, debug badge, safe-area system, compatible plugins.