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) where key = SHA-512("dsh-dispatch/key" ‖ secret)[0..32] and nonce = 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 msg verbatim 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 delivers payload via Web Push to every stored subscription of that room instead. tag collapses 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 /healthz200 {"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

typefieldsnotes
machine.statusmachine, 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.snapshotsessions: Session[]on phone connect / sessions.get
session.updatesession: Sessionon any state change
approval.requestapprovalId, sessionId, title, detail, createdAtdetail ≤ 8KB, truncated with "…[truncated]" suffix
approval.closedapprovalId, resolution: "allow"|"deny"|"local"|"expired"|"superseded"fires also when answered on desktop ("local") so phone dismisses the card
dispatch.resultrequestId, 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.requestquestionId, sessionId, items: QuestionItem[], createdAtforwarded ask_user_question tool call; each item = {id, prompt, options[], multiSelect, allowFreeText}; prompts/labels clamped like approval details
question.closedquestionId, resolution: "phone"|"local"|"cancelled"|"expired"fires also when answered on desktop ("local") so the phone dismisses the card
turn.finalsessionId, ok, summary, reason?final assistant text, ≤ 4KB truncated
transcriptrequestId, 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
errormessage, 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 and session.message would land. done means the turn ended, not that the session is gone; only live separates "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 in error / note / summary / message, which any peer can display, and reason which 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 on turn.final only when summary is 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 to read ×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

typefieldsnotes
sessions.getrequest snapshot
approval.respondrequestId, approvalId, decision: "allow"|"deny"
dispatch.requestrequestId, prompt, cwd?, worktree: booleanprompt ≤ 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.getrequestId, sessionId, before?ask for the conversation; before = return entries with seq strictly less than this, for paging older. Absent = the newest page
session.messagerequestId, sessionId, textsteer an existing session (M2)
question.respondrequestId, 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). msg for a room the socket never joined ⇒ bad-frame ("not joined to this room").
  • Presence semantics: online:true on every new join of a role; online:false only 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-unsubscribe require the socket to be joined to that room as phone (else bad-frame). A push hint 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 in message).
  • Push collapse: tag becomes the RFC 8030 Topic header 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) replays hello-ok without 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's danger-full-access permission preset. Three gates, all mandatory: machine config allowFullAccessDispatch (default false; violations get a visible dispatch.result{ok:false}), a non-default phone-side toggle with an explicit risk warning, and a permanent access:'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 kind gains '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)

  1. 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.
  2. dispatch.request.cwd is validated against configured allowed roots; violations return a visible dispatch.result{ok:false}, never a silent clamp.
  3. Decryption failure ⇒ visible error state ("re-pair needed"), never a silent drop.
  4. The relay never logs payloads; log lines carry room id prefix (8 chars) only.