aide-family/dsh-remote ↗★ 0

dsh-remote

SSH remote-server operations for DeepSeek Harness: host discovery from ~/.ssh/config, remote command execution, SFTP file tools, and Crescent-style command risk review with approval gating.

Package
dsh-remote
Compatibility
Unverified
Harness peer range
^0.2.1-rc.5
Cordis peer range
^4.0.5
Version
0.5.2
License
MIT
Last updated
Nov 1, 2026

Other repositories with this package name

Install

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:aide-family/dsh-remote

dsh-remote

SSH remote-server operations for DeepSeek Harness (DSH).

dsh-remote lets the agent in your DSH session actually operate remote servers: list the hosts you already configured in ~/.ssh/config, probe a box, run non-interactive commands, read and edit remote files over SFTP, and move files between the session workspace and a server — with a Crescent-style static command-risk review in front of every mutating action and the DSH approval dialog as the human gate.

It is inspired by the Crescent operations workbench: the same idea that the agent should gather evidence on the real host, one bounded action at a time, with risky commands reviewed before execution.

Features

  • Hosts you already have — discovers every concrete Host in ~/.ssh/config (including Includes) and merges a configurable host list; the model refers to hosts by name, exactly like ssh .

  • Remote exec — one-shot, non-interactive commands with working directory, timeout, bounded output, and a clear [exit code: N] result line.

  • Remote files over SFTP — read (line-numbered, paginated), list, write (atomic temp + rename), exact-string edit, and upload/download transfers.

  • Persistent remote shells — open an interactive PTY shell on a host and keep using it across tool calls (remote_shell_*): send input, read scrollback, interrupt with signals, close. Every submitted line is risk-reviewed like remote_exec.

  • Live terminal mirror — a Remote Terminal tab in the conversation's right sidebar shows every remote command the agent runs as it runs, with streamed stdout/stderr and status lines. It opens automatically on the first command (or from the sidebar's Start page). See Live terminal.

  • Interactive remote terminal — the same right-sidebar 远程终端 panel picks an available host and opens DSH's native terminal attached to a persistent, per-conversation SSH session through a local relay: you type commands in a real xterm, close and re-open the tab to reattach the same session, and an SSH drop reconnects automatically with backoff. See Interactive remote terminal.

  • Quick connect from the host list — one 连接 action creates the SSH session, opens a new conversation, attaches DSH's native terminal to it, and hands the host to the agent there ("已连接远程主机 …") so operations start in a dedicated conversation. Failures after the switch are shown in the 远程终端 panel (never a silent local shell), and 复制连接命令 copies the exact shell line for any terminal when the native-terminal integration is unavailable.

  • Host manager — add, edit and delete connection entries from the Remote Ops page (persisted in $DSH_HOME/remote/hosts.json; auth stays environment-variable names, never stored secrets).

  • Per-host audit — an 审计 action on every host row lists that host's command and terminal-session history recorded by the plugin.

  • Non-mutating commands never ask — under the default policy only commands the static classifier proves mutating need approval; read-only and unprovable-but-non-mutating commands (e.g. curl, awk, python -c) run directly, while policy: never, read-only hosts, denyCommandPatterns and the approval seam keep their stricter behaviour.

  • Remote Ops management page — a first-level panel in the DSH Web GUI with the discovered host inventory (per-row read-only connection test), live remote shell sessions (scrollback preview + close), and a persisted audit trail of recent remote operations. See Management page.

  • Command risk review — a port of Crescent's static classifier separates statically read-only commands (low) from clearly mutating ones (high) and unknown ones (gray); it understands shell lists, wrappers (sudo, env, nohup, time, xargs), sh -c/ssh/kubectl exec inner commands, find -delete, sed -i, yq -i, docker/kubectl/systemctl subcommands, and quoting.

  • Approval policy — auto (read-only runs, mutations ask), always, or never (fail-closed headless stance). Denials, cancelled prompts, and a missing answerer all fail closed.

  • Hard limits — per-host read-only lock, host allowlist, command deny patterns, output caps, transfer caps, and TOFU host-key verification.

Requirements

  • DSH >= 0.2.1-rc.5 (bundled in the Desktop app and the dsh CLI).
  • Node.js ^22.19.0 || >=24.0.0 (already bundled with DSH).
  • SSH access to the target hosts: key files, ssh-agent, or a password supplied through an environment variable. ~/.ssh/config settings (HostName, User, Port, IdentityFile, ProxyJump) are honored.

Install

From a local tarball (recommended for this checkout)

cd /Users/hubiao/aide-family/dsh-remote
npm install                       # once: ssh2 + test devDependencies
npm run install:profile           # packs dist/dsh-remote-.tgz and installs
                                  # it into the "desktop" profile

Or do the steps manually and choose another profile:

npm run pack:plugin
dsh plugin --profile desktop add file:$PWD/dist/dsh-remote-0.5.2.tgz --ignore-scripts

Then fully quit and reopen the DSH Desktop app (a newly installed bundle is loaded on the next boot), or boot the CLI profile you installed into.

--ignore-scripts is required with pnpm 11. pnpm refuses to install packages that declare unapproved install scripts, and ssh2 declares an install script for its optional native cipher-speed addon (cpu-features). ssh2's pure-JavaScript implementation is fully functional without it, so skipping the scripts is safe and avoids needing a C toolchain. (Alternatively, add allowBuilds: [ssh2, cpu-features] to the profile's pnpm-workspace.yaml and install without the flag; that does require a working compiler setup.)

Do not install a development checkout with link:. DSH routes @deepseek-ai/* imports only for packages that physically live inside the profile's node_modules; a symlink to a directory outside the profile cannot resolve them and the plugin fails to load. Use the tarball (file:…tgz) for local installs, or the npm package when published.

From npm (when published)

dsh plugin --profile desktop add dsh-remote --ignore-scripts

Where it lands

The plugin is a DSH bundle: installing it adds the dsh-remote package and a dsh-remote row to the profile. To remove it:

dsh plugin --profile desktop remove dsh-remote

Configure

All settings live on the plugin's row in the profile patch. The desktop profile patch is ~/.dsh/profiles/desktop/cordis.patch.yml; a --patch overlay works too (useful for headless and CI):

- id: dsh-remote
  config:
    # Hosts not present in ~/.ssh/config. Never put secrets here — only the NAME
    # of an environment variable that holds the secret.
    hosts:
      - name: prod-web-1
        host: 10.0.0.11
        port: 22
        username: ops
        identityFile: ~/.ssh/prod_ed25519
        tags: [prod, web]
        description: primary web node
      - name: legacy-db
        host: 10.0.0.31
        username: dba
        passwordEnv: LEGACY_DB_SSH_PASSWORD   # exported in the DSH environment
    policy: auto                 # auto | always | never
    readonlyHosts: ["prod-*"]    # every mutating action on these is denied
    allowHosts: []               # empty = all; otherwise only matching hosts usable
    denyCommandPatterns: ["\\b(shutdown|reboot|mkfs)\\b"]
    connectTimeoutMs: 20000
    execTimeoutMs: 120000
    maxOutputBytes: 200000
    maxReadBytes: 800000
    maxTransferBytes: 52428800
    knownHostsPolicy: tofu       # tofu | strict | insecure
    unprovenCommands: allow      # allow (only provably mutating asks) | ask
    liveTerminal: true           # right-sidebar live terminal + /dsh-remote/live
    autoOpenLiveTerminal: true
    userTerminal: true           # interactive SSH terminal (relay + local bridge)
    userTerminalTtlMs: 1800000   # keep an unattached SSH session this long (0 = forever)
    userTerminalReconnectAttempts: 5
    userTerminalScrollbackBytes: 400000
    hostRegistryPath: ""         # default: $DSH_HOME/remote/hosts.json

Hosts from ~/.ssh/config need no configuration at all. If ~/.ssh/config contains Host prod-* patterns, those wildcard entries are skipped (a pattern is not a concrete target); add a concrete host block or a hosts: entry.

Authentication

The transport tries, in order, and uses the first that applies:

  1. passwordEnv — password auth; the env var is read at connect time.
  2. identityFile — public-key auth (from plugin config or ~/.ssh/config), with an optional passphrase from passphraseEnv.
  3. ssh-agent (SSH_AUTH_SOCK).
  4. Default keys: ~/.ssh/id_ed25519, ~/.ssh/id_ecdsa, ~/.ssh/id_rsa.

Secrets are never read from YAML, never logged, and never included in tool results or approval prompts.

Host keys

knownHostsPolicy controls verification against $DSH_HOME/remote/known-hosts.json:

  • tofu (default) — trust and remember a host's key on first use; reject a changed key.
  • strict — only previously remembered hosts connect.
  • insecure — accept any key (the result carries a warning; not for production).

Tools

ToolRead-onlyPurpose
remote_hostsyesList usable hosts, auth hints, and policy flags.
remote_probeyesConnect and report hostname, OS/kernel, uptime, load, memory, disk, and latency.
remote_execper classifierRun one non-interactive command with optional workdir and timeout; only provably mutating commands ask for approval.
remote_readyesRead a remote text file with line numbers, offset/limit, and a continue hint.
remote_listyesList a remote directory with type/size/mtime/mode.
remote_writenoWrite a remote file atomically, creating missing directories.
remote_editnoReplace one exact string in a remote file (or all with replace_all).
remote_transfernoUpload/download between the session workspace and the host.
remote_shell_opennoOpen a persistent interactive PTY shell on a host.
remote_shell_sendper lineWrite input (Enter submitted by default) and wait for output silence/timeout/exit.
remote_shell_readyesRead a bounded scrollback page (newest-relative).
remote_shell_signalyesDeliver SIGINT/SIGTERM/SIGKILL/SIGTSTP/SIGHUP to the foreground process group.
remote_shell_closenoClose a session and wait for its process tree to end.
remote_shell_listyesList this agent's live shell sessions.

Typical agent loop:

  1. remote_hosts → choose the host name.
  2. remote_probe → confirm reachability and rough state.
  3. remote_exec read-only checks (systemctl status, df -h, kubectl get pods) → they run without asking.
  4. Mutating step (systemctl restart nginx, rm …, remote_write, remote_edit) → command review + approval prompt, then execution.
  5. remote_read the log/config to verify.

Persistent remote shells

remote_exec is deliberately one-shot. For work that needs shell state across calls — a REPL, an interactive prompt, a long-lived cd, environment variables, or a running command you want to interrupt — open a PTY session instead:

remote_shell_open  { host: "me" }                       -> sessionId + shell motd
remote_shell_send  { sessionId, text: "cd /var/log" }   -> output delta
remote_shell_send  { sessionId, text: "tail -n 20 syslog" }
remote_shell_read  { sessionId, offset: 0, count: 200 } -> scrollback page
remote_shell_signal{ sessionId, signal: "SIGINT" }      -> interrupt a command
remote_shell_close { sessionId }

How a send settles: output silence (inferred_idle), the send timeout (timeout), or the shell exiting (session_exit). inferred_idle and timeout never prove the foreground command exited — read again or check with remote_shell_read. Only one send may be active per session, and sessions are owned by the agent that opened them.

Safety: every submitted line is classified exactly like remote_exec — read-only lines run, mutating/unknown lines need approval, policy: never and read-only hosts deny mutating input. submit: false raw control input is not classified (it does not execute a line by itself); signals are mutating actions.

Limits: output is ANSI-sanitized line text, so full-screen TUIs (vim, top, less) are not usable — run non-interactive commands instead, and put long-running work in nohup … > log 2>&1 & then check it with remote_read. Sessions are process-local and end when the harness restarts.

Configure with:

- id: dsh-remote
  config:
    terminalEnabled: true          # register remote_shell_* (default true)
    terminalRows: 40
    terminalCols: 120
    terminalIdleSilenceMs: 800     # silence that settles a send
    terminalTimeoutMs: 30000       # absolute bound on one send
    terminalStartupTimeoutMs: 15000
    terminalScrollbackLines: 2000
    terminalScrollbackBytes: 2000000
    terminalMaxReadBytes: 262144

Interactive remote terminal (type and watch)

The agent-side shell above shows each send's output in its tool card. The interactive terminal is different: the 远程终端 panel picks a host and opens DSH's native terminal (real xterm) connected by SSH, and you type in it.

How it works:

  1. The 远程终端 panel (or a host row's 连接 on the Remote Ops page) lists the discovered hosts. Pick one and click 打开终端/连接并对话. 连接 creates/reuses the SSH session and opens a new conversation, then attaches the native terminal there; 打开终端 in the panel uses the current conversation.
  2. The plugin opens a native terminal tab in the right sidebar of that conversation. A small local bridge (bin/dsh-remote-terminal → lib/terminal/bridge.cjs) connects to the Host's terminal relay over a user-only local socket; the relay owns the SSH PTY (transport.openPty). Everything you type reaches the remote shell, and its output (ANSI included) renders in xterm. If the tab or the bridge cannot be established, the panel says so, shows the exact command with 复制命令, and points at $DSH_HOME/remote/terminal-bridge.log — paste the command into any local terminal to connect manually.
  3. One SSH session per conversation + host. Re-opening the terminal for the same host reattaches the same relay session instead of dialing again; closing the terminal tab leaves the session alive for userTerminalTtlMs so a re-open continues where you were. The pooled SSH connection is shared with the agent's tools for the same host.
  4. Auto-recovery. If the SSH channel drops, the relay reconnects with backoff (1s/2s/4s/8s/16s/30s, up to userTerminalReconnectAttempts) and prints a banner in the terminal; the bridge itself also retries when the DSH Host restarts. Nothing to re-type.

The terminal must actually be connected. Opening a tab is not proof: the panel verifies that the local bridge reached the relay (attached >= 1, bytesIn > 0), retries the typed launch command while the shell is still starting, and if it still fails shows the exact command with a 复制命令 button plus the path of $DSH_HOME/remote/terminal-bridge.log. The shell line does not depend on the terminal's PATH: it uses the Host's own node binary (directly, through ELECTRON_RUN_AS_NODE in the Desktop app, or the bundled runtime node) and falls back to the packaged launcher. The panel also shows the live session state — 接入 N (attached terminals), the short session key, and a 复用 badge when the same SSH session