dsh-ssh-bridge
A local SSH bridge service: enter the target host (user@host[:port]) and password in a browser page → local bridge process → encrypted SSH channel → target host, the password never enters the chat history. It is primarily used as a DSH (DeepSeek Harness) plugin: the agent runs remote commands from the local machine through tools like router_exec, against any SSH target with no host preset; the bridge core does not depend on DSH and can also run standalone.
> Current version: v0.4.2
Deployment
Dependencies
| Program | Version | Description |
|---|---|---|
| --- | :-: | --- |
| DSH | 0.1.0-rc series | Host (plugin mode) |
| pnpm | — | Dependency install (forwarded by dsh plugin) |
| Node.js | ≥18 | Runtime |
ssh2 | ^1.17 | SSH client library (installed automatically by npm install) |
@deepseek-ai/schemastery | ^3.18 | Plugin config schema (installed automatically by npm install) |
@deepseek-ai/cordis | ^4.0 | DSH host runtime (peer dependency, provided by the host, optional) |
Steps
# 1. Install the plugin (from the npm registry)
dsh plugin --profile web add dsh-ssh-bridge
# 2. Restart DSH
dsh web> Note: pnpm 11 blocks the native build scripts of ssh2/cpu-features (node-gyp) by default, so dsh plugin add exits non-zero and the plugin is not mounted even though the dependency is installed. Allow them in the profile's pnpm-workspace.yaml first, then re-run the install: > > ``yaml > allowBuilds: > cpu-features: true > ssh2: true > ``
Other install sources (GitHub / local tgz):
# GitHub (tag matches the npm version)
dsh plugin --profile web add github:lance-kanglu/dsh-ssh-bridge#v0.4.2
# Local tgz (downloadable from GitHub Releases)
dsh plugin --profile web add /path/to/dsh-ssh-bridge-0.4.2.tgzdsh plugin add appends the package to the profile's dsh.profile.bundles list; the plugin mounts automatically after a DSH restart, with no config file edits. Tools are registered in the global layer, visible to every session's agent.
Verify the deployment: the composed tree of dsh --profile web --dump-config should contain:
- id: ssh-bridge
name: dsh-ssh-bridge/plugin
config:
basePort: 23991Manual approach (local development, bypassing the bundle): place the package under the deployment's profiles/node_modules (a junction pointing to this project works), then add one row to a cordis patch / preset under $DSH_HOME:
- id: ssh-bridge
name: dsh-ssh-bridge/plugin
config:
basePort: 23991> Tip: for presets, agentPresets.standingKeyFor('<preset-id>') mount-validates (it really mounts the whole plugin tree).
Standalone run (optional)
The bridge core does not depend on DSH and can run standalone:
# 1. Install dependencies
npm install
# 2. Start the service
npm start
# equivalent to: node bridge.cjs
# 3. Open the page
# visit http://127.0.0.1:23991 in a browserVerify the deployment: the startup log prints the line below and the page opens in a browser:
[ssh-bridge] 已启动: http://127.0.0.1:23991Usage
Browser page
1. Open http://127.0.0.1:23991, enter the target host (user@host[:port]) and the password, then click "Connect" or press Enter; 2. Once the status turns to "Connected", the input box below becomes the target's interactive terminal (with ANSI color rendering), and a "Disconnect" button appears in the header; 3. Typing exit in the terminal ends the whole SSH session (same as a regular SSH client); the "Disconnect" button works as well; 4. After disconnecting, the target host is kept and the password is cleared, so you can reconnect with the password; 5. The log panel at the bottom shows connection and request status in real time, with details when something fails.
Command API (for agents / scripts)
A random token is generated on every start and printed in the startup log ([ssh-bridge] token=xxxx); every call below must carry it:
| Endpoint | Method | Params | Description |
|---|---|---|---|
| --- | :-: | --- | --- |
/api/auth | POST | {password, target?} | Submit the target and password to establish an SSH connection (password is never logged or persisted; target format user@host[:port]) |
/api/exec | POST | {cmd, token} | Run one command on the target host, returns {code, stdout, stderr} |
/api/input | POST | {line, token} | Write one line to the interactive terminal (writing exit ends the whole session) |
/api/ping | POST | {token} | Query connection status {ok, connected} |
/api/disconnect | POST | {token} | Disconnect the SSH connection (same as the "Disconnect" button) |
/api/reconnect | POST | {token} | Reconnect using the credentials saved during page authentication; returns 409 if never authenticated |
Example (PowerShell):
$body = @{ token = '<token from the startup log>'; cmd = 'uname -a' } | ConvertTo-Json
Invoke-RestMethod -Uri http://127.0.0.1:23991/api/exec -Method Post -ContentType 'application/json' -Body $body> Security: the service listens on 127.0.0.1 only, unreachable from the outside network; the password flows only between the browser → local process → encrypted SSH channel. > Note: if port 23991 is taken, the service rolls forward automatically (23992, 23993...), as printed in the startup log. > Tip: after updating the page code, hard-refresh (Ctrl+F5) to avoid stale cache; the web terminal echoes Enter only.
DSH plugin integration
Mechanism
A DSH plugin is a cordis plugin (npm package): the loader (@deepseek-ai/cordis-plugin-loader) imports the plugin by package name and applies its config, with rows declared in cordis.yml or patch files; model tools are surfaced through the tools registry, with schemas auto-injected into the prompt (tool-pwsh, tool-bash use the same mechanism).
Plugin adapter
plugin/index.cjs is a cordis plugin object (calibrated against the actual DSH 0.1.0-rc runtime source):
Config(schemastery) declares config with default-value validation — cordis only reads theConfigfield,schemais ignored;inject: ['tools']declares the hard dependency, otherwise property access likectx.toolsthrowscannot get property ... without inject;- Registers executors via
tools.register()only (the definition must includeoutput: { schema, render }, otherwiseregisterthrows a TypeError). Tool schemas are injected into the prompt automatically by DSH'sToolRuntime.wireSchemasfrom the registry — do not also callsystemPrompt.tools(), or the model request fails withTool names must be unique(INVALID_REQUEST 400); - Cleanup is registered through
ctx.effect()— cordis invokes a function-declarationapplywithnew, so the apply return value is discarded as a class instance and cannot be used for disposal.
Three tools are registered:
| Tool | Purpose |
|---|---|
router_exec | Run one command on the target host; the description carries the bridge page URL and explicitly tells the agent not to solicit or repeat login info from the chat |
router_disconnect | Disconnect the SSH connection (same as the page's "Disconnect") |
router_reconnect | Reconnect using the credentials saved during page authentication (password stays in process memory only) |
Three contract/e2e test suites are included: node test-plugin.cjs (cordis inject/Config/register contract, including the "no duplicate schema registration" regression assertion), node test-mount.cjs (real SystemPrompt/ToolRuntime + scoped ctx end-to-end, asserting the three tools enter the scoped layer, appear exactly once in the prompt assembly, and tool names are globally unique), and node test-e2e.cjs (a real local ssh2 server: auth → exec → disconnect → reconnect → exit ends the session).
#### Config options
| Option | Default | Description |
|---|---|---|
| --- | :-: | --- |
bindHost | 127.0.0.1 | Bind address; keep it loopback |
basePort | 23991 | Starting port; rolls forward when taken |
execTimeoutMs | 60000 | Per-command timeout |
> Note: the target host is not preset; enter user@host[:port] during page authentication. In CLI mode, environment variables can provide a default target that only pre-fills the page.
Note: the plugin ABI is currently in the 0.1.0-rc series with no public third-party docs; this adapter is calibrated against the contract above and tested.
Code structure
| File | Purpose |
|---|---|
server.cjs | Bridge core createBridge(), shared by the CLI and the plugin |
bridge.cjs | CLI entry, supports environment-variable config (SSH_BRIDGE_PORT, SSH_BRIDGE_ROUTER, etc.) |
page.js | Browser page script (ANSI rendering) |
plugin/index.cjs | DSH plugin adapter |
test-parser.cjs | ANSI renderer test (npm test) |
test-plugin.cjs | Plugin cordis contract test (node test-plugin.cjs) |
test-mount.cjs | Plugin scoped-mount e2e test (node test-mount.cjs) |
test-e2e.cjs | Bridge-core e2e test (local ssh2 server, node test-e2e.cjs) |
FAQ
dsh plugin addfails withERR_PNPM_IGNORED_BUILDS: pnpm 11 blocks the native build scripts ofssh2/cpu-featuresby default, soaddexits non-zero and the plugin is not mounted. ConfigureallowBuilds: { cpu-features: true, ssh2: true }in the profile'spnpm-workspace.yamland retry.- The page does not respond / clicking "Connect" does nothing: hard-refresh (Ctrl+F5); make sure the service is running.
- Raw escape codes like
[1;34mappear in the terminal output: stale cache — hard-refresh (an ANSI renderer is built in). /api/execreturnsnot connected: authenticate in the page first, or ask the agent to runrouter_reconnect.- The page still shows connected after typing
exit: old-version behavior; since v0.4exitends the whole session — hard-refresh (Ctrl+F5) if the page cached the old script. /api/authreturnsmissing target: the request body must carrytargetinuser@host[:port]format; in plugin mode with no default target configured, it is required.- Port taken: the service rolls forward automatically, per the startup log.
- Can the password leak?: No. The password lives in process memory only — never printed, never persisted; the token is local-only and prevents cross-site requests.
Changelog
v0.4.2
Docs:
- README restructured to be plugin-first: the deployment section is now the
dsh plugin addinstall, and the standalone run moved to an optional subsection; - Documents that pnpm 11 requires allowing the
ssh2/cpu-featuresnative builds inpnpm-workspace.yaml.
v0.4.1
Release:
- Declares a
dsh.bundle.patchmanifest, sodsh plugin add dsh-ssh-bridgeinstalls it in one step; - README documents the npm / GitHub / local tgz install methods.
v0.4
Connection management:
- The page gained a "Disconnect" button; typing
exitin the terminal now ends the whole SSH session (like a regular SSH client), no longer stuck at "connected without a shell"; - Added
/api/disconnectand/api/reconnect, plus the agent-siderouter_disconnectandrouter_reconnecttools, so the agent can disconnect/reconnect on request (reconnect reuses the credentials saved during page authentication; the password stays in process memory only); - Tool descriptions carry the bridge page URL and explicitly require the agent not to solicit or repeat login info from the chat, and not to assume a specific host; the page placeholder is now the neutral
user@192.168.x.x; - Fixed a connection-lifecycle race: after disconnect/reconnect, stale
close/shell-closeevents from the old connection no longer corrupt or kill the new connection; - Added
test-e2e.cjs(real local ssh2 server end-to-end).
v0.3
Generalization:
- The target host is no longer preset; it is entered as
user@host[:port]in the browser page, so any SSH target works; /api/authgained atargetparameter; the plugin config droppedrouterHost/routerUser/routerPort;- The plugin adapter was calibrated to the DSH 0.1.0-rc contract:
Config/inject/output {schema, render}/ctx.effect(); - Fixed duplicate tool registration: the plugin registers via
tools.register()only, no longer callingsystemPrompt.tools()— registry tools are injected into the prompt byToolRuntime.wireSchemas, and duplication made the model request fail withTool names must be unique; - Added the two plugin contract tests
test-plugin.cjsandtest-mount.cjs.
v0.2
Added:
- The DSH plugin adapter
plugin/index.cjs, registering therouter_exectool (experimental); - Core refactored into
server.cjs(createBridge()), shared by the CLI and the plugin; - CLI environment-variable config; added
LICENSE(MIT).
v0.1
First release. Core capabilities: browser password authentication, interactive terminal (ANSI color rendering), local exec API, standalone tests (npm test).