# 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.

Verified build pin: `@deepseek-ai/dsh@0.1.1-rc.2`. Install gate: `engines.dsh` `>=0.1.1-rc.2 <0.3.0` (covers `0.2.x` installs such as `0.2.1-rc.2`). Changing the verified build pin requires a new ADR (`docs/01-mvp-scope.md`). `0.1.2-alpha`, `0.1.5-rc.1`, and `0.2.1-rc.2` remain unverified candidates for runtime promotion.

## 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#<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`](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("<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`).

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.
