dsh-remote 0.1.0: master/slave remote control for DSH
- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing codes, per-device keys (hashed), revoke/remove - /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with per-device cookie minting; slave->slave relay through the master - Devices sidebar panel (all devices' sessions, new session on device), Remote devices settings page, finished-session toasts - plugin + config sync master->slave: registry bundles via pluginManager, link: plugins as tarballs, cordis.patch.yml into a managed bundle layer (!!js preserved), allow/deny, opt-in per-secret credential sync - deploy helpers + e2e probes, unit tests
This commit is contained in:
commit
8872e73e76
21 files changed
+2914
No files matched your search
@@ -0,0 +1,129 @@
|
||||
# dsh-remote
|
||||
|
||||
Remote control for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), in the style of Claude Desktop: one **master** and any number of **slaves**.
|
||||
|
||||
- **Every session on every device.** Sessions run on the machine that created them. From any device you can browse all of them, open them, and chat in them; the work still executes on the owning machine.
|
||||
- **The full remote UI at `/r/<device>/`.** The whole DSH UI of another device is proxied over the link: HTTP, the Remote WebSocket, SSE, plugin bundles and terminals. Slave→slave goes through the master's relay.
|
||||
- **Plugin and config sync, master → slaves.** Slaves get the master's npm/registry bundles, its local `link:` plugins (packed and shipped over the link) and its `cordis.patch.yml`. The config lands in a *managed* bundle layer, so each slave's own `cordis.patch.yml` still wins and slave-only plugins stay untouched.
|
||||
- **Opt-in secrets.** A credential (`OMNI_API_KEY`, …) reaches a slave only if three places agree: the master offers it, the master enables it for that device, and the slave accepts it.
|
||||
- **Devices UI.** A sidebar panel lists every device and its sessions (live running state, filter, "New session on <device>"). A "Remote devices" settings page handles pairing, revocation and per-device sync policy. You get a toast when a session on any device finishes.
|
||||
|
||||
No dependencies: the WebSocket, the multiplexer and the proxy are plain Node.
|
||||
|
||||
```
|
||||
slave (laptop) ──outbound WebSocket /dsh-remote/link──► master ◄── slave (ai-pc)
|
||||
own sessions + own local plugins plugins, config, relay hub
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
On every machine, register the plugin in the DSH web profile and restart DSH:
|
||||
|
||||
```bash
|
||||
# master (this repo as a link: plugin, e.g. in Beast-Workspace)
|
||||
# profile package.json → "@local/dsh-remote": "link:/path/to/dsh-remote" + bundle entry
|
||||
# or use the helper (also creates the node_modules link and a config block):
|
||||
node deploy/install-local.mjs --role master --name "Pelican-Node"
|
||||
node deploy/install-local.mjs --role auto --name "ai-pc" # on a slave
|
||||
systemctl --user restart dsh # or restart `dsh web` however you run it
|
||||
```
|
||||
|
||||
`deploy/dsh-web.user.service` is a ready-made systemd user unit for a rootless `dsh web` (127.0.0.1:3081).
|
||||
|
||||
## Pair a device
|
||||
|
||||
1. **Master:** Settings → *Remote devices* → **Create pairing code** (single use, valid 15 min).
|
||||
2. **Slave:** Settings → *Remote devices* → enter the master URL (e.g. `http://192.168.0.111:3080` or `https://dsh.rizzy.dev`) and the code → **Pair**. Then restart the slave's DSH once: with `role: auto` it switches from master to slave.
|
||||
|
||||
The slave gets its own device key; the master stores only its hash. **Revoke** cuts the device off immediately: open links close with code 4401 and later connections are refused with 401.
|
||||
|
||||
## Use it
|
||||
|
||||
- **Sidebar → Devices:** every device, its sessions grouped by workspace, a running indicator, search. Click a session to open it. A local session opens in place. A remote one opens `/r/<device>/#/session/<id>`, the full UI of that device, with a "Remote: <device> · back to local" pill in the corner.
|
||||
- **+ New session** on a device card creates a session in one of that device's workspaces and opens it.
|
||||
- **Open DSH ↗** opens a device's whole UI.
|
||||
- **Toasts:** "<device>: '<title>' finished", with an *Open* action.
|
||||
|
||||
## Configuration (`cordis.patch.yml`)
|
||||
|
||||
```yaml
|
||||
- id: dsh-remote
|
||||
config:
|
||||
role: master # master | slave | auto (auto = slave once paired)
|
||||
deviceName: Pelican-Node
|
||||
lanUrl: http://192.168.0.111:3080 # shown on the pairing page
|
||||
publicUrl: https://dsh.rizzy.dev
|
||||
sync:
|
||||
auto: true # slave: sync on link-up and when the master pushes
|
||||
allow: [] # slave: only these bundles (exact or prefix*)
|
||||
deny: [] # slave: never these bundles (wins over allow)
|
||||
secrets: [] # slave: credential refs it accepts from the master
|
||||
offerSecrets: [OMNI_API_KEY] # master: refs it may hand out (per-device opt-in on the Devices page)
|
||||
excludeBundles: [] # master: never sync these bundles
|
||||
excludePatchIds: [] # master: never sync these cordis.patch.yml entry ids
|
||||
```
|
||||
|
||||
Never synced: the master's `dsh-remote` and `dsh-lanmode` entries, and `connection`/`webserver` settings, since these are machine-local.
|
||||
|
||||
## How sync works
|
||||
|
||||
| Master has | Slave gets |
|
||||
| --- | --- |
|
||||
| registry bundle `name@ver` | `pluginManager.installBundle(name@ver)` (pnpm in the slave profile) |
|
||||
| `link:` local plugin | a tarball over the link, unpacked to `<profile>/dsh-remote/sync/plugins/<name>/` and linked into the profile. Its `@deepseek-ai/*` imports are re-linked to the slave's DSH install |
|
||||
| `cordis.patch.yml` entries | `<profile>/dsh-remote/managed-bundle/cordis.patch.yml`, added as the **last bundle**. The slave's own `cordis.patch.yml` is applied after it and wins. `!!js` expressions are kept |
|
||||
| offered secret (opted in on both sides) | `ctx.credentials.set(name, value)` on the slave |
|
||||
|
||||
Local plugins are compared by content hash and re-shipped only when they change. When the bundle list changes, DSH needs a restart to load host code; the sync result reports `restartRequired`. *Push sync to all devices* on the master triggers an immediate pull everywhere.
|
||||
|
||||
## Security model
|
||||
|
||||
- Browsers authenticate with DSH's own cookie. Every `/dsh-remote/*` and `/r/*` route checks `ctx.connection.requestRejection`.
|
||||
- The link is authenticated with a per-device key, sent in the upgrade headers and stored as a SHA-256 hash on the master.
|
||||
- The proxy strips the browser's DSH cookies. The **owning** device mints a short-lived cookie for its own loopback authority, signed with its own browser-session secret, and rewrites `Host`/`Origin`. A remote DSH therefore only trusts its own minted cookie, and credentials never leave the machine they belong to, except secrets you explicitly opted into.
|
||||
- `/dsh-remote/enroll` is the only unauthenticated route. It accepts a single-use pairing code.
|
||||
- Use TLS (`https://` master URL) whenever the link crosses an untrusted network.
|
||||
|
||||
## Public access via rizzy.dev
|
||||
|
||||
`dsh.rizzy.dev` is served by the edge Traefik behind **Authentik forward-auth**. Browsers log in through Authentik as usual. A slave's DSH can't do an interactive login, so the edge must let these two paths through without forward-auth (they carry their own device-key / pairing-code authentication):
|
||||
|
||||
```
|
||||
/dsh-remote/link (WebSocket upgrade)
|
||||
/dsh-remote/enroll (POST, one-time pairing code)
|
||||
```
|
||||
|
||||
For example, add a second Traefik router with a higher priority, `Host(\`dsh.rizzy.dev\`) && (Path(\`/dsh-remote/link\`) || Path(\`/dsh-remote/enroll\`))`, that uses the same service but no Authentik middleware. Until that exists, pair and link slaves over the LAN (`http://192.168.0.111:3080`).
|
||||
|
||||
## HTTP API (used by the UI)
|
||||
|
||||
| Route | |
|
||||
| --- | --- |
|
||||
| `GET /dsh-remote/state` | role, self, link status, devices |
|
||||
| `GET /dsh-remote/sessions` | sessions of every reachable device |
|
||||
| `GET /dsh-remote/stream` | SSE: `session-finished`, `devices`, `sync` events |
|
||||
| `POST /dsh-remote/pairing` | master: create a pairing code |
|
||||
| `POST /dsh-remote/pair` | slave: `{masterUrl, code}` |
|
||||
| `POST /dsh-remote/unpair` | slave: forget the master |
|
||||
| `POST /dsh-remote/devices/<id>/{revoke,remove,sync}` | master: device management / sync policy |
|
||||
| `POST /dsh-remote/sync` | slave: pull now · master: push to all |
|
||||
| `GET /dsh-remote/sync/status` | manifest (master) / last result (slave) |
|
||||
| `POST /dsh-remote/new-session` | `{device, workspaceId? , cwd?}` |
|
||||
| `* /r/<device>/…` | that device's DSH |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm test # link, mux, proxy (HTTP + WS), cookie, allow/deny
|
||||
node deploy/dshr-cli.mjs GET state # call the local API as the DSH owner (loopback)
|
||||
node deploy/e2e-ws.mjs ai-pc # Remote WebSocket through /r/ai-pc/
|
||||
node deploy/e2e-events.mjs 30 # watch the SSE event stream
|
||||
node deploy/e2e-ui.mjs <chrome> /tmp/out # headless UI check + screenshots
|
||||
deploy/push-slave.sh agent@192.168.0.110 # copy to a slave + restart its dsh
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **`/r/<device>/` needs relative URLs.** Plugins that call absolute URLs (`fetch('/x')`) miss the mount there. Currently that is `@liustack/modlens` (paste/config) and parts of `@goodandready/dsh-lanmode`'s own UI. Core DSH is fully relative.
|
||||
- **Restart after plugin changes.** A sync that adds or removes plugins needs a DSH restart on the slave to load their host code, as the result reports. Config-only changes apply after a restart too: DSH's HMR doesn't watch bundle patch files.
|
||||
- **Multiple devices on one origin.** Viewing several devices in one browser happens under one origin, so `localStorage`-based UI state (e.g. layout choices) is shared between them.
|
||||
Reference in new issue
Block a user