alextangson/dsh-dispatch--packages-relay0

dsh-dispatch-relay

Zero-knowledge WebSocket relay for dsh-dispatch: routes end-to-end encrypted frames between DeepSeek Harness (dsh) machines and phones, and fans out Web Push when the phone is offline.

包名
dsh-dispatch-relay
版本
0.1.0
许可证
MIT
最近更新
2026年8月23日

安装

此插件尚未提供可验证的 bundle,或兼容性检查未通过。请先阅读仓库说明。 阅读完整 README ↗

dsh-dispatch-relay

Zero-knowledge WebSocket router for dsh-dispatch. It connects a dsh machine (the plugin) to a phone (the PWA), forwards end-to-end encrypted frames between them, and — when no phone is connected — fans the machine's notification out over Web Push.

It never sees a key or a plaintext. Implements docs/PROTOCOL.md v1.

Run it

# node (from a checkout)
pnpm --filter dsh-dispatch-relay build
node packages/relay/dist/index.js

# npx (published)
npx dsh-dispatch-relay

# docker (build context is the repo root)
docker build -f packages/relay/Dockerfile -t dsh-dispatch-relay .
docker run -p 8787:8787 -v dsh-relay-data:/data dsh-dispatch-relay

Behind a TLS terminator (Caddy/nginx/Cloudflare), proxy /ws with upgrade headers and point the plugin at wss://your-host/ws. Phones require wss:// — browsers refuse Web Push and service workers on plaintext origins.

Health check: GET /healthz200 {"ok":true}.

Environment

VariableDefaultMeaning
PORT8787HTTP + WebSocket port. Invalid values abort startup rather than silently falling back.
DATA_DIR./relay-dataDirectory for push-subs.json, the only thing the relay persists.
VAPID_PUBLIC_KEYWeb Push VAPID public key.
VAPID_PRIVATE_KEYWeb Push VAPID private key.
VAPID_SUBJECTmailto:you@example.com or an https:// contact URL.
LOG_LEVELinfosilent | error | warn | info.

All three VAPID values are required to enable push. With any of them missing the relay logs one line — web push disabled: VAPID env not set — and keeps serving everything else; GET /vapid then answers 404 {"error":"push-disabled"} so the PWA can say "push unavailable on this relay" instead of failing an opaque subscribe call.

Generate a key pair with npx web-push generate-vapid-keys.

Endpoints

RouteResponse
GET /healthz200 {"ok":true}
GET /vapid200 {"publicKey":"…"}, or 404 {"error":"push-disabled"}
GET /ws426 {"error":"upgrade-required"} — this path is the WebSocket endpoint

Wire behaviour

  • The first frame on a socket must be a valid hello; anything else gets {"kind":"error","code":"bad-hello"} and the socket is closed (1008).
  • After that the socket is never closed for a bad frame. Unknown kinds and malformed frames get {"kind":"error","code":"bad-frame"} and the connection stays up — forward compatibility beats strictness here.
  • msg frames are forwarded verbatim (the original bytes, unknown fields included) to every socket of the other role in the room. Frames larger than 16KB are rejected with {"kind":"error","code":"too-large"} and are not forwarded.
  • One socket can hello into many rooms — that is how a phone controls N machines over a single connection. Joining the same room twice with different roles is refused.
  • presence goes to the other role on join, and on the leave of the last socket of a role (a second phone disconnecting does not report the phone as offline).
  • WebSocket ping every 25s; a socket that misses the following pong is terminated.

Push

A machine msg carrying a push hint triggers Web Push only when the room has zero phone sockets. The relay sends the machine-supplied ciphertext with TTL: 60 and, when the tag is a valid RFC 8030 topic (≤32 chars of [A-Za-z0-9_-]), collapses notifications by it. Subscriptions reported as 404/410 are pruned from the store.

Push failures are reported back to the machine rather than swallowed, so the plugin can surface them (PRODUCT.md "失败可见"):

CodeMeaning
push-disabledthis relay has no VAPID keys configured
push-no-subscribersno phone online and no stored subscription for the room
push-failedevery delivery attempt failed or was expired (counts in message)

Security note

The relay is zero-knowledge for content. Pairing secrets are exchanged out of band (QR / pairing code) between plugin and phone; the relay never receives one, so every payload it routes is an opaque base64(nonce ‖ secretbox) blob it cannot open — including push payloads, which are handed to the push service still encrypted. It links no crypto library at all.

What it does see is metadata: room ids (derived hashes, not secrets), which role is connected when, message sizes and timing, and Web Push endpoint URLs of subscribed phones. That metadata is the whole threat surface of a relay compromise: an attacker learns that something happened in a room and can withhold delivery, but cannot read or forge traffic — frames fail to decrypt without the pairing secret. Logs carry only the first 8 characters of a room id and never a payload, ciphertext or push body. The only persisted state is DATA_DIR/push-subs.json; delete it to drop all push subscriptions.

Self-host it: an operator you trust running this on a $5 VPS sees strictly less than a hosted service would.

Development

pnpm --filter dsh-dispatch-relay dev           # watch mode
pnpm --filter dsh-dispatch-relay test          # vitest, real sockets on port 0
pnpm --filter dsh-dispatch-relay typecheck
pnpm --filter dsh-dispatch-relay fake-machine  # simulated dsh machine, prints a pairing code

fake-machine is the end-to-end harness: it generates a secret, prints a pairing code plus a http://localhost:5173/#pair=… link for the PWA, joins the derived room as a machine, and then answers sessions.get, accepts dispatch.request (idempotently), raises an approval five seconds later and closes it when the phone responds. Point it elsewhere with RELAY_URL, PWA_URL and MACHINE_NAME.