返回目录
其他 插件

dsh-ssh

UynajGI/dsh-ssh

SSH remote-execution plugin for DeepSeek Harness: ProxyJump chain, SFTP filesystem, subprocess and PTY over ssh2

Stars
5
Forks
0
Issues
0
更新
今天

PROJECT TOPICS

项目标签

PROJECT README

README

dsh-ssh

English | 中文

npm version license node version CI status GitHub stars dsh-plugin

SSH remote-execution plugin for DeepSeek Harness. Moves Bash, file tools, PTY terminals, and LSP onto a remote host over a single SSH connection — with multi-hop ProxyJump chains, SFTP upload/download, and full auth coverage. Built on ssh2.

First (and as of 2026-08, only) SSH remote-development plugin in the dsh-plugin ecosystem. Verified end-to-end against a real two-hop jump environment with key auth.

Architecture: local brain, remote hands

Your machine (deepseek-harness)                      Remote host
┌────────────────────────────────────┐    SSH    ┌──────────────────────┐
│ agent loop (orchestration, memory) │◄──────────►│ bash / command exec  │
│ LLM API calls (direct, no egress)  │   exec    │ filesystem (SFTP)    │
│ credentials / config / sessions    │   pty     │ PTY terminals        │
│ ctx.subprocess → dsh-ssh           │   sftp    │ LSP / git / builds   │
│ ctx.fs → dsh-ssh                   │           │                      │
└────────────────────────────────────┘           └──────────────────────┘

The harness does not need to be installed remotely. dsh-ssh implements remote providers for two of the harness's capability seams — ctx.subprocess (remote processes) and ctx.fs (remote files). Every tool built on those seams (bash, file tools, terminals, LSP, subagent processes) switches to the remote host with zero changes: the model thinks locally, commands run remotely, results stream back into the local model context.

Install

npm i dsh-ssh

Quick start (cordis.yml)

One row mounts everything — the shared connection owner plus both remote providers:

- id: ssh-remote
  name: dsh-ssh
  config:
    host: server.example.com  # target host (required; a ~/.ssh/config alias like prod works too)
    port: 22
    username: root            # required
    privateKey: ~/.ssh/id_ed25519   # identity-file path, or PEM content
    # password: 'xxx'               # password auth (mutually usable with privateKey)
    # agent: 'pageant'              # Windows Pageant; Unix: SSH_AUTH_SOCK path
    cwd: /root/workspace           # remote working directory (required, absolute POSIX path)
    # --- ProxyJump chain (optional; first hop from local, last hop to target) ---
    jump:
      - host: bastion.example.com
        # port: 22             # defaults to the target's
        # username: ubuntu     # defaults to the target's
        privateKey: ~/.ssh/id_ed25519
      # - host: second-hop ...
    # --- Connection & security ---
    readyTimeout: 45000        # ~ ConnectTimeout (ms, default 45s; relayed links handshake slowly)
    keepaliveInterval: 0       # ~ ServerAliveInterval (0 disables)
    keepaliveCountMax: 3       # ~ ServerAliveCountMax
    strictHostKeyChecking: false   # verify the host key when true
    knownHosts:                    # required when strictHostKeyChecking: true
      - 'SHA256:xxxxxxxx...'

The aggregate row is equivalent to three subpath rows — mount them separately only when a deployment composes providers individually:

- id: ssh
  name: dsh-ssh/ssh            # ctx.ssh connection owner (config above)
- id: subprocess-ssh
  name: dsh-ssh/subprocess     # ctx.subprocess remote provider
- id: fs-ssh
  name: dsh-ssh/fs             # ctx.fs remote provider (SFTP)

Add-workspace over SSH (Web GUI)

The Web surface's Add workspace flow (the conversation hero picker and the sidebar workspace browser) is taken over by the dsh-ssh client UI, laid out as a connection sidebar beside a directory browser (VS Code Remote Explorer style): the sidebar lists ~/.ssh/config hosts, saved connections, and the local entry; the right pane browses whichever side is active. Local listing rides the ctx.directoryPicker browse capability; connection management and remote listing ride dsh-ssh's own /dsh-ssh RPC channel. While browsing the local side, the toolbar also offers a system chooser button (the local.pickNative endpoint, reusing the host's OS-native folder dialog) — the directory picked in the popup becomes the workspace directly, no level-by-level walking required.

~/.ssh/config hosts in the sidebar (config.hosts)

The「SSH 配置主机」sidebar section is driven by the config.hosts endpoint: every opening re-reads the host machine's ~/.ssh/config and lists its exact Host aliases (wildcard patterns such as *.example.com stay hidden), each with the resolved user@host:port, IdentityFile presence, and ProxyJump presence:

  • Clicking an alias resolves its full config (username, port, identity, jump chain), registers it silently, and drops you straight into that host's directory browser — no form (VS Code Remote-SSH style). Registered aliases get an「已添加」badge and simply switch on click.
  • An alias without a User skips registration and opens a prefilled form (port / identity / jumps already filled); only the username is missing.
  • An alias without an IdentityFile can be registered, but the connection fails at auth — the right pane translates All configured authentication methods failed into a readable hint with a「补全认证」button that opens the prefilled form.

The「新建连接」form is alias-first as well: type a ~/.ssh/config alias and blur or paste auto-resolves and prefills (the manual「识别 ssh 配置」button remains the loud fallback); a successful resolve shows a one-line summary (alias → user@host:port, identity path, jump chain) inside the form.

A patch layer's name is a match guard rather than a replacement, so the Web bundle's @deepseek-ai/dsh-host-directory-picker-auto row must be disabled by id (its dynamically mounted in-app picker disappears with it) and the SSH backend inserted under its own id. In the Web profile ($DSH_HOME/profiles/web/cordis.patch.yml):

# Disable the boot-resolved picker (its dynamic entries go with it).
- id: directory-picker
  name: '@deepseek-ai/dsh-host-directory-picker-auto'
  disabled: true

- insert:
    - id: ssh-remote
      name: dsh-ssh
      config: { ...same config as the quick start... }

    # The local-directory browse backend for the client directoryFlow slots.
    - id: directory-picker-ssh
      name: dsh-ssh/picker
      config:
        maxEntries: 1000

    # The connection registry + /dsh-ssh RPC (persistence, ~/.ssh/config
    # awareness, remote directory browsing).
    - id: ssh-web-channel
      name: dsh-ssh/web
      config:
        maxEntries: 1000

Picking a remote directory first asks /dsh-ssh session.route for a LOCAL placeholder directory (<DSH_HOME>/dsh-ssh-routes/<connectionId>/<remotePath>, created host-side) and creates the session with it:

const { cwd } = await rpc('session.route', { id: connectionId, path: remotePath })
ctx.sessions.create({ cwd })

The detour exists because the host's session service mkdirs the project directory through node:fs — an ssh://… cwd cannot pass that check, while mkdir succeeds silently for an existing directory. ctx.subprocess and ctx.fs recognize both the ssh://<id>/<path> spelling and the local placeholder prefix, routing that session's bash / file / terminal operations onto the registered connection's directory. Remote sessions do not enter the DSH local workspace registry (see「Known limitations」); deleting a connection also removes its placeholder tree.

Picker configuration (dsh-ssh/picker)

Field Type Default Description
maxEntries number 1000 Complete-result bound for one listed level (hidden rows count; truncated flags a cut)
remoteLabel string Retained field: the client flow no longer uses a pinned entry; remote entries live in the left connection sidebar

dsh-ssh/picker now serves only the ctx.directoryPicker browse backend (local directories keep working on Windows hosts; POSIX absolute paths go to the aggregate SSH connection). The client UI's remote connection list and remote directory browsing ride the dsh-ssh/web RPC channel instead.

Configuration reference (dsh-ssh/ssh)

Field Type Default Description
host string Target hostname or address (required)
port number 22 Target SSH port
username string Remote login user (required)
password string Password auth
privateKey string PEM key content or local identity-file path
passphrase string Passphrase for an encrypted key
agent string ssh-agent socket path or pageant
jump JumpConfig[] [] ProxyJump chain; per-hop port/user/auth overrides
cwd string Remote working directory (required, absolute POSIX path)
readyTimeout number 45000 Connection timeout (ms)
keepaliveInterval number 0 SSH keepalive interval (ms)
keepaliveCountMax number 3 Keepalive failure threshold
strictHostKeyChecking boolean false Verify the host key against knownHosts
knownHosts string[] [] Trusted fingerprints (SHA256:…) or raw base64 public keys

OpenSSH ~/.ssh/config mapping

OpenSSH directive dsh-ssh field
HostName / Port / User host / port / username
IdentityFile / IdentitiesOnly privateKey (path or PEM)
PasswordAuthentication password
ForwardAgent agent
ProxyJump (comma-separated hops) jump array (per-hop)
ConnectTimeout readyTimeout
ServerAliveInterval / ServerAliveCountMax keepaliveInterval / keepaliveCountMax
StrictHostKeyChecking + UserKnownHostsFile strictHostKeyChecking + knownHosts
RemoteCommand / RequestTTY see spawnTerminal (PTY is consumer-requested)

Capabilities

Capability Implementation
ProxyJump chains jump array, multi-hop (direct-tcpip, equivalent to OpenSSH ProxyJump), independent auth per hop
Auth password, private key (PEM or path), passphrase, ssh-agent / Pageant; with none configured, falls back to the ~/.ssh default identities (id_ed25519 / id_ecdsa / id_rsa, mirroring OpenSSH)
Upload (local → remote) SFTP atomic write (same-dir temp file + rename, mode preserved)
Download (remote → local) full fs provider: read / streamText (streaming decode) / readBytes (bounded) / listDir / stat / lstat
Remote commands subprocess provider: collect (bounded tail + local spill file), pipe, inherit, batch stdin
Interactive terminals PTY (spawnTerminal), I/O plus TERM→KILL cleanup
Add-workspace GUI dsh-ssh/picker: the directory-picker seam's browse backend; the client UI is a connection sidebar (~/.ssh/config one-click hosts + saved connections + local) beside the directory browser
Environment isolation remote login env scrubbed (DSH_* and credential-shaped names removed) + explicit overrides, launched via env -i
Concurrency safety fs writes serialized per target key (no interleaved writes)
Host verification strictHostKeyChecking + knownHosts (SHA256 fingerprints or raw keys)

Performance

  • Connection reuse — all three providers share one SSH connection (jump chain included); the SFTP channel opens lazily, is reused, and rebuilds itself after disconnects.
  • Environment cache — the remote login environment is read once per connection (env -0), not per spawn.
  • Local spill — collect-mode output keeps an in-memory tail plus a local spill file, same semantics as the official local provider.
  • No polling — one exec channel per command (cd && exec env -i -- …); no polling or intermediate state files.

Reliability

  • Exit facts are authoritative — exit code / signal come from the SSH channel close event.
  • UTF-8 safe — exec output is buffered and decoded once; SSH chunking cannot corrupt multi-byte characters.
  • Fail loud — connection, auth, jump, and SFTP failures surface with readable messages.
  • Teardown — plugin disposal terminates active processes/terminals and closes the connection; staging dirs and spill files are cleaned on failure.

Troubleshooting

Symptom Cause & fix
All configured authentication methods failed Wrong auth config: check username / privateKey path / passphrase; key permissions too open (chmod 600)
Cannot read private key privateKey is neither PEM content nor an existing file
Jump connection timeout Check hop reachability and readyTimeout; verify the hop's user/auth independently
Host key verification failed strictHostKeyChecking: true without a matching knownHosts entry; collect the fingerprint with ssh-keyscan
exec exits 127 Remote command not found; check the remote PATH (the scrubbed env keeps it)
Write fails with FS_NOT_OBSERVED File exists and createIfAbsent was used (overwrite protection, not a bug)

Known limitations

  • Remote pid invisible — SSH channels do not expose the remote pid; SubprocessHandle.pid is always -1.
  • Termination is not tree-scopedterminate signals the remote direct process (SIGTERM → grace → SIGKILL); descendants are not guaranteed to die (inherent to the SSH protocol, unlike the local provider's process groups).
  • No foreground process groupinspectForeground returns undefined and signalForeground throws (the SSH channel cannot resolve a remote foreground group).
  • No reconnection — a dropped connection requires a plugin restart.
  • Remote directories land as sessions — picking a remote directory opens a session through a session.route local placeholder (<DSH_HOME>/dsh-ssh-routes/<id>/<path>; the session list shows that cwd); no record is created in the DSH local workspace registry (dsh-workspace still only accepts local fs.realpath directories).
  • Picker is remote-only on POSIX hosts — every absolute path is a remote path there, so the local filesystem cannot share the picker's vocabulary (Windows hosts keep both worlds via drive/UNC routing).
  • Text-only streamingstreamText rejects binary files with FS_NOT_TEXT (same as the official provider).

Development

npm i
npm run typecheck
npm run build       # emits lib/ — the compiled payload the harness loader imports
  • Git hooks (husky): pre-commit typechecks; commit-msg enforces Conventional Commits; pre-push rejects a version tag that does not match package.json.
  • CI (GitHub Actions): typecheck + publishable-payload check on every push/PR.
  • Release (GitHub Actions): push a version tag to publish to npm and draft a GitHub Release:
npm version patch -m "chore(release): v%s"   # bumps package.json + commits + tags
git push origin main && git push origin --tags

The tag must match the version field in package.json (both hooks and the release workflow enforce it). Publishing uses the NPM_TOKEN repository secret (an npm Automation token — it bypasses 2FA for CI).

License

MIT

CLASSIFICATION EVIDENCE

分类依据

项目类型插件
功能分类其他
规则置信度

系统优先读取 GitHub Topics,再与站内分类词典和词根规则比对。