-
-
Notifications
You must be signed in to change notification settings - Fork 11
Relay
Relay mode reaches your harness through
dsh-relay — a plugin that runs beside the
harness and asks who you are before letting anything through. It is the answer to the problem
LAN Mode does not solve: that page serves a coding agent to everyone on your network.
Requires DSH Mobile 0.8.0 or newer and dsh-relay 0.1.1 or newer.
The harness is explicit that it has no authentication layer, and refuses dsh web --host 0.0.0.0
because doing so "would expose remote code execution to the network". LAN mode works around that
refusal with a config patch. The relay is the layer itself: the harness keeps its loopback bind,
and a second listener in front of it terminates TLS, checks a credential, and forwards.
That ordering matters. A relay that fails to start leaves the harness unreachable from the
network — never open to it. And it refuses to start at all if it finds the harness already bound to
0.0.0.0, so you cannot accidentally run both.
-
A real credential. This device holds a bearer token issued once, at pairing, and sends it on
every
/apicall and both event streams. The relay stores only a keyed hash of it. - Encryption, with the key pinned. The app pins the relay's public key rather than trusting a certificate authority, so a self-signed relay is verified rather than merely accepted.
-
Revocation you control. Every paired device is listed at
/relay/deviceswith its own revoke button, and "sign out everywhere" invalidates every token at once. - It works from outside your Wi-Fi. Forward the relay's port and it keeps working; the app's "not on this phone's network" check is skipped for an endpoint that holds a token.
What it does not do is reduce what an authenticated client can reach. Pairing a phone gives it the same power as a terminal on that computer, because the agent runs commands there. The question the relay answers is "is the remote user you", not "how much can the remote user do".
On the computer running the harness:
dsh plugin --profile web add dsh-relay
dsh webThe terminal prints the relay's addresses. Open one on that machine and set a password — until one exists the sign-in page is loopback-only, so nobody on the network can claim the relay first.
Then open /relay/pair on that machine. It shows a QR code and an eight-digit code; either works,
and both expire after a few minutes.
In the app: Relay → Pair a relay.
- Scan QR code — point the camera at the code on the pairing page. This is the better route, and not only for convenience: see below.
- Or type the code — enter the relay's address and the eight digits yourself. Useful when the camera is unavailable or the QR will not focus.
Name the device something you will recognise in the relay's device list, and tap Pair. The app stores the token and connects.
The app says which one happened, because the difference is real:
| What it establishes | |
|---|---|
| Scanned QR | The payload carries the relay's public key, so the very first byte the app sends is checked against it. |
| Typed address | Carries no key — the relay only reveals one in its answer — so the certificate is trusted on first contact. |
Trust on first contact means an attacker able to answer at that address during pairing would end up holding the enrolment. Over your own Wi-Fi that is a narrow window; over an untrusted network it is not. Scan the QR when you can.
If the relay is running tls: off, the app says so plainly: the token and everything you send
travel in the clear.
Tailscale Funnel — like nginx or Caddy terminating TLS — puts a certificate the phone already trusts in front of the relay, and speaks plain HTTP behind it. That combination changes pairing.
-
Set
tls: offon the relay. The proxy encrypts the outside leg already. Left on, pairing stores the relay's own key while every later connection meets the proxy's, and that mismatch is "This relay's key changed" every time. -
Ignore the QR. The pairing page encodes the address it was opened on. Opened on the machine,
that is
http://127.0.0.1:…— an address that, on the phone, is the phone. -
Pair by typing
https://<the proxy's hostname>and the eight-digit code. This is the typed route from the table above — trust on first use, and labelled as such. The not-encrypted notice goes once the address starts withhttps://.
The proxy's hostname must be listed in publicHostnames, or the relay refuses before the code is
read — that is Refuses this address, below. On your own Wi-Fi none of this applies: scan the
QR, which carries the relay's real key.
Two consequences to expect. The key a typed pairing pins is the one the phone observed — the
proxy's — so a proxy that renews its certificate onto a new key produces "This relay's key
changed", and you pair again. And the app sends its token as an Authorization: Bearer header,
so a proxy that demands its own password in that header (HTTP Basic) cannot sit in front of the
relay.
Find relays browses for the relay's mDNS advertisement (_dsh._tcp), which carries the port,
whether the listener speaks TLS, and the key to pin — so it answers in a second or two rather than
sweeping a subnet.
Nothing depends on it arriving. Plenty of networks filter multicast and the relay's mdns flag can
be turned off, so a quiet browse falls back to knocking the two ports a relay actually uses. It also
knocks 127.0.0.1 first, which is where a relay reached through adb reverse answers.
A relay that answers but refuses the address you reached it by is listed anyway, with the entry to add to its configuration. That is the most recoverable thing a scan can find — see below.
The relay answers HTTP 403 for every unauthorised case and never 401, so the app works out which one it is from what it already knows about the address.
"The relay refused this device." Your token expired, or it was revoked from the relay's device list. Pair again. The app stops retrying rather than backing off forever — there is nothing to wait for.
"Refuses this address" / "…it refuses requests addressed this way." The relay only answers to
authorities it knows: loopback, its own addresses, and whatever its operator listed. An emulator's
10.0.2.2, or a hostname it was never told about, gets refused before the pairing code is even
read. Add the address to publicHostnames in the relay's cordis.patch.yml and restart it — or
reach the relay by an address it already knows. In the harness log this looks like:
[dsh-relay] refused GET /relay/health from 127.0.0.1: untrusted-host
"This relay's key changed." The relay mints a new key whenever the set of addresses its certificate covers changes, so a laptop that moved networks produces this — and so would something else answering at that address. Behind an HTTPS proxy the pinned key is the proxy's, so its certificate renewing produces this too. The app will not guess between them. Pair again only if you were expecting it.
dsh-relay ships with compat.addressGrants on, which accepts requests from whatever address a
paired device was last seen on. It exists for DSH Mobile 0.5.0, which had no field that could carry
a token.
A source address is not authentication — it is shared behind NAT, reassigned by DHCP, rotated by
IPv6 privacy extensions, and spoofable by anything on the same Wi-Fi. Once every client you use is
on 0.8.0 or newer and has paired, set compat.addressGrants: false. Nothing in this app needs it.
- Security — the trust model for both modes
- LAN Mode — the unauthenticated alternative, and why to prefer this
- Connecting — the connect screen itself
-
docs/PROTOCOL.md— the wire contract, for developers
DSH Mobile — an unofficial, community-built Android remote for the
DeepSeek Harness. The harness and its brand belong
to their respective owners.
MIT ·
repository ·
canonical technical docs in
docs/.