SunNull/dsh-relay

dsh-relay

Cloud relay for DeepSeek Harness: expose your home dsh instance to any device with full real-time sync (out-of-tree plugin + wire-trunk architecture)

Uncategorizedagentcordisdeepseek

English | 中文

dsh-relay

Map a home-run DeepSeek Harness (dsh) instance to the public internet — get the exact same dsh experience as sitting at your home machine from any device, anywhere: live session sync, streaming output, tool cards, approval prompts, and workspace browsing.

Phone / remote PC ──HTTPS──→ Cloud relay (dsh-relay-cloud, one VPS)
                               │  outbound trunk (dialed by the home plugin)
                               ▼
                         Home dsh (stays bound to 127.0.0.1, zero exposed ports)
                         └─ dsh-relay-host plugin: an "invisible browser" inside
                            the dsh process, replaying every request from loopback

How it works (Wire-Trunk architecture)

  • The home side is an out-of-tree plugin (~200 lines, no build step): acting as an invisible browser inside the dsh process, it opens protocol-identical connections (POST /api/* + two downlink WebSockets) against the local 127.0.0.1:3080 and replays them verbatim over one outbound trunk to the cloud.
  • The cloud is a pure forwarding plane: remote browsers receive byte-identical stock traffic; static assets are served from the home instance and cached in the cloud — the frontend always matches the host version, so there is no protocol drift.
  • Zero modifications to the dsh repository: the plugin lives in the ~/.dsh/profiles/web/ user patch layer; git pull, auto-updates, and uninstall never interfere with each other.
  • Security: dsh itself ships no authentication and pins privileged methods to loopback — this design makes remote requests enter with a loopback identity while adding authentication (pairing code → long-lived cookie) at the cloud edge. The home side opens no inbound ports (NAT-friendly by construction).

Full Deployment Guide

Three roles: cloud (a public server), home (the machine running dsh), and remote (phone / any browser). About 15 minutes end to end.

Prerequisites

RoleRequirement
CloudLinux server with a public IP (1 vCPU / 1 GB is enough), SSH access, one open TCP port (8443 below)
HomeA working dsh web (default 127.0.0.1:3080), Node ≥ 22
RemoteAny modern browser

Step 1: Generate credentials (on the home machine)

# Trunk token (home ↔ cloud identity, 32 hex chars)
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
# → note as <TOKEN>

# Pairing code (entered once per browser, 8 hex chars)
node -e "console.log(require('crypto').randomBytes(4).toString('hex'))"
# → note as <CODE>

⚠️ These two values are the keys to the whole system. Store them safely; never commit them.

Step 2: Deploy the cloud

SSH into your server:

ssh root@<SERVER_IP>

2.1 Install Node 22 (skip if node -v shows ≥ 22):

ARCH=$(uname -m); case "$ARCH" in x86_64) NARCH=x64;; aarch64) NARCH=arm64;; esac
curl -fsSL https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-$NARCH.tar.xz -o /tmp/node.tar.xz
tar -xJf /tmp/node.tar.xz -C /opt
ln -sfn /opt/node-v22.14.0-linux-$NARCH /opt/node
ln -sf /opt/node/bin/node /usr/local/bin/node
ln -sf /opt/node/bin/npm /usr/local/bin/npm
node -v   # v22.14.0

2.2 Fetch the code and install dependencies:

git clone https://github.com/SunNull/dsh-relay.git /opt/dsh-relay
cd /opt/dsh-relay/cloud
npm install

2.3 Create a systemd service (replace <TOKEN>/<CODE>):

cat > /etc/systemd/system/dsh-relay.service <<EOF
[Unit]
Description=dsh-relay cloud
After=network-online.target

[Service]
Environment=DSH_RELAY_BIND=0.0.0.0
Environment=DSH_RELAY_PORT=8443
Environment=DSH_RELAY_TOKEN=<TOKEN>
Environment=DSH_RELAY_PAIRING_CODE=<CODE>
WorkingDirectory=/opt/dsh-relay/cloud
ExecStart=/usr/local/bin/node server.mjs
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now dsh-relay
systemctl status dsh-relay             # active (running)
curl -s http://127.0.0.1:8443/healthz  # {"ok":true,...,"trunkReady":false}

trunkReady:false is expected — the home side is not connected yet.

2.4 Open the port (TCP 8443 inbound, source 0.0.0.0/0):

  • Cloud provider console → instance → security group / firewall → add rule TCP 8443
  • If ufw is active on the server: ufw allow 8443/tcp

Verify from the home machine: open http://<SERVER_IP>:8443/healthz — you should see JSON.

Step 3: Install the home plugin

On the home machine, pick one of two ways. For the admin panel UI use Option A; Option B is functionally equivalent but the sidebar entry will not appear (see its note).

Option A: standard bundle install (the repo declares dsh.bundle; one command mounts it; the panel UI relies on this option's dsh.client discovery):

dsh plugin --profile web add "github:SunNull/dsh-relay"

Then append the trunk credentials to ~/.dsh/profiles/web/cordis.patch.yml (the bundle layer carries no credentials; the user layer overrides it):

- id: dsh-relay-host
  config:
    relayUrl: ws://<SERVER_IP>:8443/trunk
    token: <TOKEN>

Option B: installer one-liner (clones the repo and writes the config for you):

git clone https://github.com/SunNull/dsh-relay.git
cd dsh-relay
node install.mjs --relay-url ws://<SERVER_IP>:8443/trunk --token <TOKEN>

The installer:

  1. Copies the plugin's three files (index/admin/client) flat into ~/.dsh/profiles/web/plugins/ (index.mjs imports ./admin.mjs by relative path, so all three must sit side by side)
  2. Appends a managed block to ~/.dsh/profiles/web/cordis.patch.yml (dsh hot-reloads this patch layer)

Option B note: the relay and the admin route work fully, but the ⚙ Relay admin sidebar entry will not appear — the client panel rides the bundle's dsh.client discovery (Option A). Switch to Option A if you want the panel UI.

Both ways need one dsh web restart (module code needs process loading; config-only changes later need no restart). Uninstall: Option A dsh plugin --profile web remove dsh-relay-host, Option B node install.mjs --uninstall:

# restart your dsh web, e.g.
pnpm dsh web

Verify the trunk — on the server:

curl -s http://127.0.0.1:8443/healthz
# "trunkReady":true means connected; false → check plugin config and token

Step 4: Remote access

Open this on a phone (or any device):

http://<SERVER_IP>:8443
  1. The pairing page appears the first time → enter <CODE> → "配对"
  2. You land in the full dsh UI, identical to your home machine (sessions, model, workspaces all synced)
  3. "Add to Home Screen" gives an app-like experience

Repeat on more devices as needed (default cap: 10, see env vars).

By default dsh opens a native OS directory dialog on the host desktop, invisible to remote browsers. Append this patch so everyone uses the in-page directory browser:

Edit ~/.dsh/profiles/web/cordis.patch.yml and append:

- id: directory-picker
  disabled: true
- insert:
    - id: directory-picker-browse-host
      name: '@deepseek-ai/dsh-host-directory-picker-browse'
    - id: directory-picker-browse-ui
      name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'

Saves hot-apply (this is dsh's officially documented composition for remote-serving deployments). "Add workspace" now opens a web directory browser any device can use.

Plain HTTP risks interception. With a domain name you get automatic HTTPS:

# Install Caddy (Debian/Ubuntu official repo)
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddy

Point a DNS A record at the server, then:

cp /opt/dsh-relay/cloud/Caddyfile.template /etc/caddy/Caddyfile
# edit the file: replace dsh.example.com with your domain
nano /etc/caddy/Caddyfile
systemctl reload caddy

Use https://<YOUR_DOMAIN> from now on; repoint the home side (hot-applies, no dsh restart):

cd dsh-relay
node install.mjs --relay-url wss://<YOUR_DOMAIN>/trunk --token <TOKEN>

Admin panel

The ⚙ Relay admin entry in the dsh web sidebar opens the built-in admin panel — it works both locally and from remote paired browsers, so you can manage everything from your phone on the couch.

Four areas:

  1. Status card: trunk state, bridged downlinks, cache entries, cloud uptime
  2. Device table: each paired device's label / paired-at / last-seen, per-device revoke (instantly invalidates its cookie and tears down live sockets) or revoke-all in one click
  3. Pairing-code table: multiple labeled pairing codes — create, click-to-reveal, enable/disable, regenerate, delete
  4. Audit: tail of cloud-side events (pairing outcomes, revocations, code management)

The security model in one line: management follows dsh-web reachability — whoever can open your dsh web can manage the relay; if you lose a phone, revoke it from the home machine or any other paired device.

Upgrade requirement: the panel needs both the cloud and the home plugin on 0.2.0+ (at home, re-run dsh plugin --profile web add "github:SunNull/dsh-relay" or node install.mjs, then restart dsh web).


Operations

TaskHow
Cloud status & auditVisit http://<SERVER>:8443/__relay (JSON)
Health checkcurl http://<SERVER>:8443/healthz (no auth)
Cloud logsjournalctl -u dsh-relay -f
Revoke one / all paired devicesUse the "Relay admin" panel in the dsh web sidebar (no SSH needed; see Admin panel)
Update cloud codecd /opt/dsh-relay && git pull && systemctl restart dsh-relay (per-release changes: CHANGELOG)
Uninstall home pluginnode install.mjs --uninstall (delete the repo dir for a full cleanup)
After a dsh restartNothing to do — the plugin reconnects automatically with backoff

Cloud environment variables

VariableDefaultMeaning
DSH_RELAY_PORT3081Listen port
DSH_RELAY_BIND127.0.0.1Bind address; 0.0.0.0 for public deployments
DSH_RELAY_TOKENdev-tokenTrunk token (must match plugin config.token)
DSH_RELAY_PAIRING_CODErandom, printedBrowser pairing code; set explicitly in production
DSH_RELAY_STATE./relay-state.jsonDevice tokens + audit tail persistence
DSH_RELAY_MAX_TOKENS10Max paired devices
DSH_RELAY_MAX_WS16Max concurrent browser WebSockets
DSH_RELAY_BLOCK_PRIVILEGED01 = block settings/credentials methods at the cloud
DSH_RELAY_HEARTBEAT_MS20000Keepalive cycle for the trunk and every browser downlink (ms). A browser that misses a full cycle of pongs is treated as dead and dropped (the stock client auto-reconnects and replays the baseline); the ping traffic also keeps NAT mappings from expiring on quiet links

Acceptance probes (probe/)

Run these after deployment to verify the full chain:

# 1) Trunk essentials (static/RPC/WS bridge) — edit BASE at the top first:
node probe/e2e-probe.mjs

# 2) Browser end-to-end (pairing → UI → WS); needs pip install playwright && playwright install chromium:
DSH_RELAY_BASE=http://<SERVER>:8443 DSH_RELAY_PAIRING_CODE=<CODE> python probe/browser-acceptance-m1.py

Security notes (read this)

  • The pairing code is the key: a paired device ≈ sitting at your home machine (chat, commands, files). Use a strong code + HTTPS on public deployments.
  • Privileged surface: by default remote clients can reach dsh's settings/credentials plane. Set DSH_RELAY_BLOCK_PRIVILEGED=1 for a more conservative posture (chat/files unaffected).
  • The cloud persists no business data: relay-state.json holds only device tokens and audit counters — never session content.
  • Token rotation: change <TOKEN> in the cloud systemd unit and re-run install.mjs --token at home.

Troubleshooting

SymptomCause & fix
Remote shows "home offline" pageHome dsh not running / plugin not connected. Check trunkReady via healthz; verify token & network
Pairing says device limit reachedTest pairings filled the 10 slots. Use the panel's revoke-all, then re-pair
Forgot the pairing codeClick-to-reveal it in the "Relay admin" panel; pre-0.2.0 deployments can read it from the cloud's relay-state.json (or the systemd unit / boot log)
Rotate the pairing codeUse the panel's regenerate on the target code (or delete + recreate). Note: editing DSH_RELAY_PAIRING_CODE in systemd and restarting does not rotate existing codes — once the state file exists the env var only seeds a missing table and old codes keep working; revoke a code via the panel's regenerate/disable/delete instead
First load is slow (10-20 s)Normal: cold cache fetches static assets one by one; subsequent loads hit the cache
Remote "add workspace" does nothingStep 5 patch missing (the native dialog opened on the home desktop)
Blank page / infinite spinnerHard-refresh / clear cache first; then run the probes to localize (static / RPC / WS)

Known limitations

  • HTTP responses are buffered whole (except the SSE download path); very large exports wait for completion
  • The dsh web wire has no version negotiation (client/host ship together) — assets served from the home instance stay version-locked by construction; re-run the probes after major dsh upgrades
  • Home plugin module updates require a dsh web restart (patch config hot-applies; module code does not hot-swap)

Verified against

DeepSeek Harness 0.1.0-rc.5 (2026-08). dsh is in developer preview; treat the probes as the acceptance contract when the wire changes.

License

MIT