dsh-ssh-vault 0.0.0-stage → 2.0.0

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.
@@ -0,0 +1,167 @@
1
+ # 贡献指南
2
+
3
+ [English](CONTRIBUTING.md) | **简体中文**
4
+
5
+ 这个插件会**保管别人的口令**,还要在**别人的机器上执行命令**。所以它的门槛比一般小工具高:
6
+ 下面第一节的红线不是风格偏好,破了就是真实泄露或真实事故。
7
+
8
+ ## 一、红线(违反即拒绝)
9
+
10
+ ### 1. 绝不提交凭据、私钥与真实环境信息
11
+
12
+ 不要提交:私钥、口令、API key、`%DSH_HOME%` 下的任何文件、真实内网地址、真实主机名、
13
+ 真实账号名、会话日志。
14
+
15
+ 需要真实主机的测试**一律走环境变量**,源码里不得出现字面值:
16
+
17
+ ```js
18
+ // 正确
19
+ const KALI = { host: env.LIVE_KALI_HOST, user: env.LIVE_KALI_USER, pass: env.LIVE_KALI_PASS }
20
+
21
+ // 错误
22
+ const KALI = { host: '198.51.100.7', user: 'root', pass: '123456' }
23
+ ```
24
+
25
+ 示例、测试数据、文档一律使用**文档专用地址**:`192.0.2.0/24`、`198.51.100.0/24`、`203.0.113.0/24`。
26
+
27
+ 按下提交键之前自查:
28
+
29
+ ```powershell
30
+ git status --short # 确认没有 id_rsa / id_rsa.pub / dist / .env
31
+ git diff --cached # 逐眼过一遍,尤其看有没有 IP、账号、口令
32
+ ```
33
+
34
+ `.gitignore` 已排除常见项,**但那不是保证**——它挡不住一个叫 `config.local.js` 的新文件。
35
+
36
+ ### 2. 口令不得进入命令行、日志或对话
37
+
38
+ 这是本项目的核心设计约束。任何改动都必须维持:
39
+
40
+ | 位置 | 要求 |
41
+ | --- | --- |
42
+ | 本地 `ssh` 命令行 | 口令只能经 `SSH_ASKPASS` 环境变量传递 |
43
+ | 远端命令行与 `ps` | 口令只能走标准输入 |
44
+ | 会话日志 | 工具参数里不得出现口令,只能出现 `sudo: true` 这类开关 |
45
+ | 磁盘 | 只有 AES-256-GCM 密文;明文只允许存在于内存 |
46
+
47
+ 改完执行路径后,必须能通过 `test/live.mjs` 中的「口令不出现在远端进程列表」一项。
48
+
49
+ ### 3. 保持零运行时依赖
50
+
51
+ 不要引入 npm 依赖。插件只调用系统 OpenSSH 客户端(`ssh` / `scp`)与 Node 内置模块。
52
+ `package.json` 里不应出现 `dependencies`。
53
+
54
+ ### 4. 不擅自改动用户的 profile 或保险库
55
+
56
+ 运行时只读写自己的保险库目录(`%DSH_HOME%\ssh-vault`)。
57
+ 不要写 `%DSH_HOME%\.credentials.yaml`、profile 的 `package.json` 或 `cordis.patch.yml`,
58
+ 也不要碰用户的 `~/.ssh`。
59
+
60
+ ## 二、开发环境
61
+
62
+ - Node.js >= 20(见 `package.json` 的 `engines`)
63
+ - Windows 10 / 11 且启用 OpenSSH 客户端(`ssh -V` 能输出版本)。**本插件按设计仅支持 Windows**——数据密钥由
64
+ DPAPI 保护、askpass 辅助程序是 Windows `.cmd`;`package.json` 声明了 `os: ["win32"]`,运行期也会在其它平台
65
+ 拒绝加载。不要重新引入「非 Windows 上把密钥明文落盘」的代码路径。
66
+ - **不需要 `npm install`**——项目零依赖
67
+
68
+ ## 三、测试
69
+
70
+ ### 提交前必须跑
71
+
72
+ ```powershell
73
+ npm test
74
+ ```
75
+
76
+ 它依次执行三个**免主机**回归,CI 跑的是同一组:
77
+
78
+ ```powershell
79
+ node test/diagnose.mjs # shell 翻译、八进制解码、失败归因
80
+ node test/unit.mjs # 参数构造、超时/取消保留输出、wait_for / detach 脚本
81
+ node test/background.mjs # 后台任务注册契约
82
+ ```
83
+
84
+ ### 需要真实主机时
85
+
86
+ ```powershell
87
+ $env:LIVE_KALI_HOST = '<host>'
88
+ $env:LIVE_KALI_USER = '<user>'
89
+ $env:LIVE_KALI_PASS = '<password>'
90
+ $env:LIVE_WIN_HOST = '<host>'
91
+ $env:LIVE_WIN_USER = '<user>'
92
+ $env:LIVE_WIN_KEY = '<private key path>'
93
+ node test/live.mjs
94
+ node test/selftest.mjs
95
+ ```
96
+
97
+ ### 硬性要求
98
+
99
+ - 改动 `lib/ssh.js` 的参数构造或诊断 → **必须**补 `test/unit.mjs` / `test/diagnose.mjs` 用例
100
+ - 改动后台任务 → **必须**补 `test/background.mjs`
101
+ - 新增面向模型的行为 → 至少一条单元用例把它钉住
102
+ - **不许为了让测试通过而放宽断言**。如果新用例要求改旧断言,先在 PR 里说明旧断言为什么是错的
103
+
104
+ ## 四、代码风格
105
+
106
+ 格式由 [`.editorconfig`](.editorconfig) 约束,另有以下约定:
107
+
108
+ - ESM(`import` / `export`),`"type": "module"`
109
+ - 2 空格缩进、单引号、**行尾不加分号**、行宽约 120
110
+ - 导出的函数写 JSDoc(`@param` / `@returns`);**注释用中文,标识符用英文**
111
+ - 纯函数优先:参数构造(`buildSshArgs`)、脚本生成(`buildWaitScript`、`buildDetachScript`)、
112
+ 归因(`diagnose`)都保持无副作用,这样才测得动
113
+ - 错误信息面向使用者:说清**发生了什么**与**怎么恢复**,不要只把底层错误抛出去
114
+
115
+ ## 五、提交信息
116
+
117
+ 采用 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/):
118
+
119
+ ```
120
+ <type>: <简短描述>
121
+
122
+ <可选正文:为什么这么改>
123
+ ```
124
+
125
+ `type` 取 `feat` / `fix` / `docs` / `refactor` / `test` / `chore`。
126
+ 标题用英文祈使句,正文中英文皆可。**一次提交只做一件事。**
127
+
128
+ ## 六、Pull Request
129
+
130
+ 1. 行为变更先开 issue 讨论,尤其是涉及权限、执行路径、存储格式的
131
+ 2. 分支名 `feat/...`、`fix/...`、`docs/...`
132
+ 3. PR 描述写清三件事:**改了什么、为什么、怎么验证的**
133
+ 4. 命中以下任一项时,必须在描述里单独说明:
134
+ - 新增了执行路径(口令会经过哪里)
135
+ - 保险库格式变化(是否需要迁移,旧数据怎么办)
136
+ - 新增工具参数(它会不会进入会话日志)
137
+ 5. `npm test` 通过,且 `git diff` 里没有凭据
138
+
139
+ ## 七、设计约束(架构层面)
140
+
141
+ 动代码前先确认没有破坏这些:
142
+
143
+ - **别名寻址**:模型只见别名,地址与凭据由保险库提供。
144
+ 不要新增需要模型传主机名、账号或口令的参数。
145
+ - **中断不等于失明**:任何执行路径在超时或被取消时,都必须回传已经收到的输出。
146
+ - **失败要说清归因**:本地密钥问题、公钥未装机、口令错误、防火墙丢包必须能区分;
147
+ 「账号或口令不正确」只能在前几项都排除之后出现。
148
+ - **长任务不占用工具调用**:优先用 `detach`(远端脱离)与 `wait_for`(条件等待),
149
+ 不要把 `sleep` 轮询写进工具实现。
150
+ - **会话日志是唯一真相**:不新增自定义会话事件;插件状态要么能从日志重建,
151
+ 要么放进保险库或存储服务。
152
+ - **存储格式带版本号**:改动 `vault.json` / `key.json` 结构时必须升版本并能读旧数据。
153
+
154
+ ## 八、项目结构
155
+
156
+ | 路径 | 职责 |
157
+ | --- | --- |
158
+ | `index.js` | 插件入口、四个模型面向工具、配置解析、凭据解析 |
159
+ | `lib/vault.js` | 加密保险库(AES-256-GCM + DPAPI / scrypt) |
160
+ | `lib/ssh.js` | OpenSSH 驱动:参数构造、askpass 注入、失败归因、等待与脱离脚本 |
161
+ | `lib/background.js` | `ctx.jobs` 后台任务适配 |
162
+ | `scripts/set-secret.mjs` | 离线口令录入(不经过 DSH,也不经过模型) |
163
+ | `test/` | 回归;前三个免主机,后三个需要真实目标 |
164
+
165
+ ## 九、许可
166
+
167
+ 提交贡献即表示同意以 [MIT](LICENSE) 授权。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-ssh-vault contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,453 @@
1
- # Temporary Holding Version
1
+ <div align="center">
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
+ # dsh-ssh-vault
4
+
5
+ ![DSH-SSH-Vault banner](assets/banner.png)
6
+
7
+ **English** | [简体中文](README.zh-CN.md)
8
+
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
10
+ [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](package.json)
11
+ [![DSH plugin](https://img.shields.io/badge/DeepSeek%20Harness-plugin-4f7cff.svg)](https://github.com/deepseek-ai)
12
+ [![Version](https://img.shields.io/badge/version-2.0.0-informational.svg)](package.json)
13
+ [![Tests](https://img.shields.io/badge/test-321%20cases-brightgreen.svg)](test)
14
+
15
+ </div>
16
+
17
+ SSH for DeepSeek Harness agents: host address, port, account, password and private key live in an encrypted vault, and the conversation only ever uses an alias. The model never sees a password, and a password never reaches a command line or a remote process list.
18
+
19
+ ## Overview
20
+
21
+ **Windows only.** The vault data key is protected by Windows DPAPI and the `SSH_ASKPASS` helper is a Windows `.cmd` wrapper; the plugin declares `os: ["win32"]` and refuses to start anywhere else rather than storing the key as plaintext.
22
+
23
+ Three capabilities in one plugin:
24
+
25
+ - **Model-facing tools** — `ssh_hosts`, `ssh_run`, `ssh_transfer`, `ssh_probe`, so the agent operates remote hosts by alias.
26
+ - **Settings UI** — add, edit and delete hosts in Settings → SSH Connections, and test reachability. A password entered there travels in an HTTP body, never through the session log.
27
+ - **`/hosts` command** — probe hosts and run commands from the conversation without going through the model.
28
+
29
+ Under the hood it drives the local OpenSSH client (`ssh` / `scp`). It does not implement the SSH protocol itself.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ dsh plugin --profile desktop install dsh-ssh-vault
35
+ ```
36
+
37
+ From a local checkout. The argument is a path, so `.` works when you are already inside the plugin directory:
38
+
39
+ ```bash
40
+ # inside the checkout
41
+ dsh plugin --profile desktop install .
42
+
43
+ # or point at it from elsewhere
44
+ dsh plugin --profile desktop install ../dsh-ssh-vault
45
+
46
+ # or install from a packed tarball
47
+ dsh plugin --profile desktop install ./dsh-ssh-vault-<version>.tgz
48
+ ```
49
+
50
+ **`--profile` is required.** DSH isolates plugins per profile, and one machine can carry several (`desktop`, `cli`, …). Omitting it falls back to whichever profile is currently default, which is an easy way to install into the wrong place. Confirm the landing spot with:
51
+
52
+ ```bash
53
+ dsh plugin --profile desktop list
54
+ ```
55
+
56
+ **Restart DSH after installing.** Host-side tools and routes are loaded at startup.
57
+
58
+ **This package is Windows only.** `package.json` declares `os: ["win32"]`, so `dsh plugin ... install` refuses to install it on Linux or macOS; a checkout loaded on another platform refuses to start for the same reason (see [Known limitations](#platform-and-authentication)).
59
+
60
+ ### Requirements
61
+
62
+ | Item | Requirement |
63
+ | --- | --- |
64
+ | Node | ≥ 20 |
65
+ | Machine | Windows 10 / 11 with the OpenSSH Client enabled (`ssh -V` prints a version) |
66
+ | Platform | **Windows only** (`win32`) — installation and loading are refused on Linux / macOS |
67
+
68
+ ## Quick start
69
+
70
+ 1. **Install, then restart DSH.**
71
+ 2. Open **Settings → SSH Connections**, click "Add connection", fill in alias, address, port, account, and choose password or private-key authentication.
72
+ Each card carries a health dot that is checked automatically: grey = not checked, yellow = checking, green = reachable, red = unreachable.
73
+ 3. Say something in the conversation, for example:
74
+
75
+ ```
76
+ run uptime on kali
77
+ ```
78
+
79
+ The model calls `ssh_run` and the result appears as a terminal card.
80
+
81
+ Do not paste passwords into the conversation. A password entered in Settings goes straight into the encrypted vault and never enters the model context.
82
+
83
+ ## Usage
84
+
85
+ ### Model-facing tools
86
+
87
+ | Tool | Purpose | Typical phrasing |
88
+ | --- | --- | --- |
89
+ | `ssh_hosts` | Manage the vault (list / show / add / update / remove) | "which hosts do I have", "change kali's note" |
90
+ | `ssh_run` | Run a command on a host, return stdout and stderr | "check disk usage on kali" |
91
+ | `ssh_transfer` | Upload or download a file (scp) | "copy this file to /tmp on kali" |
92
+ | `ssh_probe` | Reachability self-check returning system facts | "is kali still reachable" |
93
+
94
+ **Where to see the result:** calls to `ssh_run`, `ssh_probe` and `ssh_transfer` render as a **terminal card** — a header with "host · command" and a state dot, above a scrollable monospace output area. The dot is blue while running, grey on success, red on failure (including a non-zero exit code).
95
+
96
+ ### Settings UI
97
+
98
+ Settings → SSH Connections provides:
99
+
100
+ - A host list with a health dot per card; hover for detail
101
+ - Add / edit / delete connections (the alias is editable, credentials move with it)
102
+ - A "Test connection" button per card, whose result also drives the dot
103
+ - "Open in file manager" for the vault directory
104
+ - Password and private-key authentication; a key can be uploaded whole and is written into the vault with its permissions tightened
105
+
106
+ ### Language
107
+
108
+ Everything the plugin shows follows the language chosen in **Settings → General → Language**; there is no separate switch here.
109
+
110
+ - The settings page and the in-conversation SSH cards get their copy from the host's locale service, so they change the moment the language does.
111
+ - `/hosts` output, transport diagnostics, Settings-API errors, vault errors and background-job status are generated on the Host; they read the same persisted preference (`locale.preference`) and follow later changes, falling back to the request's `Accept-Language` only when nothing was ever chosen.
112
+ - **Model-facing definitions stay English**: tool names, descriptions and parameter schemas belong to the model interface, not to the UI.
113
+
114
+ ### The `/hosts` command
115
+
116
+ Start a line with `/` in the composer and the command runs **without going through the model**.
117
+
118
+ ```
119
+ /hosts list every host, grouped into online / offline
120
+ /hosts kali probe one
121
+ /hosts kali,win10 probe several (either comma form works)
122
+ /hosts kali; apt update run a command on kali
123
+ /hosts kali,win10; uptime run on every reachable host
124
+ ```
125
+
126
+ - Separators accept both ASCII and full-width forms: `,` and `,`, `;` and `;`
127
+ - If the text after the semicolon reads as natural language (e.g. "check the disk"), the command **does not run it** and tells you to send that sentence to the conversation instead — command output never reaches the model, so such requests cannot go through the command
128
+ - Output renders as **two cards**: reachability and command execution, each with its own state dot
129
+ - **Picking `/hosts` from the `/` menu only claims it**: the composer shows the command token with its hint. Press Enter again to run it bare (list hosts), or type `alias; shell command` as its arguments. To go back to a normal prompt instead, delete the command token — until then, whatever you type is the command's argument
130
+ - **Before the first message the result appears above the composer.** DSH treats a transcript containing only command rows as an inactive view, so a bare `/hosts` in a brand-new conversation used to look like "nothing happened" until you sent a message. The plugin now mirrors the `/hosts` cards (running state included) in the composer dock while the session is still blank; once the first message exists the transcript takes over, so nothing is duplicated
131
+ - A bare call probes only when the health cache is cold, in parallel and with a 4s budget, so it answers within seconds even when several hosts are unreachable
132
+
133
+ ## Architecture
134
+
135
+ ### Two halves
136
+
137
+ This is a **Host + Client** plugin; the two halves load through different mechanisms:
138
+
139
+ | Half | Entry | Loading |
140
+ | --- | --- | --- |
141
+ | **Host** | `index.js` | Cordis plugin, inserted into the Loader through `cordis.patch.yml` |
142
+ | **Client** | `client.js` | Browser module, declared via `dsh.client` in `package.json` and injected into the page by the host |
143
+
144
+ The Host half carries every sensitive operation: vault encryption, driving the local `ssh` client, web routes, and the registration of commands and tools.
145
+ The Client half only renders and collects form input; it never executes a command.
146
+
147
+ ### Data flow
148
+
149
+ ```mermaid
150
+ flowchart TB
151
+ subgraph entry [Entry points]
152
+ chat[Conversation / model tool call]
153
+ settings[Settings form]
154
+ cmd["/hosts command"]
155
+ end
156
+
157
+ subgraph host [Host half — index.js]
158
+ resolve[Resolve alias → read credentials → build ssh args]
159
+ vault[(Vault<br/>AES-256-GCM ciphertext)]
160
+ api["Web routes /ssh-vault/*"]
161
+ exec[Spawn local ssh / scp]
162
+ end
163
+
164
+ subgraph client [Client half — client.js]
165
+ toolcard[Tool terminal card]
166
+ cmdcard["/hosts double card"]
167
+ form[Connection form]
168
+ end
169
+
170
+ proxy[Remote host]
171
+
172
+ chat --> resolve
173
+ cmd --> resolve
174
+ settings -->|POST JSON| api
175
+ form -->|same-origin, document-relative| api
176
+ api --> vault
177
+ resolve --> vault
178
+ resolve --> exec
179
+ exec -->|stdout / stderr| toolcard
180
+ exec -->|stdout / stderr| cmdcard
181
+ exec <--> proxy
182
+ ```
183
+
184
+ Three invariants hold across that chain:
185
+
186
+ 1. **A password never reaches a command line** — local ssh receives it through `SSH_ASKPASS`; remote sudo is primed over stdin
187
+ 2. **Only ciphertext touches disk** — AES-256-GCM, with the data key protected by Windows DPAPI (or, optionally, derived from a master passphrase via scrypt)
188
+ 3. **`~/.ssh` is never touched** — `known_hosts` points into the vault directory and `HashKnownHosts` is enabled
189
+
190
+ ## Configuration
191
+
192
+ The plugin has **no user-configurable options**. `config: {}` in `cordis.patch.yml` is an empty object, and the Loader entry `id` is fixed as `ssh-vault`.
193
+
194
+ Behaviour is shaped by the following:
195
+
196
+ | Item | Notes |
197
+ | --- | --- |
198
+ | Vault directory | Defaults to `$DSH_HOME/ssh-vault`; `DSH_HOME` is decided by DSH |
199
+ | Key protection | `dpapi` (Windows DPAPI, the default) or `passphrase` (scrypt-derived key, no key material on disk). `file` stores the key as plaintext and is an explicit opt-in only |
200
+ | Timeouts | `ssh_run` defaults to 120s, adjustable, capped at 30 minutes; commands inside `/hosts` are fixed at 120s |
201
+ | Health checks | Settings page and `/hosts` probe with a 15s timeout; the background sweep refreshes every 5 minutes |
202
+
203
+ ## API and extension points
204
+
205
+ Host-side services are reached through `ctx.inject` as optional dependencies; a missing service does not fail the plugin, it just disables that capability.
206
+
207
+ ### Model tools (`ctx.tools.register`)
208
+
209
+ | Tool | Key parameters |
210
+ | --- | --- |
211
+ | `ssh_hosts` | `action` required (list / show / add / update / remove); `reveal` controls whether address and account are masked |
212
+ | `ssh_run` | `host`, `command` required; optional `sudo`, `timeoutMs`, `workdir`, `run_in_background`, `wait_for`, `bindAddress` |
213
+ | `ssh_transfer` | `host`, `direction`, `local`, `remote` required; optional `recursive` |
214
+ | `ssh_probe` | `host` required; optional `timeoutMs`, `bindAddress` |
215
+
216
+ The three terminal-style tools also register `presentCall` / `presentResult` hooks, but **the current DSH version has no consumer for them**; presentation is handled by the slots below.
217
+
218
+ ### Client slots (`ctx.slots.register`)
219
+
220
+ | Slot | Key | Effect |
221
+ | --- | --- | --- |
222
+ | `settings.section` | `id: ssh-vault`, `order: 20`, `label` | The "SSH Connections" page in the settings panel |
223
+ | `tool.call.toolview` | `ssh_run`, `ssh_probe`, `ssh_transfer` | Renders those tool calls as terminal cards |
224
+ | `conversation.chat.commandview` | `hosts` | Renders `/hosts` output as two terminal cards |
225
+
226
+ The `tool.call.toolview` key space is open; none of these three names were taken, so no built-in UI is shadowed.
227
+
228
+ All three registrations also carry `locale: 'ssh-vault'` — the namespace registered with the host's locale service — which is what makes the rendered cards re-render and re-read their copy when the language changes.
229
+
230
+ ### Human command (`ctx.commands.register`)
231
+
232
+ | Command | Notes |
233
+ | --- | --- |
234
+ | `/hosts` | Probe hosts / run a command on them. Form: `alias[,alias][; shell command]` |
235
+
236
+ The command's `description` and `input.hint` are **re-registered only when the interface language changes**. The disposer returned by `commands.register` destroys the current definition, so replacing it while the user is typing arguments strips the composer of its command context (backspace stops working, and the UI needs a restart). That is why registration is never driven by timers or health results; a locale switch is a deliberate Settings action with nothing being typed, making it the one safe moment to swap the definition.
237
+
238
+ ### HTTP routes (`ctx.webServer.register`)
239
+
240
+ Mounted at `/ssh-vault` (`kind: 'prefix'`, **no trailing slash**). `POST` only, JSON in and out, with an envelope of `{ ok: true, value }` or `{ ok: false, error }`.
241
+
242
+ | Path | Effect |
243
+ | --- | --- |
244
+ | `/ssh-vault/list` | Host list (address and account masked, no passwords) |
245
+ | `/ssh-vault/save` | Create / update / rename |
246
+ | `/ssh-vault/remove` | Delete |
247
+ | `/ssh-vault/test` | Reachability probe |
248
+ | `/ssh-vault/reveal` | Return unmasked address and account |
249
+ | `/ssh-vault/openVault` | Open the vault directory in the system file manager |
250
+
251
+ `openVault` **accepts no path argument at all** — it can only open the plugin's own vault directory.
252
+
253
+ Every request crosses the Host Connection trust fence **before** the handler looks at the method, the body or the vault: it calls `ctx.connection.requestRejection(req)`, the same Host/Origin check and browser-session authentication used by `/api`. A rejected request gets `401` / `403` and never reaches the vault, so a DNS-rebinding or cross-site call cannot read host data or trigger a probe.
254
+
255
+ ### About `ctx.systemPrompt.section`
256
+
257
+ **This plugin does not use that extension point.** Every tool carries its own `description`, and no section is injected into the system prompt, so prompt length and ordering are unaffected.
258
+
259
+ ## Lifecycle
260
+
261
+ ### Startup
262
+
263
+ 1. The Loader reads `cordis.patch.yml` and inserts the entry with `id: ssh-vault`
264
+ 2. The host `import`s `index.js` and calls `apply(ctx, config)`
265
+ 3. `apply` opens the vault, registers the four tools, and — where the services exist — the web routes, the `/hosts` command and the background health timer. It also subscribes to the host settings document so it can read the interface language and follow later switches
266
+ 4. The Client half is injected into the page per the `dsh.client` declaration, and its `apply` registers the three slots and its `ssh-vault` dictionary with the locale service
267
+
268
+ ### Unload and resource reclamation
269
+
270
+ Async resources are wrapped in `ctx.effect`, and Cordis runs the returned cleanups in reverse order on teardown:
271
+
272
+ - Tool and command disposers (including the locale-switched command definition)
273
+ - Web route disposers
274
+ - The settings-document subscription and its startup retry timers
275
+ - The health sweep's `setTimeout` / `setInterval`
276
+ - The vault handle
277
+ - The client's locale dictionary and slot registrations
278
+
279
+ Vault data **survives uninstall** and is available again after reinstall.
280
+
281
+ ### Hot update
282
+
283
+ The two halves behave differently, and this is easy to get wrong:
284
+
285
+ | Change | Takes effect by |
286
+ | --- | --- |
287
+ | `client.js` and slot rendering | Reinstall the bundle, then **reload the page** |
288
+ | `index.js` / `lib/**` | **Restart DSH**; reinstalling does not replace an already-loaded module generation |
289
+ | `package.json` `version` | The version shown in Settings is read from this file by the Host |
290
+
291
+ **Do not infer that the browser has new code from an unchanged slot id** — slot registration reflects the host side, not the module actually running in the page.
292
+
293
+ ## Known limitations
294
+
295
+ ### Platform and authentication
296
+
297
+ - **Windows only.** The data key is protected by Windows DPAPI and the `SSH_ASKPASS` helper is a Windows `.cmd`; the repository therefore declares `os: ["win32"]`. There is no Linux/macOS mode: running the code there would have to fall back to a plaintext `key.json`, so the plugin refuses to load instead. SSH still targets Linux/Unix hosts *remotely* — the restriction is on the machine running DSH.
298
+ - **`dpapi` is the default key protection; `passphrase` is available** (a master passphrase, with the key derived through scrypt). `file` stores the key as plaintext and must be chosen explicitly; it is not a fallback.
299
+ - **The Settings UI supports only password and private-key authentication.** The `agent` auth type can be set through `ssh_hosts add`, but the form cannot enter or edit it
300
+ - **Remote shells supported: bash / sh / cmd / powershell.** Sudo priming applies to POSIX shells only
301
+
302
+ ### Concurrency
303
+
304
+ - **Connections to one host are serialised in-process.** Every call opens a fresh SSH
305
+ connection, and a burst of them — especially concurrent `scp` — trips sshd's connection
306
+ limits and accumulates failed-auth counters toward an account lock. Background jobs are
307
+ exempt from the queue so a long task does not block that host for everything else.
308
+ - **The vault has no cross-process lock.** Two DSH instances (different profiles pointing at the same vault directory) writing at the same time means the later write wins
309
+ - Within one profile, writes from several sessions serialize on the event loop, but frequent concurrent edits of the same host are still not advisable
310
+
311
+ ### Account lockout
312
+
313
+ Windows OpenSSH disconnects a **locked** account outright instead of returning
314
+ `Permission denied`, so a locked account looks like a network problem: the port answers, the
315
+ banner arrives, then the connection is reset. If "port open, banner fine, still cannot
316
+ connect" shows up, check the account before chasing the firewall — `net user <account>` on
317
+ Windows, `faillock --user <account>` on Linux. Locking the management account also kills
318
+ key-based login, so keep a second administrative path available when experimenting with
319
+ lockout policy. `ssh_run` warns before commands that change account state.
320
+
321
+ ### Host keys
322
+
323
+ `StrictHostKeyChecking=accept-new` is used: **the first connection is trusted automatically**, and any later fingerprint change is refused. There is no separate fingerprint-verification flow, so judging whether a first-time target is trustworthy is up to the operator.
324
+
325
+ ### Output and timeouts
326
+
327
+ - `ssh_run` caps a single result at 200000 characters and truncates beyond that
328
+ - Each command inside `/hosts` echoes at most 40 lines
329
+ - Commands inside `/hosts` have a fixed 120s timeout that is not configurable
330
+ - Foreground tool calls have **no streaming output**: the result appears in one piece when the command ends. To watch a long job, use `run_in_background`, whose output lands in a job card
331
+
332
+ ### Interface
333
+
334
+ - The **navigation icon next to "SSH Connections" is drawn by the host from the section id**. The `settings.section` registration accepts only `id` / `order` / `label`, so a plugin cannot supply its own icon
335
+ - Command arguments have **no candidate-list mechanism** (`CommandInputDescriptor` carries only `hint` and `attachments`), so `/hosts` cannot offer alias completion
336
+ - A command with an `input` hint is **claimed, not executed**, when picked from the `/` menu: this is DSH behaviour and the plugin cannot bypass it. The hint (localized) explains that a second Enter runs the command bare
337
+ - **DSH does not treat a transcript made only of command rows as an active view.** `/hosts` compensates with the composer-dock mirror described above; the built-in rule still applies to other plugins' commands
338
+ - The **interface language is read from the shared host settings**, so with several browsers attached to one host the most recently written preference wins; DSH Desktop is a single-user surface, where this is the intended behaviour
339
+
340
+ ### Open questions
341
+
342
+ Not verified in detail; check the source or upstream docs before relying on them:
343
+
344
+ - Whether DSH exposes a structured field for command results, which would replace the textual section markers (`─── 连接状态:ok ───`) used today
345
+ - Whether the host will connect a consumer to `presentCall` / `presentResult` in a future version
346
+
347
+ ## Security boundary
348
+
349
+ ### What it touches
350
+
351
+ | Category | Accessed | Detail |
352
+ | --- | --- | --- |
353
+ | Filesystem | **Yes** | The vault directory (`vault.json`, `key.json`, `known_hosts`, uploaded key copies) and any private-key path you supply; **never** `~/.ssh` |
354
+ | Network | **Yes** | Outbound SSH through the local `ssh` / `scp` processes; loopback HTTP routes (same origin, behind the Host Connection trust fence) |
355
+ | Terminal | **Yes** | Spawns the local OpenSSH client; `openVault` launches the system file manager |
356
+ | External APIs | **No** | No third-party service is contacted |
357
+ | User data | **Yes** | Host address, port, account, password, private key, note, tags |
358
+
359
+ ### Password handling
360
+
361
+ - **Local SSH**: passed through `SSH_ASKPASS`, never on the command line or in a process list
362
+ - **Remote sudo**: primed over stdin, never on the remote command line or in `ps`
363
+ - **On disk**: only AES-256-GCM ciphertext; the data key is protected by DPAPI or a scrypt-derived key
364
+ - **Model context**: a password never appears in tool arguments or results; one entered in Settings travels in an HTTP body and stays out of the session log
365
+ - **`credentialRef`**: when a host points at an entry in the Harness credential store, the password is read at connect time and never passes through this plugin
366
+
367
+ ### Trust boundary
368
+
369
+ The web routes ride on the DSH web server and ask the Host Connection service for a rejection first (the same Host/Origin fence and browser-session authentication as `/api`). The plugin adds no authentication of its own beyond that, so **do not expose the DSH web port to an untrusted network**.
370
+
371
+ ### Reporting a vulnerability
372
+
373
+ See [SECURITY.md](SECURITY.md).
374
+
375
+ ## FAQ
376
+
377
+ **"SSH Connections" is missing from Settings after installing.**
378
+ Check you installed into the right profile: `dsh plugin --profile <name> list`. Then restart DSH — the client bundle is injected at page bootstrap, so a page reload alone is not enough.
379
+
380
+ **A tool call reports a missing route, or Settings says the host is still running old code.**
381
+ The host code was updated but the process was not restarted. Restart DSH.
382
+
383
+ **The health dot stays red although the host answers ping.**
384
+ The dot tests the **SSH port 22**, not ICMP. Check that `sshd` is running, that a firewall allows the port, and that the address stored in the vault is still current.
385
+
386
+ **A connection test reports "connection not established within 15000ms".**
387
+ Again a network-layer signal: the address changed, a firewall is blocking, or the machine is up without `sshd`. It is unrelated to login credentials.
388
+
389
+ **A word gets a red squiggle while typing `/hosts`.**
390
+ That is why the command is named `hosts`: the composer is a Lexical editor whose plain text nodes are subject to browser spellcheck, and `hosts` is a dictionary word while `ssh` is not.
391
+
392
+ **`/hosts kali,win10` says "not found".**
393
+ Check the alias spelling against the vault; it is case-sensitive. Run `/hosts` with no arguments to list the valid aliases.
394
+
395
+ **A private key upload is rejected.**
396
+ Three common causes: a `.pub` public key was chosen, a PuTTY `.ppk` was chosen (convert it with `puttygen` first), or the file has no `BEGIN … PRIVATE KEY` header.
397
+
398
+ **Output from several hosts runs together.**
399
+ Use the `/hosts host; command` form; the output splits into a reachability card and a command card.
400
+
401
+ **I switched DSH to English but some plugin text is still Chinese.**
402
+ From 1.15.0 the whole surface follows the DSH language. After upgrading, reload the page once (client half) and restart DSH once (host half) to load the new code; after that, switching the language in Settings applies immediately, no reload needed. Model-facing tool definitions stay English by design.
403
+
404
+ **Does it work on Linux or macOS?**
405
+ No. The data key is protected by Windows DPAPI and the `SSH_ASKPASS` helper is a Windows `.cmd`; any non-Windows path would have to store the key as plaintext, so the plugin refuses to load there. `package.json` declares `os: ["win32"]` and installation is rejected elsewhere. The *remote* hosts can of course be Linux/Unix — only the machine running DSH must be Windows.
406
+
407
+ **Client code changed but the interface did not.**
408
+ Reload the page. Host-side changes need a DSH restart.
409
+
410
+ ## Contributing
411
+
412
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The hard rules: **zero runtime dependencies** (Node built-ins only on the Host side; only the host-provided `react` on the Client side), no real credentials or addresses in the repo, and a password must never reach a command line or the session log.
413
+
414
+ ```bash
415
+ npm test # host-free regression suite
416
+ npm run test:live # end-to-end cases against real hosts
417
+ ```
418
+
419
+ ## License
420
+
421
+ [MIT](LICENSE)
422
+
423
+ ## Cheat sheet: start · use · stop · uninstall
424
+
425
+ ```bash
426
+ # Install (profile required)
427
+ dsh plugin --profile desktop install dsh-ssh-vault
428
+ dsh plugin --profile desktop install . # from inside a local checkout
429
+
430
+ # Confirm where it landed
431
+ dsh plugin --profile desktop list
432
+
433
+ # Reinstall to refresh the installed copy
434
+ dsh plugin --profile desktop remove dsh-ssh-vault
435
+ dsh plugin --profile desktop install dsh-ssh-vault
436
+
437
+ # Uninstall
438
+ dsh plugin --profile desktop remove dsh-ssh-vault
439
+ ```
440
+
441
+ **Starting and stopping** are handled by the DSH process itself: the plugin loads when DSH starts and unloads when DSH exits. It has no separate process and no resident background service.
442
+
443
+ Uninstalling **does not delete the vault**. Data stays in `$DSH_HOME/ssh-vault` and is usable again after reinstall; delete that directory by hand to remove it entirely.
444
+
445
+ Everyday reference:
446
+
447
+ ```
448
+ Settings → SSH Connections manage hosts, test reachability, open the vault directory
449
+ /hosts list hosts and their online state
450
+ /hosts kali probe one host
451
+ /hosts kali,win10; uptime run a command on several
452
+ (just talk) let the model pick hosts and call the tools
453
+ ```