Bundle
dsh-plugin-ssh
dsh SSH remotes: manage remote harnesses over SSH and use the local machine as a thin client (codex-remote style). Ships a standalone dsh-ssh CLI plus a dsh profile bundle that adds an SSH Remotes panel to the local web GUI.
- Source
- ArcherLyu
- License
- MIT
- Updated
- Updated 3 days ago
Readme
# dsh-plugin-ssh
Manage remote DeepSeek Harness instances over SSH, with the local machine as a
thin client — the same model as Codex Remote.
A **remote harness** runs on an SSH host (agents, bash/fs tools, sessions, and
the workspace all live there). The local machine only:
1. opens an SSH tunnel to the remote harness (`ssh -L`), and
2. shows the remote harness's own web GUI in the browser at
`http://127.0.0.1:<local-port>`.
Nothing heavy runs locally — the browser is the client, exactly like Codex
Remote. This works out of the box because dsh's web surface is already a
browser → harness client-server architecture (`/api` over HTTP/WS), and dsh
deliberately refuses `--host 0.0.0.0`, so the SSH tunnel is the sanctioned way
to reach a harness from another machine.
The plugin ships two halves:
| Piece | What it does |
| --- | --- |
| `dsh-ssh` CLI (`bin/dsh-ssh.js`) | Standalone command: add/list/status/connect/disconnect/open/setup/start/stop/logs |
| dsh profile bundle (`lib/index.js` + `lib/client.js`) | Installed into the local web profile; adds an **SSH Remotes** panel to Settings and an `/ssh-remotes` HTTP route |
Both halves share the same logic (`lib/config.js`, `lib/tunnel.js`,
`lib/remote.js`, `lib/browser.js`).
---
## Install
Prerequisites: `dsh` ≥ 0.1.0-rc.6 (the web profile), `ssh` on PATH, and pnpm
(`npm i -g pnpm` if `dsh plugin` reports pnpm missing).
Install the plugin into your local web profile (takes effect on the next
`dsh web` start):
```sh
dsh plugin --profile web add /path/to/dsh-plugin-ssh
```
For the standalone CLI without a profile, run `bin/dsh-ssh.js` directly
(`node /path/to/dsh-plugin-ssh/bin/dsh-ssh.js ...`) or symlink it:
```sh
ln -s /path/to/dsh-plugin-ssh/bin/dsh-ssh.js ~/.local/bin/dsh-ssh
```
---
## Quick start
```sh
# 1. Configure a remote (hostname or ~/.ssh/config alias)
dsh-ssh add prod --host my-server --user deploy --workspace /srv/app
# 2. Provision the remote: check node, install dsh, create the workspace,
# copy local LLM credentials (settings.yaml), and launch the harness
dsh-ssh setup prod --copy-settings
# 3. Connect: tunnel to the remote harness and open it in the browser
dsh-ssh connect prod # adds --no-open to skip the browser
dsh-ssh open prod # re-open the browser at the existing tunnel
# 4. Inspect / manage
dsh-ssh status prod # tunnel + remote process + harness health
dsh-ssh logs prod # tail the remote harness log
dsh-ssh stop prod # stop the remote harness process
dsh-ssh disconnect prod # tear down the tunnel
```
Then, in the local web GUI: **Settings → SSH Remotes** lists every configured
remote with live status and Connect / Open / Disconnect / Start / Stop / Logs
buttons.
On the new-session screen, an **SSH 远端** chip sits beside the "选择工作区"
chip: click it to list configured remotes and pick one to connect — the current
page then navigates to that remote's harness GUI, so your workspace becomes the
remote's (the local page is replaced). The same in-place navigation applies to
Connect / Open in the Settings → SSH Remotes panel.
---
## CLI reference
```
dsh-ssh add <name> --host <host> [options] add/update a remote
dsh-ssh rm <name> remove a remote
dsh-ssh list list remotes with a short status
dsh-ssh status [name] detailed status (all by default)
dsh-ssh connect <name> [--no-open] tunnel + open the remote GUI
dsh-ssh disconnect <name> | --all stop the tunnel
dsh-ssh open <name> open the browser at an existing tunnel
dsh-ssh setup <name> [--copy-settings] provision the remote and launch
dsh-ssh start <name> (re)start the remote harness process
dsh-ssh stop <name> stop the remote harness process
dsh-ssh logs <name> [--lines N] tail the remote harness log
```
`add` options:
| Option | Meaning | Default |
| --- | --- | --- |
| `--host <host>` | SSH host or `~/.ssh/config` alias (required) | — |
| `--user <user>` | SSH username | ssh config |
| `--port <port>` | SSH port | 22 |
| `--identity <path>` | private key path (`~` expanded) | ssh default |
| `--jump <host>` | jump host for `-J` | — |
| `--workspace <path>` | remote working directory for the harness | — |
| `--remote-port <p>` | port the remote harness listens on | 3080 |
| `--local-port <p>` | local tunnel port; `0` = auto-pick | 0 |
| `--launch <cmd>` | override launch command; `{dsh}`/`{port}` placeholders | `{dsh} web --port {port} --host 127.0.0.1` |
| `--ssh-args <a>` | extra ssh option (repeatable) | — |
Global flags: `--json` (list/status), `--home <dir>` (override `DSH_HOME`).
---
## Configuration
Remotes live in `$DSH_HOME/remotes.json` (default `~/.dsh/remotes.json`):
```json
{
"remotes": {
"prod": {
"host": "my-server",
"user": "deploy",
"port": 22,
"identity": "~/.ssh/id_ed25519",
"jump": null,
"workspace": "/srv/app",
"remotePort": 3080,
"localPort": 0,
"launch": null,
"sshArgs": []
}
}
}
```
Runtime tunnel state is kept per remote in `$DSH_HOME/remotes-state/<name>.json`
so a later `disconnect`/`status` can find the spawned `ssh` process.
On the remote, the plugin records its own state at
`~/.dsh/remotes/<name>.json` (pid, log path, port) and writes the harness log
to `<workspace>/.dsh-ssh.log`.
---
## How it works
### Remote side (provisioned by `dsh-ssh setup`)
1. Ensures a compatible Node (dsh needs Node ≥ 20.12). If the system node is
too old and `~/.nvm` exists, it installs Node via nvm (`--node-major`, default 22)
— user-local, no sudo.
2. Locates `dsh`; if absent, runs `npm install -g @deepseek-ai/dsh` under the
active node.
3. Creates the workspace directory.
4. Optionally copies your local `~/.dsh/settings.yaml` (LLM provider config) to
the remote — or keep the remote's credentials managed independently.
5. Launches `dsh web --port <remote-port> --host 127.0.0.1` inside the remote
workspace with `nohup`, records the pid, and detaches. When dsh runs under
nvm, the launch activates that runtime so the shebang's `node` resolves
correctly.
The remote harness binds only to the remote's own `127.0.0.1`, so it is never
exposed to the network; the only way in is your SSH tunnel.
> **LLM credentials on the remote**: the harness reads `$DSH_HOME/settings.yaml`
> and the `.env` file in its working directory (dsh loads `.env` from cwd).
> Set the API key on the remote (e.g. write `ARK_PLAN_API_KEY=…` into
> `<workspace>/.env`, or put the value in `~/.dsh/settings.yaml` on the remote)
> so agents can run there.
### Local side (the thin client)
`dsh-ssh connect` picks a free local port, spawns
```
ssh -N -L <local-port>:127.0.0.1:<remote-port> [-p P] [-i key] [-J jump] user@host
```
as a detached background process, then polls `http://127.0.0.1:<local-port>/`
until the dsh boot marker (`window.__DSH_BOOT__`) appears. Because the browser
talks same-origin `/api`, a page served over the tunnel is automatically a full
client of the remote harness — including all tools, sessions, and jobs, which
execute on the remote.
### GUI panel
The bundle adds an `sshRemotes` host service and, when the web profile is
composed, an `/ssh-remotes` HTTP route (loopback-origin only). The browser half
registers a **Settings → SSH Remotes** section that lists remotes and drives
the same operations the CLI does.
---
## Security notes
- Tunnels run with `BatchMode=yes` — set up key-based auth or an ssh agent;
interactive password prompts are intentionally not supported.
- `StrictHostKeyChecking=accept-new` keeps first connects painless without
weakening existing `known_hosts`.
- The remote harness binds `127.0.0.1` on the remote and is reached only
through your tunnel.
- The local `/ssh-remotes` route rejects non-loopback `Origin` headers. It is
not a security boundary — the whole local harness already trusts
`127.0.0.1` — but it prevents cross-site calls from a remote page.
## Limitations / roadmap
- Remote provisioning assumes a POSIX shell on the remote and key-based SSH.
- Windows remotes are not targeted.
- The GUI panel currently lists and drives remotes; per-remote session browsing
inside the panel is a future step (the browser tab remains the working
surface today).
## License
MIT
Install
dsh plugin --profile web add github:ArcherLyu/dsh-plugin-ssh
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-plugin-ssh from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.