Feature: Add persistent remote terminal MCP tools
Add terminal_open / terminal_exec / terminal_close so an AI can hold one shell session per Windows host and run a sequence of commands with cwd and environment preserved, instead of the one-shot exec_command. The persistent terminal is a separate full-command write capability gated by McpTerminal (default off) plus McpReadonly=0, with no whitelist and full audit. One device maps to one terminal session via the shared m_TermSessions map; idle sessions are swept after 300s. terminal_exec rejects commands that contain & or | (they corrupt the sentinel control-operator chain) as well as control characters. Also harden exec_command and terminal_exec against newline/CR injection, fix a dangling subCtx after an abrupt shell disconnect, and refresh lastActiveAt on command completion. Add en/zh-TW translations for the new UI strings and a design document covering both exec_command and the persistent terminal. Co-Authored-By: deepseek-v4-pro
This commit is contained in:
471
docs/Mcp_Terminal_Design.md
Normal file
471
docs/Mcp_Terminal_Design.md
Normal file
@@ -0,0 +1,471 @@
|
||||
# MCP 远程命令与持久终端 — 设计与实现
|
||||
|
||||
> 本文档覆盖 MCP 侧两类**命令执行**能力:
|
||||
> 1. **一次性命令 `exec_command`**(P3a)—— 每次新建一个 shell、跑一条命令、读完即关。
|
||||
> 2. **持久终端 `terminal_open` / `terminal_exec` / `terminal_close`**(P4)—— 同一主机**一个** shell 会话,跨命令保持 cwd 与环境变量。
|
||||
>
|
||||
> 阅读顺序建议:先读 `docs/Mcp_Design.md`(Phase 1:HTTP 协议、token 校验、配置、`list_online_hosts`)与 `docs/Mcp_Phase2_Design.md`(模式 A/A′/B、工具路线图、P2a–P3a 概要),再读本文档的**实现级细节**。本文档是后续人员 / 大模型接手与排查问题的主参考。
|
||||
|
||||
---
|
||||
|
||||
## 1. 术语与总览
|
||||
|
||||
| 术语 | 含义 |
|
||||
|---|---|
|
||||
| **一次性命令** | `exec_command`:无状态,每次独立 shell,命令结束即 `CancelIO` 关闭子链接。 |
|
||||
| **持久终端** | `terminal_open`→`terminal_exec`×N→`terminal_close`:有状态,cwd/env 跨命令保持。 |
|
||||
| **哨兵(sentinel)** | 包装在命令尾部的一段随机标记,用于在输出流里定位「命令真实退出码」并截断输出。 |
|
||||
| **子链接(sub-connection / `context*`)** | 客户端为 shell 建立的独立网络连接,`context` 是其服务端句柄。 |
|
||||
| **主连接(`FindMainContext`)** | 客户端与控制端的常驻长连接,用于下发 `COMMAND_*`。 |
|
||||
| **isPty** | `true`=ConPTY(UTF-8,`cp=CP_UTF8`);`false`=老 cmd 管道(GBK,`cp=936`)。 |
|
||||
|
||||
两类能力**共享同一套底层机制**(终端链路、编码、哨兵、状态机 map、审计),区别只在**生命周期**(一次性 vs 保持)与**安全门**(白名单 vs 无白名单 + 独立开关)。这也是为什么它们会互相排他(见 §6.3)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 共享基础设施
|
||||
|
||||
### 2.1 客户端终端链路(两种能力通用)
|
||||
|
||||
无论一次性还是持久,shell 的建立过程完全相同:
|
||||
|
||||
```
|
||||
服务端 客户端
|
||||
│ COMMAND_SHELL(主连接) ──► 客户端创建 shell 子连接
|
||||
│ ◄── TOKEN_SHELL_START 或 TOKEN_TERMINAL_START(子连接)
|
||||
│ ↓ MessageHandle 顶部判定 IsTermPending → 交给 RegisterTerminalContext 接管
|
||||
│ COMMAND_NEXT(+ 若 PTY 先发 CMD_TERMINAL_RESIZE 80x24)──► 客户端启动 shell 输出回流
|
||||
│ ◄── 持续 shell 输出(TOKEN_TERMINAL_DATA 之类,经 IsTerminalContext 路由到 OnTerminalData)
|
||||
│ ◄── TOKEN_TERMINAL_CLOSE(shell 进程退出)→ OnTerminalClosed
|
||||
```
|
||||
|
||||
关键点:
|
||||
- **`COMMAND_NEXT` 不能漏发**——客户端读线程靠它才启动输出回流,漏发会导致 shell 在跑但输出永不送回(`RegisterTerminalContext` 内的注释明确强调)。
|
||||
- **PTY 需先告知初始尺寸 80×24**,否则 TUI 尺寸错乱。
|
||||
- `TOKEN_SHELL_START` 与 `TOKEN_TERMINAL_START` 二选一:老 `ShellManager`(cmd 管道)回前者,ConPTY 回后者;服务端在 `MessageHandle` 里据此判定 `isPty`。
|
||||
|
||||
### 2.2 编码
|
||||
|
||||
| isPty | 底层 | 服务端 cp |
|
||||
|---|---|---|
|
||||
| `true` | ConPTY | `CP_UTF8` |
|
||||
| `false` | 老 cmd 管道 | `936`(GBK) |
|
||||
|
||||
- **发送方向**:命令/哨兵行先 UTF-8 组好,再 `ToAnsi(line, cp)` 转成客户端编码,追加 `"\r\n"`。
|
||||
- **接收方向**:原始字节 `ToUtf8(raw, cp)` 转回 UTF-8。
|
||||
- 哨兵是纯 ASCII,两种编码下字节一致,编码无关。
|
||||
|
||||
### 2.3 哨兵机制
|
||||
|
||||
命令被包装成一行复合命令:
|
||||
|
||||
```
|
||||
[@echo off & ] <cmd> 2>&1 && echo __MCP_DONE_<nonce>__0 || echo __MCP_DONE_<nonce>__1
|
||||
```
|
||||
|
||||
- `@echo off & ` **仅 ConPTY 需要**(抑制 ConPTY 把输入整行回显)。老 `ShellManager` 已自行跳过回显,加 `@echo off` 反而破坏它的回显跳过逻辑。
|
||||
- `&&` / `||` 控制操作符取**命令真实退出码**(`%errorlevel%` 在复合句中解析期展开、已过期,故不能用 `%errorlevel%`)。
|
||||
- `<nonce>` 为 8 位随机 hex(`GenerateRandomToken().substr(0,8)`),保证命令真实输出不可能恰好包含完整哨兵串。
|
||||
- `2>&1` 让 stderr 也走哨兵链,stdout/stderr 都能被捕获且退出码正确。
|
||||
|
||||
**`FindSentinel`(检测)** 用 `rfind` **从后往前**找,且要求哨兵在**行首**(`p==0` 或前一个字节是 `\n`),后跟 `0` 或 `1`:
|
||||
|
||||
```cpp
|
||||
size_t from = npos;
|
||||
while (true) {
|
||||
size_t p = s.rfind(marker, from);
|
||||
bool lineStart = (p == 0) || (s[p-1] == '\n');
|
||||
if (lineStart && s[p+marker.size()] 是 '0' 或 '1') { exitCode=...; pos=p; return true; }
|
||||
from = p - 1;
|
||||
}
|
||||
```
|
||||
|
||||
为什么要「从后往前 + 行首」:ConPTY 会把**整条命令行回显**进输出流,回显里的哨兵字样嵌在 `&& echo ... || echo ...` 中间、前面是空格(非行首)。若用 `rfind` 直取,回显包先于真实输出到达时会命中回显里的 `__1` 提前结束、截断真实输出。行首校验能排除回显行。老 `ShellManager` 无回显,输出里只有真实哨兵(仍在行首),逻辑一致。
|
||||
|
||||
### 2.4 输出清洗管线
|
||||
|
||||
从原始字节到返回 `stdout` 字符串,固定五步(`CleanTerminalOutput`,一次性命令为内联等价逻辑):
|
||||
|
||||
```
|
||||
raw 字节
|
||||
→ ToUtf8(raw, cp) 按 2.2 编码解码
|
||||
→ StripAnsi(...) 剥 CSI/OSC/单字节 ESC,去彩色码
|
||||
→ CRLF → LF "\r\n" 归一为 "\n"
|
||||
→ StripEchoedCommand(stdout, sentLine) 剔 ConPTY 输入行回显(见下)
|
||||
→ TrimRight(...) 去尾部空白
|
||||
```
|
||||
|
||||
**`StripEchoedCommand`(回显剔除)**:ConPTY 的 `ENABLE_ECHO_INPUT` 会在 `@echo off` 生效**之前**把整条输入命令行回显进输出流。修复方法是在**服务端**把「刚发送的、含唯一 nonce 的完整命令行」(`SendTerminalCommandLine` 的返回值)从输出里整行 `find` + 剔除:
|
||||
|
||||
```cpp
|
||||
static std::string StripEchoedCommand(const std::string& s, const std::string& echoedLine) {
|
||||
if (echoedLine.empty() || s.empty()) return s;
|
||||
size_t p = s.find(echoedLine);
|
||||
if (p == npos) return s;
|
||||
size_t q = p + echoedLine.size();
|
||||
if (q < s.size() && s[q] == '\n') ++q; // CRLF 已归一为 \n
|
||||
return s.substr(0, p) + s.substr(q);
|
||||
}
|
||||
```
|
||||
|
||||
- 因 `echoedLine` 含随机 nonce,命令真实输出不可能恰好相同 → 不会误删合法输出。
|
||||
- 老 `ShellManager` 无回显,`find` 不到该行 → 无操作,安全。
|
||||
|
||||
### 2.5 状态机与互斥(两类能力共用)
|
||||
|
||||
```cpp
|
||||
struct TermSession {
|
||||
bool started; // false=已发 COMMAND_SHELL 待 START;true=已接管
|
||||
context* subCtx; // shell 子连接上下文
|
||||
bool isPty; // ConPTY(UTF-8) / cmd 管道(GBK)
|
||||
std::string command; // 原始 UTF-8 命令(审计用)
|
||||
std::string nonce; // 哨兵随机串
|
||||
std::vector<BYTE> data; // 收集的原始 shell 输出
|
||||
size_t sentPos; // 哨兵在 data 中的位置
|
||||
int exitCode; // 0/1/-1
|
||||
bool done; // 哨兵命中
|
||||
bool closed; // TOKEN_TERMINAL_CLOSE(进程退出)
|
||||
// ── 以下为持久终端新增 ──
|
||||
std::string sessionId; // 持久会话 token(一次性 exec 为空)
|
||||
bool persistent; // 是否持久会话
|
||||
bool busy; // 一条命令在飞(terminal_exec 复位 → WaitTermCommand 返回)
|
||||
time_t lastActiveAt; // idle 回收用(秒)
|
||||
};
|
||||
|
||||
std::mutex m_TermMutex; // 保护下面两个 map
|
||||
std::condition_variable m_TermCv;
|
||||
std::map<uint64_t, TermSession> m_TermSessions; // device_id → 会话
|
||||
std::map<context*, uint64_t> m_TermContextToDevice; // subCtx → device_id(顶部路由)
|
||||
```
|
||||
|
||||
- `m_TermContextToDevice` 是**反向索引**:`MessageHandle` 顶部用 `IsTerminalContext(subCtx)` 判定「这个子链接的输出是否该路由到 MCP 的 `OnTerminalData`,而不是打开 MFC 终端对话框」。
|
||||
- `httplib` 默认多线程处理请求 → **多个 `tools/call` 会并发**。所有会话状态都靠 `m_TermMutex` + `busy` 串行化,**不要假定请求串行**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 一次性命令 `exec_command`
|
||||
|
||||
### 3.1 安全门(顺序)
|
||||
|
||||
```
|
||||
ParseHostIdArg → -32602 参数非法
|
||||
FindMainContext → -32002 主机不存在/离线
|
||||
clientType==LNX/MAC → -32005 仅 Windows
|
||||
command 为空 → -32602
|
||||
mcp.IsReadonly() → -32006 只读模式默认开,需 McpReadonly=0
|
||||
含 &|<>^ 五元字符 → -32008 防注入绕过白名单
|
||||
含控制字符(\r\n 等 <0x20) → -32008 防换行拆分绕过白名单
|
||||
白名单前缀校验 → -32007 不在白名单
|
||||
```
|
||||
|
||||
- **白名单**:`IsCommandAllowed` 做「trim + ASCII 小写后前缀匹配」,且前缀后须为空/空格/制表符(防 `dirx` 误配 `dir`)。配置为空时回落到内置 `kDefaultCmdWhitelist`:
|
||||
```
|
||||
dir,type,cd,chdir,ver,hostname,whoami,where,tasklist,systeminfo,ipconfig,netstat,
|
||||
path,set,find,findstr,reg query,sc query,
|
||||
ping,tracert,nslookup,getmac,driverquery,query,gpresult,
|
||||
arp -a,route print,schtasks /query
|
||||
```
|
||||
- **元字符门 `&|<>^`** 是**安全门**(拒绝 shell 注入/命令拼接),比持久终端的 `&|` 更严。
|
||||
- **控制字符门**:同时拒绝所有 `<0x20` 的控制字符(`\r`/`\n` 等)。否则 `dir \n rd /s /q ...` 这种带内嵌换行的命令会通过「前缀 `dir` + 空格」的白名单校验,却在 `cmd.exe` 里被换行拆成两条独立命令、执行任意后续行——彻底绕过只读白名单。这是 2026-08-24 独立审查发现并修复的**安全漏洞**。
|
||||
|
||||
### 3.2 流程
|
||||
|
||||
```
|
||||
BeginTermPending(devId, command, nonce) → false 则 -32003(该 host 已有终端会话)
|
||||
ctx->Send2Client(COMMAND_SHELL) → 失败 ClearTermPending + -32004
|
||||
WaitTerminalReady(devId, subCtx, isPty, timeoutMs)
|
||||
→ 超时 -32001(内部已自清理)
|
||||
组哨兵行 Send2Client (见 §2.3)
|
||||
WaitTerminalDone(devId, raw, exitCode, closed, timeoutMs)
|
||||
→ 超时 -32001(内部已自清理 + 调用方 CancelIO)
|
||||
subCtx->CancelIO() ← 一次性:读完即关子链接,结束 shell
|
||||
清洗输出(§2.4)
|
||||
审计:PostMessageA(WM_SHOWERRORMSG, ..., "MCP命令执行")
|
||||
返回 { stdout, exit_code }
|
||||
```
|
||||
|
||||
### 3.3 `WaitTerminalDone` 三态(一次性专用)
|
||||
|
||||
- **哨兵命中(done)**:`out` = `data[0..sentPos)`(截断到哨兵前),`exitCode=0/1`,**擦除会话+路由**,返回 true。
|
||||
- **进程退出(closed)**:`out` = 全部 `data`(无哨兵),`exitCode=-1`,擦除会话+路由,返回 true。
|
||||
- **超时**:擦除会话+路由,返回 false(调用方 `CancelIO`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 持久终端 `terminal_open` / `terminal_exec` / `terminal_close`
|
||||
|
||||
### 4.1 工具契约
|
||||
|
||||
| 工具 | 入参(required) | 出参 |
|
||||
|---|---|---|
|
||||
| `terminal_open` | `id`(+`timeout_ms`) | `session_id`(32hex)、`is_pty`(bool) |
|
||||
| `terminal_exec` | `id`、`session_id`、`command`(+`timeout_ms`) | `stdout`、`exit_code` |
|
||||
| `terminal_close` | `id`、`session_id` | `closed`(bool,恒 true) |
|
||||
|
||||
`session_id` 由服务端 `GenerateRandomToken()` 生成(32 hex / 128 bit)。
|
||||
|
||||
### 4.2 共享前置 `ResolveTerminalSessionHost`
|
||||
|
||||
`terminal_open` / `terminal_exec` 复用(`terminal_close` 为幂等内联版,见 §4.5):
|
||||
|
||||
```
|
||||
IsTerminalEnabled() && !IsReadonly() → -32006(需 McpTerminal=1 且 McpReadonly=0)
|
||||
ParseHostIdArg → -32602
|
||||
FindMainContext → -32002 主机不存在/离线
|
||||
clientType==LNX/MAC → -32005 仅 Windows
|
||||
```
|
||||
|
||||
### 4.3 `terminal_open`
|
||||
|
||||
```
|
||||
SweepIdleTerminals(300) ← 先回收闲置会话
|
||||
ResolveTerminalSessionHost
|
||||
sessionId = GenerateRandomToken()
|
||||
BeginTermOpen(devId, sessionId) → false 则 -32003(单设备单终端)
|
||||
ctx->Send2Client(COMMAND_SHELL) → 失败 ClearTermPending + -32004
|
||||
WaitTerminalReady(...) → 超时 -32001(自清理)
|
||||
审计 "term-open"
|
||||
返回 { session_id, is_pty }
|
||||
```
|
||||
|
||||
**关键**:`WaitTerminalReady` 成功后**不擦除会话**(与一次性相反)——会话要留给后续 `terminal_exec`。此时 `started=true`、`busy=false`,shell 空闲运行。
|
||||
|
||||
### 4.4 `terminal_exec`
|
||||
|
||||
```
|
||||
SweepIdleTerminals(300)
|
||||
ResolveTerminalSessionHost
|
||||
session_id 为空 → -32602
|
||||
command 为空 → -32602
|
||||
command 含 & 或 | → -32008(正确性门,见 §4.6)
|
||||
nonce = 随机 8 hex
|
||||
BeginTermCommand(devId, sessionId, command, nonce, subCtx, isPty)
|
||||
→ false 则 -32002(不存在/不匹配/未就绪/忙碌/已关)
|
||||
sentLine = SendTerminalCommandLine(subCtx, isPty, command, nonce)
|
||||
WaitTermCommand(devId, raw, exitCode, closed, timeoutMs)
|
||||
→ 超时:subCtx->CancelIO() + -32001
|
||||
→ closed:subCtx->CancelIO()(会话已被清理,仅对称无害)
|
||||
stdoutStr = CleanTerminalOutput(raw, cp, sentLine)
|
||||
审计 "term-exec [sessionId]: command"
|
||||
返回 { stdout, exit_code }
|
||||
```
|
||||
|
||||
**`WaitTermCommand` 三态(持久专用,与一次性不同)**:
|
||||
|
||||
- **哨兵命中(done)**:`busy=false`、刷新 `lastActiveAt`、`out` 截到 `sentPos`、**保留会话**,返回 true。← 这是「持久」的核心:命令完成后 shell 不关。
|
||||
- **进程退出(closed)**:`out`=全部 `data`、`exitCode=-1`、**擦除会话+路由**,返回 true。
|
||||
- **超时**:擦除会话+路由,返回 false(调用方 `CancelIO`)。
|
||||
|
||||
### 4.5 `terminal_close`
|
||||
|
||||
```
|
||||
SweepIdleTerminals(300)
|
||||
IsTerminalEnabled() && !IsReadonly() → -32006
|
||||
ParseHostIdArg → -32602
|
||||
session_id 为空 → -32602
|
||||
CloseTermSession(devId, sessionId)
|
||||
r==2(sessionId 不匹配) → -32002(防拼错静默泄漏真会话)
|
||||
审计 "term-close"
|
||||
返回 { closed: true }
|
||||
```
|
||||
|
||||
`CloseTermSession` 返回值语义:**0**=已关闭;**1**=会话不存在(幂等,仍返回 `{closed:true}`);**2**=sessionId 不匹配(报错)。
|
||||
|
||||
### 4.6 正确性门 `&|`(非安全门)
|
||||
|
||||
`terminal_exec` **拒绝含 `&` 或 `|` 的命令**(`find_first_of("&|")`,含 `&&`/`||`/`|`),报 `-32008`。原因:哨兵包装 `cmd 2>&1 && echo ... || echo ...` 假定命令是「扁平」的,命令内含 `&`/`|` 会与包装的控制操作符合流、导致退出码失真/输出归属不清。
|
||||
|
||||
- 这是**正确性限制,不是白名单**——持久终端本就不设白名单。
|
||||
- 同样拒绝 `<0x20` 的控制字符(`\r`/`\n`):换行会把命令拆成多行、只有末行被哨兵包装,导致退出码与输出归属失真。
|
||||
- `> < ^`(重定向 / 转义)**允许**:不破坏哨兵链。AI 把链式/管道命令拆成多条 `terminal_exec` 调用即可(cwd/env 保持,等价)。
|
||||
|
||||
对比:一次性 `exec_command` 因有白名单,其元字符门更严(`&|<>^` 五元字符全拒,§3.1)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 状态机方法明细
|
||||
|
||||
### 5.1 一次性命令专用
|
||||
|
||||
| 方法 | 作用 |
|
||||
|---|---|
|
||||
| `BeginTermPending(devId, cmd, nonce)` | 锁内登记会话;已有会话则 false(单设备单终端)。 |
|
||||
| `WaitTerminalReady(devId, subCtx, isPty, timeout)` | 等 `started`;超时**擦除**并返回 false。成功输出 `subCtx`/`isPty`,**不擦**。 |
|
||||
| `WaitTerminalDone(...)` | 等 `done||closed`;三态见 §3.3。 |
|
||||
| `ClearTermPending(devId)` | 发送失败等提前退出路径的清理。 |
|
||||
| `IsTermPending(devId)` | `MessageHandle` 判定是否有待接管的终端会话。 |
|
||||
| `RegisterTerminalContext(devId, subCtx, isPty)` | 子连接就绪接管:置 `started/subCtx/isPty`、登记反向索引、发 `COMMAND_NEXT`(+PTY resize)、`notify_all`。 |
|
||||
| `IsTerminalContext(subCtx)` | `MessageHandle` 顶部路由判定。 |
|
||||
| `OnTerminalData(subCtx, data, len)` | 追加输出、`FindSentinel` 命中置 `done` + `notify`;`done||closed` 时忽略迟到数据。 |
|
||||
| `OnTerminalClosed(subCtx)` | 置 `closed`;空闲持久会话直接擦;`notify_all` 唤醒 busy 等待线程。 |
|
||||
|
||||
### 5.2 持久终端专用
|
||||
|
||||
| 方法 | 作用 |
|
||||
|---|---|
|
||||
| `BeginTermOpen(devId, sessionId)` | 锁内建持久会话(`persistent=true`、`lastActiveAt=now`);已有会话 false。 |
|
||||
| `BeginTermCommand(devId, sid, cmd, nonce, subCtx, isPty)` | 锁内校验「persistent && sid 匹配 && started && !busy && !closed」→ 置 `busy=true`、**原子复位** `data/sentPos/exitCode/done/closed`、更新 `command/nonce/lastActiveAt`。 |
|
||||
| `WaitTermCommand(...)` | 等 `done||closed`;三态见 §4.4。 |
|
||||
| `CloseTermSession(devId, sid)` | 不存在→1;sid 不匹配→2;`busy` 时仅置 `closed`+notify(交等待线程清理,防双重擦除);非 busy 直接擦+锁外 `CancelIO`。 |
|
||||
| `SweepIdleTerminals(idleTimeoutSec)` | 回收 `persistent && !busy && difftime(now,lastActiveAt)>idle` 的会话;收集后锁外 `CancelIO`。 |
|
||||
|
||||
### 5.3 `OnTerminalClosed` 的泄漏修复
|
||||
|
||||
`OnTerminalClosed` 有**两个入口**:
|
||||
|
||||
1. `MessageHandle` 的 `TOKEN_TERMINAL_CLOSE` 分支(客户端 shell 优雅退出时发 token)。
|
||||
2. `OfflineProc`(`2015RemoteDlg.cpp`)——连接**骤然断开**(网络断/客户端崩溃,无 token)时,与 `WebService().OnTerminalClosed` 并列调用 `McpServer().OnTerminalClosed`。此入口是 2026-08-24 独立审查发现缺失并补齐的:否则骤断的持久终端会话会在 `m_TermSessions`/`m_TermContextToDevice` 里留悬空 `subCtx`,连接池复用地地址后会误路由新连接、甚至 `CancelIO` 掉无关连接。
|
||||
|
||||
shell 进程退出时,若是一个**空闲持久会话**(`persistent && !busy && started`),没有等待线程会清理它,会泄漏死 `subCtx`。故:
|
||||
|
||||
```cpp
|
||||
s.closed = true;
|
||||
if (s.persistent && !s.busy && s.started) {
|
||||
m_TermContextToDevice.erase(subCtx); // 空闲持久会话:直接擦(无需 CancelIO,已死)
|
||||
m_TermSessions.erase(sit);
|
||||
}
|
||||
m_TermCv.notify_all(); // busy 场景唤醒等待线程由其清理;一次性 exec 路径不变
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 并发模型与不变量
|
||||
|
||||
### 6.1 关键不变量
|
||||
|
||||
- **`CancelIO` 一律在 `m_TermMutex` 之外**(所有路径:`BuildExecCommand`、`BuildTerminalExec`、`CloseTermSession`、`SweepIdleTerminals`)。这是硬约束——`CancelIO` 可能阻塞/回调,锁内调用会死锁。
|
||||
- **`wait_for` 期间不持有会被他人擦除的迭代器**:等待线程(`WaitTermCommand`)持 `it`,但**只有它自己**会在超时/closed 路径擦除该会话;`terminal_close`/`sweep` 因 `busy` 标志而**不会**擦除「正在等待的命令」的会话。这就是 `busy` 标志的核心作用。
|
||||
- **单设备单终端**:`terminal_*` 与 `exec_command` 因**共享 `m_TermSessions`** 而互斥(双向 `-32003`)。`BeginTermPending` 与 `BeginTermOpen` 都做「已有会话则 false」。
|
||||
|
||||
### 6.2 `busy` 标志生命周期
|
||||
|
||||
```
|
||||
terminal_exec: BeginTermCommand 置 busy=true
|
||||
Send2Client
|
||||
WaitTermCommand 返回时:
|
||||
done 路径 → busy=false(保留会话)
|
||||
closed 路径 → 会话被擦除
|
||||
超时路径 → 会话被擦除
|
||||
terminal_close(会话忙时)→ 仅置 closed=true + notify,不擦,交等待线程清理
|
||||
```
|
||||
|
||||
### 6.3 互斥矩阵
|
||||
|
||||
| | `exec_command` | `terminal_*` | `list_files`/`get_screenshot` 等 |
|
||||
|---|---|---|---|
|
||||
| `exec_command` | 互斥(-32003) | 互斥(-32003) | 无关(各用 `m_Pending`) |
|
||||
| `terminal_*` | 互斥 | 单设备单终端 | 无关 |
|
||||
|
||||
> Web 终端与 MCP 终端各用独立 map,理论上可同设备并存(既有现象,非本次范围)。
|
||||
|
||||
### 6.4 idle 回收语义
|
||||
|
||||
`SweepIdleTerminals(300)`:`lastActiveAt` 在 `BeginTermOpen` / `BeginTermCommand` **(命令开始时)**更新,**命令完成后不刷新**。因此「两次命令间隔 < 300s」的活跃会话安全;「一次长命令 + 长时间无新命令」的会话会在最后一次命令开始 300s 后被回收。回收是非惰性的——每次 `terminal_*` 入口先 sweep,所以空闲会话不会在无请求时被后台主动关闭(但也不占后台定时器资源)。用 `difftime` 比较防时钟回拨下溢。
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全模型
|
||||
|
||||
| 维度 | `exec_command` | 持久终端 |
|
||||
|---|---|---|
|
||||
| 开关 | `McpReadonly=0` | `McpTerminal=1` **且** `McpReadonly=0`(默认 `McpTerminal=0`) |
|
||||
| 白名单 | 有(内置/自定义前缀白名单) | **无**(完整 shell,可写命令、重定向、改环境) |
|
||||
| 元字符门 | `&|<>^` + 控制字符全拒(安全门) | `&|` + 换行拒(正确性门) |
|
||||
| 审计 | `WM_SHOWERRORMSG` 标题「MCP命令执行」 | 标题「MCP持久终端」(open/exec/close 各一条) |
|
||||
| 生命周期 | 命令结束即关 | 会话保持,闲置 300s 回收,`terminal_close` 显式关 |
|
||||
| 会话隔离 | 单设备单终端 | 单设备单终端 + `session_id` 校验防串用 |
|
||||
|
||||
审计实现:`PostMessageA(WM_SHOWERRORMSG, new CString(...), new CString(_TR("...标题...")))` → 进入服务端 `m_MessageLog`,`get_audit_log` 可查。审计不可关闭。
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置项
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `McpEnabled` | 0 | MCP 总开关 |
|
||||
| `McpPort` | 6544 | 监听端口 |
|
||||
| `McpBind` | 127.0.0.1 | 绑定地址(默认仅回环) |
|
||||
| `McpToken` | (空→运行时兜底) | Bearer token |
|
||||
| `McpReadonly` | 1 | 只读模式(禁 `exec_command` 与持久终端) |
|
||||
| `McpCmdWhitelist` | (空→内置白名单) | `exec_command` 白名单,逗号分隔 |
|
||||
| `McpTerminal` | 0 | 持久终端开关(要求 `McpReadonly=0`) |
|
||||
|
||||
**存储位置**:Release 版存**注册表** `HKCU\Software\YAMA\settings`(**不是** `settings.ini`;`settings.ini` 仅 Debug 版读取)。改动需**重启程序**生效。
|
||||
|
||||
**启动读取**(`2015RemoteDlg.cpp`):
|
||||
```cpp
|
||||
McpServer().SetReadonly(THIS_CFG.GetInt("settings", "McpReadonly", 1) != 0);
|
||||
McpServer().SetCmdWhitelist(THIS_CFG.GetStr("settings", "McpCmdWhitelist", ""));
|
||||
McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0);
|
||||
```
|
||||
|
||||
**工具可见性**:`terminal_*` 三个工具在 `tools/list` 里**仅在** `IsTerminalEnabled() && !IsReadonly()` 时出现(门控在 `BuildToolsListResult`)。故默认配置下 `tools/list` 看不到它们。
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误码
|
||||
|
||||
| 码 | 含义 | 触发点 |
|
||||
|---|---|---|
|
||||
| `-32001` | 超时 | `WaitTerminalReady`/`WaitTermCommand`/`WaitPending` 等超时 |
|
||||
| `-32002` | 主机/会话不存在 | `FindMainContext` 失败、`session_id` 不匹配/未就绪/已关 |
|
||||
| `-32003` | 设备忙 | 单设备单终端冲突、同 host 已有挂起请求 |
|
||||
| `-32004` | 发送失败 | `Send2Client` 失败 |
|
||||
| `-32005` | 非 Windows | LNX/MAC 客户端 |
|
||||
| `-32006` | 已禁用/只读 | `IsReadonly()` 或 `!IsTerminalEnabled()` |
|
||||
| `-32007` | 白名单拒绝 | `IsCommandAllowed` false |
|
||||
| `-32008` | 含禁用字符 | `exec_command` 的 `&|<>^`;`terminal_exec` 的 `&|` |
|
||||
| `-32602` | 参数非法 | `ParseHostIdArg` 失败、缺 required 参数 |
|
||||
| `-32700` | JSON 解析错误 | 请求体非合法 JSON-RPC |
|
||||
|
||||
---
|
||||
|
||||
## 10. 排查指南(供后续人员/大模型)
|
||||
|
||||
### 10.1 `exec_command` 返回 `-32001` 超时
|
||||
|
||||
- 先看是不是**目标机环境问题**而非代码 bug:`nslookup`/`where` 在部分机器会因 DNS/文件系统异常稳定超时(见项目记忆)。裸 `nslookup <host>` 卡默认重试 20~40s > MCP 超时;`where cmd.exe` 在特定机器卡 PATH 解析。
|
||||
- 服务端行为正确:超时返回 `-32001` 并清理终端会话,不挂起/崩溃。
|
||||
|
||||
### 10.2 `terminal_*` 在 `tools/list` 里看不到
|
||||
|
||||
- 检查 `McpTerminal=1` **且** `McpReadonly=0`。任一不满足,工具不出现且调用返回 `-32006`。
|
||||
- 改配置后需**重启程序**(注册表配置启动时读)。
|
||||
|
||||
### 10.3 `terminal_exec` 返回 `-32002`
|
||||
|
||||
- 会话已被回收(闲置 >300s)、shell 已退出、或 `session_id` 拼错。重新 `terminal_open`。
|
||||
- `terminal_close` 的 `-32002` 特指 **session_id 不匹配**(防串用)。
|
||||
|
||||
### 10.4 `-32003` 设备忙
|
||||
|
||||
- 单设备单终端:同 host 已有终端会话(持久或一次性)在飞。等它结束或 `terminal_close`。
|
||||
- 紧接 `terminal_close`(会话 busy 时)后立刻 `terminal_open` 可能偶发 `-32003`:busy 会话的关闭是「标记 closed + 交等待线程清理」,清理完成前有极短窗口。重试一次即可,非死锁。
|
||||
|
||||
### 10.5 stdout 里出现命令本身 / 哨兵
|
||||
|
||||
- 命令本身回显 = ConPTY 输入回显,已由 `StripEchoedCommand` 服务端剔除(§2.4)。若仍出现,检查是否该行因编码(非 PTY 老管道的 GBK)与 `echoedLine` 字节不一致而 `find` 不到——老管道本应无回显。
|
||||
- 哨兵 `__MCP_DONE_...` 出现 = 哨兵截断/清洗异常,通常意味着命令含 `&|` 破坏了控制操作符链(持久终端会提前 `-32008` 拒绝,一次性会 `-32008` 拒绝五元字符)。
|
||||
|
||||
### 10.6 中文/乱码
|
||||
|
||||
- 输出乱码:检查 `isPty` 与 `cp` 是否匹配(PTY=UTF-8,老管道=GBK)。
|
||||
- **`get_audit_log` 中文乱码**:已知问题(GBK 写入 UTF-8 JSON),影响**所有**审计条目,非终端特有,暂未修复。
|
||||
|
||||
### 10.7 会话泄漏 / 子链接不关
|
||||
|
||||
- 三个擦除点:`WaitTermCommand`(closed/超时)、`CloseTermSession`、`SweepIdleTerminals`;以及 `OnTerminalClosed` 对**空闲持久会话**的直接擦除(§5.3)。排查时确认 shell 退出路径 `OnTerminalClosed` 是否命中、`COMMAND_NEXT` 是否漏发导致输出永不回流。
|
||||
|
||||
---
|
||||
|
||||
## 11. 关键源文件索引
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `server/2015Remote/McpServer.h` | `TermSession` 结构、`BeginTermOpen/Command/WaitTermCommand/CloseTermSession/SweepIdleTerminals` 声明、`m_terminalEnabled` |
|
||||
| `server/2015Remote/McpServer.cpp` | `BuildExecCommand`(~1946)、`BuildTerminalOpen/Exec/Close`(~2345)、状态机方法(~2660)、`FindSentinel`(~2569)、`StripEchoedCommand`(~1935)、`SendTerminalCommandLine`(~2192)、`CleanTerminalOutput`(~2206)、`ResolveTerminalSessionHost`(~2160) |
|
||||
| `server/2015Remote/McpSettingsDlg.h/.cpp` | `IDC_MCP_TERMINAL` 复选框、回填/落盘 `McpTerminal` |
|
||||
| `server/2015Remote/2015RemoteDlg.cpp` | 启动读 `McpTerminal` → `SetTerminalEnabled` |
|
||||
|
||||
> 构建由 VS2019(MSBuild v143)完成;项目工具集 v142,无法在外部环境构建。
|
||||
Reference in New Issue
Block a user