alextangson/dsh-dispatch--packages-relay ↗★ 0
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.
安装
此插件尚未提供可验证的 bundle,或兼容性检查未通过。请先阅读仓库说明。 阅读完整 README ↗
说明文档
阅读完整 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 /healthz → 200 {"ok":true}.
Environment
| Variable | Default | Meaning |
|---|---|---|
PORT | 8787 | HTTP + WebSocket port. Invalid values abort startup rather than silently falling back. |
DATA_DIR | ./relay-data | Directory for push-subs.json, the only thing the relay persists. |
VAPID_PUBLIC_KEY | — | Web Push VAPID public key. |
VAPID_PRIVATE_KEY | — | Web Push VAPID private key. |
VAPID_SUBJECT | — | mailto:you@example.com or an https:// contact URL. |
LOG_LEVEL | info | silent | 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
| Route | Response |
|---|---|
GET /healthz | 200 {"ok":true} |
GET /vapid | 200 {"publicKey":"…"}, or 404 {"error":"push-disabled"} |
GET /ws | 426 {"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. msgframes 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
hellointo many rooms — that is how a phone controls N machines over a single connection. Joining the same room twice with different roles is refused. presencegoes 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 "失败可见"):
| Code | Meaning |
|---|---|
push-disabled | this relay has no VAPID keys configured |
push-no-subscribers | no phone online and no stored subscription for the room |
push-failed | every 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.