Files
navop/docs/design/ssh-runtime-shell-integration.md
胡飞 5236415412 fix(terminal): 修复 PROMPT_COMMAND 钩子在子 shell 报「未找到命令」(#217)
bash 分支把 PROMPT_COMMAND 注册成裸函数名。PROMPT_COMMAND 是 shell 局部机制,
但它的值可能被环境导出并被后续 shell(su、tmux、exec bash、嵌套 ssh)继承,
而那些 shell 里没有本文件定义的函数,于是每个提示符都多输出一行
「bash: __onetcli_precmd_bash:未找到命令」,即 issue #217 的现象。

- 钩子改为自带存在性判断的命令串:`command -v` 是内建命令,函数缺失时静默跳过、
  不产生 fork,也不受继承链路影响;
- 退出码必须在钩子里先取好再显式传给函数:钩子里的 `command -v` 会覆盖 `$?`,
  若仍让函数自己去读 `$?`,上报的 exit code 会恒为 0、`133;D` 失效;
- 补两个回归测试:继承钩子但没有函数的 shell 必须静默、主 shell 退出码必须仍上报;
- 设计文档记录该约束,避免以后被简化回裸函数名。

验证:`cargo test -p terminal`(503 lib 测试通过)、
`cargo clippy -p terminal --all-targets`(改动文件零告警);另在真实交互式 bash 中
对比复现:修复前子 shell 输出 2 行 command not found,修复后 0 行,
主 shell 的 `133;D` 退出码上报仍为 `false`→1、`true`→0。
2026-09-18 17:57:46 +08:00

216 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SSH Shell Integration 运行时注入(无远端写入)设计
> 提交:`82e3c5a3 feat(terminal): runtime SSH shell integration without remote writes`
## 背景与动机
旧版 SSH Shell Integration 在每次连接时通过独立 exec 通道向远端写入:
- `$HOME/.config/onetcli/shell_integration.sh`(集成脚本)
-`.bashrc` / `.zshrc` / `.bash_profile` / `.profile` 中插入 `# BEGIN/END ONETCLI SHELL INTEGRATION` managed block
这是"向目标系统文件写入",带来三个问题:
1. **合规风险**:对生产服务器、客户环境写入用户未显式同意的文件,需要默认禁用 + 风险提示 + 卸载入口等一整套安全设计;
2. **残留污染**managed block 逻辑复杂(awk 过滤、幂等替换、目录冲突恢复),出错时可能破坏用户 rc 文件;
3. **缓存复杂**:安装结果按 SSH client 生命周期缓存(`ShellIntegrationSetup`),重连/失效/过期写入需要专门防串。
参考 meatshell 的做法(运行时通过 PTY 注入,不落盘),navop 改为**每次连接时把集成脚本当作一条用户敲入的命令注入交互 shell**,会话结束一切消失,远端零写入。
## 核心方案
```
┌─ 连接建立 ─────────────────────────────────────────────┐
│ exec 通道: case $SHELL in *bash*|*zsh*) → 只读探测 │
│ ├─ 支持 → 交互通道 pty+shell,标记"请求注入" │
│ └─ 不支持 → 交互通道 pty+shell,纯裸终端 │
└────────────────────────────────────────────────────────┘
↓ 登录 expect 完成,首段有效输出(prompt 出现)
┌─ 注入 ────────────────────────────────────────────────┐
│ PTY 输入: " : __ONETCLI_RUNTIME_SETUP_1; eval $'...'\r" │
│ (脚本单行化 + ANSI-C 引号;期间用户输入进暂存队列) │
└────────────────────────────────────────────────────────┘
↓ 远端回显 + 脚本执行
┌─ 回显抑制 ────────────────────────────────────────────┐
│ 输出进抑制缓冲,直到私有标记 OSC 1337;ShellIntegration │
│ Ready=1;跨 chunk 拆分可对齐,64KB 截断保护 │
└────────────────────────────────────────────────────────┘
↓ 标记之后的输出恢复转发
┌─ 就绪握手 ────────────────────────────────────────────┐
│ 等首个 OSC 133;B(prompt 渲染完成)→ 放行暂存的用户输入│
│ 5s 超时 → 发 Ctrl+C、丢弃缓冲、降级为裸终端 │
└────────────────────────────────────────────────────────┘
↓ 正常会话
OSC 133 A/B/C/D + OSC 7 + OSC 1337 协议与旧版完全一致
```
## 关键实现细节
### 1. 注入命令构造(`ssh_shell_integration.rs`
```
" : __ONETCLI_RUNTIME_SETUP_1; eval $'<脚本体>'\r"
```
- **前导空格**`HISTCONTROL=ignorespace`(多数发行版默认)使该行不进 shell history
- **`eval $'...'`ANSI-C quoting**:把 99 行脚本压成单行。单引号内的 `'` 转义为 `\'`,换行转义为 `\n`。bash/zsh 都支持;
- **bash 历史兜底清理**:脚本尾部用 `history -d` 删除本条目(防 `ignorespace` 未生效);
- **`\r` 结尾**:模拟终端 Enter(网络设备 CLI 也只认 CR)。
### 2. 脚本体来源(`shell_integration.sh`
复用嵌入式脚本(`include_str!`),仅两处适配:
- 跳过文件头部的交互守卫与 `_ONETCLI_SHELL_INTEGRATED` 幂等守卫(前 3 行,运行时注入天然满足这些条件);
- `__onetcli_precmd_common` 中注入轮次识别:`_ONETCLI_RUNTIME_SETUP` 存在时跳过命令记录(避免把注入命令自己记为"用户命令")。
#### bash 钩子必须自带存在性判断(Issue #217)
bash 分支注册到 `PROMPT_COMMAND` 的不是裸函数名,而是自带判断的命令串:
```sh
__ONETCLI_EXIT=$?; command -v __onetcli_precmd_bash >/dev/null 2>&1 && __onetcli_precmd_bash "$__ONETCLI_EXIT"
```
`PROMPT_COMMAND` 是 shell 局部机制,但它的值可以被导出并被后续 shell 继承
(服务器 profile 里显式 `export PROMPT_COMMAND=...`,再叠加 `su``tmux``exec bash`
嵌套 ssh 等链路)。继承它的 shell 里并没有本文件的函数定义,裸函数名会让这些 shell 的
每个提示符都多输出一行 `bash: __onetcli_precmd_bash: 未找到命令`,即 Issue #217
两点实现约束:退出码必须在钩子里先取好再显式传给函数——钩子里的 `command -v` 会覆盖 `$?`
若还像以前那样让函数自己去读 `$?`,上报的 exit code 会恒为 0`133;D` 失效;
`command -v` 是内建命令,不产生 fork。
另注意 `set -a`(allexport)会把函数定义一并导出,那种环境下子 shell 反而能拿到函数、
不会报错——所以只有判断存在性才是通用兜底,不能依赖某一种导出方式。
### 3. 探测(`ssh_backend.rs`
```sh
case "${SHELL:-}" in *bash*|*zsh*) printf '__ONETCLI_SHELL_SUPPORTED__=1\n';; esac
```
- 通过独立 exec 通道执行(SSH exec 经登录 shell 解析,`$SHELL` 与交互 shell 一致);
- 完整排空到 Close,避免与服务端 channel 复用竞争;
- 1s 超时;任何失败只记 warn,降级为不注入,不阻断连接;
- ash/dash 解析不了 bash 函数语法(实测 `Syntax error: "(" unexpected`),fish 被 `$SHELL` 探测排除——探测是必需的安全门。
#### 受限设备探测断连降级(Issue #183)
华为 USG 等嵌入式网络设备只允许单个 SSH 会话:探测通道本身会触发设备回
`SSH_MSG_DISCONNECT`,把整个传输层一起掐断(日志表现为 `Disconnected`,随后在死
transport 上开交互通道报 `Channel send error`)。因此探测返回后必须检查
`client.is_connected()`
- 传输层已死 → 失效该 transport generation,重建连接并以
`plain_channel_only` 模式重试(只开一个交互 channel,跳过探测);
- 传输层存活 → 按原有路径继续(探测失败仍只降级不注入)。
兜底之外,连接表单高级设置提供「禁用 Shell 集成」开关(`disable_shell_integration`
复用存储模型既有字段),针对网络设备/工控主机显式跳过探测;降级重试后仍被设备
断连时,`add_connect_error_context` 会为 russh `Disconnect` / `SendError`
补充设备 VTY/会话限制排查提示。
### 4. 回显抑制状态机(`RuntimeShellIntegration`
```
Disabled ────────────────────────────────────────┐ (未请求注入)
WaitingForFirstOutput ──should_inject──► Injecting│
│ (期间收到 133;B) │
└────────────► Integrated (legacy 跳过注入)│
Injecting ──ready marker──► AwaitingPrompt │
│ │
└──5s 超时──► PlainAwaitingOutput ──► Plain │
AwaitingPrompt ──133;B──► Integrated │
```
- `Injecting` 期间所有输出进 `suppressed` 缓冲(上限 64KB,防止挂死 shell 无限撑内存);
- 在缓冲中跨 chunk 搜索完成标记 `OSC 1337;ShellIntegrationReady=1`,找到后标记之后的 suffix 恢复转发;
- 注入期间 `accepts_terminal_input() == false`actor 把用户 `Write`/`TerminalResponse` 命令压入 deferred 队列,就绪后按序重放。
#### 就绪握手看门狗(Issue #206
握手态(`WaitingForFirstOutput` / `Injecting` / `AwaitingPrompt` / `PlainAwaitingOutput`
统一表现为 `accepts_terminal_input() == false`,即**所有用户键盘输入(含 Enter、Ctrl+C
都被压入 deferred 队列**。这四个状态只能靠远端输出推进:
| 状态 | 解除条件 |
|---|---|
| `WaitingForFirstOutput` | `should_inject`(需登录 expect 完成)或 OSC 133;B |
| `Injecting` | ready marker 或 5s 注入超时 |
| `AwaitingPrompt` | 下一个 OSC 133;B |
| `PlainAwaitingOutput` | 下一段输出 |
当远端长时间不再产生可解除握手的输出时——登录 expect 永不匹配(`login_expect` 未完成,
`should_inject` 便永不触发)、远端不复现 prompt 结束标记等——会话会永久停在暂存态:
远端输出照常刷新、终端看起来完全正常,但键盘输入被静默吞掉,界面没有任何提示
Issue #206 的现象)。`Injecting` 有 5s 超时兜底,另外三个状态在引入本看门狗前没有任何超时。
兜底实现(`SshBackend` actor):
- actor 启动时若 `accepts_terminal_input() == false`,武装
`SHELL_INTEGRATION_HANDSHAKE_TIMEOUT`30s)看门狗;
- 到期时 `RuntimeShellIntegration::force_release_input()` 把上述四个状态强制推进到
`Plain`(丢弃注入回显缓冲,此后不再抑制输出、不再注入),并置 `shell_ready = true`
让初始化命令不再等 OSC prompt 信号;
- 下一轮循环按序重放 deferred 队列,用户输入恢复投递;
- 已可输入的阶段返回 `false`,看门狗只生效一次,不干扰正常会话。
30s 取值依据:正常连接的首段输出在毫秒级到达,慢链路注入由 5s 注入超时处理;30s 只用于
「远端彻底沉默」的异常场景,同时兼顾慢登录脚本,不误伤正常会话。
诊断提示:输入被暂存时打 `terminal.ssh.runtime` 的 debug 日志(含当前 `phase` 与队列长度),
看门狗触发时打 `terminal.ssh.setup` 的 warn 日志,可据此定位输入被哪一阶段吞掉。
### 5. Actor 接入(`ssh_backend.rs` 连接循环)
输出处理顺序:
```
decode → login_expect.advance → filter_output(抑制/转发)
→ osc_parser.push → exec_supervisor.on_terminal_chunk
→ TerminalEvent 分发(133;B 时 on_input_start + shell_ready
→ should_inject? → 发送注入命令 + 起 5s 定时器
→ shell_ready 后发送 init_commandsintegrated 无延迟;plain 保持 250ms 间隔)
```
- 注入完成(收到 ready marker)即取消定时器;
- 超时触发:`send 0x03`Ctrl+C)→ 降级 Plain → 下一段输出携带 `ShellIntegrationReady::Plain` 恢复 `shell_ready`,放行暂存输入。
### 6. 旧版遗留兼容
- 远端 rc 仍带旧持久注入的机器:首个 prompt 自带 `OSC 133;B`,在 `WaitingForFirstOutput` 阶段被 `on_input_start()` 捕获,直接进入 `Integrated`,跳过重复注入;
- `SshBackend::uninstall_shell_integration`(10s 超时 + 成功标记确认)保留,表单提供"清理旧版文件"入口,一次性清理 `~/.config/onetcli` 与 rc managed block。
### 7. 删除的内容
- `build_shell_integration_setup_script` / `managed_shell_integration_block`(持久安装)
- `ShellIntegrationSetup` 结构体与 `SshSessionManager``cached_shell_integration` / `set_shell_integration`(每会话注入无需缓存)
- 表单"禁用 Shell 集成"开关(无远端写入后无风险可禁)
- `terminal.rs` 解析默认值回到 `unwrap_or(false)`(默认启用;已存储的显式禁用值仍被尊重)
## 验证
| 层级 | 内容 |
|---|---|
| 单元契约(7 个) | 命令单行化/不含远端路径与 mkdir;跨 chunk 回显抑制;超时降级;禁用路径直通;登录未完成/expect 轮延迟注入;legacy 133;B 检测;**真实 bash/zsh 执行注入命令并输出完成标记** |
| ssh_backend mock | 探测通道只读(Exec+Close);不支持 shell 跳过注入;channel open 失败单通道重连;探测超时降级 |
| 真机(181`#[ignore]`) | 旧残留自动卸载 → 探测 → 注入 → 回显无泄漏 → 133;B 就绪 → `echo` 经 OSC 1337 记录 → **连接前后远端 md5 快照一致** |
| 回归 | terminal 446+5、ssh 144、terminal_view 35 全通过 |
真机测试入口:
```sh
NAVOP_LIVE_SSH=user:password@host:port \
cargo test -p terminal --test ssh_runtime_integration_live -- --ignored --nocapture
```
(密码含 `@` 时从右往左解析。)
## 权衡与已知边界
- **每连接注入一次**:新开终端tab 都要注入(~百毫秒级),换来零残留;相比旧版首次安装+缓存略慢但更稳;
- **注入命令回显**:依赖完成标记对齐,理论上 shell 极端挂死时 5s 超时兜底(丢弃回显、Ctrl+C、降级);
- **探测基于 `$SHELL`**:登录 shell 与实际交互 shell 不一致的边缘场景(罕见)会误注入/漏注入,bash/zsh 误注入也只是语法错误提示,不破坏会话;
- **`eval $'...'` 是 bash/zsh 特性**:正因如此探测只放行 bash/zsh;未来要支持 fish 需单独的注入体。