Feature: Add persistent remote terminal MCP tools

Add terminal_open / terminal_exec / terminal_close so an AI can hold one
shell session per Windows host and run a sequence of commands with cwd and
environment preserved, instead of the one-shot exec_command.

The persistent terminal is a separate full-command write capability gated by
McpTerminal (default off) plus McpReadonly=0, with no whitelist and full
audit. One device maps to one terminal session via the shared m_TermSessions
map; idle sessions are swept after 300s. terminal_exec rejects commands that
contain & or | (they corrupt the sentinel control-operator chain) as well as
control characters.

Also harden exec_command and terminal_exec against newline/CR injection, fix a
dangling subCtx after an abrupt shell disconnect, and refresh lastActiveAt on
command completion. Add en/zh-TW translations for the new UI strings and a
design document covering both exec_command and the persistent terminal.

Co-Authored-By: deepseek-v4-pro
This commit is contained in:
yuanyuanxiang
2026-08-24 19:11:16 +02:00
parent 0ddbc1aced
commit 70dbb6b255
8 changed files with 1099 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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