Skip to content
dsh.fish
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

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source