Architecture

September 2, 2026 · View on GitHub

中文版 · 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 remains an unverified candidate.

1. Dual plane

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

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 sessions / approvals / questions

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 + webServer typing.
  • 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: host apiProxy session/approval/question bridge.
  • 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):

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):

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):

FileRole
server-key.jsonX25519 identity; 0600; created on first boot
devices.jsonpaired devices; SHA-256 of deviceToken only
audit.jsonlrpc_write / offer / revoke / tunnel events; no payloads
tunnel.jsonQuick Tunnel persist so a crash can reap a stale child
relay.jsonrendezvous 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#<offer> (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. Device-token rotation beyond settings revoke / idle expiry is not in this release; see docs/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

ArtifactRole
lib/server/index.jsBundled Cordis entry (packages: "external" — do not bundle tweetnacl)
lib/client.jsSettings classic-script
lib/mobile/Phone page + service worker
lib/** transpiled treeUnit-test import surface only

package.json exports must include ".", "./client", and "./package.json" (DSH scans client modules via require.resolve("<pkg>/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).