- lib/edge-auth.mjs: basic / bearer / oauth2 client_credentials (Authentik M2M) / custom headers (Cloudflare Access), proxy session cookie jar, retry with fresh token, clear edge-refusal vs DSH-refusal errors - link + enroll go through the edge; master.edgeAuth stored at pair time, secrets as DSH credential refs only; config edgeAuth override - /dsh-remote/edge-auth: show/change/probe; Settings UI section + pair form - pair.mjs / install-mac.sh: --edge … flags, secrets prompted via /dev/tty - tests: fake Authentik-like proxy for all modes + WebSocket through the edge
214 lines
17 KiB
Markdown
214 lines
17 KiB
Markdown
# 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).
|
||
|
||
### macOS: one-line slave install
|
||
|
||
Create a pairing code on the master (Settings → *Remote devices* → **Create pairing code**). Then, in Terminal on the Mac:
|
||
|
||
```bash
|
||
curl -fsSL https://git.rizzy.dev/DSH-Plugins/dsh-remote/raw/branch/main/deploy/install-mac.sh \
|
||
| bash -s -- --master http://192.168.0.111:3080 --code XXXX-XXXX-XXXX-XXXX --name my-mac
|
||
```
|
||
|
||
No sudo and no Homebrew. The script:
|
||
|
||
- installs Node 22 to `~/.local/opt/node` (checksum-verified), and DSH and pnpm to `~/.local`;
|
||
- puts dsh-remote in `~/.dsh/plugins/dsh-remote` and registers it as a slave that accepts `OMNI_API_KEY` and `FORGE_CREDENTIALS` (`--accept` changes the list);
|
||
- pairs with the code (`deploy/pair.mjs`, no running DSH needed);
|
||
- installs the LaunchAgent `dev.rizzy.dsh-web`: `dsh web` on 127.0.0.1:3081, started at login and restarted if it dies. Logs go to `~/.dsh/logs/`.
|
||
|
||
On first link-up the Mac pulls the master's plugins and config. With `sync.restartOnSync: true`, set by the script, DSH restarts itself once so new plugins load. It waits until no session is running and restarts at most once per 10 minutes.
|
||
|
||
Re-running the script updates everything and keeps the pairing. To remove it: `launchctl bootout gui/$(id -u)/dev.rizzy.dsh-web`, then delete `~/Library/LaunchAgents/dev.rizzy.dsh-web.plist`, `~/.dsh` and `~/.local/opt/node`.
|
||
|
||
Away from home, point `--master` at the public URL. If an auth proxy guards it, add the `--edge …` flags (see *Masters behind an auth proxy*). Secrets are prompted for on the terminal and never passed on the command line:
|
||
|
||
```bash
|
||
curl -fsSL https://git.rizzy.dev/DSH-Plugins/dsh-remote/raw/branch/main/deploy/install-mac.sh \
|
||
| bash -s -- --master https://dsh.rizzy.dev --code XXXX-XXXX-XXXX-XXXX --name my-mac \
|
||
--edge basic --edge-user dsh-remote-mac
|
||
```
|
||
|
||
## 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.
|
||
|
||
## Agent tools: the master's AI drives the slaves
|
||
|
||
On the master, sessions get four tools. Every paired, online device can be targeted by id or name; `self` means the master itself.
|
||
|
||
| Tool | What it does |
|
||
| --- | --- |
|
||
| `remote_devices` | Lists devices with online state, user, home and kernel. With `sessions: true` it also returns each device's workspaces and recent sessions. |
|
||
| `remote_exec` | Runs `bash -lc <command>` on the device as its DSH user and returns exit code, stdout and stderr. It is non-interactive (`GIT_TERMINAL_PROMPT=0`). Options: `cwd`, `timeoutMs` (up to 30 min), `stdin`. Use it for quick deterministic steps: clone/pull, build, run tests. |
|
||
| `remote_file` | `read`, `write` (utf8 or base64, with append) and `list` for files on the device. Up to 4 MiB per call. |
|
||
| `remote_task` | Delegates a job to the device's **own AI**. It creates a session there (attached to the `cwd` workspace), or continues `sessionId`, sends the prompt and waits for the turn to end, up to 2 h. It returns the final answer, a count of tools used, and a link to the session (`/r/<device>/#/session/…`). `permission: "danger-full-access"` runs the session without the workspace sandbox. `action: status` / `cancel` poll or stop a running task. |
|
||
|
||
Example, typed into any master session:
|
||
|
||
> On ai-pc, clone https://git.rizzy.dev/Python/ai-pc into ~/work/ai-pc (or git pull), run its unit tests and report the result.
|
||
|
||
The master's AI calls `remote_exec` (clone, venv, `pytest`) and reports "20 passed, 5 deselected, commit 32d4434". With `remote_task` the AI on ai-pc does the work itself, and its session stays browsable under Devices.
|
||
|
||
**Choosing a tool:** `remote_exec` is cheap and exact; you get the command output. `remote_task` uses the slave's model tokens and judgement, which suits open-ended work ("fix the failing test"). The full transcript stays on the slave.
|
||
|
||
**Who can control whom:** only the master controls devices. Slaves reject agent ops from anyone except their master, and the master rejects them from slaves. `agentTools.allowPeerControl: true` on the master lets slaves relay ops to each other. `agentTools.enabled: false` on a device refuses all agent control of that device.
|
||
|
||
### Git credentials on slaves (`FORGE_CREDENTIALS`)
|
||
|
||
Private repos need a login on the slave. `FORGE_CREDENTIALS` is a pseudo-secret that works like the other opt-in secrets: list it in the master's `offerSecrets`, enable it for the device on the Devices page, and add it to the slave's `sync.secrets`. On the next sync the slave gets the master's Gitea/Forgejo logins from `tea-cli`, written as:
|
||
|
||
- `~/.config/tea/config.yml` (0600), so the synced git-forge plugin's `forge_*` tools work there;
|
||
- `~/.git-credentials` (0600) plus `git config --global credential.helper store`, so plain `git clone`/`push` over https works;
|
||
- a default git identity, unless one is already set.
|
||
|
||
GitHub `gh` logins are not shipped.
|
||
|
||
## 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
|
||
restartOnSync: false # slave: restart DSH itself after plugins change (needs a service manager)
|
||
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 (+ FORGE_CREDENTIALS)
|
||
offerSecrets: [OMNI_API_KEY, FORGE_CREDENTIALS] # master: what 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
|
||
agentTools:
|
||
tools: true # register remote_* tools (default: on for the master, off for slaves)
|
||
enabled: true # false = refuse agent control of THIS device
|
||
allowPeerControl: false # master: let slaves relay exec/file/task ops to other devices
|
||
```
|
||
|
||
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.
|
||
- **Agent control is real remote code execution.** `remote_exec`, `remote_file` and `remote_task` run as the device's DSH user. Anyone who can prompt a master session can run code on every paired device. Revoke a device, or set `agentTools.enabled: false` on it, to opt it out.
|
||
|
||
## Masters behind an auth proxy (edge auth)
|
||
|
||
A master published behind an identity-aware proxy (Authentik, oauth2-proxy, Cloudflare Access, Traefik/nginx Basic auth) redirects every request that isn't logged in. Browsers log in normally. A slave's DSH logs in to the proxy itself with a machine credential, keeps the proxy's session cookie, and sends both on `/dsh-remote/enroll` and on the `/dsh-remote/link` WebSocket. It never sends them anywhere else. Behind the proxy, the usual device key still authenticates the slave to DSH.
|
||
|
||
| Mode | Sends | Typical use |
|
||
| --- | --- | --- |
|
||
| `basic` | `Authorization: Basic user:password` | **Authentik**: a service-account user plus an *App password*, with *Intercept header authentication* enabled on the proxy provider (the default). Also plain Basic-auth proxies. Authentik accepts user `goauthentik.io/token` with an API token as the password too. |
|
||
| `oauth2` | `client_credentials` grant at `tokenUrl`, then `Authorization: Bearer <JWT>`, refreshed before it expires | Authentik machine-to-machine (`client_id` of the proxy provider, service-account `username` + app password, optional `client_secret`), Keycloak, oauth2-proxy |
|
||
| `bearer` | `Authorization: Bearer <static token>` | gateways that accept a static API token |
|
||
| `headers` | any headers, e.g. `CF-Access-Client-Id` / `CF-Access-Client-Secret` | Cloudflare Access service tokens, custom gateways |
|
||
|
||
**Setting it up:**
|
||
|
||
- **New slave:** the *Pair with a master* form has an **Auth proxy** section. On the command line, use `pair.mjs` or `install-mac.sh` with `--edge basic --edge-user USER`, or `--edge oauth2 --edge-token-url URL --edge-client-id ID [--edge-user USER] [--edge-client-secret]`, or `--edge bearer`, or `--edge headers --edge-header "Name: value"`.
|
||
- **Paired slave:** Settings → *Remote devices* → **Auth proxy (edge) login** → **Change**. **Test** checks whether the proxy now lets this device through to the master's DSH.
|
||
- **Config override:** an `edgeAuth: { mode, … }` block in the slave's dsh-remote config takes precedence. Secrets are referenced as `passwordRef` / `tokenRef` / `clientSecretRef` / `"ref:NAME"`, never inline.
|
||
|
||
**Secrets:** passwords, tokens and header values are stored as DSH credentials (`DSH_REMOTE_EDGE_PASSWORD`, `DSH_REMOTE_EDGE_TOKEN`, `DSH_REMOTE_EDGE_CLIENT_SECRET`, `DSH_REMOTE_EDGE_HEADER_n` in `~/.dsh/.credentials.yaml`, 0600). The link state holds only the ref names.
|
||
|
||
**How it behaves:**
|
||
|
||
- A login redirect, or a 401/403 that isn't from DSH, counts as an *edge refusal*. The link reports "auth proxy … blocked the link / rejected the edgeAuth credentials". It retries with a fresh token and a cleared cookie jar, then backs off to 15–30 s.
|
||
- DSH's own "unknown or revoked device" 401 still means *re-pair*.
|
||
- The proxy's session cookie is reused, so the proxy isn't asked to verify the credential on every reconnect, as Authentik recommends.
|
||
|
||
**rizzy.dev with Authentik:** create a service-account user (e.g. `dsh-remote-mac`), make sure the DSH application's policies allow it, and create an *App password* for it under Directory → Tokens and App passwords. Then pair with `--edge basic --edge-user dsh-remote-mac`. The alternative is to exempt `/dsh-remote/link` and `/dsh-remote/enroll` from forward-auth with a higher-priority Traefik router; those paths carry their own device-key / pairing-code auth.
|
||
|
||
## 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?}` |
|
||
| `GET /dsh-remote/debug/inspect` | `?sessionId=…&device=…`: last events of a session on any device |
|
||
| `GET/POST /dsh-remote/edge-auth` | slave: show / set `{edgeAuth}` and probe whether the auth proxy lets it through |
|
||
| `* /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
|
||
node deploy/session-log.mjs <sessionId> # compact transcript (DEVICE=ai-pc for a remote one)
|
||
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.
|