Feature: Add download_file MCP tool (V2 protocol, SHA-256 verified)
download_file pulls a remote file or directory back to the controller over the existing V2 file-transfer protocol. It reuses COMMAND_LIST_DRIVE to open the file-manager sub-link, then sends CMD_DOWN_FILES_V2 so the Windows client streams COMMAND_SEND_FILE_V2 chunks and a per-file COMMAND_FILE_COMPLETE_V2 SHA-256 checksum over its own authenticated sub-connection. The server routes those two packet types into a new per-device FileTransferSession whenever a download is pending, so the headless path never opens the GUI progress dialog; the C2C and existing GUI branches are left untouched. Files are written under a normalized local_dir, and every chunk filename is resolved and verified to stay inside local_dir (directories included) to block .. traversal; overwrite=false skips existing files and counts them as skipped. One transfer per host (mutually exclusive with the one-shot pending registry), a dedicated McpFileTransfer=0-by-default gate surfaced in the settings dialog, and audit logging complete the change. upload_file remains future work. Co-Authored-By: deepseek-v4-pro
This commit is contained in:
@@ -2201,6 +2201,8 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
||||
McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0);
|
||||
// 远程控制开关:默认关;要求只读关(McpReadonly=0)才生效(工具列表/分派双重门控)。
|
||||
McpServer().SetRemoteControlEnabled(THIS_CFG.GetInt("settings", "McpRemoteControl", 0) != 0);
|
||||
// 文件传输开关:默认关;仅 download_file 门控(不要求只读关)。
|
||||
McpServer().SetFileTransferEnabled(THIS_CFG.GetInt("settings", "McpFileTransfer", 0) != 0);
|
||||
if (!McpServer().Start(mcpBind, mcpPort)) {
|
||||
Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort);
|
||||
} else {
|
||||
@@ -5843,6 +5845,14 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
// V2 文件传输(支持 C2C)
|
||||
FileChunkPacketV2* pkt = (FileChunkPacketV2*)szBuffer;
|
||||
|
||||
// P6:MCP download_file 挂起时接管流式子连接(dstClientID==0 → 主控端)。
|
||||
// OnFileChunkV2 自身会做 len 校验,这里先判长度再读 dstClientID,避免越界。
|
||||
if (len >= sizeof(FileChunkPacketV2) && pkt->dstClientID == 0 &&
|
||||
McpServer().IsFileTransferPending(ContextObject->GetClientID())) {
|
||||
McpServer().OnFileChunkV2(ContextObject->GetClientID(), ContextObject, szBuffer, len);
|
||||
break;
|
||||
}
|
||||
|
||||
if (pkt->dstClientID == 0) {
|
||||
// 目标是主控端:本地接收
|
||||
if (ContextObject->hDlg == NULL) {
|
||||
@@ -6103,6 +6113,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
if (len < sizeof(FileCompletePacketV2)) break;
|
||||
FileCompletePacketV2* pkt = (FileCompletePacketV2*)szBuffer;
|
||||
|
||||
// P6:MCP download_file 挂起时接管完成校验包(dstClientID==0 → 主控端)。
|
||||
if (pkt->dstClientID == 0 &&
|
||||
McpServer().IsFileTransferPending(ContextObject->GetClientID())) {
|
||||
McpServer().OnFileCompleteV2(ContextObject->GetClientID(), szBuffer, len);
|
||||
break;
|
||||
}
|
||||
|
||||
if (pkt->dstClientID == 0) {
|
||||
// 目标是主控端:本地校验
|
||||
bool verifyOk = HandleFileCompleteV2((char*)szBuffer, len, 0);
|
||||
@@ -6436,6 +6453,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
ContextObject->CancelIO(); // 只列盘 → 用完即关一次性子链接
|
||||
break;
|
||||
}
|
||||
// P6:MCP download_file 挂起时接管文件管理器子链接,下发 CMD_DOWN_FILES_V2。
|
||||
// true=已接管(保持子链接,ClearFileTransfer 收尾);false=会话已清理(迟到包)→ 关孤儿子链接。
|
||||
if (McpServer().IsFileTransferPending(devId)) {
|
||||
if (!McpServer().OnDownloadDriveList(devId, ContextObject))
|
||||
ContextObject->CancelIO();
|
||||
break;
|
||||
}
|
||||
ContextObject->EnableZstdContext(6);
|
||||
g_2015RemoteDlg->SendMessage(WM_OPENFILEMANAGERDIALOG, 0, (LPARAM)ContextObject);
|
||||
break;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "WebService.h" // 远程控制复用屏幕子连接(StartRemoteDesktop / GetScreenContext / GetScreenSize)
|
||||
#include "Server.h" // CONTEXT_OBJECT 定义(GetScreenContext 返回 CONTEXT_OBJECT* → context* 上转型)
|
||||
#include "LangManager.h" // _TR(审计日志标题语言映射)
|
||||
#include "common/file_upload.h" // V2 文件传输(RecvFileChunkV2 / HandleFileCompleteV2 / 包结构)
|
||||
|
||||
#include <sstream>
|
||||
|
||||
@@ -24,6 +25,11 @@
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// P6 download_file 复用 V2 落盘接口,需 FileManagerDlg 同款密码哈希 / HMAC 参数
|
||||
// (声明见 CPasswordDlg.h,避免在此引入重量级 MFC 头)。
|
||||
std::string GetPwdHash();
|
||||
std::string GetHMAC(int offset);
|
||||
|
||||
namespace {
|
||||
|
||||
// P2b 工具等待响应的超时(ms)。MCP 一次性请求:等待子连接回传进程/窗口列表。
|
||||
@@ -1147,6 +1153,11 @@ std::string BuildRemoteOpen(const Json::Value& id, const Json::Value& args, CMy2
|
||||
std::string BuildRemoteClose(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
|
||||
std::string BuildRemoteKeyboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
|
||||
std::string BuildRemoteMouse(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
|
||||
|
||||
// ===== P6 前置声明(定义见下方「tools/call 分派」前)=====
|
||||
Json::Value BuildDownloadFileInputSchema();
|
||||
Json::Value BuildDownloadFileOutputSchema();
|
||||
std::string BuildDownloadFile(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
|
||||
std::string BuildRemoteClipboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
|
||||
|
||||
// tools/list
|
||||
@@ -1397,6 +1408,17 @@ std::string BuildToolsListResult(const Json::Value& id) {
|
||||
}
|
||||
}
|
||||
|
||||
// 16) download_file(P6:MCP 文件传输,仅 Windows,安全门:McpFileTransfer=1)
|
||||
// 独立开关(不要求 McpReadonly=0):下载是只读方向(主控落盘),风险可控。
|
||||
if (CMcpServer::Instance().IsFileTransferEnabled()) {
|
||||
Json::Value tool(Json::objectValue);
|
||||
tool["name"] = "download_file";
|
||||
tool["description"] = u8"从指定在线 Windows 主机下载文件或目录到主控本机(V2 协议,逐文件 SHA-256 校验)。remote_path 为远程绝对路径(文件或目录,目录递归下载);local_dir 为本机保存目录;overwrite=false 时跳过已存在文件。返回 files[{path,size,sha256}] 与 total_bytes/skipped。";
|
||||
tool["inputSchema"] = BuildDownloadFileInputSchema();
|
||||
tool["outputSchema"] = BuildDownloadFileOutputSchema();
|
||||
tools.append(tool);
|
||||
}
|
||||
|
||||
result["tools"] = tools;
|
||||
return BuildResult(id, result);
|
||||
}
|
||||
@@ -3516,6 +3538,264 @@ std::string BuildRemoteClipboard(const Json::Value& id, const Json::Value& args,
|
||||
return BuildResult(id, result);
|
||||
}
|
||||
|
||||
// ===== P6:download_file 辅助 =====
|
||||
|
||||
// SHA-256 摘要转小写 hex(32 字节 → 64 字符)
|
||||
std::string Sha256Hex(const uint8_t* b, size_t n) {
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(n * 2);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
out += hex[b[i] >> 4];
|
||||
out += hex[b[i] & 0xF];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 逐层创建目录(Windows 盘符路径;失败返回 false)。
|
||||
bool EnsureDirA(const std::string& path) {
|
||||
std::string current;
|
||||
for (size_t i = 0; i < path.size(); ++i) {
|
||||
current += path[i];
|
||||
if (path[i] == '\\' || path[i] == '/' || i + 1 == path.size()) {
|
||||
if (current.size() == 2 && current[1] == ':') continue; // 跳过盘符 "C:"
|
||||
if (GetFileAttributesA(current.c_str()) == INVALID_FILE_ATTRIBUTES) {
|
||||
if (!CreateDirectoryA(current.c_str(), nullptr) &&
|
||||
GetLastError() != ERROR_ALREADY_EXISTS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return GetFileAttributesA(path.c_str()) != INVALID_FILE_ATTRIBUTES;
|
||||
}
|
||||
|
||||
// 把 UTF-8 local_dir 转为 ANSI 绝对路径(结尾 '\'),并创建目录。失败返回 false 并写 err。
|
||||
bool NormalizeLocalDir(const std::string& utf8, std::string& ansiOut, std::string& err) {
|
||||
if (utf8.empty()) { err = "Missing required parameter: local_dir"; return false; }
|
||||
std::string ansi = ToAnsi(utf8, CP_ACP);
|
||||
if (ansi.empty()) { err = "Invalid local_dir encoding"; return false; }
|
||||
|
||||
char full[MAX_PATH];
|
||||
DWORD n = GetFullPathNameA(ansi.c_str(), MAX_PATH, full, nullptr);
|
||||
if (n == 0 || n >= MAX_PATH) { err = "Invalid local_dir: " + utf8; return false; }
|
||||
|
||||
std::string dir = full;
|
||||
while (!dir.empty() && (dir.back() == '\\' || dir.back() == '/')) dir.pop_back();
|
||||
if (dir.size() == 2 && dir[1] == ':') { err = "Invalid local_dir: " + utf8; return false; }
|
||||
|
||||
if (!EnsureDirA(dir)) { err = "Failed to create local_dir: " + utf8; return false; }
|
||||
ansiOut = dir + "\\";
|
||||
return true;
|
||||
}
|
||||
|
||||
// 防路径穿越:fullPath 规范化后必须位于 baseDir 内(含 baseDir 本身)。
|
||||
bool PathWithinDirA(const std::string& baseDir, const std::string& fullPath) {
|
||||
char baseFull[MAX_PATH], pathFull[MAX_PATH];
|
||||
if (!GetFullPathNameA(baseDir.c_str(), MAX_PATH, baseFull, nullptr)) return false;
|
||||
if (!GetFullPathNameA(fullPath.c_str(), MAX_PATH, pathFull, nullptr)) return false;
|
||||
|
||||
for (char* p = baseFull; *p; ++p) { if (*p == '/') *p = '\\'; else if (*p >= 'A' && *p <= 'Z') *p = (char)(*p - 'A' + 'a'); }
|
||||
for (char* p = pathFull; *p; ++p) { if (*p == '/') *p = '\\'; else if (*p >= 'A' && *p <= 'Z') *p = (char)(*p - 'A' + 'a'); }
|
||||
|
||||
size_t bl = strlen(baseFull);
|
||||
while (bl > 0 && baseFull[bl - 1] == '\\') --bl;
|
||||
if (bl == 0) return true; // 根目录
|
||||
if (strncmp(pathFull, baseFull, bl) != 0) return false;
|
||||
return pathFull[bl] == '\\' || pathFull[bl] == '\0';
|
||||
}
|
||||
|
||||
Json::Value BuildDownloadFileInputSchema() {
|
||||
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 rp(Json::objectValue);
|
||||
rp["type"] = "string";
|
||||
rp["description"] = u8"远程绝对路径(文件或目录;目录递归下载),如 C:\\Users\\foo\\Pictures";
|
||||
props["remote_path"] = rp;
|
||||
|
||||
Json::Value ld(Json::objectValue);
|
||||
ld["type"] = "string";
|
||||
ld["description"] = u8"主控本机保存目录(不存在则自动创建),如 C:\\Downloads";
|
||||
props["local_dir"] = ld;
|
||||
|
||||
Json::Value ow(Json::objectValue);
|
||||
ow["type"] = "boolean";
|
||||
ow["description"] = u8"是否覆盖已存在的同名文件;默认 false(跳过并计入 skipped)";
|
||||
props["overwrite"] = ow;
|
||||
|
||||
Json::Value to(Json::objectValue);
|
||||
to["type"] = "integer";
|
||||
to["description"] = u8"超时毫秒(默认 600000=10 分钟,上限 3600000=1 小时)";
|
||||
props["timeout_ms"] = to;
|
||||
|
||||
Json::Value schema(Json::objectValue);
|
||||
schema["type"] = "object";
|
||||
schema["properties"] = props;
|
||||
Json::Value required(Json::arrayValue);
|
||||
required.append("id");
|
||||
required.append("remote_path");
|
||||
required.append("local_dir");
|
||||
schema["required"] = required;
|
||||
return schema;
|
||||
}
|
||||
|
||||
Json::Value BuildDownloadFileOutputSchema() {
|
||||
Json::Value props(Json::objectValue);
|
||||
|
||||
Json::Value filesProp(Json::objectValue);
|
||||
filesProp["type"] = "array";
|
||||
Json::Value items(Json::objectValue);
|
||||
items["type"] = "object";
|
||||
Json::Value itemProps(Json::objectValue);
|
||||
Json::Value pathProp(Json::objectValue); pathProp["type"] = "string"; itemProps["path"] = pathProp;
|
||||
Json::Value sizeProp(Json::objectValue); sizeProp["type"] = "integer"; itemProps["size"] = sizeProp;
|
||||
Json::Value shaProp(Json::objectValue); shaProp["type"] = "string"; itemProps["sha256"] = shaProp;
|
||||
items["properties"] = itemProps;
|
||||
filesProp["items"] = items;
|
||||
props["files"] = filesProp;
|
||||
|
||||
Json::Value tb(Json::objectValue); tb["type"] = "integer"; props["total_bytes"] = tb;
|
||||
Json::Value sk(Json::objectValue); sk["type"] = "integer"; props["skipped"] = sk;
|
||||
|
||||
Json::Value schema(Json::objectValue);
|
||||
schema["type"] = "object";
|
||||
schema["properties"] = props;
|
||||
return schema;
|
||||
}
|
||||
|
||||
// tools/call:download_file(下载远程文件/目录到本机,V2 协议)
|
||||
std::string BuildDownloadFile(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
|
||||
// 分派门控(独立开关 McpFileTransfer,不要求 McpReadonly=0):即便绕过 tools/list 直调也拒绝。
|
||||
if (!CMcpServer::Instance().IsFileTransferEnabled())
|
||||
return BuildError(id, -32006, "File transfer is disabled: requires McpFileTransfer=1");
|
||||
|
||||
uint64_t devId = 0;
|
||||
std::string err;
|
||||
if (!ParseHostIdArg(args, devId, err))
|
||||
return BuildError(id, -32602, err);
|
||||
|
||||
context* ctx = FindMainContext(parent, devId);
|
||||
if (!ctx)
|
||||
return BuildError(id, -32002, "Host not found or offline: " + std::to_string(devId));
|
||||
|
||||
// 仅 Windows 客户端实现 CMD_DOWN_FILES_V2(client/FileManager.cpp),LNX/MAC 提前拒绝。
|
||||
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
if (clientType == "LNX" || clientType == "MAC")
|
||||
return BuildError(id, -32005, "download_file is only supported on Windows hosts");
|
||||
|
||||
// 客户端须支持 V2 文件传输(否则 CMD_DOWN_FILES_V2 会被静默忽略 → 超时)。
|
||||
if (!ctx->SupportsFileV2())
|
||||
return BuildError(id, -32006, "Host does not support V2 file transfer");
|
||||
|
||||
std::string remotePathUtf8 = GetStringArg(args, "remote_path");
|
||||
if (remotePathUtf8.empty())
|
||||
return BuildError(id, -32602, "Missing required parameter: remote_path");
|
||||
|
||||
std::string localDirUtf8 = GetStringArg(args, "local_dir");
|
||||
if (localDirUtf8.empty())
|
||||
return BuildError(id, -32602, "Missing required parameter: local_dir");
|
||||
|
||||
bool overwrite = false;
|
||||
if (args.isMember("overwrite") && args["overwrite"].isBool())
|
||||
overwrite = args["overwrite"].asBool();
|
||||
|
||||
int timeoutMs = 600000;
|
||||
if (args.isMember("timeout_ms")) {
|
||||
int v = 0;
|
||||
if (!GetIntArg(args, "timeout_ms", v) || v <= 0 || v > 3600000)
|
||||
return BuildError(id, -32602, "timeout_ms must be in range 1..3600000");
|
||||
timeoutMs = v;
|
||||
}
|
||||
|
||||
// 本机保存目录:UTF-8 → ANSI 绝对路径(结尾 '\'),并创建。
|
||||
std::string localDirAnsi;
|
||||
if (!NormalizeLocalDir(localDirUtf8, localDirAnsi, err))
|
||||
return BuildError(id, -32602, err);
|
||||
|
||||
// 远程路径:UTF-8 → 客户端 ANSI(Windows=936,与 list_files 一致)。
|
||||
std::string remotePathAnsi = ToAnsi(remotePathUtf8, 936);
|
||||
if (remotePathAnsi.empty())
|
||||
return BuildError(id, -32602, "Invalid remote_path encoding");
|
||||
|
||||
CMcpServer& mcp = CMcpServer::Instance();
|
||||
if (!mcp.BeginFileTransferPending(devId, "download_file", localDirAnsi, remotePathAnsi, overwrite))
|
||||
return BuildError(id, -32003, "Device busy: another request is pending for this host");
|
||||
|
||||
BYTE cmd = COMMAND_LIST_DRIVE;
|
||||
if (!ctx->Send2Client(&cmd, 1)) {
|
||||
mcp.ClearFileTransfer(devId);
|
||||
return BuildError(id, -32004, "Failed to send command to host");
|
||||
}
|
||||
|
||||
std::vector<FileTransferEntry> files;
|
||||
int skipped = 0, error = 0;
|
||||
bool ok = mcp.WaitFileTransferDone(devId, timeoutMs, files, skipped, error);
|
||||
mcp.ClearFileTransfer(devId); // 擦会话 + CancelIO 子链接
|
||||
|
||||
if (ok) {
|
||||
uint64_t totalBytes = 0;
|
||||
for (auto& f : files) totalBytes += f.size;
|
||||
|
||||
// 审计(不可关闭;只记路径与字节数,不落文件内容)
|
||||
if (parent) {
|
||||
std::string audit = "host " + std::to_string(devId) + " download_file: "
|
||||
+ remotePathUtf8 + " -> " + localDirUtf8
|
||||
+ " (" + std::to_string(files.size()) + " files, "
|
||||
+ std::to_string(totalBytes) + " bytes, skipped " + std::to_string(skipped) + ")";
|
||||
parent->PostMessageA(WM_SHOWERRORMSG,
|
||||
(WPARAM)new CString(ToAnsi(audit, 936).c_str()),
|
||||
(LPARAM)new CString(_TR("MCP文件传输")));
|
||||
}
|
||||
|
||||
Json::Value result(Json::objectValue);
|
||||
Json::Value structuredContent(Json::objectValue);
|
||||
Json::Value filesArr(Json::arrayValue);
|
||||
for (auto& f : files) {
|
||||
Json::Value fo(Json::objectValue);
|
||||
fo["path"] = ToUtf8(f.path.c_str(), CP_ACP);
|
||||
fo["size"] = (Json::UInt64)f.size;
|
||||
fo["sha256"] = f.sha256;
|
||||
filesArr.append(fo);
|
||||
}
|
||||
structuredContent["files"] = filesArr;
|
||||
structuredContent["total_bytes"] = (Json::UInt64)totalBytes;
|
||||
structuredContent["skipped"] = skipped;
|
||||
result["structuredContent"] = structuredContent;
|
||||
|
||||
Json::Value content(Json::arrayValue);
|
||||
Json::Value item(Json::objectValue);
|
||||
item["type"] = "text";
|
||||
item["text"] = std::string(u8"下载完成:") + std::to_string(files.size()) + u8" 个文件("
|
||||
+ std::to_string(totalBytes) + u8" 字节),跳过 " + std::to_string(skipped) + u8" 个。";
|
||||
content.append(item);
|
||||
result["content"] = content;
|
||||
result["isError"] = false;
|
||||
return BuildResult(id, result);
|
||||
}
|
||||
|
||||
// 失败:删除已落盘的半成品文件(跳过的文件是既有的,不删)。
|
||||
for (auto& f : files) {
|
||||
if (!f.path.empty()) DeleteFileA(f.path.c_str());
|
||||
}
|
||||
if (parent) {
|
||||
std::string audit = "host " + std::to_string(devId) + " download_file FAILED (error " + std::to_string(error)
|
||||
+ "): " + remotePathUtf8 + " -> " + localDirUtf8;
|
||||
parent->PostMessageA(WM_SHOWERRORMSG,
|
||||
(WPARAM)new CString(ToAnsi(audit, 936).c_str()),
|
||||
(LPARAM)new CString(_TR("MCP文件传输")));
|
||||
}
|
||||
|
||||
const char* msg = (error == 1002) ? "Timeout waiting for file transfer"
|
||||
: (error == 1001) ? "Path traversal detected in received path"
|
||||
: (error == FEV2_HASH_MISMATCH) ? "File SHA-256 verification failed"
|
||||
: "File transfer failed";
|
||||
return BuildError(id, -32001, msg);
|
||||
}
|
||||
|
||||
// tools/call 分派
|
||||
std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
|
||||
const Json::Value& id = root["id"];
|
||||
@@ -3549,6 +3829,7 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
|
||||
if (toolName == "remote_keyboard") return BuildRemoteKeyboard(id, args, parent);
|
||||
if (toolName == "remote_mouse") return BuildRemoteMouse(id, args, parent);
|
||||
if (toolName == "remote_clipboard") return BuildRemoteClipboard(id, args, parent);
|
||||
if (toolName == "download_file") return BuildDownloadFile(id, args, parent);
|
||||
|
||||
return BuildError(id, -32602,
|
||||
"Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName));
|
||||
@@ -4073,6 +4354,7 @@ void CMcpServer::TakeMainResponse(uint64_t device_id, const BYTE* data, ULONG le
|
||||
}
|
||||
|
||||
bool CMcpServer::BeginPending(uint64_t device_id, const std::string& tool) {
|
||||
if (IsFileTransferPending(device_id)) return false; // 该 host 有文件传输会话在飞(互斥,见 F2)
|
||||
std::lock_guard<std::mutex> lk(m_PendingMutex);
|
||||
if (m_Pending.find(device_id) != m_Pending.end()) return false; // 设备忙
|
||||
PendingRequest r;
|
||||
@@ -4105,6 +4387,7 @@ void CMcpServer::ClearPending(uint64_t device_id) {
|
||||
// ===== P2c:list_files / get_screenshot 扩展 =====
|
||||
|
||||
bool CMcpServer::BeginPending(uint64_t device_id, const std::string& tool, const std::string& path) {
|
||||
if (IsFileTransferPending(device_id)) return false; // 该 host 有文件传输会话在飞(互斥,见 F2)
|
||||
std::lock_guard<std::mutex> lk(m_PendingMutex);
|
||||
if (m_Pending.find(device_id) != m_Pending.end()) return false; // 设备忙
|
||||
PendingRequest r;
|
||||
@@ -4258,3 +4541,205 @@ std::string GenerateRandomToken() {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===== P6:download_file 文件传输会话(状态机方法)=====
|
||||
|
||||
bool CMcpServer::IsFileTransferPending(uint64_t device_id) {
|
||||
std::lock_guard<std::mutex> lk(m_FileXferMutex);
|
||||
return m_FileXferSessions.find(device_id) != m_FileXferSessions.end();
|
||||
}
|
||||
|
||||
bool CMcpServer::BeginFileTransferPending(uint64_t device_id, const std::string& tool,
|
||||
const std::string& localDir, const std::string& remotePath,
|
||||
bool overwrite) {
|
||||
// 与一次性挂起请求互斥(F2):先查对方注册表再查自己的,避免跨锁嵌套死锁。
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_PendingMutex);
|
||||
if (m_Pending.find(device_id) != m_Pending.end()) return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(m_FileXferMutex);
|
||||
if (m_FileXferSessions.find(device_id) != m_FileXferSessions.end()) return false; // 单设备单传输
|
||||
FileTransferSession s;
|
||||
s.tool = tool;
|
||||
s.localDir = localDir;
|
||||
s.remotePath = remotePath;
|
||||
s.overwrite = overwrite;
|
||||
s.startAt = time(nullptr);
|
||||
m_FileXferSessions[device_id] = std::move(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CMcpServer::OnDownloadDriveList(uint64_t device_id, context* fmSubCtx) {
|
||||
std::string localDir, remotePath;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_FileXferMutex);
|
||||
auto it = m_FileXferSessions.find(device_id);
|
||||
if (it == m_FileXferSessions.end() || it->second.tool != "download_file")
|
||||
return false; // 会话已清理 → 调用方 CancelIO 收尾
|
||||
FileTransferSession& s = it->second;
|
||||
if (s.fmSubCtx != nullptr) return true; // 已下发过,防重复
|
||||
s.fmSubCtx = fmSubCtx;
|
||||
localDir = s.localDir;
|
||||
remotePath = s.remotePath;
|
||||
}
|
||||
|
||||
// 锁外组包下发(Send2Client 异步投递,子链接保持到 ClearFileTransfer 收尾,见 §13 F3)。
|
||||
// 布局与 FileManagerDlg::OnTransferV2ToLocal 一致:[cmd][targetDir\0][remotePath\0][\0]
|
||||
std::vector<BYTE> pkt;
|
||||
pkt.reserve(1 + localDir.size() + 1 + remotePath.size() + 2);
|
||||
pkt.push_back((BYTE)CMD_DOWN_FILES_V2);
|
||||
pkt.insert(pkt.end(), localDir.begin(), localDir.end());
|
||||
pkt.push_back(0);
|
||||
pkt.insert(pkt.end(), remotePath.begin(), remotePath.end());
|
||||
pkt.push_back(0);
|
||||
pkt.push_back(0);
|
||||
fmSubCtx->Send2Client(pkt.data(), (ULONG)pkt.size());
|
||||
Mprintf("[MCP] download_file: 下发 CMD_DOWN_FILES_V2 remote=%s\n", remotePath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
void CMcpServer::OnFileChunkV2(uint64_t device_id, context* streamSubCtx, const BYTE* buf, ULONG len) {
|
||||
if (len < (ULONG)sizeof(FileChunkPacketV2)) return;
|
||||
const FileChunkPacketV2* pkt = (const FileChunkPacketV2*)buf;
|
||||
if (pkt->nameLength > (uint64_t)(len - sizeof(FileChunkPacketV2))) return; // 畸形包:名字越界
|
||||
|
||||
// 与 FileManagerDlg.cpp 落盘同款参数(镜像其 GetPwdHash()/GetHMAC(100))。
|
||||
std::string hash = GetPwdHash(), hmac = GetHMAC(100);
|
||||
|
||||
std::lock_guard<std::mutex> lk(m_FileXferMutex);
|
||||
auto it = m_FileXferSessions.find(device_id);
|
||||
if (it == m_FileXferSessions.end()) return;
|
||||
FileTransferSession& s = it->second;
|
||||
if (s.tool != "download_file") return;
|
||||
if (s.done) return; // 已出错/完成,丢弃迟到包
|
||||
|
||||
if (s.streamSubCtx == nullptr) s.streamSubCtx = streamSubCtx;
|
||||
if (s.transferID == 0) {
|
||||
s.transferID = pkt->transferID;
|
||||
s.totalFiles = pkt->totalFiles;
|
||||
}
|
||||
|
||||
uint32_t fileIndex = pkt->fileIndex;
|
||||
std::string fileName((char*)(pkt + 1), pkt->nameLength); // ANSI = localDir + relPath
|
||||
|
||||
// 目录项:只建目录(RecvFileChunkV2 会 mkdir),不参与 files 输出(见 §13 F4)。
|
||||
// 与文件项一样先做路径穿越校验(F5):目录项的 filename 同样可能越界(如远程目录名含 ..)。
|
||||
if (pkt->flags & FFV2_DIRECTORY) {
|
||||
if (!PathWithinDirA(s.localDir, fileName)) {
|
||||
s.error = 1001;
|
||||
s.done = true;
|
||||
m_FileXferCv.notify_one();
|
||||
return;
|
||||
}
|
||||
s.directoryCount++;
|
||||
RecvFileChunkV2((char*)buf, len, nullptr, nullptr, hash, hmac, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pkt->offset == 0) {
|
||||
// 路径穿越校验(F5):规范化后必须在 localDir 内。
|
||||
if (!PathWithinDirA(s.localDir, fileName)) {
|
||||
s.error = 1001;
|
||||
s.done = true;
|
||||
m_FileXferCv.notify_one();
|
||||
return;
|
||||
}
|
||||
// overwrite=false 且同名已存在 → 跳过该文件(丢弃其后续分块,不落盘)。
|
||||
if (!s.overwrite && GetFileAttributesA(fileName.c_str()) != INVALID_FILE_ATTRIBUTES) {
|
||||
s.skipIndexes.insert(fileIndex);
|
||||
FileTransferEntry e;
|
||||
e.path = fileName;
|
||||
e.size = pkt->fileSize;
|
||||
s.skipped.push_back(std::move(e));
|
||||
return;
|
||||
}
|
||||
// 记录文件条目(size 已知;sha256 待 COMPLETE 包回填)。
|
||||
FileTransferEntry e;
|
||||
e.path = fileName;
|
||||
e.size = pkt->fileSize;
|
||||
s.fileEntries[fileIndex] = std::move(e);
|
||||
} else {
|
||||
if (s.skipIndexes.count(fileIndex)) return; // 已跳过的文件,丢弃后续分块
|
||||
}
|
||||
|
||||
int n = RecvFileChunkV2((char*)buf, len, nullptr, nullptr, hash, hmac, 0);
|
||||
if (n) {
|
||||
s.error = n;
|
||||
s.done = true;
|
||||
m_FileXferCv.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
void CMcpServer::OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len) {
|
||||
if (len < (ULONG)sizeof(FileCompletePacketV2)) return;
|
||||
const FileCompletePacketV2* pkt = (const FileCompletePacketV2*)buf;
|
||||
|
||||
std::lock_guard<std::mutex> lk(m_FileXferMutex);
|
||||
auto it = m_FileXferSessions.find(device_id);
|
||||
if (it == m_FileXferSessions.end()) return;
|
||||
FileTransferSession& s = it->second;
|
||||
if (s.tool != "download_file") return;
|
||||
if (s.done) return;
|
||||
|
||||
uint32_t fileIndex = pkt->fileIndex;
|
||||
|
||||
if (s.skipIndexes.count(fileIndex)) {
|
||||
s.filesDone++; // 跳过的文件:不校验,直接计数
|
||||
} else {
|
||||
bool verifyOk = HandleFileCompleteV2((const char*)buf, len, 0);
|
||||
auto fe = s.fileEntries.find(fileIndex);
|
||||
if (fe != s.fileEntries.end()) fe->second.sha256 = Sha256Hex(pkt->sha256, 32);
|
||||
if (!verifyOk) {
|
||||
s.error = FEV2_HASH_MISMATCH;
|
||||
s.done = true;
|
||||
m_FileXferCv.notify_one();
|
||||
return;
|
||||
}
|
||||
s.filesDone++;
|
||||
}
|
||||
|
||||
// 完成判定:目录不发 COMPLETE 包,故 filesDone 达 totalFiles - directoryCount 即完成(F4)。
|
||||
uint32_t expectedFiles = (s.totalFiles > s.directoryCount) ? (s.totalFiles - s.directoryCount) : 0;
|
||||
if (s.filesDone >= expectedFiles) {
|
||||
s.done = true;
|
||||
m_FileXferCv.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
bool CMcpServer::WaitFileTransferDone(uint64_t device_id, int timeoutMs,
|
||||
std::vector<FileTransferEntry>& files, int& skipped, int& error) {
|
||||
std::unique_lock<std::mutex> lk(m_FileXferMutex);
|
||||
auto it = m_FileXferSessions.find(device_id);
|
||||
if (it == m_FileXferSessions.end()) { error = 1002; return false; }
|
||||
|
||||
bool signaled = m_FileXferCv.wait_for(lk, std::chrono::milliseconds(timeoutMs),
|
||||
[&] { return it->second.done; });
|
||||
|
||||
FileTransferSession& s = it->second;
|
||||
if (!signaled) s.error = 1002; // 超时
|
||||
|
||||
error = s.error;
|
||||
// 复制已见文件(成功=完整文件;失败=供调用方删半成品)。跳过的既有文件不在此列。
|
||||
for (auto& kv : s.fileEntries) files.push_back(kv.second);
|
||||
skipped = (int)s.skipped.size();
|
||||
|
||||
return signaled && s.error == 0;
|
||||
}
|
||||
|
||||
void CMcpServer::ClearFileTransfer(uint64_t device_id) {
|
||||
FileTransferSession s;
|
||||
bool found = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_FileXferMutex);
|
||||
auto it = m_FileXferSessions.find(device_id);
|
||||
if (it != m_FileXferSessions.end()) {
|
||||
s = std::move(it->second);
|
||||
m_FileXferSessions.erase(it);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) return;
|
||||
// 锁外关闭两条子链接(CancelIO 触发客户端断开,停掉仍在飞的流式数据)。
|
||||
if (s.fmSubCtx) s.fmSubCtx->CancelIO();
|
||||
if (s.streamSubCtx) s.streamSubCtx->CancelIO();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
@@ -28,6 +29,14 @@
|
||||
class CMy2015RemoteDlg;
|
||||
class context;
|
||||
|
||||
// 文件传输结果条目(download_file 输出 files[] 的一项)。
|
||||
// path 为本机落盘绝对路径(ANSI,输出 JSON 时再转 UTF-8);sha256 为小写 hex。
|
||||
struct FileTransferEntry {
|
||||
std::string path;
|
||||
uint64_t size = 0;
|
||||
std::string sha256;
|
||||
};
|
||||
|
||||
// MCP (Model Context Protocol) 服务端:httplib 封装 + JSON-RPC 2.0 分发 + 静态 token 校验。
|
||||
// 与 CWebService 平级、互不依赖;默认禁用,经「扩展 → MCP设置」开启后监听
|
||||
// (默认 127.0.0.1:6544,仅本机回环)。见 docs/Mcp_Design.md。
|
||||
@@ -194,6 +203,40 @@ public:
|
||||
// 屏幕子连接断开(OfflineProc 调用):擦除该子连接对应的会话 + 路由(幂等)。
|
||||
void OnScreenControlClosed(context* subCtx);
|
||||
|
||||
// ===== P6:MCP 文件传输(download_file,V2 协议)=====
|
||||
// 下载走 CMD_DOWN_FILES_V2 → 客户端新开流式子连接回传 COMMAND_SEND_FILE_V2 /
|
||||
// COMMAND_FILE_COMPLETE_V2(dstClientID==0)。会话按 device_id 键控,单设备单传输;
|
||||
// 复用 list_files 的 COMMAND_LIST_DRIVE → TOKEN_DRIVE_LIST 开文件管理器子链接下发命令。
|
||||
void SetFileTransferEnabled(bool enabled) { m_fileTransferEnabled = enabled; }
|
||||
bool IsFileTransferEnabled() const { return m_fileTransferEnabled; }
|
||||
|
||||
// 该 host 是否有进行中的文件传输会话(MessageHandle 分派守卫)。
|
||||
bool IsFileTransferPending(uint64_t device_id);
|
||||
|
||||
// 登记文件传输会话(false = 该 host 已有文件会话或一次性挂起请求)。
|
||||
bool BeginFileTransferPending(uint64_t device_id, const std::string& tool,
|
||||
const std::string& localDir, const std::string& remotePath,
|
||||
bool overwrite);
|
||||
|
||||
// TOKEN_DRIVE_LIST:识别 download_file,存文件管理器子链接并下发 CMD_DOWN_FILES_V2。
|
||||
// 返回 true=已接管(保持子链接,收尾由 ClearFileTransfer);false=会话已清理(调用方 CancelIO)。
|
||||
bool OnDownloadDriveList(uint64_t device_id, context* fmSubCtx);
|
||||
|
||||
// COMMAND_SEND_FILE_V2:解析 chunk,路径校验 + overwrite 判定 + 落盘(RecvFileChunkV2)。
|
||||
void OnFileChunkV2(uint64_t device_id, context* streamSubCtx, const BYTE* buf, ULONG len);
|
||||
|
||||
// COMMAND_FILE_COMPLETE_V2:SHA-256 校验(HandleFileCompleteV2)+ 完成判定。
|
||||
void OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len);
|
||||
|
||||
// 工具线程:等待传输完成;true=成功(files 已填,error=0),false=失败/超时(error 填原因)。
|
||||
// 无论成败,files 都会返回已见文件的落盘路径(成功=完整文件,失败=供调用方删半成品)。
|
||||
// 调用方随后必须调 ClearFileTransfer 收尾(擦会话 + CancelIO 子链接)。
|
||||
bool WaitFileTransferDone(uint64_t device_id, int timeoutMs,
|
||||
std::vector<FileTransferEntry>& files, int& skipped, int& error);
|
||||
|
||||
// 收尾:擦会话 + 锁外 CancelIO 文件管理器/流式两条子链接(幂等)。
|
||||
void ClearFileTransfer(uint64_t device_id);
|
||||
|
||||
// 安全配置(启动时由 CMy2015RemoteDlg 读 THIS_CFG 后设置)。
|
||||
void SetReadonly(bool readonly) { m_readonly = readonly; }
|
||||
void SetCmdWhitelist(const std::string& whitelist) { m_cmdWhitelist = whitelist; }
|
||||
@@ -272,10 +315,34 @@ private:
|
||||
std::map<uint64_t, ScreenCtrlSession> m_ScreenCtrlSessions; // device_id → 会话
|
||||
std::map<context*, uint64_t> m_ScreenCtrlContextToDevice; // subCtx → device_id(OfflineProc 反查)
|
||||
|
||||
// ===== P6:文件传输会话(受 m_FileXferMutex 保护;单设备单传输)=====
|
||||
struct FileTransferSession {
|
||||
std::string tool; // 恒为 "download_file"
|
||||
std::string localDir; // 本机保存目录(ANSI,结尾 '\')
|
||||
std::string remotePath; // 客户端远程路径(ANSI,发 CMD_DOWN_FILES_V2 用)
|
||||
bool overwrite = false; // 是否覆盖已存在文件
|
||||
context* fmSubCtx = nullptr; // 文件管理器子链接(下发 CMD_DOWN_FILES_V2)
|
||||
context* streamSubCtx = nullptr; // 流式子链接(首个 chunk 填,收尾 CancelIO)
|
||||
uint64_t transferID = 0; // 客户端回填的传输会话 ID
|
||||
uint32_t totalFiles = 0; // 客户端声明的文件总数(含目录项,见 F4)
|
||||
uint32_t directoryCount = 0; // 目录项计数(目录不发 COMPLETE 包)
|
||||
uint32_t filesDone = 0; // 已完成的文件数(含跳过的)
|
||||
bool done = false; // 全部完成或出错
|
||||
int error = 0; // 0=ok;8=哈希不匹配;1001=路径逃逸;1002=超时
|
||||
std::map<uint32_t, FileTransferEntry> fileEntries; // fileIndex → {path,size,sha256}
|
||||
std::set<uint32_t> skipIndexes; // overwrite=false 跳过的 fileIndex
|
||||
std::vector<FileTransferEntry> skipped; // 跳过的文件(供输出)
|
||||
time_t startAt = 0;
|
||||
};
|
||||
std::mutex m_FileXferMutex;
|
||||
std::condition_variable m_FileXferCv;
|
||||
std::map<uint64_t, FileTransferSession> m_FileXferSessions; // device_id → 会话
|
||||
|
||||
bool m_readonly = true;
|
||||
std::string m_cmdWhitelist;
|
||||
bool m_terminalEnabled = false; // 持久终端开关(默认关;要求 m_readonly=false)
|
||||
bool m_remoteControlEnabled = false; // 远程控制开关(默认关;要求 m_readonly=false)
|
||||
bool m_fileTransferEnabled = false; // 文件传输开关(默认关;download_file)
|
||||
};
|
||||
|
||||
// 全局访问器(仿 WebService(),见 WebService.h 末尾)
|
||||
|
||||
@@ -106,7 +106,7 @@ INT_PTR CMcpSettingsDlg::DoModal()
|
||||
{
|
||||
USES_CONVERSION;
|
||||
CString title = _TR("MCP设置");
|
||||
BuildDialogTemplate(m_Template, T2CW(title), 320, 390);
|
||||
BuildDialogTemplate(m_Template, T2CW(title), 320, 420);
|
||||
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
|
||||
return CDialog::DoModal();
|
||||
}
|
||||
@@ -142,6 +142,9 @@ BOOL CMcpSettingsDlg::OnInitDialog()
|
||||
m_btnRemoteControl.Create(_TR("启用远程控制(AI 操控桌面)"),
|
||||
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
|
||||
r0, this, IDC_MCP_REMOTECONTROL);
|
||||
m_btnFileTransfer.Create(_TR("启用文件传输(download_file)"),
|
||||
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
|
||||
r0, this, IDC_MCP_FILETRANSFER);
|
||||
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,
|
||||
@@ -164,6 +167,7 @@ BOOL CMcpSettingsDlg::OnInitDialog()
|
||||
m_btnReadonly.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||
m_btnTerminal.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||
m_btnRemoteControl.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||
m_btnFileTransfer.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));
|
||||
@@ -179,6 +183,7 @@ BOOL CMcpSettingsDlg::OnInitDialog()
|
||||
int readonly = THIS_CFG.GetInt("settings", "McpReadonly", 1);
|
||||
int terminal = THIS_CFG.GetInt("settings", "McpTerminal", 0);
|
||||
int remoteControl = THIS_CFG.GetInt("settings", "McpRemoteControl", 0);
|
||||
int fileTransfer = THIS_CFG.GetInt("settings", "McpFileTransfer", 0);
|
||||
std::string whitelist = THIS_CFG.GetStr("settings", "McpCmdWhitelist", "");
|
||||
|
||||
m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED);
|
||||
@@ -188,6 +193,7 @@ BOOL CMcpSettingsDlg::OnInitDialog()
|
||||
m_btnReadonly.SetCheck(readonly ? BST_CHECKED : BST_UNCHECKED);
|
||||
m_btnTerminal.SetCheck(terminal ? BST_CHECKED : BST_UNCHECKED);
|
||||
m_btnRemoteControl.SetCheck(remoteControl ? BST_CHECKED : BST_UNCHECKED);
|
||||
m_btnFileTransfer.SetCheck(fileTransfer ? BST_CHECKED : BST_UNCHECKED);
|
||||
// 白名单存储为逗号分隔,展示为每行一条。
|
||||
m_editWhitelist.SetWindowText(CString(WhitelistForDisplay(whitelist).c_str()));
|
||||
|
||||
@@ -206,6 +212,7 @@ void CMcpSettingsDlg::OnOK()
|
||||
bool readonly = (m_btnReadonly.GetCheck() == BST_CHECKED);
|
||||
bool terminal = (m_btnTerminal.GetCheck() == BST_CHECKED);
|
||||
bool remoteControl = (m_btnRemoteControl.GetCheck() == BST_CHECKED);
|
||||
bool fileTransfer = (m_btnFileTransfer.GetCheck() == BST_CHECKED);
|
||||
|
||||
// 端口校验:1-65535
|
||||
int port = atoi(CT2A(sPort));
|
||||
@@ -231,13 +238,14 @@ void CMcpSettingsDlg::OnOK()
|
||||
THIS_CFG.SetInt("settings", "McpReadonly", readonly ? 1 : 0);
|
||||
THIS_CFG.SetInt("settings", "McpTerminal", terminal ? 1 : 0);
|
||||
THIS_CFG.SetInt("settings", "McpRemoteControl", remoteControl ? 1 : 0);
|
||||
THIS_CFG.SetInt("settings", "McpFileTransfer", fileTransfer ? 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/只读/白名单/持久终端/远程控制的改动需重启程序生效。") + _T("\r\n") +
|
||||
_TR("启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制/文件传输的改动需重启程序生效。") + _T("\r\n") +
|
||||
_TR("持久终端与远程控制仅在只读模式关闭时生效。"),
|
||||
_TR("提示"), MB_ICONINFORMATION);
|
||||
|
||||
@@ -278,6 +286,9 @@ void CMcpSettingsDlg::LayoutControls(int cx, int cy)
|
||||
m_btnRemoteControl.MoveWindow(margin, y, cx - margin * 2, 22);
|
||||
y += 30;
|
||||
|
||||
m_btnFileTransfer.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);
|
||||
|
||||
@@ -30,6 +30,7 @@ private:
|
||||
IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀)
|
||||
IDC_MCP_TERMINAL = 1007, // 「启用持久终端」复选框(全命令,无白名单,要求只读关)
|
||||
IDC_MCP_REMOTECONTROL = 1008, // 「启用远程控制」复选框(AI 操控桌面,要求只读关)
|
||||
IDC_MCP_FILETRANSFER = 1009, // 「启用文件传输」复选框(download_file,不要求只读关)
|
||||
};
|
||||
|
||||
CButton m_btnEnable;
|
||||
@@ -38,6 +39,7 @@ private:
|
||||
CButton m_btnReadonly;
|
||||
CButton m_btnTerminal;
|
||||
CButton m_btnRemoteControl;
|
||||
CButton m_btnFileTransfer;
|
||||
CStatic m_lblWhitelist;
|
||||
CEdit m_editWhitelist;
|
||||
CButton m_btnOK, m_btnCancel;
|
||||
|
||||
Reference in New Issue
Block a user