# Architecture > [**中文版**](02-architecture.zh-CN.md) · English This document describes the internal architecture of `dsh-coding-remote-kit`. It is the source for the technical notes in `README.md` and is intended for contributors and maintainers. Host pin: `@deepseek-ai/dsh@0.1.1-rc.2`. Changing the pin requires a new ADR (`docs/01-mvp-scope.md`). `0.1.2-alpha` and `0.1.5-rc.1` remain unverified candidates. ## 1. Dual plane ```text Harness webServer (loopback, typically 127.0.0.1:3080) └─ management routes /api/mobile-remote/* pairing offers, device list, revoke, tunnel / rendezvous switch OwnerRequestPolicy: loopback/SSH or complete trusted-HTTPS proxy proof Dedicated data plane (default 127.0.0.1:6879, may widen to 0.0.0.0) ├─ GET /m, /m/* static mobile page (no-store; CSP + `frame-ancestors 'none'`) ├─ POST /m/claim pairing PIN → offer (rate-limited) └─ WS /m/ws E2EE handshake + allowlisted RPC ``` The plugin does **not** reverse-proxy `dsh web` and does **not** steal the host `api-proxy` approval/question provider. Session observation and writes go through a narrow RPC allowlist on the data plane. MVP route: **B — semantic narrow RPC + dual plane** (`docs/01-mvp-scope.md`). Route A (full Web passthrough) was rejected. Route C (signed native app) is deferred. ## 2. Host data flow ```text Settings (src/client) └─ slots.register "移动远程" GET /api/mobile-remote/status POST /api/mobile-remote/offers → widen or advertise tunnel + QR / PIN GET /api/mobile-remote/devices POST /api/mobile-remote/revoke GET/POST /api/mobile-remote/tunnel GET/POST /api/mobile-remote/relay POST /api/mobile-remote/cloudflared Phone browser /m (src/mobile) └─ location.hash fragment (pairing offer) or POST /m/claim { code } └─ X25519 device key (generated on phone) └─ WebSocket /m/ws e2ee_hello → transcript → session keys (secretbox) └─ status.get → session.list / subscribe / respond / prompt src/server ├─ DeviceRegistry devices.json (token SHA-256 only) ├─ OfferRegistry in-memory pending offers ├─ AuditLogger audit.jsonl (method + ids, no payloads) ├─ server-key.json X25519 identity (0600) ├─ MobileDataPlane HTTP + ws ├─ CloudflareQuickTunnel data plane only (never port 3080) ├─ RendezvousClient outbound WSS to a self-hosted Worker (never 3080) └─ UpstreamHub apiProxy (0.1.1) / sessionController (0.1.5+) session RPC ``` Unauthenticated WebSocket connections handle **handshake only**. Business RPC starts after `e2ee_auth`. ## 3. Module responsibilities ### `src/index.ts` Re-exports Cordis `name` / `inject` / `Config` / `apply` from `src/server/index.ts`. ### `src/server/` - `index.ts`: plugin `apply`. Storage, server key, data-plane listen, management routes, tunnel + rendezvous disposers. - `config.ts`: Zod `enabled` / `bind` / `port` / `offerTtlMs` / fail-closed `ownerRequest`; legacy `trustedHosts` no longer grants access. - `context.ts`: host `apiProxy` / `sessionController` / `webServer` typing and capability diagnostics (either session service is a valid backend; `healthy` no longer requires the legacy one). - `routes.ts`: one `webServer.register` per path (DSH de-duplicates by path, not method). GET/POST branch inside the handler. - `security.ts`: prefer a host owner policy; fallback validates loopback/SSH or trusted HTTPS peer + Origin/Host + owner proof + Fetch Metadata + independent CSRF, with bounded JSON bodies. A throwing or malformed host policy fails closed. - `dataplane.ts`: dedicated `node:http` + `ws` on the data-plane port; static `/m`; `/m/claim`; `/m/ws`. - `connection.ts`: `acceptMobileSocket` — E2EE + RPC session used by `/m/ws` and by rendezvous accept sockets. - `e2ee.ts` / `crypto.ts`: server handshake, token lookup, tweetnacl secretbox. - `rpc.ts`: allowlist dispatch; unknown methods → `forbidden`. - `upstream.ts`: legacy host `apiProxy` session/approval/question bridge (kept for `0.1.1-rc.2`). - `session-controller-upstream.ts`: DSH `0.1.5+` `sessionController` backend -- cold `list` / `page` history, one `follow` iterator per subscribed session, `prompt` / `cancel` / `create`, and `api-session/*` list mirroring. - `interactions.ts`: shared pending approval/question registry -- phone-facing `rpcId` cards, reconnect replay, and settlement when the last subscriber leaves. - `approval-bridge.ts`: prepended `approval/request` + `user-questions/request` answerers that race the watching phone against the composed chain and fail closed when nobody answers. - `registry.ts`: devices + in-memory offers + JSONL audit. - `keys.ts` / `storage.ts`: `$DSH_HOME/storages/mobile-remote/` (dir 0700, files 0600, atomic write). - `net.ts`: LAN candidate addresses for QR advertise. - `backpressure.ts`: per-connection outbound queue limits. - `tunnel.ts`: Cloudflare Quick Tunnel child process; persist `tunnel.json`; kill on unload. - `relay.ts`: outbound rendezvous client (`dshmr-relay/v1`); persist `relay.json`; stop on unload. Never mixed into `cloudflared`. - `cloudflared-install.ts`: opt-in official binary install (never at `apply()`). ### `src/shared/` Dependency-free protocol constants and codecs used by both Node and the mobile page: `constants.ts` (RPC allowlist, frame sizes, HKDF labels), `offer.ts`, `pair-code.ts`, `handshake.ts`, `frame.ts`, `hkdf.ts`, `transcript.ts`, `validation.ts`, `base64.ts`, `version.ts`, `relay.ts` (outer rendezvous envelope). ### `src/client/` Classic-script Settings page (`window.__ModuleLoader__.load`). QR, 8-digit PIN, device list, LAN / Quick Tunnel / rendezvous channels. Injects `@deepseek-ai/dsh-client-ui-settings` + `dsh-client-ui-slots`. ### `src/mobile/` Phone browser page, built to `lib/mobile/`. `main.ts` reads the fragment offer, keeps one WebSocket, runs the four-step handshake. `app.ts` renders session list / transcript / short reply / approval and question cards. `sw.js` caches only the `/m` static shell. ## 4. HTTP / WebSocket API Management plane (host `webServer`, loopback-only): ```text POST /api/mobile-remote/offers GET /api/mobile-remote/status GET /api/mobile-remote/devices # never includes tokenHash POST /api/mobile-remote/revoke # { deviceId } GET /api/mobile-remote/tunnel POST /api/mobile-remote/tunnel # { kind: "cloudflare-quick", action: "start"|"stop" } GET /api/mobile-remote/relay POST /api/mobile-remote/relay # { action: "start"|"stop", origin?, hostToken? } POST /api/mobile-remote/cloudflared # { action: "install" } ``` JSON write bodies are bounded. Responses contain status, offer metadata, QR text, and non-secret expiry — never `deviceToken` or the server secret key. Data plane (port 6879 by default): ```text GET /m → 302 /m/ GET /m/* static mobile assets, no-store POST /m/claim { code } → { offer } # 8-digit PIN; 8 failures / IP / minute WS /m/ws E2EE + RPC ``` RPC methods and push envelopes: `docs/03-protocol.md`. ## 5. Storage `$DSH_HOME/storages/mobile-remote/` (`$DSH_HOME` defaults to `~/.dsh`): | File | Role | |---|---| | `server-key.json` | X25519 identity; 0600; created on first boot | | `devices.json` | paired devices; **SHA-256 of deviceToken only** | | `audit.jsonl` | `rpc_write` / offer / revoke / tunnel events; no payloads | | `tunnel.json` | Quick Tunnel persist so a crash can reap a stale child | | `relay.json` | rendezvous origin / hostId / hostToken (0600); never returned by GET | ## 6. Pairing and E2EE 1. Desktop creates a pairing offer (endpoint, page URL, server public key, TTL). 2. Phone opens `/m#` (QR) or POSTs the 8-digit PIN to `/m/claim`. 3. Phone generates its own X25519 key and connects to `/m/ws`. 4. Four-step handshake (`dshmr-e2ee/v1`) pins the desktop public key, derives session keys via HKDF, then `e2ee_auth` with the device token. 5. Further frames are tweetnacl secretbox. Five consecutive decrypt failures close the socket. Crypto library replacement is **deferred** (stay on tweetnacl until an E2EE version bump); see [`docs/research/adr-tweetnacl-vs-libsodium-webcrypto.md`](research/adr-tweetnacl-vs-libsodium-webcrypto.md). Device-token rotation beyond settings revoke / idle expiry is **not** in this release; see [`docs/research/adr-device-key-rotation.md`](research/adr-device-key-rotation.md). Honest v0 boundary: the **first HTTP download of `/m`** on a raw LAN is MITM-able. E2EE does not protect a replaced page. Prefer Tailscale / WireGuard; optional Cloudflare Quick Tunnel terminates TLS at the edge and must never include port 3080. Optional self-hosted rendezvous Worker (M5) serves `/m` over HTTPS and splices outbound WebSockets; it must never see port 3080. Details: `docs/04-threat-model.md`, `docs/05-cloud-relay.md`. ## 7. Build outputs | Artifact | Role | |---|---| | `lib/server/index.js` | Bundled Cordis entry (`packages: "external"` — do not bundle `tweetnacl`) | | `lib/client.js` | Settings classic-script | | `lib/mobile/` | Phone page + service worker | | `lib/**` transpiled tree | Unit-test import surface only | `package.json` `exports` must include `"."`, `"./client"`, and `"./package.json"` (DSH scans client modules via `require.resolve("/package.json")`). ## 8. Compatibility - Cordis plugin id: `mobile-remote`. - Config defaults: `enabled: true`, `bind: "127.0.0.1"`, `port: 6879`. - Pairing widens the data plane to `0.0.0.0` when advertising LAN candidates and no public tunnel / rendezvous is running. - Wire protocol version: `MOBILE_PROTOCOL_VERSION = 1` (`src/shared/constants.ts`). When a phone answers first, the bridge ends the forwarded child lifetime to retire the desktop card without aborting the host request. Disconnect grace expiry withdraws only the phone branch while the host answerer remains pending. Mobile history is read after subscription snapshot acknowledgement and merged with buffered live events.