Files

249 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- **Continue a conversation on another device.** "Continue here" on any remote chat (Devices page or sidebar), or "Continue on another device…" in a chat's "…" menu, copies the conversation to that device as a real native chat (its own history, tools and workspace), and the original is marked as moved. See [Conversation sync](#conversation-sync).
- **Works while the other device is offline.** Each device keeps synced copies of the other devices' recent chats, so you can continue one even when its device is off. That device is told the moment it is reachable again, and if it got new messages in the meantime both sides are warned that the copies diverged.
- **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 dsh-plugins/)
# 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
First choose what this installation is: **Settings → Remote devices → Node**.
| Setting | |
| --- | --- |
| **Type**: Master / Slave | Master holds plugins, config and sessions and accepts links. Slave connects to a master and syncs from it. The choice is stored in the plugin state and beats `role` in the config file. A change applies after a restart (a **Restart DSH** button appears when DSH runs under systemd / launchd). |
| **Origin** | Master: the address slaves use to reach it (read-only, taken from `publicUrl` / `lanUrl` or the page's own origin). Slave: the master's origin; read-only once paired, to change it re-pair. |
| **Master has an auth middleware** | On: a non-DSH answer on the link opens the sign-in window (see *Masters behind an auth proxy*), then the link retries. Off: work as usual. |
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
- **Workspaces list:** the workspaces of every *other* machine appear in the stock Workspaces list, below your own, with a **server icon** instead of the folder icon and the device name on the right. Click one to fold its sessions open (newest first, "Show N more" for the rest); hover for **+** to start a session in that workspace on that device. Clicking a remote session opens it as the usual conversation view, served by that device at `/r/<device>/#/session/<id>`. A "Remote: <device> · back to local" pill marks the remote view. While you are viewing a device, its own workspaces are the native rows and are not repeated.
- **Sidebar → Devices:** every device with its state (online / offline, last seen, platform, counts of workspaces, sessions and running ones), then its workspaces and sessions. The same server icon marks other machines' workspaces. **Open DSH ↗** opens a device's whole UI, **+ New session** creates a session in one of its workspaces.
- **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
sessionSync:
mirrorRecent: 10 # keep synced copies of each other device's 10 most recent chats (0 = off)
mirrorIntervalMs: 60000 # how often copies are refreshed (only chats that changed are fetched)
watchIntervalMs: 20000 # how often moved chats are checked for divergence and queued notices retried
```
Never synced: the master's `dsh-remote` and `dsh-lanmode` entries, and `connection`/`webserver` settings, since these are machine-local.
## Conversation sync
A chat can move between devices the way a branch moves between git clones.
- **Continue here / on another device.** The conversation is copied through its **last finished turn** (a turn that is still running is left behind) and written on the target as a *seeded* session, which is the same log shape DSH writes for its own *Fork*. The target's session controller then adopts it like any native chat: own agent, tools and preset, attached to the matching workspace (same path, else a workspace with the same folder name, else the original path if it exists, else the home directory). It shows up in the normal sidebar and is marked `⇣` (continued from …).
- **The original is told it moved** and shows `→` (continues on …) in its sidebar row and the Devices page.
- **Versioning.** Each copy records its base: the event seq it was cut at plus a commit id (a hash of that event). Logs are append-only, so the same commit id at the same seq means the same history up to there.
- **Offline.** Every device keeps synced copies of the other devices' recent chats (`<profile>/dsh-remote/mirrors/`, fetched incrementally). When a device is offline, the Devices page and the sidebar list its chats from those copies, and *Continue here* uses them. The move notice is queued and delivered **as soon as the device is reachable**: on link-up, on the master's peer broadcast, and retried every `watchIntervalMs`.
- **Conflicts.** If the original got new messages after the base (someone kept chatting there while the devices could not see each other, or after the move), both sides are marked `⚠ diverged` and get a toast. Nothing is merged or deleted, and both chats stay usable. *Continue here* on the newer branch creates a fresh copy from it.
## 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 |
| `cookie` | the proxy session cookie you paste (`name=value`, or a whole `Cookie:` header) | **Authentik** `authentik_proxy_<hash>` copied from a browser that is signed in (DevTools → Application → Cookies), or filled in by the sign-in window below. The proxy's refreshed cookies are saved back automatically. The master DSH's own `dsh-auth-*` cookie is ignored. |
| `headers` | any headers, e.g. `CF-Access-Client-Id` / `CF-Access-Client-Secret` | Cloudflare Access service tokens, custom gateways |
**Sign-in window (no copy-paste):** with *Master has an auth middleware* switched on (Settings → Remote devices → **Node**), a link that runs into something that is not DSH (a redirect to a login page, 401/403, an HTML page instead of a WebSocket) raises an *auth required* prompt in the slave's UI. **Sign in** opens `<master>/dsh-remote/signin` in a popup. You sign in to the proxy there as usual. The page then offers **Send session**; one click hands the proxy session cookie back to the slave UI (`postMessage`, only to the origin that opened the popup, and only if that origin is loopback / private-network or listed in `signInOrigins` on the master). The slave stores it as a `cookie` edge login and retries the link. Off: nothing prompts, as before.
**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`, `session-moved`, `session-conflict`, `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?}` |
| `POST /dsh-remote/continue-session` | `{sourceDevice, sessionId, targetDevice?, workspaceId?, title?}`: continue a chat on a device (default: this one) |
| `GET /dsh-remote/session-sync` | this device's lineage ledger (moved / continued-from / conflicts) and queued notices |
| `POST /dsh-remote/session-sync/resolve` | `{sessionId}`: dismiss a divergence warning (both chats are kept) |
| `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 |
| `GET/POST /dsh-remote/node` | `{nodeType, authMiddleware}`: node settings and the pending `authRequired` state |
| `POST /dsh-remote/retry-link` | slave: reconnect now (after signing in) |
| `POST /dsh-remote/restart` | restart this DSH (needs systemd / launchd to bring it back) |
| `GET /dsh-remote/signin?origin=…` | master: sign-in hand-over page, opened in a popup through the auth proxy |
| `* /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)
node deploy/fake-edge.mjs 3098 <master> u p # Authentik-like auth proxy for tests
node deploy/e2e-signin.mjs <master> <code> x # pasted cookie, auth-required prompt, popup hand-over
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.