dsh-dispatch wire protocol v1
August 25, 2026 · View on GitHub
Status: frozen for M1–M3. All three packages (plugin / relay / pwa) code against this file.
Any change requires bumping v and updating this doc first.
Topology
[dsh plugin] ←wss→ [relay] ←wss→ [PWA (phone)]
machine router controller
- One room per (machine, pairing). A phone that controls N machines joins N rooms.
- The relay is zero-knowledge for content: it sees only the envelope (room id, role, ciphertext) and stores only room presence + Web Push subscriptions. It never sees keys or plaintext.
- All application payloads are end-to-end encrypted between plugin and phone.
Crypto
- Library:
tweetnacl(both TS sides). - Pairing secret: 32 random bytes generated by the plugin, transported ONLY out-of-band via QR / copy-paste code. Never sent to the relay.
- Pairing payload (QR content, JSON, base64url-encoded):
{"v":1,"relay":"wss://…","secret":"<base64url 32B>","machine":"<name>"} - Room id:
base64url(SHA-512("dsh-dispatch/room" ‖ secret)[0..16])— both ends derive it; relay learns nothing from it. (SHA-512 =nacl.hash, no extra dependency.) - Message encryption:
nacl.secretbox(plaintextJSON, nonce, key)wherekey = SHA-512("dsh-dispatch/key" ‖ secret)[0..32]andnonce= 24 random bytes per message. Ciphertext on the wire =base64(nonce ‖ box). - Threat model v0: relay compromise ⇒ metadata only (timing, room ids, message sizes, push endpoints). MITM is impossible without the QR secret. Key rotation = re-pair (old secret discarded on both sides). Multi-phone on one machine: scan the same QR.
Transport envelope (plaintext to relay)
WebSocket, JSON text frames.
Client → relay, first frame after connect:
{"kind":"hello","room":"<roomId>","role":"machine|phone"}
Relay replies {"kind":"hello-ok","peers":{"machine":1,"phone":0}} or
{"kind":"error","code":"bad-hello"} and closes. A client may send additional hello
frames on the same socket to join more rooms (phone side).
Data frame (either direction):
{"kind":"msg","room":"<roomId>","payload":"<base64 nonce‖box>","push":null}
- Relay forwards
msgverbatim to all sockets of the other role in that room. push(machine→relay only, optional):{"payload":"<base64 nonce‖box, ≤3KB>","tag":"<string>"}— if no phone socket is currently connected to the room, relay deliverspayloadvia Web Push to every stored subscription of that room instead.tagcollapses notifications.- Presence (relay-generated, plaintext, no content):
{"kind":"presence","room":"…","role":"machine|phone","online":true|false}sent to the other role on join/leave. - Push subscription (phone→relay, after successful hello):
{"kind":"push-subscribe","room":"…","subscription":{<standard PushSubscription JSON>}}Relay stores it keyed by (room, endpoint).{"kind":"push-unsubscribe","room":"…","endpoint":"…"}removes. - Heartbeat: WebSocket ping/pong at 25s, both hops. Reconnect with exponential backoff 0.5s→30s, jittered, unlimited on phone, max 10 consecutive failures on plugin before surfacing a visible error state (then keep retrying at 60s).
Relay HTTP: GET /healthz → 200 {"ok":true}; GET /vapid → {"publicKey":"…"}.
Inner messages (encrypted JSON)
Every message: {"v":1,"type":"<type>","ts":<unix ms>, …fields}.
Unknown type or v ⇒ receiver ignores the message but logs it (forward compat).
machine → phone
| type | fields | notes |
|---|---|---|
machine.status | machine, pluginVersion, dshVersion?, allowedRoots? | on connect + every 60s; allowedRoots = the dispatch roots resolved exactly as the cwd guard enforces them (absolute, ~ expanded), so the phone can offer a choice instead of asking for a typed path. Absent ⇒ plugin predates the field (phone falls back to free text); [] ⇒ dispatch is off |
session.snapshot | sessions: Session[] | on phone connect / sessions.get |
session.update | session: Session | on any state change |
approval.request | approvalId, sessionId, title, detail, createdAt | detail ≤ 8KB, truncated with "…[truncated]" suffix |
approval.closed | approvalId, resolution: "allow"|"deny"|"local"|"expired"|"superseded" | fires also when answered on desktop ("local") so phone dismisses the card |
dispatch.result | requestId, ok, sessionId?, error?, note?, reason? | reply to dispatch.request; note = advisory on an ok result (e.g. "ran without worktree: not a git repo") — UI renders it as info, never as failure |
question.request | questionId, sessionId, items: QuestionItem[], createdAt | forwarded ask_user_question tool call; each item = {id, prompt, options[], multiSelect, allowFreeText}; prompts/labels clamped like approval details |
question.closed | questionId, resolution: "phone"|"local"|"cancelled"|"expired" | fires also when answered on desktop ("local") so the phone dismisses the card |
turn.final | sessionId, ok, summary, reason? | final assistant text, ≤ 4KB truncated |
transcript | requestId, sessionId, entries, more?, reason? | reply to transcript.get; entries oldest-first, each {seq, role: "user"|"assistant", text, ts, truncated?}. Built backwards from the newest until the byte budget is spent, so more means older entries remain — the phone must not read a page as the start of the conversation. Only what a human typed survives: dsh records its runtime snapshot and skill catalogue as user/message too, distinguished by data.source.kind |
error | message, context?, sessionId?, reason? | any surfaced failure (worktree fail, spawn fail…); sessionId is set when the failure belongs to one session (e.g. a refused session.message) so the phone can pin it to that card instead of only toasting it |
Session = {sessionId, title, cwd, state, lastActivity, dispatched, access?, live?, activity?, steps?, stepCount?}
where state ∈ idle|running|awaiting_approval|done|error and dispatched marks sessions
started by this plugin. title = first 80 chars of the initial prompt.
live— the machine still holds the session andsession.messagewould land.donemeans the turn ended, not that the session is gone; onlyliveseparates "finished, talk to it" from "disposed, cannot". Absent ⇒ plugin too old to say, and the phone offers no reply box rather than one that fails after the user has typed.reason— a stable code (plus params) for machine-authored prose. The machine cannot know the phone's language, so every sentence it writes travels twice: its own wording inerror/note/summary/message, which any peer can display, andreasonwhich a peer that knows the code renders in its own language. An unknown code falls back to the wording — a downgrade in language, never in information. Set onturn.finalonly whensummaryis a placeholder rather than model output. Codes:dispatch-disabled,cwd-not-allowed,prompt-too-long-chars|bytes,message-too-long-chars|bytes,no-remote-followup,worktree-failed,dispatch-failed,not-a-git-repo,no-assistant-text,full-access-disabled|unavailable|not-applied,bad-access-value.steps/stepCount— the tools this turn has run, oldest first, so a tool that returns in 200ms leaves a trace instead of a blink. Consecutive repeats collapse toread ×5, which is why the entry count says nothing about the number of calls. Only the most recent entries survive;stepCount(the true total) is sent ONLY when earlier entries were dropped, so its absence means the trail is whole. Cleared when a new turn starts, not when the turn ends — "what did it just do" outlives the doing.activity— name of the tool running right now, absent when nothing is in flight. Never arguments or output. Machines coalesce activity-only updates (800ms) so a tool-heavy turn cannot flood a phone; state changes are never delayed behind them.
phone → machine
| type | fields | notes |
|---|---|---|
sessions.get | — | request snapshot |
approval.respond | requestId, approvalId, decision: "allow"|"deny" | |
dispatch.request | requestId, prompt, cwd?, worktree: boolean | prompt ≤ 8192 chars AND ≤ 10KB UTF-8 bytes (sender clamps via shared clampBytes before seal — char limits alone don't fit CJK in the 16KB envelope); cwd must be inside plugin-configured allowed roots |
transcript.get | requestId, sessionId, before? | ask for the conversation; before = return entries with seq strictly less than this, for paging older. Absent = the newest page |
session.message | requestId, sessionId, text | steer an existing session (M2) |
question.respond | requestId, questionId, answers: [{itemId, optionIds[], freeText?}] | phone's answer; races the desktop, first decision wins |
Idempotency & limits
- Every phone-initiated action carries a UUID
requestId; machine keeps an LRU (256) of seen ids and replays the original result for duplicates (double-tap safe). - Every sender truncates outbound free text BEFORE encryption — by chars first, then by
UTF-8 bytes (shared
clampBytes, 10KB per free-text field); hard cap 16KB per envelope (relay rejects larger with{"kind":"error","code":"too-large"}). - Push variant payloads are compact:
{"v":1,"type":"push","kind":"approval"|"done"|"error","sessionId","title"}≤ 3KB.
Relay enforcement details (normative, matches implementation)
- First-frame rule: before a successful hello, ANY invalid frame (bad JSON, wrong kind,
oversized) ⇒
bad-hello+ close(1008). After hello, malformed JSON / unknown kind ⇒bad-frame, socket stays open (forward compat).msgfor a room the socket never joined ⇒bad-frame("not joined to this room"). - Presence semantics:
online:trueon every new join of a role;online:falseonly when the LAST socket of that role leaves the room. - Size caps measure raw WebSocket frame bytes, checked before JSON parsing: 16KB for
data frames, 3KB for
push.payload. - Room id validation: relay accepts
^[A-Za-z0-9_-]{8,128}$(derived ids are 22 chars). - Push authorization:
push-subscribe/push-unsubscriberequire the socket to be joined to that room asphone(elsebad-frame). Apushhint from a phone-role socket ⇒bad-frame. No ack frame for subscribe in v1 (candidate for v2). - Push delivery receipts (relay→machine error frames, satisfies PRODUCT.md 失败可见):
push-disabled(no VAPID configured),push-no-subscribers,push-failed(count inmessage). - Push collapse:
tagbecomes the RFC 8030Topicheader only when it matches^[A-Za-z0-9_-]{1,32}$; otherwise delivered uncollapsed. - Same socket joining the same room with both roles ⇒
bad-hello(no close). Repeated hello for the same (room, role) replayshello-okwithout duplicate presence.
v1.2 additions (additive, wire version stays v:1)
question.request/question.closed/question.respond(ask_user_question forwarding).dispatch.request.access?: 'standard'|'full'— 'full' runs the session under dsh'sdanger-full-accesspermission preset. Three gates, all mandatory: machine configallowFullAccessDispatch(default false; violations get a visibledispatch.result{ok:false}), a non-default phone-side toggle with an explicit risk warning, and a permanentaccess:'full'mark on the Session for audit.Session.access?: 'standard'|'full',machine.status.capabilities?: {fullAccessDispatch, questionForwarding}— absent capability = false (v0.1 plugin); the phone hides/disables the corresponding UI with a reason instead of failing.- Push compact
kindgains'question'. - Receivers ignore unknown fields/types (already required), so v1.2 and v1.1 peers interop: an old phone simply never sees question cards; the machine's re-remind loop and the desktop web UI still cover the ask.
Security invariants (do not violate in any package)
- Approvals are NEVER auto-allowed — no timeout-approve, anywhere. User questions are NEVER auto-answered. Full-access is never the default and never silently applied — it is an explicit per-dispatch human choice, gated machine-side.
dispatch.request.cwdis validated against configured allowed roots; violations return a visibledispatch.result{ok:false}, never a silent clamp.- Decryption failure ⇒ visible error state ("re-pair needed"), never a silent drop.
- The relay never logs payloads; log lines carry room id prefix (8 chars) only.