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:
yuanyuanxiang
2026-08-24 19:11:16 +02:00
parent 0ddbc1aced
commit 70dbb6b255
8 changed files with 1099 additions and 9 deletions

471
docs/Mcp_Terminal_Design.md Normal file
View 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 1HTTP 协议、token 校验、配置、`list_online_hosts`)与 `docs/Mcp_Phase2_Design.md`(模式 A/A/B、工具路线图、P2aP3a 概要),再读本文档的**实现级细节**。本文档是后续人员 / 大模型接手与排查问题的主参考。
---
## 1. 术语与总览
| 术语 | 含义 |
|---|---|
| **一次性命令** | `exec_command`:无状态,每次独立 shell命令结束即 `CancelIO` 关闭子链接。 |
| **持久终端** | `terminal_open``terminal_exec`×N→`terminal_close`有状态cwd/env 跨命令保持。 |
| **哨兵sentinel** | 包装在命令尾部的一段随机标记,用于在输出流里定位「命令真实退出码」并截断输出。 |
| **子链接sub-connection / `context*`** | 客户端为 shell 建立的独立网络连接,`context` 是其服务端句柄。 |
| **主连接(`FindMainContext`** | 客户端与控制端的常驻长连接,用于下发 `COMMAND_*`。 |
| **isPty** | `true`=ConPTYUTF-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_CLOSEshell 进程退出)→ 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 待 STARTtrue=已接管
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
→ closedsubCtx->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==2sessionId 不匹配) → -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)` | 不存在→1sid 不匹配→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` |
> 构建由 VS2019MSBuild v143完成项目工具集 v142无法在外部环境构建。

View File

@@ -2193,6 +2193,8 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
// 安全配置:只读默认 1exec_command 默认禁用);白名单空则用内置只读前缀。
McpServer().SetReadonly(THIS_CFG.GetInt("settings", "McpReadonly", 1) != 0);
McpServer().SetCmdWhitelist(THIS_CFG.GetStr("settings", "McpCmdWhitelist", ""));
// 持久终端开关默认关要求只读关McpReadonly=0才生效工具列表/分派双重门控)。
McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0);
if (!McpServer().Start(mcpBind, mcpPort)) {
Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort);
} else {
@@ -5158,6 +5160,12 @@ BOOL CALLBACK CMy2015RemoteDlg::OfflineProc(CONTEXT_OBJECT* ContextObject)
WebService().OnTerminalClosed(ContextObject);
}
// MCP 终端(一次性 exec / 持久终端)的 shell 子上下文断开:同步清理会话,避免悬空 subCtx
// 被连接池复用时误路由新连接(与 WebService 清理同理,需在 RemoveFromHostList 之前)。
if (McpServer().IsRunning() && McpServer().IsTerminalContext(ContextObject)) {
McpServer().OnTerminalClosed(ContextObject);
}
SOCKET nSocket = ContextObject->sClientSocket;
CDialogBase* p = (CDialogBase*)ContextObject->hDlg;

View File

@@ -1114,6 +1114,17 @@ Json::Value BuildListFilesOutputSchema() {
return schema;
}
// ===== P4 前置声明定义见下方「tools/call 分派」前)=====
Json::Value BuildTerminalOpenInputSchema();
Json::Value BuildTerminalOpenOutputSchema();
Json::Value BuildTerminalExecInputSchema();
Json::Value BuildTerminalExecOutputSchema();
Json::Value BuildTerminalCloseInputSchema();
Json::Value BuildTerminalCloseOutputSchema();
std::string BuildTerminalOpen(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
std::string BuildTerminalExec(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
std::string BuildTerminalClose(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
// tools/list
std::string BuildToolsListResult(const Json::Value& id) {
Json::Value result(Json::objectValue);
@@ -1288,6 +1299,35 @@ std::string BuildToolsListResult(const Json::Value& id) {
tools.append(tool);
}
// 14) terminal_open / terminal_exec / terminal_closeP4持久远程终端仅 Windows
// 安全门McpTerminal=1 且 McpReadonly=0无白名单全命令审计 + idle 回收)
if (CMcpServer::Instance().IsTerminalEnabled() && !CMcpServer::Instance().IsReadonly()) {
{
Json::Value tool(Json::objectValue);
tool["name"] = "terminal_open";
tool["description"] = u8"在指定在线 Windows 主机上打开一个持久 shell 会话并返回 session_id 与终端模式。后续 terminal_exec 复用该会话cwd/环境变量跨命令保持),用毕须 terminal_close。";
tool["inputSchema"] = BuildTerminalOpenInputSchema();
tool["outputSchema"] = BuildTerminalOpenOutputSchema();
tools.append(tool);
}
{
Json::Value tool(Json::objectValue);
tool["name"] = "terminal_exec";
tool["description"] = u8"在已打开的持久会话中执行一条命令并返回 stdout 与退出码。命令不受白名单约束,但不能含 & 或 |(会破坏输出哨兵捕获),请拆成多条调用;重定向 > < 与转义 ^ 允许。";
tool["inputSchema"] = BuildTerminalExecInputSchema();
tool["outputSchema"] = BuildTerminalExecOutputSchema();
tools.append(tool);
}
{
Json::Value tool(Json::objectValue);
tool["name"] = "terminal_close";
tool["description"] = u8"关闭并释放指定持久会话(幂等:会话已不存在也返回成功)。";
tool["inputSchema"] = BuildTerminalCloseInputSchema();
tool["outputSchema"] = BuildTerminalCloseOutputSchema();
tools.append(tool);
}
}
result["tools"] = tools;
return BuildResult(id, result);
}
@@ -1889,6 +1929,26 @@ static std::string StripAnsi(const std::string& s) {
return out;
}
// 去掉 ConPTY 把整行输入回显造成的噪声echoedLine 是发送的哨兵命令行(含唯一 nonce
// 输出里若出现该行(连同其后换行)则整行剔除。非 PTY 老 ShellManager 已自行跳回显,
// 找不到该行即无操作。echoedLine 含随机 nonce命令真实输出不可能恰好相同。
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 == std::string::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);
}
// 命令是否含控制字符(<0x20含 \r\n。\r\n 会把命令拆成多行:一次性 exec 里会绕过
// 白名单执行任意后续行(安全);持久终端里只有末行被哨兵包装、退出码失真(正确性)。二者都拒绝。
static bool ContainsControlChar(const std::string& s) {
for (char ch : s)
if ((unsigned char)ch < 0x20) return true;
return false;
}
// tools/callexec_commandWindows 一次性远程命令:主连接 COMMAND_SHELL → 子连接终端 →
// 哨兵命令行 → 收集 stdout + exit_code → 关子链接)。安全门:只读默认 + 白名单 + 审计。
std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
@@ -1916,8 +1976,8 @@ std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy
if (mcp.IsReadonly())
return BuildError(id, -32006, "exec_command is disabled: MCP is in read-only mode (set McpReadonly=0 to enable)");
// 安全门 2拒绝 shell 元字符,防止 "dir && del ..." 之类绕过白名单注入。
if (command.find_first_of("&|<>^") != std::string::npos)
// 安全门 2拒绝 shell 元字符与控制字符(\r\n 会把命令拆成多行绕过白名单),防注入。
if (command.find_first_of("&|<>^") != std::string::npos || ContainsControlChar(command))
return BuildError(id, -32008, "Command contains forbidden shell characters: " + command);
// 安全门 3命令白名单前缀校验。
@@ -1971,7 +2031,8 @@ std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy
}
subCtx->CancelIO(); // 关子链接,结束 shell 进程
// 清洗raw → UTF-8 → 剥 ANSI → CRLF 归一。raw 已截断到哨兵前
// 清洗raw → UTF-8 → 剥 ANSI → CRLF 归一 → 去 ConPTY 回显命令行 → TrimRight
// raw 已截断到哨兵前。
std::string stdoutStr;
if (!raw.empty()) {
std::string rawStr((const char*)raw.data(), raw.size());
@@ -1979,6 +2040,7 @@ std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy
size_t p = 0;
while ((p = stdoutStr.find("\r\n", p)) != std::string::npos)
stdoutStr.erase(p, 1);
stdoutStr = StripEchoedCommand(stdoutStr, line);
stdoutStr = TrimRight(stdoutStr);
}
@@ -2097,6 +2159,380 @@ std::string BuildGetAuditLog(const Json::Value& id, CMy2015RemoteDlg* parent) {
return BuildResult(id, result);
}
// ===== P4持久远程终端terminal_open / terminal_exec / terminal_close=====
static const int kTermIdleTimeoutSec = 300; // 持久终端 idle 回收超时(秒)
// 共享前置:校验 McpTerminal && !readonly → 解析 id → FindMainContext → 拒绝 LNX/MAC。
// 成功返回 true 并输出 devId/ctx失败时 errJson 已写入对应 JSON 错误串。
static bool ResolveTerminalSessionHost(const Json::Value& id, const Json::Value& args,
CMy2015RemoteDlg* parent,
uint64_t& devId, context*& ctx, std::string& errJson) {
CMcpServer& mcp = CMcpServer::Instance();
if (!mcp.IsTerminalEnabled() || mcp.IsReadonly()) {
errJson = BuildError(id, -32006,
"Persistent terminal is disabled: requires McpTerminal=1 and McpReadonly=0");
return false;
}
std::string err;
if (!ParseHostIdArg(args, devId, err)) {
errJson = BuildError(id, -32602, err);
return false;
}
ctx = FindMainContext(parent, devId);
if (!ctx) {
errJson = BuildError(id, -32002, "Host not found or offline: " + std::to_string(devId));
return false;
}
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
if (clientType == "LNX" || clientType == "MAC") {
errJson = BuildError(id, -32005, "Persistent terminal is only supported on Windows hosts");
return false;
}
return true;
}
// 发送哨兵命令行(复用 exec_command 的编码/哨兵逻辑);返回所发送的完整命令行,
// 供调用方剔除 ConPTY 把输入整行回显造成的噪声。cp 由 isPty 决定。
static std::string SendTerminalCommandLine(context* subCtx, bool isPty, const std::string& command,
const std::string& nonce) {
UINT cp = isPty ? CP_UTF8 : 936;
std::string line;
if (isPty) line += "@echo off & ";
line += command;
line += " 2>&1 && echo __MCP_DONE_" + nonce + "__0 || echo __MCP_DONE_" + nonce + "__1";
std::string wireLine = ToAnsi(line, cp) + "\r\n";
subCtx->Send2Client((BYTE*)wireLine.data(), (ULONG)wireLine.size());
return line;
}
// 输出清洗(复用 exec_command 尾部逻辑raw → UTF-8 → 剥 ANSI → CRLF 归一 →
// 去 ConPTY 回显命令行 → TrimRight。
static std::string CleanTerminalOutput(const std::vector<BYTE>& raw, UINT cp,
const std::string& echoedLine) {
std::string stdoutStr;
if (!raw.empty()) {
std::string rawStr((const char*)raw.data(), raw.size());
stdoutStr = StripAnsi(ToUtf8(rawStr.c_str(), cp));
size_t p = 0;
while ((p = stdoutStr.find("\r\n", p)) != std::string::npos)
stdoutStr.erase(p, 1);
stdoutStr = StripEchoedCommand(stdoutStr, echoedLine);
stdoutStr = TrimRight(stdoutStr);
}
return stdoutStr;
}
// terminal_open 的 inputSchemaid 必填、timeout_ms 可选)
Json::Value BuildTerminalOpenInputSchema() {
Json::Value props(Json::objectValue);
Json::Value idProp(Json::objectValue);
idProp["type"] = "string";
idProp["description"] = u8"主机 id取 list_online_hosts / search_hosts 返回的 id 字段";
props["id"] = idProp;
Json::Value timeoutProp(Json::objectValue);
timeoutProp["type"] = "integer";
timeoutProp["description"] = u8"等待 shell 启动的超时毫秒数(可选,默认 20000上限 600000";
props["timeout_ms"] = timeoutProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("id");
schema["required"] = required;
return schema;
}
Json::Value BuildTerminalOpenOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value sid(Json::objectValue);
sid["type"] = "string";
sid["description"] = u8"持久会话 token后续 terminal_exec / terminal_close 用";
props["session_id"] = sid;
Json::Value pty(Json::objectValue);
pty["type"] = "boolean";
pty["description"] = u8"true=ConPTY(UTF-8)false=老 cmd 管道(GBK)";
props["is_pty"] = pty;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("session_id");
required.append("is_pty");
schema["required"] = required;
return schema;
}
Json::Value BuildTerminalExecInputSchema() {
Json::Value props(Json::objectValue);
Json::Value idProp(Json::objectValue);
idProp["type"] = "string";
idProp["description"] = u8"主机 id取 list_online_hosts / search_hosts 返回的 id 字段";
props["id"] = idProp;
Json::Value sid(Json::objectValue);
sid["type"] = "string";
sid["description"] = u8"terminal_open 返回的 session_id";
props["session_id"] = sid;
Json::Value cmdProp(Json::objectValue);
cmdProp["type"] = "string";
cmdProp["description"] = u8"要执行的命令(完整 shell不受白名单约束但不能含 & 或 |,请拆成多条调用)";
props["command"] = cmdProp;
Json::Value timeoutProp(Json::objectValue);
timeoutProp["type"] = "integer";
timeoutProp["description"] = u8"等待输出完成的超时毫秒数(可选,默认 20000上限 600000";
props["timeout_ms"] = timeoutProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("id");
required.append("session_id");
required.append("command");
schema["required"] = required;
return schema;
}
Json::Value BuildTerminalExecOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value stdoutProp(Json::objectValue);
stdoutProp["type"] = "string";
stdoutProp["description"] = u8"命令输出(已剥哨兵与 ANSI 转义)";
props["stdout"] = stdoutProp;
Json::Value exitProp(Json::objectValue);
exitProp["type"] = "integer";
exitProp["description"] = u8"退出码0=成功、1=非零退出、-1=未知(进程异常退出)";
props["exit_code"] = exitProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("stdout");
required.append("exit_code");
schema["required"] = required;
return schema;
}
Json::Value BuildTerminalCloseInputSchema() {
Json::Value props(Json::objectValue);
Json::Value idProp(Json::objectValue);
idProp["type"] = "string";
idProp["description"] = u8"主机 id取 list_online_hosts / search_hosts 返回的 id 字段";
props["id"] = idProp;
Json::Value sid(Json::objectValue);
sid["type"] = "string";
sid["description"] = u8"terminal_open 返回的 session_id";
props["session_id"] = sid;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("id");
required.append("session_id");
schema["required"] = required;
return schema;
}
Json::Value BuildTerminalCloseOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value closed(Json::objectValue);
closed["type"] = "boolean";
closed["description"] = u8"恒为 true关闭幂等";
props["closed"] = closed;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("closed");
schema["required"] = required;
return schema;
}
// tools/callterminal_open打开持久 shell 会话,返回 session_id用毕须 terminal_close
std::string BuildTerminalOpen(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
CMcpServer& mcp = CMcpServer::Instance();
mcp.SweepIdleTerminals(kTermIdleTimeoutSec);
uint64_t devId = 0;
context* ctx = nullptr;
std::string errJson;
if (!ResolveTerminalSessionHost(id, args, parent, devId, ctx, errJson))
return errJson;
int timeoutMs = kMcpToolTimeoutMs;
std::string t = GetStringArg(args, "timeout_ms");
if (!t.empty() && IsDigits(t)) {
int v = atoi(t.c_str());
if (v > 0 && v <= 600000) timeoutMs = v;
}
std::string sessionId = GenerateRandomToken();
if (!mcp.BeginTermOpen(devId, sessionId))
return BuildError(id, -32003, "Device busy: a terminal session is already active for this host");
BYTE cmd = COMMAND_SHELL;
if (!ctx->Send2Client(&cmd, 1)) {
mcp.ClearTermPending(devId);
return BuildError(id, -32004, "Failed to send command to host");
}
context* subCtx = nullptr;
bool isPty = false;
if (!mcp.WaitTerminalReady(devId, subCtx, isPty, timeoutMs))
return BuildError(id, -32001, "Timeout waiting for shell to start");
// 审计:打开会话(不可关闭)。
if (parent) {
std::string text = "host " + std::to_string(devId) + " term-open: session " + sessionId;
parent->PostMessageA(WM_SHOWERRORMSG,
(WPARAM)new CString(ToAnsi(text, 936).c_str()),
(LPARAM)new CString(_TR("MCP持久终端")));
}
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["session_id"] = sessionId;
structuredContent["is_pty"] = isPty;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = sessionId;
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/callterminal_exec在已打开的持久会话中执行一条命令无白名单但禁 & |
std::string BuildTerminalExec(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
CMcpServer& mcp = CMcpServer::Instance();
mcp.SweepIdleTerminals(kTermIdleTimeoutSec);
uint64_t devId = 0;
context* ctx = nullptr;
std::string errJson;
if (!ResolveTerminalSessionHost(id, args, parent, devId, ctx, errJson))
return errJson;
std::string sessionId = GetStringArg(args, "session_id");
if (sessionId.empty())
return BuildError(id, -32602, "Missing required parameter: session_id");
std::string command = Trim(GetStringArg(args, "command"));
if (command.empty())
return BuildError(id, -32602, "Missing required parameter: command");
// 正确性门(非安全门):& 与 | 会破坏哨兵控制操作符链、导致退出码失真;拆成多条调用即可。
// \r\n 会把命令拆成多行、只有末行被哨兵包装,同样破坏退出码捕获,一并拒绝。
if (command.find_first_of("&|") != std::string::npos || ContainsControlChar(command))
return BuildError(id, -32008,
"Command contains & or | or a newline (breaks output capture); run chained/piped commands as separate terminal_exec calls");
int timeoutMs = kMcpToolTimeoutMs;
std::string t = GetStringArg(args, "timeout_ms");
if (!t.empty() && IsDigits(t)) {
int v = atoi(t.c_str());
if (v > 0 && v <= 600000) timeoutMs = v;
}
std::string nonce = GenerateRandomToken().substr(0, 8);
context* subCtx = nullptr;
bool isPty = false;
if (!mcp.BeginTermCommand(devId, sessionId, command, nonce, subCtx, isPty))
return BuildError(id, -32002, "Terminal session not found, not ready, or busy: " + sessionId);
std::string sentLine = SendTerminalCommandLine(subCtx, isPty, command, nonce);
std::vector<BYTE> raw;
int exitCode = -1;
bool closed = false;
if (!mcp.WaitTermCommand(devId, raw, exitCode, closed, timeoutMs)) {
subCtx->CancelIO();
return BuildError(id, -32001, "Timeout waiting for command output");
}
if (closed) {
// shell 进程退出:会话已被 WaitTermCommand 清理子链接已死CancelIO 仅对称(无害)。
subCtx->CancelIO();
}
std::string stdoutStr = CleanTerminalOutput(raw, isPty ? CP_UTF8 : 936, sentLine);
// 审计:命令执行(不可关闭)。
if (parent) {
std::string text = "host " + std::to_string(devId) + " term-exec [" + sessionId + "]: " + command;
parent->PostMessageA(WM_SHOWERRORMSG,
(WPARAM)new CString(ToAnsi(text, 936).c_str()),
(LPARAM)new CString(_TR("MCP持久终端")));
}
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["stdout"] = stdoutStr;
structuredContent["exit_code"] = exitCode;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = stdoutStr.empty() ? std::string(u8"(无输出)") : stdoutStr;
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/callterminal_close关闭持久会话幂等session_id 不匹配报错)
std::string BuildTerminalClose(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
CMcpServer& mcp = CMcpServer::Instance();
mcp.SweepIdleTerminals(kTermIdleTimeoutSec);
if (!mcp.IsTerminalEnabled() || mcp.IsReadonly())
return BuildError(id, -32006, "Persistent terminal is disabled: requires McpTerminal=1 and McpReadonly=0");
uint64_t devId = 0;
std::string err;
if (!ParseHostIdArg(args, devId, err))
return BuildError(id, -32602, err);
std::string sessionId = GetStringArg(args, "session_id");
if (sessionId.empty())
return BuildError(id, -32602, "Missing required parameter: session_id");
int r = mcp.CloseTermSession(devId, sessionId);
if (r == 2)
return BuildError(id, -32002, "Terminal session_id mismatch: " + sessionId);
// 审计:关闭会话(不可关闭)。
if (parent) {
std::string text = "host " + std::to_string(devId) + " term-close: session " + sessionId;
parent->PostMessageA(WM_SHOWERRORMSG,
(WPARAM)new CString(ToAnsi(text, 936).c_str()),
(LPARAM)new CString(_TR("MCP持久终端")));
}
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["closed"] = true;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = std::string(u8"closed");
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/call 分派
std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
const Json::Value& id = root["id"];
@@ -2122,6 +2558,9 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
if (toolName == "get_audit_log") return BuildGetAuditLog(id, parent);
if (toolName == "list_registry") return BuildListRegistry(id, args, parent);
if (toolName == "exec_command") return BuildExecCommand(id, args, parent);
if (toolName == "terminal_open") return BuildTerminalOpen(id, args, parent);
if (toolName == "terminal_exec") return BuildTerminalExec(id, args, parent);
if (toolName == "terminal_close") return BuildTerminalClose(id, args, parent);
return BuildError(id, -32602,
"Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName));
@@ -2217,8 +2656,14 @@ void CMcpServer::OnTerminalClosed(context* subCtx) {
if (it == m_TermContextToDevice.end()) return;
auto sit = m_TermSessions.find(it->second);
if (sit == m_TermSessions.end()) return;
sit->second.closed = true;
m_TermCv.notify_all();
TermSession& s = sit->second;
s.closed = true;
if (s.persistent && !s.busy && s.started) {
// 空闲持久会话shell 已退、无等待线程会清理,直接擦路由+会话(无需 CancelIO已死
m_TermContextToDevice.erase(subCtx);
m_TermSessions.erase(sit);
}
m_TermCv.notify_all(); // busy 场景唤醒等待线程由其清理;一次性 exec 路径不变
}
bool CMcpServer::BeginTermPending(uint64_t device_id, const std::string& command, const std::string& nonce) {
@@ -2281,6 +2726,122 @@ void CMcpServer::ClearTermPending(uint64_t device_id) {
if (it != m_TermSessions.end()) m_TermSessions.erase(it);
}
// ===== P4持久远程终端状态机方法=====
bool CMcpServer::BeginTermOpen(uint64_t device_id, const std::string& sessionId) {
std::lock_guard<std::mutex> lk(m_TermMutex);
if (m_TermSessions.find(device_id) != m_TermSessions.end()) return false; // 单设备单终端
TermSession s;
s.sessionId = sessionId;
s.persistent = true;
s.lastActiveAt = time(nullptr);
m_TermSessions[device_id] = std::move(s);
return true;
}
bool CMcpServer::BeginTermCommand(uint64_t device_id, const std::string& sessionId,
const std::string& command, const std::string& nonce,
context*& subCtx, bool& isPty) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return false;
TermSession& s = it->second;
if (!s.persistent || s.sessionId != sessionId) return false; // 非持久或 token 不匹配
if (!s.started) return false; // 未就绪
if (s.busy) return false; // 已有命令在飞
if (s.closed) return false; // 已关闭
s.busy = true;
s.command = command;
s.nonce = nonce;
s.data.clear();
s.sentPos = 0;
s.exitCode = -1;
s.done = false;
s.closed = false;
s.lastActiveAt = time(nullptr);
subCtx = s.subCtx;
isPty = s.isPty;
return true;
}
bool CMcpServer::WaitTermCommand(uint64_t device_id, std::vector<BYTE>& out,
int& exitCode, bool& closed, int timeoutMs) {
std::unique_lock<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return false;
bool signaled = m_TermCv.wait_for(lk, std::chrono::milliseconds(timeoutMs),
[&] { return it->second.done || it->second.closed; });
if (!signaled) { // 超时 → 清理会话+路由(调用方负责 CancelIO
context* subCtx = it->second.subCtx;
m_TermContextToDevice.erase(subCtx);
m_TermSessions.erase(it);
return false;
}
if (it->second.closed) { // 进程退出 → 清理,返回已收集输出(无哨兵)
context* subCtx = it->second.subCtx;
out = it->second.data;
exitCode = -1;
closed = true;
m_TermContextToDevice.erase(subCtx);
m_TermSessions.erase(it);
return true;
}
// 哨兵命中 → 保持会话,复位 busy刷新 lastActiveAt避免「长命令刚完成即被 idle 回收」
// idle 语义应为「距上次活动」,而非「距上次命令开始」)。
it->second.busy = false;
it->second.lastActiveAt = time(nullptr);
exitCode = it->second.exitCode;
closed = false;
if (it->second.sentPos <= it->second.data.size())
out.assign(it->second.data.begin(), it->second.data.begin() + it->second.sentPos);
return true;
}
int CMcpServer::CloseTermSession(uint64_t device_id, const std::string& sessionId) {
context* subCtx = nullptr;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return 1; // 不存在(幂等)
TermSession& s = it->second;
if (!s.persistent || s.sessionId != sessionId) return 2; // token 不匹配
if (s.busy) {
// 有命令在飞:置 closed 唤醒等待线程,由其清理(避免双重擦除)。
s.closed = true;
m_TermCv.notify_all();
return 0;
}
subCtx = s.subCtx;
m_TermContextToDevice.erase(s.subCtx);
m_TermSessions.erase(it);
}
if (subCtx) subCtx->CancelIO(); // 锁外取消 IO触发客户端 shell 退出
return 0;
}
int CMcpServer::SweepIdleTerminals(time_t idleTimeoutSec) {
time_t now = time(nullptr);
std::vector<context*> toCancel;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
for (auto it = m_TermSessions.begin(); it != m_TermSessions.end(); ) {
TermSession& s = it->second;
if (s.persistent && !s.busy && difftime(now, s.lastActiveAt) > (double)idleTimeoutSec) {
if (s.subCtx) { m_TermContextToDevice.erase(s.subCtx); toCancel.push_back(s.subCtx); }
it = m_TermSessions.erase(it);
} else {
++it;
}
}
}
for (context* c : toCancel) if (c) c->CancelIO(); // 锁外 CancelIO
return (int)toCancel.size();
}
//////////////////////////////////////////////////////////////////////////
// CMcpServer Implementation
//////////////////////////////////////////////////////////////////////////

View File

@@ -10,6 +10,7 @@
#include <map>
#include <vector>
#include <cstdint>
#include <ctime>
// httplib 与 Windows 头部的 min/max 宏冲突,按 file_server.h 的既有约定处理。
#undef min
@@ -133,6 +134,29 @@ public:
// 工具线程:清理终端挂起(发送失败等提前退出路径)。
void ClearTermPending(uint64_t device_id);
// ===== P4持久远程终端terminal_open / terminal_exec / terminal_close=====
void SetTerminalEnabled(bool enabled) { m_terminalEnabled = enabled; }
bool IsTerminalEnabled() const { return m_terminalEnabled; }
// 登记持久终端挂起复用单设备单终端约束sessionId 由调用方生成)。
bool BeginTermOpen(uint64_t device_id, const std::string& sessionId);
// 复位单条命令字段并置 busy输出 subCtx/isPty。false = 不存在/不匹配/未就绪/忙碌/已关。
bool BeginTermCommand(uint64_t device_id, const std::string& sessionId,
const std::string& command, const std::string& nonce,
context*& subCtx, bool& isPty);
// 等待本条命令完成done || closed || 超时。done=保持会话closed/超时=清理会话+路由。
bool WaitTermCommand(uint64_t device_id, std::vector<BYTE>& out, int& exitCode,
bool& closed, int timeoutMs);
// 关闭持久终端(校验 sessionIdCancelIO + 清理路由/会话)。
// 返回0=已关闭1=会话不存在幂等2=sessionId 不匹配(调用方报错)。
int CloseTermSession(uint64_t device_id, const std::string& sessionId);
// idle 回收:关闭 lastActiveAt 超时且非 busy 的持久会话。返回回收数量。
int SweepIdleTerminals(time_t idleTimeoutSec);
// 安全配置(启动时由 CMy2015RemoteDlg 读 THIS_CFG 后设置)。
void SetReadonly(bool readonly) { m_readonly = readonly; }
void SetCmdWhitelist(const std::string& whitelist) { m_cmdWhitelist = whitelist; }
@@ -185,6 +209,11 @@ private:
int exitCode = -1;
bool done = false; // 哨兵命中
bool closed = false; // TOKEN_TERMINAL_CLOSE进程退出
// ===== 新增(持久终端)=====
std::string sessionId; // 持久会话 token一次性 exec 为空)
bool persistent = false;
bool busy = false; // 一条命令在飞terminal_exec 复位→WaitTermCommand 返回)
time_t lastActiveAt = 0; // idle 回收用(秒)
};
std::mutex m_TermMutex;
std::condition_variable m_TermCv;
@@ -193,6 +222,7 @@ private:
bool m_readonly = true;
std::string m_cmdWhitelist;
bool m_terminalEnabled = false; // 持久终端开关(默认关;要求 m_readonly=false
};
// 全局访问器(仿 WebService(),见 WebService.h 末尾)

View File

@@ -106,7 +106,7 @@ INT_PTR CMcpSettingsDlg::DoModal()
{
USES_CONVERSION;
CString title = _TR("MCP设置");
BuildDialogTemplate(m_Template, T2CW(title), 320, 320);
BuildDialogTemplate(m_Template, T2CW(title), 320, 360);
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
return CDialog::DoModal();
}
@@ -136,6 +136,9 @@ BOOL CMcpSettingsDlg::OnInitDialog()
m_btnReadonly.Create(_TR("只读模式(禁命令执行)"),
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
r0, this, IDC_MCP_READONLY);
m_btnTerminal.Create(_TR("启用持久终端(全命令,无白名单)"),
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
r0, this, IDC_MCP_TERMINAL);
m_lblWhitelist.Create(_TR("命令白名单"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
m_editWhitelist.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP |
ES_MULTILINE | ES_AUTOVSCROLL | ES_WANTRETURN | WS_VSCROLL,
@@ -156,6 +159,7 @@ BOOL CMcpSettingsDlg::OnInitDialog()
m_lblToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnReadonly.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnTerminal.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_lblWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
@@ -169,6 +173,7 @@ BOOL CMcpSettingsDlg::OnInitDialog()
std::string tok = THIS_CFG.GetStr("settings", "McpToken", "");
if (tok.empty()) tok = GenerateRandomToken();
int readonly = THIS_CFG.GetInt("settings", "McpReadonly", 1);
int terminal = THIS_CFG.GetInt("settings", "McpTerminal", 0);
std::string whitelist = THIS_CFG.GetStr("settings", "McpCmdWhitelist", "");
m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED);
@@ -176,6 +181,7 @@ BOOL CMcpSettingsDlg::OnInitDialog()
m_editBind.SetWindowText(CString(bind.c_str()));
m_editToken.SetWindowText(CString(tok.c_str()));
m_btnReadonly.SetCheck(readonly ? BST_CHECKED : BST_UNCHECKED);
m_btnTerminal.SetCheck(terminal ? BST_CHECKED : BST_UNCHECKED);
// 白名单存储为逗号分隔,展示为每行一条。
m_editWhitelist.SetWindowText(CString(WhitelistForDisplay(whitelist).c_str()));
@@ -192,6 +198,7 @@ void CMcpSettingsDlg::OnOK()
m_editWhitelist.GetWindowText(sWhitelist);
bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED);
bool readonly = (m_btnReadonly.GetCheck() == BST_CHECKED);
bool terminal = (m_btnTerminal.GetCheck() == BST_CHECKED);
// 端口校验1-65535
int port = atoi(CT2A(sPort));
@@ -215,13 +222,15 @@ void CMcpSettingsDlg::OnOK()
THIS_CFG.SetStr("settings", "McpBind", bind);
THIS_CFG.SetStr("settings", "McpToken", token);
THIS_CFG.SetInt("settings", "McpReadonly", readonly ? 1 : 0);
THIS_CFG.SetInt("settings", "McpTerminal", terminal ? 1 : 0);
std::string whitelist = CT2A(sWhitelist);
whitelist = NormalizeWhitelist(whitelist);
THIS_CFG.SetStr("settings", "McpCmdWhitelist", whitelist);
// 拆成两段可翻译的单行键,中间用 \r\n 连接(多行键无法在 INI 中表示)
MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") +
_TR("启用/端口/绑定地址/Token/只读/白名单的改动需重启程序生效。"),
_TR("启用/端口/绑定地址/Token/只读/白名单/持久终端的改动需重启程序生效。") + _T("\r\n") +
_TR("持久终端仅在只读模式关闭时生效。"),
_TR("提示"), MB_ICONINFORMATION);
CDialog::OnOK();
@@ -255,6 +264,9 @@ void CMcpSettingsDlg::LayoutControls(int cx, int cy)
m_btnReadonly.MoveWindow(margin, y, cx - margin * 2, 22);
y += 30;
m_btnTerminal.MoveWindow(margin, y, cx - margin * 2, 22);
y += 30;
const int whitelistH = 90;
m_lblWhitelist.MoveWindow(margin, y, labelW, rowH);
m_editWhitelist.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, whitelistH);

View File

@@ -28,12 +28,14 @@ private:
IDC_MCP_TOKEN = 1004, // Token 编辑框
IDC_MCP_READONLY = 1005, // 「只读模式」复选框(默认勾选,禁 exec_command
IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀)
IDC_MCP_TERMINAL = 1007, // 「启用持久终端」复选框(全命令,无白名单,要求只读关)
};
CButton m_btnEnable;
CStatic m_lblPort, m_lblBind, m_lblToken;
CEdit m_editPort, m_editBind, m_editToken;
CButton m_btnReadonly;
CButton m_btnTerminal;
CStatic m_lblWhitelist;
CEdit m_editWhitelist;
CButton m_btnOK, m_btnCancel;

View File

@@ -1987,7 +1987,10 @@ MCP
端口需为 1-65535 的数字=Port must be a number between 1 and 65535
Token 不能为空=Token cannot be empty
MCP 设置已保存。=MCP settings saved.
启用/端口/绑定地址/Token/只读/白名单的改动需重启程序生效。=Changes to enable/port/bind/token/read-only/whitelist take effect after restart.
启用/端口/绑定地址/Token/只读/白名单/持久终端的改动需重启程序生效。=Changes to enable/port/bind/token/read-only/whitelist/persistent terminal take effect after restart.
只读模式(禁命令执行)=Read-only mode (disable command execution)
命令白名单=Command whitelist
MCP命令执行=MCP command execution
启用持久终端(全命令,无白名单)=Enable persistent terminal (full commands, no whitelist)
持久终端仅在只读模式关闭时生效。=Persistent terminal takes effect only when read-only mode is off.
MCP持久终端=MCP persistent terminal

View File

@@ -1978,7 +1978,10 @@ MCP
端口需为 1-65535 的数字=連接埠需為 1-65535 的數字
Token 不能为空=Token 不能為空
MCP 设置已保存。=MCP 設定已儲存。
启用/端口/绑定地址/Token/只读/白名单的改动需重启程序生效。=啟用/連接埠/綁定位址/Token/唯讀/白名單的變更需重新啟動程式後生效。
启用/端口/绑定地址/Token/只读/白名单/持久终端的改动需重启程序生效。=啟用/連接埠/綁定位址/Token/唯讀/白名單/持久終端的變更需重新啟動程式後生效。
只读模式(禁命令执行)=唯讀模式(禁命令執行)
命令白名单=命令白名單
MCP命令执行=MCP命令執行
启用持久终端(全命令,无白名单)=啟用持久終端(完整命令,無白名單)
持久终端仅在只读模式关闭时生效。=持久終端僅在唯讀模式關閉時生效。
MCP持久终端=MCP持久終端