@winking-r/dsh-uniterminal 0.0.0-stage → 0.2.16

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WinKing-R
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,220 @@
1
- # Temporary Holding Version
1
+ # UniTerminal · DSH 远程终端插件
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 / SFTP / 端口转发,挂在 DSH 右侧栏里。**一个侧边栏标签里装多条会话**(顶部会话栏切换;同一台主机连两次显示 `#2`),
4
+ 每条会话的终端、文件、隧道三视图并列。
5
+
6
+ ## 状态
7
+
8
+ | 项 | 值 |
9
+ |---|---|
10
+ | 版本 | `0.2.16`(`0.x`:API 与配置项仍可能变,按语义化版本升号) |
11
+ | 许可证 | **MIT**(见 [LICENSE](LICENSE))——可自由使用、修改、商用,保留版权声明即可 |
12
+ | 环境 | DSH `>=0.2.1-rc.2`、Node `>=20` |
13
+
14
+ **本插件把密码交给操作系统密码箱保管(Windows 凭据管理器 / macOS 钥匙串 / Linux Secret Service),自己不实现任何加密。**
15
+ 只有「这台机器没有系统密码箱」时才回落到明文文件,并在界面上如实说明——细节见 [docs/SECURITY.md](docs/SECURITY.md)。
16
+
17
+ ```
18
+ 主机管理 → 连接 → ┌ 终端 (xterm + shell)
19
+ ├ 文件 (SFTP:浏览 / 上传 / 下载 / 删除)
20
+ └ 隧道 (-L 本地转发 / -R 远程转发 / -D SOCKS5)
21
+ ```
22
+
23
+ ## 安装
24
+
25
+ 前提:DSH `>=0.2.1-rc.2`(见本包 `package.json` 的 `dsh.engines`)、Node `>=20`。
26
+ 从源码装还需要 pnpm;用打好的 `tgz` 安装则**不需要**自己构建。
27
+
28
+ ```bash
29
+ # 在 DSH 的 desktop profile 目录下执行
30
+ cd ~/.dsh/profiles/desktop
31
+
32
+ # 方式一:从源码链接(改代码即生效,开发用)
33
+ pnpm add "link:<你的本仓库绝对路径>"
34
+
35
+ # 方式二:从目录安装(用打包好的 tgz,无需 Node 工具链)
36
+ pnpm add "file:<tgz 的绝对路径>"
37
+ ```
38
+
39
+ 然后在 `~/.dsh/profiles/desktop/package.json` 的 `dsh.profile.bundles` 数组里追加一行本包名:
40
+
41
+ ```json
42
+ "@winking-r/dsh-uniterminal"
43
+ ```
44
+
45
+ 重启 DSH。若右栏没有自动出现本插件,用右栏的 `+` 手动添加一个 `UniTerminal` 标签。
46
+
47
+ > **改动代码后必须重启 DSH**:宿主半边(`lib/index.js`)在启动时加载,硬刷新页面不会生效。
48
+
49
+ ### 卸载
50
+
51
+ ```bash
52
+ # 先从 dsh.profile.bundles 里删掉本包名,然后
53
+ cd ~/.dsh/profiles/desktop
54
+ pnpm remove @winking-r/dsh-uniterminal
55
+ ```
56
+
57
+ 重启 DSH。主机档案与凭据留在 `~/.dsh/uniterminal/`,如需一并清除请见下方「数据存放」。
58
+
59
+ ## 功能
60
+
61
+ | 域 | 能力 |
62
+ |---|---|
63
+ | 主机 | 添加 / 编辑 / 删除(二次确认)/ 分组;从 `~/.ssh/config` 导入;连接前「测试」连通性;**孤儿会话可接管或关闭** |
64
+ | 认证 | 密码、私钥(读本地路径或粘贴内容)、**两者可同时启用**;按主机「记住密码」;跳板链 `ProxyJump` |
65
+ | 终端 | xterm.js,自适应尺寸,断线重连;面板重挂载与**页面刷新**后会话与输出不丢(存档落 `sessionStorage`) |
66
+ | 文件 | SFTP 浏览、多选上传(带进度、可取消)、下载、新建目录、删除 |
67
+ | 隧道 | `-L` 本地转发、`-R` 远程转发、`-D` SOCKS5;仅监听 `127.0.0.1`;随会话关闭自动回收 |
68
+ | **AI 可调用** | 注册两个工具:`uniterminal_list_sessions`(发现会话)与 `uniterminal_exec`(在会话里执行命令并取回 stdout/退出码)。**任何 AI 会话无需额外上下文即可使用**。默认 `mode=exec`(独立通道、不碰用户终端);`mode=shell` 会把命令与标记打进用户正在看的终端并留在屏幕上,仅用于延续 shell 状态 |
69
+
70
+ ## 配置项(DSH 插件设置)
71
+
72
+ | 键 | 默认 | 说明 |
73
+ |---|---|---|
74
+ | `term` | `xterm-256color` | 终端 TERM 类型 |
75
+ | `bufferBytes` | 1 MiB(`1048576`) | 每个会话的输出回放缓冲 |
76
+ | `connectTimeoutMs` | 20000 | 连接超时(毫秒) |
77
+ | `knownHostsPolicy` | `reject` | 未知主机指纹策略:`reject` 拒绝并要求显式信任 / `trust` 自动信任并落盘 |
78
+ | `agentExec` | `on` | 面向 AI 会话的 exec 入口:`off` 时既不注册 `uniterminal_exec` 工具,`/sessions/exec` 也一律 403 |
79
+ | `appearance` | 内置默认 | 终端外观默认值(字体、字号、行高、字距、主题模式与四个颜色)。用户改过的值存 `~/.dsh/uniterminal/appearance.json`;插件写不了宿主配置,只把这里当默认值来源 |
80
+
81
+ ## 数据存放(`~/.dsh/uniterminal/`)
82
+
83
+ | 文件 | 内容 | 敏感性 |
84
+ |---|---|---|
85
+ | `profiles.json` | 主机、分组、认证方式、跳板链。**绝不含密码或私钥内容** | 低 |
86
+ | `credentials.json` | 记住的口令与粘贴的私钥(**明文**)——**只在没有系统密码箱的机器上存在** | **高** |
87
+ | `security.json` | 凭据登记(`mode: plain` + `storage: keychain\|file`,即"密码存在哪") | 低 |
88
+ | `vault.bin` | **旧版**(已停用)加密凭据文件——不再被读取,可在界面上「清理旧版凭据」删掉 | — |
89
+ | `known_hosts.json` | 已信任的主机指纹 | 低 |
90
+ | `appearance.json` | 终端外观偏好(字体、字号、行高、主题与颜色),只影响界面 | 低 |
91
+ | `diag.log` | 诊断事件(不含任何凭据内容) | 低 |
92
+
93
+ ## 安全
94
+
95
+ 密码存在哪、谁能读到它、怎么清、怎么迁移:**[docs/SECURITY.md](docs/SECURITY.md)**(威胁模型 + 已知限制)。
96
+
97
+ 一句话版:**有系统密码箱就用系统密码箱**(Windows 凭据管理器 / macOS 钥匙串 / Linux Secret Service),
98
+ **插件自己不实现任何加密**;没有系统密码箱的机器回落明文文件,界面上会如实说明。
99
+
100
+ ## 已知限制(请先读这一段)
101
+
102
+ 1. **凭据存在哪,取决于这台机器有没有系统密码箱**:
103
+ - **有**(Windows 凭据管理器 / macOS 钥匙串 / Linux Secret Service)→ 密码交给操作系统保管,插件**不实现任何加密**;旧的明文库会**逐条搬进密码箱,搬完删掉**,不留明文副本。
104
+ - **没有** → 回落明文文件 `credentials.json`,靠 `chmod 0600` 与 Windows 账户目录权限保护;Windows 上 `chmod` 实际无效,真实防护来自 NTFS ACL。**不要在多用户共用的机器上勾选「记住密码」。**
105
+ - **密码箱这一刻用不了**(被锁 / 服务没起)→ 临时改用本机文件,界面会明说;此前存在密码箱里的条目暂时取不到。
106
+ 界面表单页常驻小字会说明当前用的是哪一种(详见 [docs/SECURITY.md](docs/SECURITY.md))。
107
+ **不再提供插件自管的加密档位**:旧版那套「主密码 + 插件自管加密文件」已整体删除,既不提供也不支持。
108
+ 本机若还留着旧版凭据文件,界面会提示「清理旧版凭据」。**清理只会丢弃旧格式文件里那份读不出来的数据——明文库里已记住的密码不受影响**;旧档里的那份密码需要重新输入一次。
109
+ 2. **不提供终端锁屏**(明确不做的决策,不是遗漏)。离开座位时已连接的会话没有保护——需要时请自行锁屏或断开连接。
110
+ 3. **`-R` 远程转发**已在真实服务器(OpenSSH 9.9,`AllowTcpForwarding yes`)验证通路成立。仍未解决的是:**停止隧道不会立即释放服务端的远端端口**,要等会话关闭(ssh2 不提供 cancel-tcpip-forward);在此期间到达的连接会被明确拒绝,不会穿透。
111
+ 4. **跳板链每跳使用同一套凭据**(同一口令或同一私钥)。「跳板机用 A 钥、目标机用 B 钥」尚未支持。
112
+ 5. **覆盖式上传**优先用 OpenSSH 的 `posix-rename@openssh.com` 做原子替换;服务器不支持时会退化为「先删目标再改名」,存在极短窗口(目标暂时不存在)。
113
+ 6. ~~没有会话列表 UI:刷新页面会丢失前端会话存档~~ → **已解决(M4-1)**:存档写入 `sessionStorage`,刷新后自动回到终端并重连;列表页会列出「未接管的会话」,可一键接管(无需重新输入密码)或关闭。
114
+ 7. **SFTP 路径记忆、`-R` 端口即时释放**在待办中。终端锁屏已明确不做。
115
+ 8. **shell 模式拒绝交互式/全屏程序**(vi/vim/nano/less/top/htop/watch/man/tmux/screen/ssh/su):它们会占满用户终端、提示符回不来。按分段首词判定,sudo vim、xargs vim 之类仍可绕过——真正的防线是审计日志。若将来需要让 AI 驱动 TUI,主流做法是塞进 detached tmux 会话(send-keys + capture-pane),届时需要在服务器安装 tmux。
116
+ 9. **AI 命令执行的安全边界**:工具默认开启,但只能操作**你已打开的会话**——AI 无法自行建立连接或提交凭据。每次执行都会写入 `diag.log`(命令预览截断 120 字符并 scrub 口令/私钥)。如不需要,把 `agentExec` 设为 `off`。
117
+
118
+ ## 结构
119
+
120
+ ```
121
+ src/
122
+ index.ts 宿主入口:注册路由、装配 store、启动 WS 桥
123
+ routes.ts HTTP API(全部 exact 路由 + loopback 围栏)
124
+ ws-bridge.ts 终端 WebSocket(每会话一条升级路径,回放 ring buffer)
125
+ config.ts 插件配置 schema
126
+ engine/ 协议无关层
127
+ types.ts SessionHandle / 通道能力接口
128
+ registry.ts 协议注册表(ssh 已实现;telnet/serial 留位)
129
+ ssh-transport.ts ssh2 适配:认证、跳板链、SFTP 通道缓存、转发通道
130
+ session-manager.ts 会话生命周期、重连、通道能力访问器
131
+ ring-buffer.ts 输出回放缓冲
132
+ sftp.ts SFTP 能力与三级 rename 策略
133
+ tunnel.ts -L/-R/-D 隧道管理与回收
134
+ store/ 持久化
135
+ profile-store.ts / known-hosts.ts / security.ts / ssh-config-import.ts
136
+ client/ React 半边(预打包进 __ModuleLoader__)
137
+ UniterminalTab.tsx 列表 / 表单 / 三视图切换
138
+ TerminalView.tsx xterm 封装
139
+ SftpPanel.tsx 文件面板
140
+ TunnelPanel.tsx 隧道面板
141
+ ```
142
+
143
+ ## API 一览
144
+
145
+ 全部 29 条路径(`src/routes.ts` 的 `UNITERMINAL_ROUTES`)。除最后一行的内部端点外,都是用户可见接口;
146
+ 所有端点都要求 **loopback 来源**,非回环地址一律 403。
147
+
148
+ | 方法 | 路径 | 用途 |
149
+ |---|---|---|
150
+ | GET | `/api/uniterminal/health` | 健康检查 |
151
+ | GET/POST | `/api/uniterminal/profiles` | 主机列表(GET)/ 新增与编辑(POST) |
152
+ | DELETE | `/api/uniterminal/profiles/<id>` | 删除主机 |
153
+ | POST | `/api/uniterminal/profiles/import` | 导入 ssh_config(预览,不写盘) |
154
+ | POST | `/api/uniterminal/profiles/test` | 测试连通性 |
155
+ | POST | `/api/uniterminal/profiles/key` · `/profiles/key/delete` | 粘贴私钥 / 清除 |
156
+ | POST | `/api/uniterminal/profiles/forget-password` | 取消「记住密码」并删掉那条凭据 |
157
+ | POST | `/api/uniterminal/folders` | 新建分组 |
158
+ | POST | `/api/uniterminal/folders/rename` | 分组改名 |
159
+ | POST | `/api/uniterminal/folders/delete` | 删除分组 |
160
+ | GET | `/api/uniterminal/groups` | 分组列表 |
161
+ | POST | `/api/uniterminal/groups/color` | 设置分组颜色 |
162
+ | POST | `/api/uniterminal/hosts/trust` | 首次连接信任主机指纹(写入 `known_hosts.json`;指纹已变则 409 拒绝) |
163
+ | GET/POST | `/api/uniterminal/appearance` | 外观设置:读取(GET)/ 保存增量(POST) |
164
+ | GET/POST | `/api/uniterminal/sessions` | 会话列表(GET)/ 建立会话(POST) |
165
+ | POST | `/api/uniterminal/sessions/exec` | 执行命令(`mode: exec` 独立通道 / `shell` 打进当前 shell) |
166
+ | POST | `/api/uniterminal/sessions/close` | 断开连接 |
167
+ | GET/POST | `/api/uniterminal/sftp/download` · `/sftp/upload` | 流式下载 / 上传 |
168
+ | POST | `/api/uniterminal/sftp/op` | ls / stat / mkdir / unlink / rmdir / rename / chmod |
169
+ | GET/POST | `/api/uniterminal/tunnels` | 隧道列表(GET)/ 启动(POST) |
170
+ | POST | `/api/uniterminal/tunnels/stop` | 停止隧道(`-R` 的服务端端口要等会话关闭才释放) |
171
+ | GET | `/api/uniterminal/security` | 凭据状态(`mode` 恒为 `plain`,另带 `storage` / `degraded` / `legacy`:密码存在哪、是否临时降级、本机是否还留着旧版文件) |
172
+ | GET | `/api/uniterminal/security/refs` | 已记住的条目名(只有名字,没有密钥) |
173
+ | POST | `/api/uniterminal/security/clear-legacy` | 清理旧版凭据文件(破坏性动作,body 需 `confirm: true`) |
174
+ | WS | `/api/uniterminal/ws/<sessionId>` | 终端数据流 |
175
+ | GET/POST | `/api/uniterminal/diag` · `/monitor` · `/sftp/upload-progress` | **内部用**:前端诊断上报、状态栏采样轮询、上传进度轮询。供本插件前端自己用,接口形状不承诺稳定 |
176
+
177
+ ## 开发与验证
178
+
179
+ ```bash
180
+ pnpm typecheck # tsc --noEmit
181
+ pnpm test # vitest,915 用例 / 64 文件
182
+ pnpm build # tsdown → lib/index.js (ESM) + lib/client.js (CJS)
183
+ ```
184
+
185
+ **915 个用例**覆盖:路由匹配器(复刻宿主 prefix 语义)、凭据库与密码箱降级、profile store 与分组迁移、SFTP 通道复用、隧道 loopback 围栏与回收、跳板链 sock 传递、上传原子提交与中断清理、前端组件的键盘可达性与对比度。
186
+
187
+ 验收记录见 `docs/acceptance-*.md`,决策与踩坑台账见 `docs/superpowers/plans/ledger-m0.md`(含 20 余条裁定与教训)。
188
+
189
+ ## 故障排查
190
+
191
+ **出问题先看 `~/.dsh/uniterminal/diag.log`**——事件里只有 id、路径、状态与错误文本,不含口令或私钥(写入前 scrub)。
192
+ 报问题时把**最后 50 行**贴出来,足够定位大多数故障。
193
+
194
+ ```bash
195
+ tail -n 50 ~/.dsh/uniterminal/diag.log
196
+ ```
197
+
198
+ 常见症状:
199
+
200
+ | 症状 | 先查什么 |
201
+ |---|---|
202
+ | 插件在右栏不出现 | `dsh.profile.bundles` 里有没有追加本包名;**重启过** DSH 没有(硬刷新不够) |
203
+ | 连接报 `unknown host key` | 主机指纹未信任;走一次「首次连接信任主机指纹」流程,或在设置里把 `knownHostsPolicy` 设为 `trust` |
204
+ | 提示「系统密码箱不可用」 | 密码箱被锁 / 服务没起;此时新密码会临时写进本机文件,此前存在密码箱里的条目暂时取不到 |
205
+ | 状态栏一直空白 | 远端不是 Linux(解析器按 `/proc` 取值),或该会话已断开 |
206
+
207
+ ## 设计取舍(供后续维护参考)
208
+
209
+ - **认证方式以界面为准**:请求里带 `auth` 就覆盖磁盘存档;未表态时才用存档默认值。明确表态(只选私钥)失败就报错,绝不静默降级用密码。
210
+ - **能力访问器,不暴露会话句柄**:`SessionManager` 只暴露 `openSftp(id)` / `openTunnels(id)`,路由层拿不到会话控制面。
211
+ - **SFTP 每连接一个通道**(缓存 Promise + 失效重建);隧道通道与真实 TCP 连接 1:1。
212
+ - **上传原子化**:写临时文件 → 成功才改名;中断或失败清理临时文件。
213
+ - **诊断日志不含凭据**:`diag.log` 只记 id / 路径 / 错误文本,口令与私钥在写入前 scrub。
214
+
215
+ ## 许可证
216
+
217
+ [MIT](LICENSE) © 2026 WinKing-R
218
+
219
+ 可以自由使用、修改、分发、商用,保留版权与许可声明即可。**软件按「原样」提供,不附任何担保**——
220
+ 这个插件会连 SSH、执行命令、读写远端文件,请自行承担使用风险。
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: uniterminal
3
+ name: "@winking-r/dsh-uniterminal"
@@ -0,0 +1,114 @@
1
+ # 安全说明(UniTerminal 凭据存储)
2
+
3
+ > 这一份是**凭据存储**的对外说明:威胁模型、边界、密码存在哪、怎么清、已知限制。
4
+ > 相关台账:`docs/superpowers/plans/ledger-m0.md` 的 M65(删掉自研密码学)与 M66(接操作系统密码箱)。
5
+
6
+ ## 一、威胁模型
7
+
8
+ 先说清楚**我们在防谁**,再说我们做了什么。分界线是"能碰到这台机器的用户账户"。
9
+
10
+ ### 1.1 本地攻击者(拿到了磁盘 / 用户账户)
11
+
12
+ | 攻击者能做的 | 我们的处境 |
13
+ |---|---|
14
+ | 读 `~/.dsh/uniterminal/` 下的文件 | `profiles.json` 里**没有密码**(只有主机信息与 `remember` 布尔)。凭据在**系统密码箱**里(Windows 凭据管理器 / macOS 钥匙串 / Linux Secret Service),它的访问控制由操作系统负责:同一台机器上的其他用户、离线拷贝的磁盘镜像都拿不到里面的内容。 |
15
+ | 读 `credentials.json`(**只在没有系统密码箱的机器上存在**) | 那台机器上密码是**明文**的。文件的保护只有两条:POSIX `0600`、Windows 上的 NTFS ACL(`%USERPROFILE%` 只有本人可读)。**同一台机器上的其他账户、拿到管理员权限的人、离线拷贝的磁盘镜像都能读到。** |
16
+ | 以你的账户身份运行程序 | 那他就是"你":系统密码箱会以你的身份解锁,凭据能被读走。**这一层不是插件能挡的**——操作系统把"你的账户"就定义为"你"。 |
17
+ | 读 `diag.log` | 诊断上报里**没有凭据字段**(见第二节)。 |
18
+
19
+ ### 1.2 远程攻击者(只能通过网络碰到插件)
20
+
21
+ | 攻击者能做的 | 我们的处境 |
22
+ |---|---|
23
+ | 从别的机器连插件的 HTTP 端点 | 所有 `/api/uniterminal/*` 端点都要求 **loopback 来源**,非回环地址一律 403,连接**不读请求体**。 |
24
+ | 通过插件去连攻击者控制的服务器 | 插件不代理任意 TCP:隧道只允许 SSH 的 `-L` / `-R` / `-D`,SFTP 与 exec 只能作用在**用户已经建立的会话**上。 |
25
+ | 在 SSH 会话里看密码 | 密码只在**宿主 Node 进程**里。会话建立后密码被交给 ssh2 客户端,之后不再持有。 |
26
+
27
+ ### 1.3 我们**不**防的
28
+
29
+ - 已经被攻陷的操作系统账户(等于"你就是攻击者")。
30
+ - 键盘记录器、屏幕录制这类**运行期**窃取——插件层面无解。
31
+ - 共享机器上的**其他人**:系统密码箱通常按**登录用户**隔离,所以共用同一个 Windows / Linux 账户的人仍然能读到凭据。
32
+
33
+ ## 二、永远不发生的事
34
+
35
+ 这四条是硬约束,任何改动都不许破坏(对应的测试在 `test/store/security.test.ts` 与 `test/routes.test.ts`):
36
+
37
+ 1. **密码永远不进浏览器。**
38
+ `GET /api/uniterminal/security/refs` **只回 ref 名字**(`profile:<id>` / `key:<id>` / `key-passphrase:<id>`),
39
+ 永远不回密钥。前端**不回填**密码框、不把密码放进任何变量 / DOM / 诊断。
40
+ 2. **密码永远不进 `profiles.json`。**
41
+ 档案里只有主机信息、`remember` 布尔与认证方式;请求体里带 `password` / `privateKey` / `passphrase` /
42
+ `secret` 的字段一律被路由层拒绝(`CREDENTIAL_FIELDS` 白名单)。
43
+ 3. **密码永远不进日志与诊断。**
44
+ `diag.log` 与 `reportClient()` 上报里只有 id、状态、类别;写入失败时错误消息里的密码会被 `scrub` 抹掉。
45
+ 4. **密码永远不落进插件自己的加密文件。**
46
+ 插件**不实现任何加密**:上一批的自研密码学(主密码 + scrypt + AES-256-GCM)已整体删除,
47
+ 那是一段"走不到"的攻击面。现在要么交给操作系统密码箱,要么明文文件(并如实说明)。
48
+
49
+ ## 三、密码存在哪
50
+
51
+ 启动时插件做一次**只读探测**(列举一次系统密码箱,不为了探测去写测试凭据),
52
+ 把结果登记进 `~/.dsh/uniterminal/security.json` 的 `storage` 字段:
53
+
54
+ | 后端 | `storage` | 密码存在哪 | 界面上的话 |
55
+ |---|---|---|---|
56
+ | Windows 凭据管理器 | `keychain` | Windows 凭据管理器(service `dsh-uniterminal`) | 密码保存在 Windows 凭据管理器 |
57
+ | macOS 钥匙串 | `keychain` | macOS 钥匙串(service `dsh-uniterminal`) | 密码保存在 macOS 钥匙串 |
58
+ | Linux Secret Service | `keychain` | 系统钥匙环(Secret Service) | 密码保存在系统钥匙环(Secret Service) |
59
+ | 没有系统密码箱 | `file` | 本机明文文件 `credentials.json`(0600 / NTFS ACL) | 这台机器没有可用的系统密码箱,密码明文保存在本机凭据文件(仅当前账户可读) |
60
+ | 曾用密码箱、现在临时降级 | `file` + `degraded: true` | 本机明文文件(**新写的**) | 系统密码箱暂时不可用,已临时改用本机文件保存 |
61
+
62
+ 界面常驻小字与上表最后两行的关系见 `docs/acceptance-m2a.md` 判据 3 的注记:
63
+ **文件后端下常驻小字是验收单那句原文**("密码以明文保存在本机凭据文件,受 Windows 账户权限保护"),
64
+ **密码箱后端下换成上表对应平台那一句**。
65
+
66
+ ### 3.1 为什么 Linux 必须显式 pin `secret-service`
67
+
68
+ Linux 上不显式指定后端时,底层库会"Secret Service → 内核 keyring"自动回落。
69
+ 而内核 keyring **不跨重启、也不是加密存储**——静默用它会让用户以为"密码存了",
70
+ 重启后却发现没了。插件因此显式 pin `{ linux: { store: "secret-service" } }`:
71
+ **该后端不可用时直接抛错**(然后降级到文件后端并如实告诉用户),而不是悄悄换一个更弱的存储。
72
+
73
+ ### 3.2 怎么清
74
+
75
+ - **取消某台主机的"记住密码"**:界面里取消勾选并保存,或调 `POST /api/uniterminal/profiles/forget-password`。
76
+ 那条凭据(系统密码箱里的条目 / 文件里的一行)会被删掉。
77
+ - **删掉粘贴的私钥**:`POST /api/uniterminal/profiles/key/delete`(连同它的口令短语)。
78
+ - **删掉系统密码箱里的全部条目**:用系统自己的工具清(Windows 凭据管理器 / 钥匙串访问 / Secret Service 的客户端)。
79
+ 插件不提供"一键清空系统密码箱"——那会动到别的应用写在同一个 service 下的东西。
80
+ - **删掉本机明文库**:删 `~/.dsh/uniterminal/credentials.json`(只在没有系统密码箱的机器上存在)。
81
+
82
+ ### 3.3 怎么迁移
83
+
84
+ 从"明文文件"升级到"系统密码箱"是**自动**的:
85
+
86
+ 1. 启动时探测到系统密码箱可用;
87
+ 2. 把 `credentials.json` 里的条目**逐条**写进密码箱;
88
+ 3. **全部写成功之后**才删掉 `credentials.json`(搬完不留明文副本);
89
+ 4. 中途失败 → **保留原文件、继续用文件后端**,并把这件事告诉界面(`degraded`)。
90
+
91
+ 绝不会出现"文件删了但密码没搬进去":删文件是最后一步,且只在全部写成功之后。
92
+
93
+ ## 四、已知限制
94
+
95
+ 1. **没有系统密码箱的机器上,密码是明文落盘的。**
96
+ 防护只有文件权限(POSIX `0600` / Windows NTFS ACL)。
97
+ **不要在多用户共用的机器上勾选"记住密码"。**
98
+ 2. **共用同一台机器、同一个系统账户的人,能读到你的凭据。**
99
+ 系统密码箱按登录用户隔离,但"同一个账户"就等于"同一把钥匙"。
100
+ 3. **系统密码箱被锁 / 服务没起时**,插件会**降级到本机文件**并如实说明。
101
+ 此时:
102
+ - **新记住**的密码会写进本机文件;
103
+ - **此前存在密码箱里**的那些条目**取不到**(界面会给"系统密码箱不可用,请稍后重试",
104
+ 而不是"请输入密码"——后者会骗你以为密码没记住);
105
+ - 密码箱恢复后,下次启动会重新探测并继续用密码箱。
106
+ 4. **降级期间写进文件的条目,在下次启动成功迁移时会一并搬进密码箱**;
107
+ 但**密码箱里已有而文件里没有的条目不会自动倒灌到文件**(降级时不做全量回写:
108
+ 那需要把密码箱里的每一条都读出来,风险大于收益)。
109
+ 5. **Linux 上没有 Secret Service**(无 GNOME Keyring / KWallet 的精简桌面)时,
110
+ 插件会走文件后端并明说——**不会**退到内核 keyring(不跨重启)。
111
+ 6. **没有终端锁屏**:离开座位时已建立的会话没有任何保护,需要时请自行锁屏或断开连接。
112
+ 7. **插件不提供自研加密**(主密码 / 自建加密文件已整体删除,见台账 M65)。
113
+ 需要更强的保护时,请用操作系统的全盘加密与账户隔离。
114
+