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
26 KiB
MCP 远程命令与持久终端 — 设计与实现
本文档覆盖 MCP 侧两类命令执行能力:
- 一次性命令
exec_command(P3a)—— 每次新建一个 shell、跑一条命令、读完即关。- 持久终端
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:
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 + 剔除:
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 状态机与互斥(两类能力共用)
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 |
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 |
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 |
CloseTermSession(devId, sid) |
不存在→1;sid 不匹配→2;busy 时仅置 closed+notify(交等待线程清理,防双重擦除);非 busy 直接擦+锁外 CancelIO。 |
SweepIdleTerminals(idleTimeoutSec) |
回收 persistent && !busy && difftime(now,lastActiveAt)>idle 的会话;收集后锁外 CancelIO。 |
5.3 OnTerminalClosed 的泄漏修复
OnTerminalClosed 有两个入口:
MessageHandle的TOKEN_TERMINAL_CLOSE分支(客户端 shell 优雅退出时发 token)。OfflineProc(2015RemoteDlg.cpp)——连接骤然断开(网络断/客户端崩溃,无 token)时,与WebService().OnTerminalClosed并列调用McpServer().OnTerminalClosed。此入口是 2026-08-24 独立审查发现缺失并补齐的:否则骤断的持久终端会话会在m_TermSessions/m_TermContextToDevice里留悬空subCtx,连接池复用地地址后会误路由新连接、甚至CancelIO掉无关连接。
shell 进程退出时,若是一个空闲持久会话(persistent && !busy && started),没有等待线程会清理它,会泄漏死 subCtx。故:
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):
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 的 `& |
-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,无法在外部环境构建。