From 70dbb6b255ceafa0ea878ea48250482dbcc7a674 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Mon, 24 Aug 2026 19:11:16 +0200 Subject: [PATCH] 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 --- docs/Mcp_Terminal_Design.md | 471 ++++++++++++++++++++++ server/2015Remote/2015RemoteDlg.cpp | 8 + server/2015Remote/McpServer.cpp | 571 ++++++++++++++++++++++++++- server/2015Remote/McpServer.h | 30 ++ server/2015Remote/McpSettingsDlg.cpp | 16 +- server/2015Remote/McpSettingsDlg.h | 2 + server/2015Remote/lang/en_US.ini | 5 +- server/2015Remote/lang/zh_TW.ini | 5 +- 8 files changed, 1099 insertions(+), 9 deletions(-) create mode 100644 docs/Mcp_Terminal_Design.md diff --git a/docs/Mcp_Terminal_Design.md b/docs/Mcp_Terminal_Design.md new file mode 100644 index 0000000..e73c744 --- /dev/null +++ b/docs/Mcp_Terminal_Design.md @@ -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 & ] 2>&1 && echo __MCP_DONE___0 || echo __MCP_DONE___1 +``` + +- `@echo off & ` **仅 ConPTY 需要**(抑制 ConPTY 把输入整行回显)。老 `ShellManager` 已自行跳过回显,加 `@echo off` 反而破坏它的回显跳过逻辑。 +- `&&` / `||` 控制操作符取**命令真实退出码**(`%errorlevel%` 在复合句中解析期展开、已过期,故不能用 `%errorlevel%`)。 +- `` 为 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 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 m_TermSessions; // device_id → 会话 +std::map 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 ` 卡默认重试 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,无法在外部环境构建。 diff --git a/server/2015Remote/2015RemoteDlg.cpp b/server/2015Remote/2015RemoteDlg.cpp index 61424b5..2ce018c 100644 --- a/server/2015Remote/2015RemoteDlg.cpp +++ b/server/2015Remote/2015RemoteDlg.cpp @@ -2193,6 +2193,8 @@ BOOL CMy2015RemoteDlg::OnInitDialog() // 安全配置:只读默认 1(exec_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; diff --git a/server/2015Remote/McpServer.cpp b/server/2015Remote/McpServer.cpp index 5a30090..601eef7 100644 --- a/server/2015Remote/McpServer.cpp +++ b/server/2015Remote/McpServer.cpp @@ -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_close(P4:持久远程终端,仅 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/call:exec_command(Windows 一次性远程命令:主连接 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& 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 的 inputSchema(id 必填、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/call:terminal_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/call:terminal_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 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/call:terminal_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 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 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& out, + int& exitCode, bool& closed, int timeoutMs) { + std::unique_lock 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 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 toCancel; + { + std::lock_guard 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 ////////////////////////////////////////////////////////////////////////// diff --git a/server/2015Remote/McpServer.h b/server/2015Remote/McpServer.h index f960711..cb15131 100644 --- a/server/2015Remote/McpServer.h +++ b/server/2015Remote/McpServer.h @@ -10,6 +10,7 @@ #include #include #include +#include // 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& out, int& exitCode, + bool& closed, int timeoutMs); + + // 关闭持久终端(校验 sessionId;CancelIO + 清理路由/会话)。 + // 返回: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 末尾) diff --git a/server/2015Remote/McpSettingsDlg.cpp b/server/2015Remote/McpSettingsDlg.cpp index 57fbcee..ffe2427 100644 --- a/server/2015Remote/McpSettingsDlg.cpp +++ b/server/2015Remote/McpSettingsDlg.cpp @@ -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); diff --git a/server/2015Remote/McpSettingsDlg.h b/server/2015Remote/McpSettingsDlg.h index 2566b28..c11e8aa 100644 --- a/server/2015Remote/McpSettingsDlg.h +++ b/server/2015Remote/McpSettingsDlg.h @@ -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; diff --git a/server/2015Remote/lang/en_US.ini b/server/2015Remote/lang/en_US.ini index 5173136..1d9fe1d 100644 --- a/server/2015Remote/lang/en_US.ini +++ b/server/2015Remote/lang/en_US.ini @@ -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 diff --git a/server/2015Remote/lang/zh_TW.ini b/server/2015Remote/lang/zh_TW.ini index 21909bb..57a42da 100644 --- a/server/2015Remote/lang/zh_TW.ini +++ b/server/2015Remote/lang/zh_TW.ini @@ -1978,7 +1978,10 @@ MCP ˿Ϊ 1-65535 =BӲ 1-65535 Ĕ Token Ϊ=Token ܞ MCP ѱ档=MCP Oу档 -/˿/󶨵ַ/Token/ֻ/ĸĶЧ=/BӲ/λַ/Token/Ψx/ε׃†ӳʽЧ +/˿/󶨵ַ/Token/ֻ//־ն˵ĸĶЧ=/BӲ/λַ/Token/Ψx//־ýK˵׃†ӳʽЧ ֻģʽִУ=ΨxģʽУ = MCPִ=MCP +ó־նˣȫް=ó־ýKˣoΣ +־ն˽ֻģʽرʱЧ=־ýK˃HΨxģʽP]rЧ +MCP־ն=MCP־ýK