@aide-family/dsh-remote 0.0.0-stage → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +793 -2
- package/bin/dsh-remote-terminal +82 -0
- package/cordis.patch.yml +3 -0
- package/docs/TERMINAL_ACTIONS.md +39 -0
- package/docs/assets/host-audit.png +0 -0
- package/docs/assets/host-manager.png +0 -0
- package/docs/assets/live-terminal.png +0 -0
- package/docs/assets/remote-ops.png +0 -0
- package/docs/assets/remote-terminal.png +0 -0
- package/docs/icon.svg +11 -0
- package/lib/api-helpers.js +325 -0
- package/lib/api.js +715 -0
- package/lib/approval.js +238 -0
- package/lib/audit.js +224 -0
- package/lib/client.js +4919 -0
- package/lib/config.js +762 -0
- package/lib/connection-profile.js +65 -0
- package/lib/host-registry.js +703 -0
- package/lib/host-store.js +139 -0
- package/lib/hosts.js +442 -0
- package/lib/index.js +419 -0
- package/lib/live-stream.js +121 -0
- package/lib/live.js +245 -0
- package/lib/policy/argv.js +77 -0
- package/lib/policy/command-guard.js +404 -0
- package/lib/policy/docker.js +140 -0
- package/lib/policy/index.js +21 -0
- package/lib/policy/inspection.js +111 -0
- package/lib/policy/kubectl.js +128 -0
- package/lib/policy/shell-command.js +525 -0
- package/lib/policy/shell-wrappers.js +143 -0
- package/lib/policy/ssh.js +53 -0
- package/lib/policy/sudo.js +38 -0
- package/lib/policy/systemctl.js +22 -0
- package/lib/policy/types.js +12 -0
- package/lib/prompt.js +29 -0
- package/lib/render.js +442 -0
- package/lib/runtime-deps.js +50 -0
- package/lib/ssh/auth.js +256 -0
- package/lib/ssh/connection.js +504 -0
- package/lib/ssh/errors.js +209 -0
- package/lib/ssh/exec.js +394 -0
- package/lib/ssh/known-hosts.js +313 -0
- package/lib/ssh/pty.js +472 -0
- package/lib/ssh/sftp.js +589 -0
- package/lib/ssh/transport.js +801 -0
- package/lib/ssh-config.js +395 -0
- package/lib/terminal/bridge.cjs +305 -0
- package/lib/terminal/connection-login.js +188 -0
- package/lib/terminal/manager.js +656 -0
- package/lib/terminal/node-launcher.js +211 -0
- package/lib/terminal/pty-session.js +673 -0
- package/lib/terminal/relay-socket.js +605 -0
- package/lib/terminal/relay.js +905 -0
- package/lib/terminal/scrollback.js +588 -0
- package/lib/terminal/tools.js +532 -0
- package/lib/tools.js +751 -0
- package/package.json +90 -4
package/README.md
CHANGED
|
@@ -1,3 +1,794 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-remote
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SSH remote-server operations for [DeepSeek Harness](https://www.deepseek.com/harness/) (DSH).
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
### 1. Install DSH
|
|
8
|
+
|
|
9
|
+
`dsh-remote` is a DSH plugin, so install and launch DSH first.
|
|
10
|
+
|
|
11
|
+
**Desktop app (recommended).** Download the official app for
|
|
12
|
+
[macOS or Windows](https://www.deepseek.com/en/download/), open it, and finish the
|
|
13
|
+
first-run sign-in. The desktop app bundles its own `dsh` CLI and Node.js.
|
|
14
|
+
|
|
15
|
+
**CLI / Web UI.** Requires Node.js `>= 24.9.0`:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
# run without a global install
|
|
19
|
+
npx -y @deepseek-ai/dsh web
|
|
20
|
+
|
|
21
|
+
# or install the CLI once and reuse it
|
|
22
|
+
npm install -g @deepseek-ai/dsh
|
|
23
|
+
dsh web
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`dsh web` prints an authenticated local URL (for example
|
|
27
|
+
`http://127.0.0.1:3080/`). Open it in a browser. Then check the version:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
dsh --version # must be >= 0.2.1-rc.5
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> With the Desktop app, `dsh` may not be on your shell `PATH`; either use the
|
|
34
|
+
> app or install the CLI as above. The plugin also needs SSH access to the
|
|
35
|
+
> target hosts: key files, `ssh-agent`, or a password supplied through an
|
|
36
|
+
> environment variable. `~/.ssh/config` settings (`HostName`, `User`, `Port`,
|
|
37
|
+
> `IdentityFile`, `ProxyJump`) are honored.
|
|
38
|
+
|
|
39
|
+
### 2. Install dsh-remote
|
|
40
|
+
|
|
41
|
+
From npm:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
dsh plugin --profile desktop add @aide-family/dsh-remote --ignore-scripts
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
From a local checkout:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
cd /path/to/dsh-remote
|
|
51
|
+
npm install # once: ssh2 + test devDependencies
|
|
52
|
+
npm run install:profile # packs dist/aide-family-dsh-remote-<version>.tgz and installs
|
|
53
|
+
# it into the "desktop" profile
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or pack and install manually into another profile:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npm run pack:plugin
|
|
60
|
+
dsh plugin --profile desktop add file:$PWD/dist/aide-family-dsh-remote-0.6.2.tgz --ignore-scripts
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Install into the profile you actually boot: `desktop` for the Desktop app, or
|
|
64
|
+
`web` when you launched `dsh web` (the CLI also accepts `--profile web`).
|
|
65
|
+
|
|
66
|
+
Then fully quit and reopen the DSH Desktop app (a newly installed bundle is
|
|
67
|
+
loaded on the next boot), or boot the CLI profile you installed into. Open the
|
|
68
|
+
**远程运维** panel in the sidebar, or ask the agent to call `remote_hosts`, to
|
|
69
|
+
confirm the plugin loaded.
|
|
70
|
+
|
|
71
|
+
> **`--ignore-scripts` is required with pnpm 11.** pnpm refuses to install
|
|
72
|
+
> packages that declare unapproved install scripts, and `ssh2` declares an
|
|
73
|
+
> install script for its *optional* native cipher-speed addon (`cpu-features`).
|
|
74
|
+
> ssh2's pure-JavaScript implementation is fully functional without it, so
|
|
75
|
+
> skipping the scripts is safe and avoids needing a C toolchain. (Alternatively,
|
|
76
|
+
> add `allowBuilds: [ssh2, cpu-features]` to the profile's `pnpm-workspace.yaml`
|
|
77
|
+
> and install without the flag; that does require a working compiler setup.)
|
|
78
|
+
>
|
|
79
|
+
> **Do not install a development checkout with `link:`.** DSH routes
|
|
80
|
+
> `@deepseek-ai/*` imports only for packages that physically live inside the
|
|
81
|
+
> profile's `node_modules`; a symlink to a directory outside the profile cannot
|
|
82
|
+
> resolve them and the plugin fails to load. Use the tarball (`file:…tgz`) for
|
|
83
|
+
> local installs, or the npm package when published.
|
|
84
|
+
|
|
85
|
+
The plugin is a DSH **bundle**: installing it adds the
|
|
86
|
+
`@aide-family/dsh-remote` package and a `dsh-remote` plugin row to the profile.
|
|
87
|
+
To remove it:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
dsh plugin --profile desktop remove @aide-family/dsh-remote
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## What dsh-remote does
|
|
94
|
+
|
|
95
|
+
`dsh-remote` lets the agent in your DSH session actually operate remote servers:
|
|
96
|
+
list the hosts you already configured in `~/.ssh/config`, probe a box, run
|
|
97
|
+
non-interactive commands, read and edit remote files over SFTP, and move files
|
|
98
|
+
between the session workspace and a server — with a Crescent-style static
|
|
99
|
+
command-risk review in front of every mutating action and the DSH approval
|
|
100
|
+
dialog as the human gate.
|
|
101
|
+
|
|
102
|
+
It is inspired by the [Crescent](https://github.com/aide-family/Crescent)
|
|
103
|
+
operations workbench: the same idea that the agent should gather evidence on the
|
|
104
|
+
real host, one bounded action at a time, with risky commands reviewed before
|
|
105
|
+
execution.
|
|
106
|
+
|
|
107
|
+
## Screenshots
|
|
108
|
+
|
|
109
|
+
The screenshots below come from an isolated documentation profile: synthetic
|
|
110
|
+
hosts on RFC 5737 documentation addresses, a loopback SSH fixture, and synthetic
|
|
111
|
+
command output. No real host, account, key, or local path is shown.
|
|
112
|
+
|
|
113
|
+
**Remote Ops management page.** Host inventory, per-host detail, live shell
|
|
114
|
+
sessions, and the recent-operation audit trail in one panel.
|
|
115
|
+
|
|
116
|
+

|
|
117
|
+
|
|
118
|
+
**Live terminal mirror.** Every agent command streams into the conversation's
|
|
119
|
+
right sidebar; the same panel opens DSH's native terminal on a chosen host.
|
|
120
|
+
|
|
121
|
+

|
|
122
|
+
|
|
123
|
+
**Host manager.** Add, edit, or remove managed connections. Secrets stay as
|
|
124
|
+
environment-variable names or key paths.
|
|
125
|
+
|
|
126
|
+

|
|
127
|
+
|
|
128
|
+
**Per-host audit.** Filter by host, expand the full command, or export
|
|
129
|
+
JSON/CSV/TXT.
|
|
130
|
+
|
|
131
|
+

|
|
132
|
+
|
|
133
|
+
**Native remote terminal.** A real xterm attached to the per-conversation SSH
|
|
134
|
+
session; close and re-open the tab to reattach it.
|
|
135
|
+
|
|
136
|
+

|
|
137
|
+
|
|
138
|
+
## Features
|
|
139
|
+
|
|
140
|
+
- **Hosts you already have** — discovers every concrete `Host` in
|
|
141
|
+
`~/.ssh/config` (including `Include`s) and merges a configurable host list;
|
|
142
|
+
the model refers to hosts by name, exactly like `ssh <name>`.
|
|
143
|
+
- **Remote exec** — one-shot, non-interactive commands with working directory,
|
|
144
|
+
timeout, bounded output, and a clear `[exit code: N]` result line.
|
|
145
|
+
- **Remote files over SFTP** — read (line-numbered, paginated), list, write
|
|
146
|
+
(atomic temp + rename), exact-string edit, and upload/download transfers.
|
|
147
|
+
- **Persistent remote shells** — open an interactive PTY shell on a host and
|
|
148
|
+
keep using it across tool calls (`remote_shell_*`): send input, read
|
|
149
|
+
scrollback, interrupt with signals, close. Every submitted line is
|
|
150
|
+
risk-reviewed like `remote_exec`.
|
|
151
|
+
- **Live terminal mirror** — a **Remote Terminal** tab in the conversation's
|
|
152
|
+
right sidebar shows every remote command the agent runs as it runs, with
|
|
153
|
+
streamed stdout/stderr and status lines. It opens automatically on the first
|
|
154
|
+
command (or from the sidebar's Start page). See [Live terminal](#live-terminal-right-sidebar).
|
|
155
|
+
|
|
156
|
+
- **Interactive remote terminal** — the same right-sidebar **远程终端** panel
|
|
157
|
+
picks an available host and opens DSH's native terminal attached to a
|
|
158
|
+
persistent, per-conversation SSH session through a local relay: you type
|
|
159
|
+
commands in a real xterm, close and re-open the tab to reattach the same
|
|
160
|
+
session, and an SSH drop reconnects automatically with backoff. See
|
|
161
|
+
[Interactive remote terminal](#interactive-remote-terminal-type-and-watch).
|
|
162
|
+
- **Quick connect from the host list** — one 连接 action creates the SSH
|
|
163
|
+
session, opens a **new conversation**, attaches DSH's native terminal to it,
|
|
164
|
+
and hands the host to the agent there ("已连接远程主机 …") so operations start
|
|
165
|
+
in a dedicated conversation. Failures after the switch are shown in the
|
|
166
|
+
远程终端 panel (never a silent local shell), and 复制连接命令 copies the exact
|
|
167
|
+
shell line for any terminal when the native-terminal integration is
|
|
168
|
+
unavailable.
|
|
169
|
+
- **Host manager** — add, edit and delete connection entries from the Remote Ops
|
|
170
|
+
page (persisted in `$DSH_HOME/remote/hosts.json`; auth stays
|
|
171
|
+
environment-variable names, never stored secrets).
|
|
172
|
+
- **Per-host audit** — an 审计 action on every host row lists that host's
|
|
173
|
+
command and terminal-session history recorded by the plugin.
|
|
174
|
+
- **Non-mutating commands never ask** — under the default policy only commands
|
|
175
|
+
the static classifier *proves* mutating need approval; read-only and
|
|
176
|
+
unprovable-but-non-mutating commands (e.g. `curl`, `awk`, `python -c`) run
|
|
177
|
+
directly, while `policy: never`, read-only hosts, `denyCommandPatterns` and the
|
|
178
|
+
approval seam keep their stricter behaviour.
|
|
179
|
+
- **Remote Ops management page** — a first-level panel in the DSH Web GUI with
|
|
180
|
+
the discovered host inventory (per-row read-only connection test), live remote
|
|
181
|
+
shell sessions (scrollback preview + close), and a persisted audit trail of
|
|
182
|
+
recent remote operations. See [Management page](#management-page-remote-ops).
|
|
183
|
+
- **Command risk review** — a port of Crescent's static classifier separates
|
|
184
|
+
statically read-only commands (`low`) from clearly mutating ones (`high`) and
|
|
185
|
+
unknown ones (`gray`); it understands shell lists, wrappers (`sudo`, `env`,
|
|
186
|
+
`nohup`, `time`, `xargs`), `sh -c`/`ssh`/`kubectl exec` inner commands,
|
|
187
|
+
`find -delete`, `sed -i`, `yq -i`, `docker`/`kubectl`/`systemctl` subcommands,
|
|
188
|
+
and quoting.
|
|
189
|
+
- **Approval policy** — `auto` (read-only runs, mutations ask), `always`, or
|
|
190
|
+
`never` (fail-closed headless stance). Denials, cancelled prompts, and a
|
|
191
|
+
missing answerer all fail closed.
|
|
192
|
+
- **Hard limits** — per-host read-only lock, host allowlist, command deny
|
|
193
|
+
patterns, output caps, transfer caps, and TOFU host-key verification.
|
|
194
|
+
|
|
195
|
+
## Configure
|
|
196
|
+
|
|
197
|
+
All settings live on the plugin's row in the profile patch. The desktop profile
|
|
198
|
+
patch is `~/.dsh/profiles/desktop/cordis.patch.yml`; a `--patch` overlay works
|
|
199
|
+
too (useful for headless and CI):
|
|
200
|
+
|
|
201
|
+
```yaml
|
|
202
|
+
- id: dsh-remote
|
|
203
|
+
config:
|
|
204
|
+
# Hosts not present in ~/.ssh/config. Never put secrets here — only the NAME
|
|
205
|
+
# of an environment variable that holds the secret.
|
|
206
|
+
hosts:
|
|
207
|
+
- name: prod-web-1
|
|
208
|
+
host: 10.0.0.11
|
|
209
|
+
port: 22
|
|
210
|
+
username: ops
|
|
211
|
+
identityFile: ~/.ssh/prod_ed25519
|
|
212
|
+
tags: [prod, web]
|
|
213
|
+
description: primary web node
|
|
214
|
+
- name: legacy-db
|
|
215
|
+
host: 10.0.0.31
|
|
216
|
+
username: dba
|
|
217
|
+
passwordEnv: LEGACY_DB_SSH_PASSWORD # exported in the DSH environment
|
|
218
|
+
policy: auto # auto | always | never
|
|
219
|
+
readonlyHosts: ["prod-*"] # every mutating action on these is denied
|
|
220
|
+
allowHosts: [] # empty = all; otherwise only matching hosts usable
|
|
221
|
+
denyCommandPatterns: ["\\b(shutdown|reboot|mkfs)\\b"]
|
|
222
|
+
connectTimeoutMs: 20000
|
|
223
|
+
execTimeoutMs: 120000
|
|
224
|
+
maxOutputBytes: 200000
|
|
225
|
+
maxReadBytes: 800000
|
|
226
|
+
maxTransferBytes: 52428800
|
|
227
|
+
knownHostsPolicy: tofu # tofu | strict | insecure
|
|
228
|
+
unprovenCommands: allow # allow (only provably mutating asks) | ask
|
|
229
|
+
liveTerminal: true # right-sidebar live terminal + /dsh-remote/live
|
|
230
|
+
autoOpenLiveTerminal: true
|
|
231
|
+
userTerminal: true # interactive SSH terminal (relay + local bridge)
|
|
232
|
+
userTerminalTtlMs: 1800000 # keep an unattached SSH session this long (0 = forever)
|
|
233
|
+
userTerminalReconnectAttempts: 5
|
|
234
|
+
userTerminalScrollbackBytes: 400000
|
|
235
|
+
hostRegistryPath: "" # default: $DSH_HOME/remote/hosts.json
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Hosts from `~/.ssh/config` need no configuration at all. If `~/.ssh/config`
|
|
239
|
+
contains `Host prod-*` patterns, those wildcard entries are skipped (a pattern
|
|
240
|
+
is not a concrete target); add a concrete host block or a `hosts:` entry.
|
|
241
|
+
|
|
242
|
+
### Cluster shortcuts with ordered terminal actions
|
|
243
|
+
|
|
244
|
+
The host manager can save a **cluster shortcut** through an existing SSH entry
|
|
245
|
+
(for example `bastion-demo`). Replace the former fixed login command with an ordered
|
|
246
|
+
**action list**: add/remove/reorder actions, select each type, and configure its
|
|
247
|
+
completion condition and timeout. The same `actions` field works in plugin `hosts`
|
|
248
|
+
configuration:
|
|
249
|
+
|
|
250
|
+
```json
|
|
251
|
+
{
|
|
252
|
+
"name": "cluster-a",
|
|
253
|
+
"viaHost": "bastion-demo",
|
|
254
|
+
"actions": [
|
|
255
|
+
{ "type": "command", "value": "sww -r -u demo.user cluster-a", "expectCluster": "cluster-a" },
|
|
256
|
+
{ "type": "command", "value": "cd /tmp" }
|
|
257
|
+
],
|
|
258
|
+
"tags": ["cluster", "cluster-a"],
|
|
259
|
+
"description": "cluster-a cluster through bastion-demo"
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
After entrance SSH authentication, actions run sequentially in the **same shared
|
|
264
|
+
PTY** used by the human and Agent. A new ordinary-shell sequence waits for the
|
|
265
|
+
entry prompt before sending. For a menu or custom entrance prompt, put a literal
|
|
266
|
+
`wait` action first: that output becomes the readiness barrier instead of a generic
|
|
267
|
+
Shell prompt. Connection readiness means all configured action completion
|
|
268
|
+
conditions were met, not merely that a terminal tab opened.
|
|
269
|
+
|
|
270
|
+
| Type | Input | Completion |
|
|
271
|
+
| --- | --- | --- |
|
|
272
|
+
| `command` | Printable single-line `value`, automatically followed by Enter | Shell prompt by default; optional literal `expect`, or `expectCluster` for a root prompt on that cluster |
|
|
273
|
+
| `text` | Printable `value`, no Enter appended | Input issuance, or optional literal `expect` |
|
|
274
|
+
| `key` | `Enter`, `Tab`, `Escape`, `Ctrl-C`, `Ctrl-D`, arrow keys, or `Backspace` | Input issuance, or optional literal `expect` |
|
|
275
|
+
| `wait` | Literal output `value`, not a regular expression | That unconsumed output is observed |
|
|
276
|
+
| `delay` | `durationMs` | Cancellable timer completes |
|
|
277
|
+
|
|
278
|
+
Example menu interaction: `wait` for `Select cluster:` → `text` with `1` → `key`
|
|
279
|
+
`Enter` → `wait` for the target's success message. Text/key issuance alone does
|
|
280
|
+
not prove an interaction completed. A Shell prompt does **not** prove exit code 0
|
|
281
|
+
or the intended target was reached; for a login/target change, configure explicit
|
|
282
|
+
target-success output or the target-cluster root check.
|
|
283
|
+
|
|
284
|
+
Since 0.6.1, default completion accepts mode-prefixed user@host prompts such as
|
|
285
|
+
`[ K8S-RONLY ]root@web1.region-a.example.test:~#`, including streamed ANSI output.
|
|
286
|
+
The explicit `expectCluster` check still matches the actual root hostname's domain
|
|
287
|
+
segment, not a Kubernetes context name: a logical `cluster-b` context may use a
|
|
288
|
+
`.region-a` pod hostname. In that case use observed literal `expect` output rather than
|
|
289
|
+
requiring a nonexistent `.cluster-b` hostname. Mode labels alone do not mark ready;
|
|
290
|
+
matching a prompt does not grant write permission or override read-only policy.
|
|
291
|
+
|
|
292
|
+
There may be 1–32 actions, each optionally labelled. Send text is bounded to 4096
|
|
293
|
+
characters with no control characters; waits are bounded to 2048 characters.
|
|
294
|
+
`timeoutMs` defaults to 30000, range 100–120000. Delays are 0–120000 ms and the
|
|
295
|
+
whole sequence is capped at 20 minutes. Progress exposes counts/type/label only,
|
|
296
|
+
not raw action input or output. **Do not embed passwords, tokens or private keys
|
|
297
|
+
in actions**; entrance authentication uses the secure user-only dialog or existing
|
|
298
|
+
credential references.
|
|
299
|
+
|
|
300
|
+
A timeout, cancellation, PTY exit or execution error stops the sequence without
|
|
301
|
+
sending remaining actions. Observing a completion condition is not a command
|
|
302
|
+
exit-code check. Reattaching/adopting an initialized PTY does not replay actions;
|
|
303
|
+
user-terminal reconnects initialize the genuinely new channel once. Agent-owned
|
|
304
|
+
PTYs keep the no-silent-reconnect rule. Legacy `viaHost` + `loginCommand` records
|
|
305
|
+
are automatically migrated into one command action while retaining the original
|
|
306
|
+
root-on-target confirmation; saving the editor uses `actions` only.
|
|
307
|
+
|
|
308
|
+
Shortcuts inherit entrance SSH settings, `allowHosts` and read-only restrictions;
|
|
309
|
+
missing entrances, self-reference, cycles and multi-level shortcuts still fail.
|
|
310
|
+
They remain **terminal-only**: select the shortcut in Remote Ops or use
|
|
311
|
+
`remote_shell_open`, then reuse its session id with `remote_shell_send/read/signal`.
|
|
312
|
+
Exec, probe and SFTP would target the entrance and remain disabled. Every automatic
|
|
313
|
+
send respects command risk and deny patterns; no saved action grants approval.
|
|
314
|
+
Strict policies (`always`, `never`, `unprovenCommands: ask`), read-only unproven
|
|
315
|
+
inputs or a high-risk send requiring unavailable approval fail explicitly. Use the
|
|
316
|
+
ordinary entrance terminal and the normal approved manual flow in those cases.
|
|
317
|
+
|
|
318
|
+
### Authentication
|
|
319
|
+
|
|
320
|
+
The transport tries, in order, and uses the first that applies:
|
|
321
|
+
|
|
322
|
+
1. `passwordEnv` — password auth; the env var is read at connect time.
|
|
323
|
+
2. `identityFile` — public-key auth (from plugin config or `~/.ssh/config`), with
|
|
324
|
+
an optional passphrase from `passphraseEnv`.
|
|
325
|
+
3. `ssh-agent` (`SSH_AUTH_SOCK`).
|
|
326
|
+
4. Default keys: `~/.ssh/id_ed25519`, `~/.ssh/id_ecdsa`, `~/.ssh/id_rsa`.
|
|
327
|
+
|
|
328
|
+
For an interactive terminal connection that fails with `AUTH`, the UI now opens
|
|
329
|
+
a masked **SSH 密码登录** dialog. A successful fixed password is retained only in
|
|
330
|
+
Host process memory for that exact host/port/user until plugin disposal or app
|
|
331
|
+
restart; it is never saved to the host registry, sent to the Agent, or recorded
|
|
332
|
+
in audit/debug logs. Failed passwords are not cached or automatically retried.
|
|
333
|
+
The browser uses the existing authenticated Host/Origin-fenced API.
|
|
334
|
+
|
|
335
|
+
Password auth also supports a single non-echoing `Password:` keyboard-interactive
|
|
336
|
+
challenge. OTP/MFA prompts, multiple prompts, echoing prompts, repeated rounds,
|
|
337
|
+
and interactive password login through ProxyJump are not supported; a fixed
|
|
338
|
+
password is never automatically supplied to those challenges. The parser honors
|
|
339
|
+
`PubkeyAuthentication`, `PasswordAuthentication`, and
|
|
340
|
+
`KbdInteractiveAuthentication` for concrete SSH Host blocks. In particular,
|
|
341
|
+
`PubkeyAuthentication no` prevents fallback to default private keys.
|
|
342
|
+
|
|
343
|
+
Secrets are never read from YAML, never logged, and never included in tool
|
|
344
|
+
results or approval prompts.
|
|
345
|
+
|
|
346
|
+
### Host keys
|
|
347
|
+
|
|
348
|
+
`knownHostsPolicy` controls verification against
|
|
349
|
+
`$DSH_HOME/remote/known-hosts.json`:
|
|
350
|
+
|
|
351
|
+
- `tofu` (default) — trust and remember a host's key on first use; reject a
|
|
352
|
+
changed key.
|
|
353
|
+
- `strict` — only previously remembered hosts connect.
|
|
354
|
+
- `insecure` — accept any key (the result carries a warning; not for production).
|
|
355
|
+
|
|
356
|
+
## Tools
|
|
357
|
+
|
|
358
|
+
| Tool | Read-only | Purpose |
|
|
359
|
+
| --- | --- | --- |
|
|
360
|
+
| `remote_hosts` | yes | List usable hosts, auth hints, and policy flags. |
|
|
361
|
+
| `remote_probe` | yes | Connect and report hostname, OS/kernel, uptime, load, memory, disk, and latency. |
|
|
362
|
+
| `remote_exec` | per classifier | Run one non-interactive command with optional `workdir` and timeout; only provably mutating commands ask for approval. |
|
|
363
|
+
| `remote_read` | yes | Read a remote text file with line numbers, `offset`/`limit`, and a continue hint. |
|
|
364
|
+
| `remote_list` | yes | List a remote directory with type/size/mtime/mode. |
|
|
365
|
+
| `remote_write` | no | Write a remote file atomically, creating missing directories. |
|
|
366
|
+
| `remote_edit` | no | Replace one exact string in a remote file (or all with `replace_all`). |
|
|
367
|
+
| `remote_transfer` | no | Upload/download between the session workspace and the host. |
|
|
368
|
+
| `remote_shell_open` | no | Open a persistent interactive PTY shell on a host. |
|
|
369
|
+
| `remote_shell_send` | per line | Write input (Enter submitted by default) and wait for output silence/timeout/exit. |
|
|
370
|
+
| `remote_shell_read` | yes | Read a bounded scrollback page (newest-relative). |
|
|
371
|
+
| `remote_shell_signal` | yes | Deliver SIGINT/SIGTERM/SIGKILL/SIGTSTP/SIGHUP to the foreground process group. |
|
|
372
|
+
| `remote_shell_close` | no | Close a session and wait for its process tree to end. |
|
|
373
|
+
| `remote_shell_list` | yes | List this agent's live shell sessions. |
|
|
374
|
+
|
|
375
|
+
Typical agent loop:
|
|
376
|
+
|
|
377
|
+
1. `remote_hosts` → choose the host name.
|
|
378
|
+
2. `remote_probe` → confirm reachability and rough state.
|
|
379
|
+
3. `remote_exec` read-only checks (`systemctl status`, `df -h`, `kubectl get pods`) → they run without asking.
|
|
380
|
+
4. Mutating step (`systemctl restart nginx`, `rm …`, `remote_write`, `remote_edit`) → command review + approval prompt, then execution.
|
|
381
|
+
5. `remote_read` the log/config to verify.
|
|
382
|
+
|
|
383
|
+
## Persistent remote shells
|
|
384
|
+
|
|
385
|
+
`remote_exec` is deliberately one-shot. For work that needs shell state across
|
|
386
|
+
calls — a REPL, an interactive prompt, a long-lived `cd`, environment variables,
|
|
387
|
+
or a running command you want to interrupt — open a PTY session instead:
|
|
388
|
+
|
|
389
|
+
```
|
|
390
|
+
remote_shell_open { host: "me" } -> sessionId + shell motd
|
|
391
|
+
remote_shell_send { sessionId, text: "cd /var/log" } -> output delta
|
|
392
|
+
remote_shell_send { sessionId, text: "tail -n 20 syslog" }
|
|
393
|
+
remote_shell_read { sessionId, offset: 0, count: 200 } -> scrollback page
|
|
394
|
+
remote_shell_signal{ sessionId, signal: "SIGINT" } -> interrupt a command
|
|
395
|
+
remote_shell_close { sessionId }
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
How a send settles: output silence (`inferred_idle`), the send timeout
|
|
399
|
+
(`timeout`), or the shell exiting (`session_exit`). `inferred_idle` and `timeout`
|
|
400
|
+
never prove the foreground command exited — read again or check with
|
|
401
|
+
`remote_shell_read`. Only one send may be active per session, and sessions are
|
|
402
|
+
owned by the agent that opened them.
|
|
403
|
+
|
|
404
|
+
Safety: every submitted line is classified exactly like `remote_exec` — read-only
|
|
405
|
+
lines run, mutating/unknown lines need approval, `policy: never` and read-only
|
|
406
|
+
hosts deny mutating input. `submit: false` raw control input is not classified
|
|
407
|
+
(it does not execute a line by itself); signals are mutating actions.
|
|
408
|
+
|
|
409
|
+
Limits: output is ANSI-sanitized line text, so full-screen TUIs (`vim`, `top`,
|
|
410
|
+
`less`) are not usable — run non-interactive commands instead, and put
|
|
411
|
+
long-running work in `nohup … > log 2>&1 &` then check it with `remote_read`.
|
|
412
|
+
Sessions are process-local and end when the harness restarts.
|
|
413
|
+
|
|
414
|
+
Configure with:
|
|
415
|
+
|
|
416
|
+
```yaml
|
|
417
|
+
- id: dsh-remote
|
|
418
|
+
config:
|
|
419
|
+
terminalEnabled: true # register remote_shell_* (default true)
|
|
420
|
+
terminalRows: 40
|
|
421
|
+
terminalCols: 120
|
|
422
|
+
terminalIdleSilenceMs: 800 # silence that settles a send
|
|
423
|
+
terminalTimeoutMs: 30000 # absolute bound on one send
|
|
424
|
+
terminalStartupTimeoutMs: 15000
|
|
425
|
+
terminalScrollbackLines: 2000
|
|
426
|
+
terminalScrollbackBytes: 2000000
|
|
427
|
+
terminalMaxReadBytes: 262144
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Interactive remote terminal (type and watch)
|
|
431
|
+
|
|
432
|
+
The agent-side shell above shows each send's output in its tool card. The
|
|
433
|
+
interactive terminal is different: the **远程终端** panel picks a host and opens
|
|
434
|
+
DSH's native terminal (real xterm) connected by SSH, and **you** type in it.
|
|
435
|
+
|
|
436
|
+
How it works:
|
|
437
|
+
|
|
438
|
+
1. The **远程终端** panel (or a host row's **连接** on the Remote Ops page)
|
|
439
|
+
lists the discovered hosts. Pick one and click **打开终端**/**连接并对话**.
|
|
440
|
+
**连接** creates/reuses the SSH session and opens a **new conversation**,
|
|
441
|
+
then attaches the native terminal there; **打开终端** in the panel uses the
|
|
442
|
+
current conversation.
|
|
443
|
+
2. The plugin opens a native terminal tab in the right sidebar of that
|
|
444
|
+
conversation. A small local bridge (`bin/dsh-remote-terminal` →
|
|
445
|
+
`lib/terminal/bridge.cjs`) connects to the Host's terminal relay over a
|
|
446
|
+
user-only local socket; the relay in the DSH Host process owns the SSH PTY
|
|
447
|
+
(`transport.openPty`). This is an interactive SSH shell in DSH's native
|
|
448
|
+
terminal, **not a local
|
|
449
|
+
`ssh` process**. The browser plugin cannot spawn OS processes; it writes one
|
|
450
|
+
quoted bridge-launch line via DSH's terminal-view API and verifies the relay
|
|
451
|
+
attachment.
|
|
452
|
+
Everything you type reaches the remote shell, and its output (ANSI included)
|
|
453
|
+
renders in xterm. If the tab or the bridge cannot be established, the panel
|
|
454
|
+
says so, shows the exact command with **复制命令**, and points at
|
|
455
|
+
`$DSH_HOME/remote/terminal-bridge.log` — paste the command into any local
|
|
456
|
+
terminal to connect manually.
|
|
457
|
+
3. **The Agent and human share the exact PTY.** The relay's canonical key is
|
|
458
|
+
`(DSH conversation id, host)`. When `remote_shell_open` runs in that
|
|
459
|
+
conversation, the Agent shell manager borrows that relay PTY rather than
|
|
460
|
+
opening another SSH shell channel; opens are reused per Agent + host. Reopening
|
|
461
|
+
the native terminal attaches to the same relay session. Closing its tab does
|
|
462
|
+
not idle-close an Agent-owned PTY; `remote_shell_close`, Agent disposal, or
|
|
463
|
+
plugin shutdown closes the shared session.
|
|
464
|
+
4. **Recovery is state-aware.** A terminal-only relay can reconnect with backoff
|
|
465
|
+
(1s/2s/4s/8s/16s/30s, up to `userTerminalReconnectAttempts`) and the bridge
|
|
466
|
+
retries when the DSH Host restarts. If an Agent owns the PTY and its SSH
|
|
467
|
+
channel exits, the relay marks that shared shell ended instead of reconnecting
|
|
468
|
+
to a fresh shell that would falsely appear to preserve state; explicitly open
|
|
469
|
+
a new session to continue.
|
|
470
|
+
|
|
471
|
+
**The terminal must actually be connected.** Opening a tab is not proof: after
|
|
472
|
+
writing the local bridge launch line once, the panel waits for a *new* bridge
|
|
473
|
+
attach beyond the session's pre-launch counter and requires the relay SSH state
|
|
474
|
+
to be `connected`. It never replays the launch line automatically: if the first
|
|
475
|
+
write was delayed, replaying it could reach an already-connected remote shell as
|
|
476
|
+
user input. On failure it does not claim success; it shows the exact command
|
|
477
|
+
with a **复制命令** button plus the path of
|
|
478
|
+
`$DSH_HOME/remote/terminal-bridge.log`. The shell line
|
|
479
|
+
does not depend on the terminal's `PATH`: it uses the Host's own node binary
|
|
480
|
+
(directly, through `ELECTRON_RUN_AS_NODE` in the Desktop app, or the bundled
|
|
481
|
+
runtime node) and falls back to the packaged launcher. The panel also shows the
|
|
482
|
+
live session state — `接入 N` (attached terminals), the short session key, and a
|
|
483
|
+
`复用` badge when the same SSH session was reattached — so connection reuse is
|
|
484
|
+
visible instead of assumed. The relay records `terminal attach/detach` audit
|
|
485
|
+
events, so a session that opened but never attached is visible in the host's
|
|
486
|
+
audit history.
|
|
487
|
+
|
|
488
|
+
Limits: the relay keeps a bounded replay buffer (`userTerminalScrollbackBytes`,
|
|
489
|
+
default 400 KB) so a reattached terminal shows recent output. The native terminal
|
|
490
|
+
is a full PTY and supports TUI interaction; Agent `remote_shell_read` returns
|
|
491
|
+
ANSI-sanitized line text, so the Agent should use it for shell state and rely on
|
|
492
|
+
the native terminal for full-screen rendering. Agent-submitted lines retain the
|
|
493
|
+
normal risk/approval checks. Human keystrokes are explicit operator actions and
|
|
494
|
+
are never copied into the audit trail (they may contain passwords). Terminal
|
|
495
|
+
sessions are process-local: a harness restart ends the remote process shell; do
|
|
496
|
+
not infer that a new shell has the old state.
|
|
497
|
+
|
|
498
|
+
The old host-picker shell (`scripts/dsh-remote-shell`, an `ssh -t` session
|
|
499
|
+
configured through `terminal-controller`'s `shell` profile) still exists as a
|
|
500
|
+
standalone fallback, but it is not used by the panel.
|
|
501
|
+
|
|
502
|
+
## Live terminal (right sidebar)
|
|
503
|
+
|
|
504
|
+
Every `remote_exec` command and submitted `remote_shell_send` line is mirrored
|
|
505
|
+
into a right-sidebar tab, so you watch the real execution instead of only the
|
|
506
|
+
final tool card:
|
|
507
|
+
|
|
508
|
+
- **Opens automatically** on the first remote command of the session (the
|
|
509
|
+
right-sidebar column expands), and is also available from the right sidebar's
|
|
510
|
+
Start page as a **远程终端** entry.
|
|
511
|
+
- **Streams in real time**: a prompt line (`$ command`, host, session), the
|
|
512
|
+
command's stdout/stderr chunks as they arrive, and a status line
|
|
513
|
+
(`[exit 0 · 812ms]` / `[error: …]` / `[running]`). Denied commands appear too,
|
|
514
|
+
so you see exactly what the agent attempted.
|
|
515
|
+
- **Bounded and private**: the stream is served from the running Host over a
|
|
516
|
+
fenced same-origin SSE route (`/dsh-remote/live`); the Host keeps a bounded
|
|
517
|
+
replay buffer (last 200 commands, 64 KiB per command, 1 MiB total) so a late
|
|
518
|
+
panel still shows recent activity. Nothing is written to disk for the stream.
|
|
519
|
+
- **Controls**: connection state, per-host filter, local clear, auto-scroll
|
|
520
|
+
(unless you scrolled up), automatic reconnect.
|
|
521
|
+
|
|
522
|
+
```yaml
|
|
523
|
+
- id: dsh-remote
|
|
524
|
+
config:
|
|
525
|
+
liveTerminal: true # serve /dsh-remote/live + register the tab
|
|
526
|
+
autoOpenLiveTerminal: true # auto-open on the first command
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
**Approval policy (default `auto`)**: only provably mutating commands ask. Set
|
|
530
|
+
`unprovenCommands: "ask"` to require approval for commands the classifier cannot
|
|
531
|
+
prove read-only (unknown binaries, `curl`, `awk`, …), or keep the strict
|
|
532
|
+
`policy: never` for unattended runs (mutating and unproven commands are denied,
|
|
533
|
+
read-only commands run).
|
|
534
|
+
|
|
535
|
+
## Management page (Remote Ops / 远程运维)
|
|
536
|
+
|
|
537
|
+
The plugin registers a first-level sidebar panel in the DSH Web/Desktop GUI:
|
|
538
|
+
|
|
539
|
+
| Section | What you get |
|
|
540
|
+
| --- | --- |
|
|
541
|
+
| Connection rail + host workbench | The connection rail selects a discovered host; the workbench shows source, `user@host:port`, auth hint, readonly / allowHosts policy, connectivity, terminal and Agent-session state, and the selected host's actions. |
|
|
542
|
+
| Host actions | **测试连接 / Test connection** runs a read-only probe (connect + `uname -srm; hostname`) and shows latency/output; **连接** creates a conversation, opens its native terminal, and hands the host to the Agent using the same conversation-host PTY; **审计**, **编辑** / **删除** for managed hosts. **添加主机** opens the connection form. |
|
|
543
|
+
| Remote shell sessions | Live `remote_shell_*` sessions for every agent (session id, host, status) with a bounded scrollback preview and a **结束会话 / Close** action. |
|
|
544
|
+
| Recent operations | The newest **20** audit entries: time, tool, host, action, risk level (low/gray/high/write), outcome (ok / denied / error), duration, and the command/path, with a JSON/CSV/TXT export of the full retained trail. |
|
|
545
|
+
|
|
546
|
+
### Host manager
|
|
547
|
+
|
|
548
|
+
**添加主机** opens a form for a connection entry that does not live in
|
|
549
|
+
`~/.ssh/config`: name, host, port, user, key file, environment-variable names for
|
|
550
|
+
the password/passphrase, proxy jump, tags and description. Entries are persisted
|
|
551
|
+
in `$DSH_HOME/remote/hosts.json` (mode 0600, atomic writes) and merged into the
|
|
552
|
+
same host list as `~/.ssh/config` and the plugin `hosts:` config, winning on a
|
|
553
|
+
name collision. Raw secrets are never accepted: auth values are environment
|
|
554
|
+
variable **names**. Hosts discovered from `~/.ssh/config` or the plugin config
|
|
555
|
+
are listed read-only here (edit/delete only for managed entries).
|
|
556
|
+
|
|
557
|
+
### Per-host audit
|
|
558
|
+
|
|
559
|
+
**审计** on a host row opens that host's operation history from the same audit
|
|
560
|
+
trail: every model-facing remote call (tool, action, risk, outcome, duration,
|
|
561
|
+
command/path) plus interactive-terminal lifecycle events (open / attach /
|
|
562
|
+
reconnect / close). Each row expands to the **complete** command (the raw text,
|
|
563
|
+
whitespace preserved — the table keeps a short one-line summary), and
|
|
564
|
+
JSON / CSV / TXT export covers either the current host or the whole retained
|
|
565
|
+
trail. User keystrokes typed in the interactive terminal are never recorded
|
|
566
|
+
(they can contain passwords typed at a sudo prompt).
|
|
567
|
+
|
|
568
|
+
The page polls the Host every 10 seconds and has manual refresh and audit-clear
|
|
569
|
+
buttons. It is backed by a small same-origin JSON API at `/dsh-remote/api`
|
|
570
|
+
(`overview`, `host.test`, `host.create`, `host.update`, `host.delete`,
|
|
571
|
+
`audit.list`, `terminal.open`, `terminal.list`, `terminal.close`, `session.read`,
|
|
572
|
+
`session.close`, `audit.clear`) that is mounted only when a web server exists and
|
|
573
|
+
is fenced by the same Host/Origin + browser-cookie check DSH's own `/api` uses.
|
|
574
|
+
The audit trail is also appended to `$DSH_HOME/remote/audit.jsonl` (bounded tail
|
|
575
|
+
reloaded on startup), so it survives a restart.
|
|
576
|
+
|
|
577
|
+
Disable the HTTP API/page backend with `managementApi: false` in the plugin
|
|
578
|
+
config if you do not want the route registered.
|
|
579
|
+
|
|
580
|
+
## Safety model
|
|
581
|
+
|
|
582
|
+
- **Static review before approval.** Every `remote_exec` command and submitted
|
|
583
|
+
`remote_shell_send` line is classified by the ported Crescent classifier.
|
|
584
|
+
Under `policy: auto` only `high` (provably mutating) commands need approval;
|
|
585
|
+
`low` and `gray` (unproven) run directly, with the live terminal and audit
|
|
586
|
+
trail recording Agent actions. Direct keystrokes in the native terminal are
|
|
587
|
+
explicit human operator actions: they bypass Agent approval and are not stored
|
|
588
|
+
in audit payloads. `unprovenCommands: ask` restores approval for the unproven
|
|
589
|
+
class, and `remote_write` / `remote_edit` / `remote_transfer` always need
|
|
590
|
+
approval.
|
|
591
|
+
- **Fail closed.** No approval answerer mounted, a cancelled prompt, a rejected
|
|
592
|
+
prompt, or an approval-service failure all deny the action.
|
|
593
|
+
- **`policy: never`** is the headless/unattended stance: statically read-only
|
|
594
|
+
actions work; mutating and unproven actions are denied with an explanation.
|
|
595
|
+
- **Host-level guards.** `readonlyHosts` denies every mutating action on matching
|
|
596
|
+
hosts in every policy mode; `allowHosts` (non-empty) hides and rejects
|
|
597
|
+
everything else; `denyCommandPatterns` always denies a matching command.
|
|
598
|
+
- **Bounded everything.** Exec output is capped (tail kept, marker added), reads
|
|
599
|
+
are paginated, transfers are capped, and connections/commands time out.
|
|
600
|
+
- **Workspace-scoped transfers.** Local transfer paths resolve against the
|
|
601
|
+
session working directory and must stay inside it unless
|
|
602
|
+
`allowLocalOutsideWorkspace: true`.
|
|
603
|
+
|
|
604
|
+
## Development
|
|
605
|
+
|
|
606
|
+
```sh
|
|
607
|
+
npm install # ssh2 + test devDependencies
|
|
608
|
+
npm test # unit + integration tests (no real servers)
|
|
609
|
+
npm run e2e # real DSH headless run against a fixture SSH server
|
|
610
|
+
npm run dev:profile # create the isolated DSH dev profile
|
|
611
|
+
npm run dev:sync # copy lib/ into the dev profile's node_modules
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
The integration and e2e suites run a **real SSH server fixture**
|
|
615
|
+
(`test/fixtures/ssh-server.mjs`, built on ssh2's server) on `127.0.0.1`, so no
|
|
616
|
+
external host, key, or network access is required.
|
|
617
|
+
|
|
618
|
+
The e2e suite uses an isolated `DSH_HOME` (`./.dsh-test`), a `remotedev` profile
|
|
619
|
+
created from the shipped headless template, and the credentials already saved by
|
|
620
|
+
the desktop app (symlinked, never copied). It asserts the real harness loads the
|
|
621
|
+
plugin, registers the tools, and executes read-only remote actions; mutating
|
|
622
|
+
actions are asserted to fail closed in headless mode.
|
|
623
|
+
|
|
624
|
+
## Automated npm publishing
|
|
625
|
+
|
|
626
|
+
The npm package name is `@aide-family/dsh-remote`; the DSH plugin row id remains
|
|
627
|
+
`dsh-remote`. Before enabling releases, add a repository Actions secret named
|
|
628
|
+
`NPM_TOKEN` with publish permission for the `@aide-family` scope. Do not put the
|
|
629
|
+
token in the repository or a checked-in `.npmrc`.
|
|
630
|
+
|
|
631
|
+
Pushing a tag that exactly matches the manifest version (for example, `v0.6.2`)
|
|
632
|
+
starts the [publish workflow](https://github.com/aide-family/dsh-remote/blob/main/.github/workflows/publish-npm.yml).
|
|
633
|
+
The workflow installs from the lockfile, runs unit tests plus the DSH-independent
|
|
634
|
+
integration suites, checks the tag/package version match, previews the tarball,
|
|
635
|
+
then publishes publicly with provenance.
|
|
636
|
+
The publisher must have rights to create packages in the `@aide-family` scope.
|
|
637
|
+
|
|
638
|
+
```sh
|
|
639
|
+
git tag v0.6.2
|
|
640
|
+
git push origin v0.6.2
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Published versions cannot be reused. If a release has a mistake, fix it and
|
|
644
|
+
publish a higher version; do not try to overwrite the existing version.
|
|
645
|
+
|
|
646
|
+
## Known limitations
|
|
647
|
+
|
|
648
|
+
- **No persistent remote shell.** `remote_exec` is one-shot (no PTY): each call
|
|
649
|
+
is a fresh shell. Long-running processes must be started detached
|
|
650
|
+
(`nohup … &`); interactive programs (vim, top) are not supported.
|
|
651
|
+
- **`remote_read` pages within `maxReadBytes`.** Line `offset`/`limit` work on
|
|
652
|
+
the file window returned by one read; a file larger than `maxReadBytes`
|
|
653
|
+
cannot be paged past that window. Use `remote_exec` with `tail -n`/`grep`/
|
|
654
|
+
`sed -n` or `remote_transfer` for very large files.
|
|
655
|
+
- **Workspace containment is lexical.** Transfer local paths are checked
|
|
656
|
+
lexically against the session working directory (no symlink resolution);
|
|
657
|
+
keep `allowLocalOutsideWorkspace: false` unless you need otherwise.
|
|
658
|
+
- **Saved host authentication contains references only.** The host form stores
|
|
659
|
+
metadata and environment-variable names, never raw passwords. Interactive
|
|
660
|
+
terminal connections can request a fixed password in the masked login dialog,
|
|
661
|
+
retained only in Host memory; encrypted-key passphrases still use environment
|
|
662
|
+
references. A restart requires entering a transient password again.
|
|
663
|
+
- **One connection per host.** Operations for one host are serialized through a
|
|
664
|
+
single pooled SSH connection (plus ProxyJump hops), so concurrent calls to the
|
|
665
|
+
same host queue rather than open many sessions.
|
|
666
|
+
- **No full-screen TUI in agent shells.** `remote_shell_*` output is
|
|
667
|
+
line-sanitized, so `vim`/`top`/`less` are not usable from the agent; use
|
|
668
|
+
non-interactive commands. The interactive terminal (above) is a real xterm and
|
|
669
|
+
supports them for the user.
|
|
670
|
+
- **Shell sessions and the live stream are process-local.** The terminal mirror
|
|
671
|
+
retains a bounded in-memory buffer only; a harness restart clears it (the audit
|
|
672
|
+
trail persists). Interactive-terminal sessions behave the same way: the relay
|
|
673
|
+
keeps them across terminal close/reload and reconnects an SSH drop, but a
|
|
674
|
+
harness restart starts a fresh shell on the next open.
|
|
675
|
+
- **The interactive terminal is user-driven.** It runs with the Host user's SSH
|
|
676
|
+
credentials and is not gated by the agent approval policy; `allowHosts` still
|
|
677
|
+
restricts which hosts may be opened. The local bridge needs `node` on the
|
|
678
|
+
terminal `PATH` (or `DSH_REMOTE_NODE`).
|
|
679
|
+
- **The live mirror shows `remote_exec` and shell sends**, not file reads/writes
|
|
680
|
+
(those keep their tool cards) and not commands run outside the plugin.
|
|
681
|
+
- **One send at a time per remote shell**, bounded by `terminalTimeoutMs`.
|
|
682
|
+
- **The management page needs the Web/Desktop surface.** Headless CLI runs have
|
|
683
|
+
no HTTP server, so the API and page are not mounted there (tools still work).
|
|
684
|
+
- **Session list is process-local.** The page shows sessions of the running
|
|
685
|
+
harness process; the audit trail is persisted, sessions are not.
|
|
686
|
+
- **No port forwarding / tunnels.** The plugin runs commands and file transfers;
|
|
687
|
+
`ssh -L`-style forwarding is out of scope.
|
|
688
|
+
|
|
689
|
+
## Layout
|
|
690
|
+
|
|
691
|
+
```
|
|
692
|
+
lib/
|
|
693
|
+
index.js plugin entry (apply, Config, prompt section, wiring)
|
|
694
|
+
client.js browser half: sidebar "Remote Ops" panel + page
|
|
695
|
+
api.js Host JSON API behind the management page (fenced)
|
|
696
|
+
audit.js bounded audit trail (memory ring + JSONL persistence)
|
|
697
|
+
live.js live command hub for the right-sidebar terminal
|
|
698
|
+
live-stream.js fenced SSE transport (/dsh-remote/live)
|
|
699
|
+
tools.js 8 defineTool definitions
|
|
700
|
+
render.js canonical value -> model text
|
|
701
|
+
approval.js policy + risk review + DSH approval seam
|
|
702
|
+
host-store.js cached host discovery/resolution
|
|
703
|
+
config.js Schemastery Config + normalizeConfig
|
|
704
|
+
hosts.js ~/.ssh/config + config + managed host merge/resolve
|
|
705
|
+
host-registry.js persisted managed-host CRUD ($DSH_HOME/remote/hosts.json)
|
|
706
|
+
ssh-config.js ~/.ssh/config parser (Include, ProxyJump, …)
|
|
707
|
+
policy/ ported Crescent static command-risk classifier
|
|
708
|
+
ssh/ ssh2 pool, auth, exec, SFTP, known hosts
|
|
709
|
+
terminal/ agent PTY shells (v0.2) + interactive user terminal relay/bridge (v0.5)
|
|
710
|
+
bin/
|
|
711
|
+
dsh-remote-terminal POSIX launcher run inside the native terminal (v0.5)
|
|
712
|
+
test/
|
|
713
|
+
unit/ pure-module tests
|
|
714
|
+
integration/ fake-ctx plugin tests + fixture SSH server
|
|
715
|
+
fixtures/ ssh-server.mjs
|
|
716
|
+
scripts/ dev-profile, sync-profile, e2e, dev-web, docs-screenshots
|
|
717
|
+
docs/assets/ README screenshots (generated from the synthetic profile only)
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
Attribution: the command-risk classifier in `lib/policy/` is ported from the
|
|
721
|
+
Crescent project (`aide-family/Crescent`, `src/shared/command-guard.ts`,
|
|
722
|
+
`src/shared/shell-command.ts`, `src/shared/command-policy/`).
|
|
723
|
+
|
|
724
|
+
## Repository privacy
|
|
725
|
+
|
|
726
|
+
Source, tests, examples, documentation and published assets must not contain
|
|
727
|
+
private organization names, personal identifiers or real test connection details.
|
|
728
|
+
Use synthetic cluster/host aliases and accounts, reserved example domains such as
|
|
729
|
+
`example.test`, and documentation IP ranges (`192.0.2.0/24`, `198.51.100.0/24`,
|
|
730
|
+
`203.0.113.0/24`). Keep loopback addresses for local fixture servers. Use generic
|
|
731
|
+
paths instead of a developer's home directory.
|
|
732
|
+
|
|
733
|
+
Browser captures must disable SSH-config discovery (`includeSshConfig: false`),
|
|
734
|
+
use isolated fixture inventory, and show synthetic command output rather than
|
|
735
|
+
local usernames or machine details. Review images, logs, archives and package
|
|
736
|
+
contents before publication; never commit live connection records or secrets.
|
|
737
|
+
|
|
738
|
+
## Verification
|
|
739
|
+
|
|
740
|
+
```sh
|
|
741
|
+
npm test # unit + real-fixture integration (no external servers)
|
|
742
|
+
npm run e2e # real DSH headless run against a fixture SSH server
|
|
743
|
+
node scripts/dev-web-test.mjs --remote-terminal --docs-shots # real browser loop (v0.5)
|
|
744
|
+
npm run docs:screenshots # regenerate docs/assets/ from an isolated synthetic profile
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
The e2e suite proves the model actually calls the tools through the DSH pipeline,
|
|
748
|
+
that read-only remote actions work, that mutating actions fail closed in headless
|
|
749
|
+
mode, that a persistent PTY shell opens, echoes a command, and closes cleanly,
|
|
750
|
+
and that an unproven (gray) command now runs without approval. The management page is verified in a real browser against an isolated
|
|
751
|
+
DSH Web profile (`npm run dev:web` boots it, drives it with headless Chrome, and
|
|
752
|
+
writes a local screenshot). `npm run docs:screenshots` regenerates the README
|
|
753
|
+
images from a profile that disables SSH-config discovery, contains only synthetic
|
|
754
|
+
inventory and a loopback fixture, and refuses to write a capture if the page
|
|
755
|
+
shows a real-looking host, account, or local path. Earlier screenshots were
|
|
756
|
+
removed because they contained real connection details; the README now uses
|
|
757
|
+
generated synthetic captures, but still review every image before publishing it.
|
|
758
|
+
`--emit-live` verifies the v0.4 live mirror (guide entry, auto-open, streamed
|
|
759
|
+
commands/output, session and host filters, scrolling, Clear). `--remote-terminal`
|
|
760
|
+
boots the same profile with a real SSH fixture host and verifies v0.5 end to end:
|
|
761
|
+
the actions column, quick connect creating the relay PTY and opening DSH's native
|
|
762
|
+
terminal, typing a command into the xterm, the `已连接远程主机 …` conversation
|
|
763
|
+
handoff, the per-host audit modal, the host-manager add/delete round-trip, and
|
|
764
|
+
per-conversation session reuse (same sessionKey on a second open) — with zero
|
|
765
|
+
browser console errors. `--docs-shots` additionally captures
|
|
766
|
+
a local terminal screenshot from a fixture-only panel with synthetic output (no
|
|
767
|
+
agent handoff). Review captures for private data before publishing them. The v0.5.1 pass additionally asserts the relay `attached`/`attachCount`
|
|
768
|
+
/`bytesIn` counters (the local bridge really reached the SSH session), the
|
|
769
|
+
per-host audit `attach` event, one-click full-detail expansion, a real JSON
|
|
770
|
+
export download with `detailFull`, and that `overview.audit` returns at most the
|
|
771
|
+
newest 20 entries. Run the browser loop with full process access: the DSH Host must be able
|
|
772
|
+
to allocate a POSIX PTY for the native terminal, which an agent workspace sandbox
|
|
773
|
+
can deny (`posix_openpt: Operation not permitted`). A packaged tarball
|
|
774
|
+
install is verified separately:
|
|
775
|
+
|
|
776
|
+
```sh
|
|
777
|
+
npm run pack:plugin
|
|
778
|
+
dsh plugin --profile <test-profile> add file:$PWD/dist/aide-family-dsh-remote-<version>.tgz --ignore-scripts
|
|
779
|
+
E2E_SKIP_SYNC=1 DSH_TEST_PROFILE=<test-profile> node scripts/e2e.mjs
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
## Troubleshooting
|
|
783
|
+
|
|
784
|
+
| Symptom | Cause / fix |
|
|
785
|
+
| --- | --- |
|
|
786
|
+
| `Cannot find package '@deepseek-ai/dsh-tools'` | The plugin was installed with a symlink (`link:`) outside the profile. Reinstall from a tarball/npm so it lives under `<profile>/node_modules`. |
|
|
787
|
+
| `denied because no approval answerer is mounted` | Headless or unattended run; use `policy: never` for read-only automation, or run the interactive Web/Desktop surface for mutating work. |
|
|
788
|
+
| `HOST_KEY` / changed host key | A previous key is remembered in `$DSH_HOME/remote/known-hosts.json`; verify the server key and remove the stale entry (or switch `knownHostsPolicy`). |
|
|
789
|
+
| `AUTH` failure | Check `ssh -v <host>` works with the same user/key; verify `identityFile`, `SSH_AUTH_SOCK`, or the `passwordEnv` variable is visible to the DSH process (the app may not inherit your shell's exports). |
|
|
790
|
+
| Host missing from `remote_hosts` | Wildcard-only `Host` blocks and hostname-less entries are skipped; add a concrete host block or a `hosts:` config entry. |
|
|
791
|
+
|
|
792
|
+
## License
|
|
793
|
+
|
|
794
|
+
MIT. See [LICENSE](./LICENSE).
|