@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +793 -2
  3. package/bin/dsh-remote-terminal +82 -0
  4. package/cordis.patch.yml +3 -0
  5. package/docs/TERMINAL_ACTIONS.md +39 -0
  6. package/docs/assets/host-audit.png +0 -0
  7. package/docs/assets/host-manager.png +0 -0
  8. package/docs/assets/live-terminal.png +0 -0
  9. package/docs/assets/remote-ops.png +0 -0
  10. package/docs/assets/remote-terminal.png +0 -0
  11. package/docs/icon.svg +11 -0
  12. package/lib/api-helpers.js +325 -0
  13. package/lib/api.js +715 -0
  14. package/lib/approval.js +238 -0
  15. package/lib/audit.js +224 -0
  16. package/lib/client.js +4919 -0
  17. package/lib/config.js +762 -0
  18. package/lib/connection-profile.js +65 -0
  19. package/lib/host-registry.js +703 -0
  20. package/lib/host-store.js +139 -0
  21. package/lib/hosts.js +442 -0
  22. package/lib/index.js +419 -0
  23. package/lib/live-stream.js +121 -0
  24. package/lib/live.js +245 -0
  25. package/lib/policy/argv.js +77 -0
  26. package/lib/policy/command-guard.js +404 -0
  27. package/lib/policy/docker.js +140 -0
  28. package/lib/policy/index.js +21 -0
  29. package/lib/policy/inspection.js +111 -0
  30. package/lib/policy/kubectl.js +128 -0
  31. package/lib/policy/shell-command.js +525 -0
  32. package/lib/policy/shell-wrappers.js +143 -0
  33. package/lib/policy/ssh.js +53 -0
  34. package/lib/policy/sudo.js +38 -0
  35. package/lib/policy/systemctl.js +22 -0
  36. package/lib/policy/types.js +12 -0
  37. package/lib/prompt.js +29 -0
  38. package/lib/render.js +442 -0
  39. package/lib/runtime-deps.js +50 -0
  40. package/lib/ssh/auth.js +256 -0
  41. package/lib/ssh/connection.js +504 -0
  42. package/lib/ssh/errors.js +209 -0
  43. package/lib/ssh/exec.js +394 -0
  44. package/lib/ssh/known-hosts.js +313 -0
  45. package/lib/ssh/pty.js +472 -0
  46. package/lib/ssh/sftp.js +589 -0
  47. package/lib/ssh/transport.js +801 -0
  48. package/lib/ssh-config.js +395 -0
  49. package/lib/terminal/bridge.cjs +305 -0
  50. package/lib/terminal/connection-login.js +188 -0
  51. package/lib/terminal/manager.js +656 -0
  52. package/lib/terminal/node-launcher.js +211 -0
  53. package/lib/terminal/pty-session.js +673 -0
  54. package/lib/terminal/relay-socket.js +605 -0
  55. package/lib/terminal/relay.js +905 -0
  56. package/lib/terminal/scrollback.js +588 -0
  57. package/lib/terminal/tools.js +532 -0
  58. package/lib/tools.js +751 -0
  59. package/package.json +90 -4
package/README.md CHANGED
@@ -1,3 +1,794 @@
1
- # Temporary Holding Version
1
+ # dsh-remote
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ![Remote Ops management page](https://cdn.jsdelivr.net/gh/aide-family/dsh-remote@v0.6.2/docs/assets/remote-ops.png)
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
+ ![Live terminal mirror](https://cdn.jsdelivr.net/gh/aide-family/dsh-remote@v0.6.2/docs/assets/live-terminal.png)
122
+
123
+ **Host manager.** Add, edit, or remove managed connections. Secrets stay as
124
+ environment-variable names or key paths.
125
+
126
+ ![Host manager](https://cdn.jsdelivr.net/gh/aide-family/dsh-remote@v0.6.2/docs/assets/host-manager.png)
127
+
128
+ **Per-host audit.** Filter by host, expand the full command, or export
129
+ JSON/CSV/TXT.
130
+
131
+ ![Per-host audit](https://cdn.jsdelivr.net/gh/aide-family/dsh-remote@v0.6.2/docs/assets/host-audit.png)
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
+ ![Native remote terminal](https://cdn.jsdelivr.net/gh/aide-family/dsh-remote@v0.6.2/docs/assets/remote-terminal.png)
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).