# 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. - **Seamless conversation sync & continuation.** Continue any remote conversation directly on the current instance (or any other available device). The conversation history and events are smoothly synced locally so it behaves as if created natively on the current machine. - **Git-like version control & conflict detection.** Tracks branch sequence points (`baseSeq`), commits (`versionToken`), and divergence. If a session is modified across multiple instances or continued while an instance was offline, conflicts are flagged immediately to prevent sync anomalies. - **Offline remote notification ASAP.** If a session is moved or continued to another instance while the original host is offline, move notices are securely queued and transmitted the instant the offline device reconnects, safely updating state and preventing split-brain edits. - **The full remote UI at `/r//`.** 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 "). 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//#/session/`. A "Remote: · 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:** ": '' 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 | | `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`, `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 | | `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.