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:
@@ -1114,6 +1114,17 @@ Json::Value BuildListFilesOutputSchema() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
// ===== P4 前置声明(定义见下方「tools/call 分派」前)=====
|
||||
Json::Value BuildTerminalOpenInputSchema();
|
||||
Json::Value BuildTerminalOpenOutputSchema();
|
||||
Json::Value BuildTerminalExecInputSchema();
|
||||
Json::Value BuildTerminalExecOutputSchema();
|
||||
Json::Value BuildTerminalCloseInputSchema();
|
||||
Json::Value BuildTerminalCloseOutputSchema();
|
||||
std::string BuildTerminalOpen(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
|
||||
std::string BuildTerminalExec(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
|
||||
std::string BuildTerminalClose(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
|
||||
|
||||
// tools/list
|
||||
std::string BuildToolsListResult(const Json::Value& id) {
|
||||
Json::Value result(Json::objectValue);
|
||||
@@ -1288,6 +1299,35 @@ std::string BuildToolsListResult(const Json::Value& id) {
|
||||
tools.append(tool);
|
||||
}
|
||||
|
||||
// 14) terminal_open / terminal_exec / terminal_close(P4:持久远程终端,仅 Windows,
|
||||
// 安全门:McpTerminal=1 且 McpReadonly=0;无白名单;全命令审计 + idle 回收)
|
||||
if (CMcpServer::Instance().IsTerminalEnabled() && !CMcpServer::Instance().IsReadonly()) {
|
||||
{
|
||||
Json::Value tool(Json::objectValue);
|
||||
tool["name"] = "terminal_open";
|
||||
tool["description"] = u8"在指定在线 Windows 主机上打开一个持久 shell 会话并返回 session_id 与终端模式。后续 terminal_exec 复用该会话(cwd/环境变量跨命令保持),用毕须 terminal_close。";
|
||||
tool["inputSchema"] = BuildTerminalOpenInputSchema();
|
||||
tool["outputSchema"] = BuildTerminalOpenOutputSchema();
|
||||
tools.append(tool);
|
||||
}
|
||||
{
|
||||
Json::Value tool(Json::objectValue);
|
||||
tool["name"] = "terminal_exec";
|
||||
tool["description"] = u8"在已打开的持久会话中执行一条命令并返回 stdout 与退出码。命令不受白名单约束,但不能含 & 或 |(会破坏输出哨兵捕获),请拆成多条调用;重定向 > < 与转义 ^ 允许。";
|
||||
tool["inputSchema"] = BuildTerminalExecInputSchema();
|
||||
tool["outputSchema"] = BuildTerminalExecOutputSchema();
|
||||
tools.append(tool);
|
||||
}
|
||||
{
|
||||
Json::Value tool(Json::objectValue);
|
||||
tool["name"] = "terminal_close";
|
||||
tool["description"] = u8"关闭并释放指定持久会话(幂等:会话已不存在也返回成功)。";
|
||||
tool["inputSchema"] = BuildTerminalCloseInputSchema();
|
||||
tool["outputSchema"] = BuildTerminalCloseOutputSchema();
|
||||
tools.append(tool);
|
||||
}
|
||||
}
|
||||
|
||||
result["tools"] = tools;
|
||||
return BuildResult(id, result);
|
||||
}
|
||||
@@ -1889,6 +1929,26 @@ static std::string StripAnsi(const std::string& s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// 去掉 ConPTY 把整行输入回显造成的噪声:echoedLine 是发送的哨兵命令行(含唯一 nonce),
|
||||
// 输出里若出现该行(连同其后换行)则整行剔除。非 PTY 老 ShellManager 已自行跳回显,
|
||||
// 找不到该行即无操作。echoedLine 含随机 nonce,命令真实输出不可能恰好相同。
|
||||
static std::string StripEchoedCommand(const std::string& s, const std::string& echoedLine) {
|
||||
if (echoedLine.empty() || s.empty()) return s;
|
||||
size_t p = s.find(echoedLine);
|
||||
if (p == std::string::npos) return s;
|
||||
size_t q = p + echoedLine.size();
|
||||
if (q < s.size() && s[q] == '\n') ++q; // CRLF 已归一为 \n
|
||||
return s.substr(0, p) + s.substr(q);
|
||||
}
|
||||
|
||||
// 命令是否含控制字符(<0x20,含 \r\n)。\r\n 会把命令拆成多行:一次性 exec 里会绕过
|
||||
// 白名单执行任意后续行(安全);持久终端里只有末行被哨兵包装、退出码失真(正确性)。二者都拒绝。
|
||||
static bool ContainsControlChar(const std::string& s) {
|
||||
for (char ch : s)
|
||||
if ((unsigned char)ch < 0x20) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// tools/call:exec_command(Windows 一次性远程命令:主连接 COMMAND_SHELL → 子连接终端 →
|
||||
// 哨兵命令行 → 收集 stdout + exit_code → 关子链接)。安全门:只读默认 + 白名单 + 审计。
|
||||
std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
|
||||
@@ -1916,8 +1976,8 @@ std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy
|
||||
if (mcp.IsReadonly())
|
||||
return BuildError(id, -32006, "exec_command is disabled: MCP is in read-only mode (set McpReadonly=0 to enable)");
|
||||
|
||||
// 安全门 2:拒绝 shell 元字符,防止 "dir && del ..." 之类绕过白名单的注入。
|
||||
if (command.find_first_of("&|<>^") != std::string::npos)
|
||||
// 安全门 2:拒绝 shell 元字符与控制字符(\r\n 会把命令拆成多行绕过白名单),防注入。
|
||||
if (command.find_first_of("&|<>^") != std::string::npos || ContainsControlChar(command))
|
||||
return BuildError(id, -32008, "Command contains forbidden shell characters: " + command);
|
||||
|
||||
// 安全门 3:命令白名单前缀校验。
|
||||
@@ -1971,7 +2031,8 @@ std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy
|
||||
}
|
||||
subCtx->CancelIO(); // 关子链接,结束 shell 进程
|
||||
|
||||
// 清洗:raw → UTF-8 → 剥 ANSI → CRLF 归一。raw 已截断到哨兵前。
|
||||
// 清洗:raw → UTF-8 → 剥 ANSI → CRLF 归一 → 去 ConPTY 回显命令行 → TrimRight。
|
||||
// raw 已截断到哨兵前。
|
||||
std::string stdoutStr;
|
||||
if (!raw.empty()) {
|
||||
std::string rawStr((const char*)raw.data(), raw.size());
|
||||
@@ -1979,6 +2040,7 @@ std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy
|
||||
size_t p = 0;
|
||||
while ((p = stdoutStr.find("\r\n", p)) != std::string::npos)
|
||||
stdoutStr.erase(p, 1);
|
||||
stdoutStr = StripEchoedCommand(stdoutStr, line);
|
||||
stdoutStr = TrimRight(stdoutStr);
|
||||
}
|
||||
|
||||
@@ -2097,6 +2159,380 @@ std::string BuildGetAuditLog(const Json::Value& id, CMy2015RemoteDlg* parent) {
|
||||
return BuildResult(id, result);
|
||||
}
|
||||
|
||||
// ===== P4:持久远程终端(terminal_open / terminal_exec / terminal_close)=====
|
||||
|
||||
static const int kTermIdleTimeoutSec = 300; // 持久终端 idle 回收超时(秒)
|
||||
|
||||
// 共享前置:校验 McpTerminal && !readonly → 解析 id → FindMainContext → 拒绝 LNX/MAC。
|
||||
// 成功返回 true 并输出 devId/ctx;失败时 errJson 已写入对应 JSON 错误串。
|
||||
static bool ResolveTerminalSessionHost(const Json::Value& id, const Json::Value& args,
|
||||
CMy2015RemoteDlg* parent,
|
||||
uint64_t& devId, context*& ctx, std::string& errJson) {
|
||||
CMcpServer& mcp = CMcpServer::Instance();
|
||||
if (!mcp.IsTerminalEnabled() || mcp.IsReadonly()) {
|
||||
errJson = BuildError(id, -32006,
|
||||
"Persistent terminal is disabled: requires McpTerminal=1 and McpReadonly=0");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string err;
|
||||
if (!ParseHostIdArg(args, devId, err)) {
|
||||
errJson = BuildError(id, -32602, err);
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx = FindMainContext(parent, devId);
|
||||
if (!ctx) {
|
||||
errJson = BuildError(id, -32002, "Host not found or offline: " + std::to_string(devId));
|
||||
return false;
|
||||
}
|
||||
|
||||
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
if (clientType == "LNX" || clientType == "MAC") {
|
||||
errJson = BuildError(id, -32005, "Persistent terminal is only supported on Windows hosts");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 发送哨兵命令行(复用 exec_command 的编码/哨兵逻辑);返回所发送的完整命令行,
|
||||
// 供调用方剔除 ConPTY 把输入整行回显造成的噪声。cp 由 isPty 决定。
|
||||
static std::string SendTerminalCommandLine(context* subCtx, bool isPty, const std::string& command,
|
||||
const std::string& nonce) {
|
||||
UINT cp = isPty ? CP_UTF8 : 936;
|
||||
std::string line;
|
||||
if (isPty) line += "@echo off & ";
|
||||
line += command;
|
||||
line += " 2>&1 && echo __MCP_DONE_" + nonce + "__0 || echo __MCP_DONE_" + nonce + "__1";
|
||||
std::string wireLine = ToAnsi(line, cp) + "\r\n";
|
||||
subCtx->Send2Client((BYTE*)wireLine.data(), (ULONG)wireLine.size());
|
||||
return line;
|
||||
}
|
||||
|
||||
// 输出清洗(复用 exec_command 尾部逻辑):raw → UTF-8 → 剥 ANSI → CRLF 归一 →
|
||||
// 去 ConPTY 回显命令行 → TrimRight。
|
||||
static std::string CleanTerminalOutput(const std::vector<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 分派
|
||||
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
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
Reference in New Issue
Block a user