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