2 Commits
Author SHA1 Message Date
yuanyuanxianganddeepseek-v4-pro 5c77f5ce14 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
2026-08-30 13:19:51 +02:00
yuanyuanxianganddeepseek-v4-pro ac2855198b Fix: terminal_exec sentinel timeout on numeric timeout_ms and silent commands
Two independent bugs made MCP terminal tools time out despite the command
finishing.

First, timeout_ms is declared integer in the tool schemas but was parsed
through GetStringArg, which only reads JSON strings. A numeric value fell
through to the 20s default, so callers asking for a longer wait were cut off
at 20s. exec_command, terminal_open, terminal_exec and remote_open now parse
timeout_ms through GetIntArg, which accepts both JSON numbers and digit
strings, keeping the 1..600000 range guard.

Second, FindSentinel treated the __MCP_DONE_<nonce>__ marker as a line start
only when preceded by \n. Commands that produce no output (ping > nul,
Start-Sleep, tar -czf) echo their command line ending in \r, so the marker
never matched and the wait ran to timeout even though the command had
completed. The check now also accepts \r.

Co-Authored-By: deepseek-v4-pro
2026-08-30 13:19:22 +02:00
6 changed files with 889 additions and 23 deletions

No files matched your search

+284
View File
@@ -0,0 +1,284 @@
# YAMA MCP 文件传输功能设计(download_file / upload_file)
> **状态**:设计定稿(经专家审查修订,结论见 §13)。`download_file` 采用 **V2 文件传输协议**(`CMD_DOWN_FILES_V2` + 流式子连接 + SHA-256 校验),`upload_file` 复用服务端既有 `FileBatchTransferWorkerV2`。两者共享同一套「流式文件会话」注册表。
> **读者**:MCP 后续功能研发/评审人员。
> **关联文档**:[Mcp_Phase2_Design.md](./Mcp_Phase2_Design.md)(双模式无头驱动机制)、[FILE_TRANSFER_V2.md](./FILE_TRANSFER_V2.md)(V2 协议清单)、[Mcp_Design.md](./Mcp_Design.md)(Phase 1 协议/架构/配置)。
---
## 1. 背景与目标
MCP 目前已暴露 `list_files`(只读列目录,`McpServer.cpp:1741`),但**没有文件传输工具**:把远程主机的文件/目录拉回主控本机(下载),或把本机文件推到远程(上传),只能通过 GUI 文件管理器手工操作。
本技术书解决:
1. **`download_file`**(本期 P1)——远程文件/目录 → 主控本机指定目录,复用 V2 推送协议,带 SHA-256 完整性校验、支持大文件。
2. **`upload_file`**(后续 P2)——主控本机文件/目录 → 远程指定目录,复用服务端 V2 发送器。
3. **一套共享的「流式文件会话」基础设施**——一次投入,下载/上传两处受益,镜像现有终端(`TermSession`)与远程控制(`ScreenCtrlSession`)会话模式。
---
## 2. 现状回顾
| 能力 | 现有协议/命令 | 服务端现有实现 | 是否暴露给 MCP |
|---|---|---|---|
| 远程列目录 | `COMMAND_LIST_DRIVE`→`TOKEN_DRIVE_LIST`;`COMMAND_LIST_FILES`→`TOKEN_FILE_LIST` | `McpServer.cpp:1741`(`list_files`) | ✅ |
| 远程→本地下载(V1) | `COMMAND_DOWN_FILES`(`commands.h:166`) + `TOKEN_FILE_SIZE/DATA/FINISH` | `FileManagerDlg.cpp:2164` / `file/CFileManagerDlg.cpp:1136` | ❌ 仅 GUI |
| 远程→本地下载(V2) | `CMD_DOWN_FILES_V2`(94, `commands.h:260`) + `COMMAND_SEND_FILE_V2`(85) + `COMMAND_FILE_COMPLETE_V2`(91) | `FileManagerDlg.cpp:3337-3384`(下发)、`2756`(`RecvFileChunkV2` 落盘) | ❌ 仅 GUI |
| 本地→远程上传(V2) | `FileBatchTransferWorkerV2`(服务端作 sender) | `2015RemoteDlg.cpp:7572` | ❌ 仅 GUI |
### 2.1 关键事实
1. **V2 下载的客户端会另开一条新子连接**:`client/FileManager.cpp:1101-1190` `UploadToRemoteV2()` 解析完文件列表后,`new IOCPClient(...)` → `EnableSubConnAuth()` → `ConnectServer(...)`(`FileManager.cpp:1170-1172`),在**独立鉴权子连接**上推 `COMMAND_SEND_FILE_V2` 分块 + `COMMAND_FILE_COMPLETE_V2` 校验包。即下载 ≠ 复用文件管理器那条子链接,而是一条**持续流子链接**——正属 `Mcp_Phase2_Design.md §4.3` 当初判定「本期不做」的流式能力。
2. **接收端 `RecvFileChunkV2` 无 UI 可调**:`CDlgFileSend.cpp:128` 已 `RecvFileChunkV2(buf, len, nullptr, nullptr, hash, hmac, 0)` 无头落盘;落盘状态在 `SimplePlugins/file_upload.cpp` 内部按 `transferID` 自维护,天然适合 MCP 无头复用。
3. **V2 落盘路径由客户端回填**:`CMD_DOWN_FILES_V2` 的 `targetDir` 是**主控本机**保存目录(`FileManagerDlg.cpp:3370` `m_Local_Path`);客户端把它拼进每个 chunk 的 `filename` 发回,服务端 `RecvFileChunkV2` 直接按该完整路径写盘。因此「下载保存到哪」由服务端在命令里指定、客户端原样回填。
---
## 3. 协议选型(已定:V2)
| 维度 | **V2 推送(选定)** | V1 拉取(回退) |
|---|---|---|
| 命令 | `CMD_DOWN_FILES_V2` + `COMMAND_SEND_FILE_V2` + `COMMAND_FILE_COMPLETE_V2` | `COMMAND_DOWN_FILES` + `TOKEN_FILE_SIZE/DATA/FINISH` |
| 连接 | 客户端新开一条鉴权流式子连接 | 复用文件管理器子链接 |
| 完整性 | ✅ SHA-256(`COMMAND_FILE_COMPLETE_V2`) | ❌ 无 |
| 大文件 | ✅ 64 位偏移,>4GB | 64 位偏移但无校验 |
| 客户端代码 | ✅ 已存在,是当前 GUI 正规路径 | ⚠️ 遗留路径 |
| MCP 侧复杂度 | 需路由一条**新流式子连接**(新增会话注册表) | 需在 `MessageHandle` 维护「发 CONTINUE/收 DATA」状态机,逐块 ACK 慢 |
**结论:采用 V2。** 代价是服务端要新增「流式文件会话」注册与路由;但这是 `upload_file` 同样要复用的基础设施,一次投入两处受益。V1 仅作「不想引入新子连接路由」时的降级备案,不在本期实现。
---
## 4. 设计原则
1. **复用现有协议,不新造命令**:`CMD_DOWN_FILES_V2`、`COMMAND_SEND_FILE_V2`、`COMMAND_FILE_COMPLETE_V2`、`COMMAND_LIST_DRIVE` 全部为 `common/commands.h` 既有;客户端零改动。
2. **无头接管复用会话模式**:镜像 `McpServer.h` 的 `TermSession` / `ScreenCtrlSession`,新增 `FileTransferSession`;`MessageHandle` 用 `IsFileTransferContext(context*)` 判定是否路由到 MCP 无头落盘,否则回落 GUI 进度框。
3. **单设备单传输会话**:与终端/远程控制一致,避免同 host 并发传输的归属歧义;并发返回 `-32003 Device busy`。
4. **超时与清理是硬约束**:大文件传输是长任务,超时需独立于 `kMcpToolTimeoutMs`(20s);断线/超时必须清会话 + 关子链接 + 删半成品文件。
5. **输出 schema 先行**:两个工具先在 `tools/list` 声明完整 `inputSchema`/`outputSchema`。
---
## 5. 核心机制:流式文件会话
### 5.1 会话注册表(`McpServer.h/.cpp`)
```cpp
struct FileTransferSession {
std::string tool; // "download_file" / "upload_file"
std::string localDir; // download: 本机落盘目录(规范化后绝对路径,用于穿越校验)
std::string remotePath; // 审计用原始远程路径(UTF-8)
uint64_t transferID = 0; // 绑定后从首包填充(download);upload 由本端 GenerateTransferID()
uint32_t totalFiles = 0; // 完成判定:首个 COMMAND_SEND_FILE_V2 chunk 的 totalFiles(§13 F4)
uint32_t filesDone = 0; // 完成判定:累计收到的 COMMAND_FILE_COMPLETE_V2 数
bool done = false;
int error = 0; // FEV2_* / 自定义
std::vector<FileEntry> files; // {path, size} 已落盘/已发送文件
std::vector<FileEntry> skipped; // overwrite=false 时跳过的同名文件
time_t startAt = 0;
};
std::mutex m_FileXferMutex;
std::map<uint64_t, FileTransferSession> m_FileXferSessions; // device_id → 会话
```
> **取消 `m_FileXferContextToDevice` 映射与 `IsFileTransferContext(context*)`**:流式子连接经 `TOKEN_CONN_AUTH` 已把 `clientID` 钉在 `ContextObject` 上(`2015RemoteDlg.cpp:6411 SetID`),分派时直接用 `ContextObject->GetClientID()` 定位 `device_id`,无需 context 路由表(§13 F1)。
配套接口(与 `BeginTermPending` / `OnTerminalData` / `WaitTerminalDone` 同构):
```cpp
bool BeginFileTransferPending(uint64_t device_id, const std::string& tool,
const std::string& localDir, const std::string& remotePath);
bool IsFileTransferPending(uint64_t device_id); // 分支顶部守卫
void OnFileChunkV2(uint64_t device_id, const BYTE* buf, ULONG len); // 路径校验 + 无头 RecvFileChunkV2
void OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len);// SHA-256 + filesDone 计数
bool WaitFileTransferDone(uint64_t device_id, int timeoutMs, std::vector<FileEntry>& out);
void ClearFileTransfer(uint64_t device_id); // 失败/超时/断线收尾(删半成品)
```
### 5.2 流式子连接的路由(device_id 键,镜像 `TOKEN_DRIVE_LIST` 守卫)
V2 下载的数据包 `COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 本就由 `MessageHandle` 按 `ContextObject->GetClientID()` 分派(`2015RemoteDlg.cpp:5842` / `6101`)。MCP 只需在这两个 case 的 **`dstClientID==0`(M2C)分支顶部**加守卫:
```cpp
case COMMAND_SEND_FILE_V2: {
FileChunkPacketV2* pkt = (FileChunkPacketV2*)szBuffer;
if (pkt->dstClientID == 0) {
uint64_t devId = ContextObject->GetClientID();
if (McpServer().IsFileTransferPending(devId)) { // ← 新增守卫:无头接管
McpServer().OnFileChunkV2(devId, szBuffer, len);
break; // 不建 CDlgFileSend、不设 hDlg
}
// ... 原 GUI 逻辑(建 CDlgFileSend + OnReceiveComplete)不变
}
// ... C2C 分支(dstClientID != 0)完全不动
}
```
- `TOKEN_CONN_AUTH`(`6378`) **完全不动**——它只负责把 `clientID` 钉在 ctx 上,下载复用既有行为。
- `TOKEN_DRIVE_LIST`(`6430`) 既有 `IsPending(devId)` 守卫**扩展复用**:`OnDriveList` 识别当前挂起工具为 `download_file` 时,改发 `CMD_DOWN_FILES_V2` 而非 `COMMAND_LIST_FILES`,发完即 `CancelIO` 文件管理器子链接(数据走新流式子连接,§13 F3)。
- 守卫为假时全部回落原 GUI/C2C 路径——对既有功能零影响(原则 #1,§13 F7)。
---
## 6. `download_file` 设计(P1)
### 6.1 工具 Schema
```
input: {
id: string (必填, 主机 id)
remote_path: string (必填, 远程文件或目录绝对路径, 如 C:\Users\shaun\Pictures)
local_dir: string (必填, 主控本机保存目录; 不存在会自动创建)
overwrite: boolean (可选, 默认 false; true 覆盖同名文件, false 则跳过)
timeout_ms: integer (可选, 默认 600000, 上限 3600000)
}
output: {
files: [{ path: string, size: integer, sha256: string }] // 实际落盘文件
total_bytes: integer
skipped: integer
}
```
### 6.2 时序
```
MCP工具线程 服务端 MessageHandle 客户端
│ 1. BeginFileTransferPending(id,"download_file",localDir,remotePath) │
│ 2. 主连接发 COMMAND_LIST_DRIVE ────────────────────────────────────────► 开文件管理器子链接
│ 3. ◄── TOKEN_DRIVE_LIST ─────────────────────┤
│ OnDriveList → 锁外下发 CMD_DOWN_FILES_V2[targetDir\0][remote_path\0]\0 │
│ 4. ────────────────────────────────────────► UploadToRemoteV2()
│ (客户端另开一条鉴权流式子连接 ConnectServer)
│ 5. 新子连接 TOKEN_CONN_AUTH 钉 clientID → 后续分块按 device_id 守卫接管 │
│ 6. ◄── COMMAND_SEND_FILE_V2 分块流 ──────────┤
│ → OnFileChunkV2 → RecvFileChunkV2() 无头落盘
│ 7. ◄── COMMAND_FILE_COMPLETE_V2(SHA-256) ────┤
│ 8. 校验通过 → done → 唤醒工具线程 → CancelIO 两条子连接 → 返回 │
```
### 6.3 服务端改动
1. **`McpServer.h`**:新增 `FileTransferSession` 结构 + 注册表 + 接口声明(§5.1)。
2. **`McpServer.cpp`**:
- `BuildDownloadFileInputSchema/OutputSchema` + `BuildDownloadFile(...)`(`tools/call` 分派);
- `OnFileChunkV2`:**先路径校验**(chunk `filename` 规范化后仍在 `local_dir` 内,防 `..` 穿越,§13 F5)→ 调 `RecvFileChunkV2(buf,len,nullptr,nullptr,hash,hmac,0)` 无头落盘,记 `totalFiles`/进度;
- `OnFileCompleteV2`:`HandleFileCompleteV2` 校验 SHA-256 → `filesDone++`;`filesDone==totalFiles` 时置 `done` + `notify`(§13 F4);
- `hash/hmac` 镜像 `FileManagerDlg.cpp:2755` 的 `GetPwdHash()/GetHMAC(100)`,兼容客户端 M2C `hmac` 为空(`client/FileManager.cpp:1167`,§13 F6);
- `ClearFileTransfer`(发送失败/超时/断线):`CancelIO` 子链接 + 删除半成品。
3. **`2015RemoteDlg.cpp`(`MessageHandle`)**:`COMMAND_SEND_FILE_V2`(85)、`COMMAND_FILE_COMPLETE_V2`(91) 的 `dstClientID==0` 分支顶部加 `IsFileTransferPending(devId)` 守卫;`TOKEN_DRIVE_LIST` 处扩展 `OnDriveList` 识别 `download_file` 改发 `CMD_DOWN_FILES_V2`。`TOKEN_CONN_AUTH` 与 C2C 分支**完全不动**。
4. **`McpSettingsDlg.cpp/.h`**:新增 `McpFileTransfer` 配置项(默认 0)。
### 6.4 落盘与路径安全
- 发 `CMD_DOWN_FILES_V2` 前把 `local_dir` 规范化为绝对路径并 `CreateDirectory`;`targetDir` 用 ANSI(MBCS 构建本机路径)。
- 每收到一个 chunk,校验其 `filename` 规范化后**前缀必须在 `local_dir` 内**(防 `..` 穿越,呼应 `FILE_TRANSFER_V2.md §8.3` 已识别的风险);逃逸则置 `error` 并 `CancelIO`。
- 编码:`remote_path` 走 UTF-8→ANSI(936)(Windows 客户端,复用 `McpServer.cpp` 既有 `ToAnsi`,与 `list_files` 一致)。
### 6.5 超时与并发
- **P1 范围:同步 + 单文件/已打包目录**。一次 `tools/call` 阻塞到底,不引入 job/task 异步模型(决策见 §12)。核心场景「先 `terminal_exec` 压缩成单 zip,再 `download_file` 拉回」天然是单文件,同步足够。
- 默认 `timeout_ms=600000`、上限 3600000,因大文件远超 `kMcpToolTimeoutMs`(20s)。
- **单飞互斥(§13 F2)**:`BeginFileTransferPending` 与既有一次性 `m_Pending` **双向互斥**——下载登记时占用 `m_Pending`,反之 `BeginPending` 也检查 `m_FileXferSessions`,保证同 host 任意时刻只一个 MCP 工具在飞;并发返回 `-32003 Device busy`。
- 断线(`OfflineProc` 擦会话)+ 超时(删半成品)双收尾。
---
## 7. `upload_file` 设计(P2,未来)
- **复用服务端 sender**:`2015RemoteDlg.cpp:7572` 已有 `FileBatchTransferWorkerV2(files, targetDir, ..., SendFileChunkToClientV2, ...)`,服务端读本机文件分块 `COMMAND_SEND_FILE_V2` 推给客户端;客户端 `RecvFileChunkV2` 落盘并回 `COMMAND_FILE_COMPLETE_V2`。
- **比 download 更简单**:服务端是发送方,工具线程直接驱动 `FileBatchTransferWorkerV2`,无需等待外来流;只需等客户端回 `COMMAND_FILE_COMPLETE_V2`(复用同一文件会话注册表)。
- **Schema**(§3.2 已列):`id` + `local_path` + `remote_dir` + `overwrite` + `timeout_ms`。
- **安全**:写远程盘,独立评审;`remote_dir` 路径规范化,可选系统目录黑名单。
---
## 8. 安全门
| 工具 | 定性 | 门槛 |
|---|---|---|
| `download_file` | 不改远程状态,但**数据外带** | `McpFileTransfer=1`(仅此一开关;**不依赖** `McpReadonly`,与 `list_files`/`get_screenshot` 同权) |
| `upload_file` | **写远程盘** | `McpFileTransfer=1` **且** `McpReadonly=0`(复用既有「允许写」主开关) |
- **只新增一个开关 `McpFileTransfer`(默认 0)**:download 只看它;upload 额外要求 `McpReadonly=0`。决策依据见 §12。
- `tools/list` 据此隐藏两个工具(与 `exec_command`/`terminal_*`/`remote_*` 同款开关判断)。
- 审计:`host_id + tool + remote_path + local_dir + 结果`,写 `Mprintf` + 审计日志。
---
## 9. 改动文件清单
| 文件 | 改动 |
|---|---|
| `server/2015Remote/McpServer.h` | `FileTransferSession` + 注册表 + 8 个接口声明 |
| `server/2015Remote/McpServer.cpp` | `download_file` schema/实现 + 会话状态机 + 路由分支 |
| `server/2015Remote/2015RemoteDlg.cpp` | `MessageHandle`:`COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 两处守卫分支 + `TOKEN_DRIVE_LIST` 处 `OnDriveList` 扩展;`TOKEN_CONN_AUTH` 不动 |
| `server/2015Remote/McpSettingsDlg.cpp/.h` | `McpFileTransfer` 配置项 |
| (P2)`McpServer.cpp` | `upload_file` 实现(复用 `FileBatchTransferWorkerV2`) |
**客户端零改动**(`UploadToRemoteV2` / `RecvFileChunkV2` / `FileBatchTransferWorkerV2` 均已存在)。
---
## 10. 分阶段实施与回滚
- **P1**:`download_file` + `McpFileTransfer` 开关 + 路由分支。可独立合入、独立验收(真实主机拖回一个目录,SHA-256 与 `certutil -hashfile` 比对一致)。
- **P2**:`upload_file`。
- **P3**(可选):断点续传(需先验证服务端续传状态落盘;文件管理器侧现 `enableResume=false`,`client/FileManager.cpp:1164`)、大文件进度流式上报。
- **回滚**:改动集中在 `McpServer.*` + `MessageHandle` 三个 `if` 分支,revert 当期 commit 即可,不影响既有 GUI 文件管理器。
---
## 11. 测试点
- 单文件 / 目录(含中文名、深层嵌套)下载;SHA-256 与 `certutil -hashfile` 比对一致。
- `overwrite=false` 同名跳过;`local_dir` 不存在自动创建。
- 路径穿越:`remote_path` 含 `..` 或 chunk 文件名逃逸 `local_dir` 时被拒。
- 大文件(>2GB)超时与断线;并发下载同主机返回 `-32003`。
- 编码:GBK 中文文件名往返无乱码(与 `list_files` 同规则)。
- 断线收尾:传输中客户端掉线 → 会话擦除 + 半成品删除,无句柄/内存泄漏。
---
## 12. 决策记录(以「简单易用」为原则)
原则:最少开关、最少认知负担、复用既有机制、不为低频场景预留复杂度;只有威胁模型确实需要时才加复杂度。
### 12.1 download 数据外带 → **不加目录白名单,信任 `McpFileTransfer` + 审计**
- **威胁模型**:MCP 默认 `127.0.0.1` + token;「数据外带」的实际顾虑是 AI 误操作或提示注入。但一旦开启 `terminal_exec`(全 shell、无白名单),外带通道远大于 download,目录白名单对已开终端者是冗余防御。
- **一致性**:`terminal_*` / `remote_*` 均只有开关 + 审计、无路径白名单;给 download 单独加白名单是特例,增加解释负担。
- **简单**:工具契约保持 `id + remote_path + local_dir`,不引入「允许根目录」概念。
- **逃生舱**:若未来确有需要,复用 `McpCmdWhitelist` 那种**单字符串**配置作可选收紧项,进 P3,不进 P1。
### 12.2 大文件模型 → **P1 同步 + 大超时,收窄到单文件/已打包目录**
- MCP 是 request/response;为一次性传输引入「提交任务 + 轮询进度 + 任务清理」违背「简单 + 每阶段独立可交付」。
- 核心场景(先压缩成单 zip 再拉回)天然是单文件,同步足够;`get_screenshot` 已先例式地内联返回字节。
- 异步任务模型进 P3,且**只在确有超大流式需求时**才做,不预建。
### 12.3 upload 开关 → **单一 `McpFileTransfer`,upload 复用 `McpReadonly=0`**
- `download_file` = `McpFileTransfer=1`(不依赖 `McpReadonly`):它只读远程,与 `list_files`/`get_screenshot` 同权,readonly 语义本就只拦「改远程」。
- `upload_file` = `McpFileTransfer=1 && McpReadonly=0`:upload 改远程,复用既有「允许写」主开关,与 `terminal_*`/`remote_*` 的 `X && !Readonly` 模式一致。
- 只新增 **1 个**开关;代价是「不能只开 upload 不开 download」——这是可接受的简化(无人有此诉求)。
---
## 13. 专家审查记录(实施前)
原则优先级:① 对既有功能影响最小 → ② 简单易用 → ③ 优先 V2。逐条核对了 `2015RemoteDlg.cpp` 的 `MessageHandle` 分派、`client/FileManager.cpp` 与 `SimplePlugins/file_upload.cpp` 的实现,结论如下:
| # | 审查发现 | 结论 |
|---|---|---|
| F1 | 路由机制比草案更简单:`COMMAND_SEND_FILE_V2`(`5842`)/`COMMAND_FILE_COMPLETE_V2`(`6101`) 本就按 `ContextObject->GetClientID()` 分派,流式子连接经 `TOKEN_CONN_AUTH`(`6411 SetID`) 钉住 clientID | **无需 context 路由表**。改为 `device_id` 键 + 两个 case 顶部的 `IsFileTransferPending(devId)` 守卫,`TOKEN_CONN_AUTH` 完全不动 |
| F2 | 一次性 `m_Pending` 与新增文件会话可能并发冲突(同 host 双工具) | `BeginFileTransferPending` 与 `m_Pending` **双向互斥**,同 host 全 MCP 工具单飞 |
| F3 | 文件管理器子链接发完 `CMD_DOWN_FILES_V2` 后即无用(数据走新流式子连接) | `OnDriveList` 发完命令即 `CancelIO` 该子链接;客户端 `UploadToRemoteV2` 用独立 `IOCPClient`,不依赖它 |
| F4 | 完成信号是**每文件一个** `COMMAND_FILE_COMPLETE_V2`(`FileCompletePacketV2.fileIndex`) | 会话记 `totalFiles`(首 chunk)/`filesDone`(complete 计数),`filesDone==totalFiles` 才算 done |
| F5 | `RecvFileChunkV2` 内部直接按 chunk `filename` 落盘 | MCP 在调用**前**校验 `filename` 规范化仍在 `local_dir` 内,逃逸即取消传输 |
| F6 | `RecvFileChunkV2` 需正确 `hash/hmac` | 镜像 `FileManagerDlg.cpp:2755` 的 `GetPwdHash()/GetHMAC(100)`;客户端 M2C `hmac` 为空(`FileManager.cpp:1167`) |
| F7 | 三处改动均为「加 if 守卫 + 现有逻辑作 else」,C2C 分支不动 | 结构性满足原则 #1,对既有 GUI/C2C 零影响 |
**判定**:7 项问题均已在正文对应章节修正,无阻塞项,**定稿**。
+24
View File
@@ -2201,6 +2201,8 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0); McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0);
// 远程控制开关:默认关;要求只读关(McpReadonly=0)才生效(工具列表/分派双重门控)。 // 远程控制开关:默认关;要求只读关(McpReadonly=0)才生效(工具列表/分派双重门控)。
McpServer().SetRemoteControlEnabled(THIS_CFG.GetInt("settings", "McpRemoteControl", 0) != 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)) { if (!McpServer().Start(mcpBind, mcpPort)) {
Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort); Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort);
} else { } else {
@@ -5843,6 +5845,14 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
// V2 文件传输(支持 C2C) // V2 文件传输(支持 C2C)
FileChunkPacketV2* pkt = (FileChunkPacketV2*)szBuffer; 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 (pkt->dstClientID == 0) {
// 目标是主控端:本地接收 // 目标是主控端:本地接收
if (ContextObject->hDlg == NULL) { if (ContextObject->hDlg == NULL) {
@@ -6103,6 +6113,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
if (len < sizeof(FileCompletePacketV2)) break; if (len < sizeof(FileCompletePacketV2)) break;
FileCompletePacketV2* pkt = (FileCompletePacketV2*)szBuffer; 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) { if (pkt->dstClientID == 0) {
// 目标是主控端:本地校验 // 目标是主控端:本地校验
bool verifyOk = HandleFileCompleteV2((char*)szBuffer, len, 0); bool verifyOk = HandleFileCompleteV2((char*)szBuffer, len, 0);
@@ -6436,6 +6453,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
ContextObject->CancelIO(); // 只列盘 → 用完即关一次性子链接 ContextObject->CancelIO(); // 只列盘 → 用完即关一次性子链接
break; 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); ContextObject->EnableZstdContext(6);
g_2015RemoteDlg->SendMessage(WM_OPENFILEMANAGERDIALOG, 0, (LPARAM)ContextObject); g_2015RemoteDlg->SendMessage(WM_OPENFILEMANAGERDIALOG, 0, (LPARAM)ContextObject);
break; break;
+499 -21
View File
@@ -7,6 +7,7 @@
#include "WebService.h" // 远程控制复用屏幕子连接(StartRemoteDesktop / GetScreenContext / GetScreenSize) #include "WebService.h" // 远程控制复用屏幕子连接(StartRemoteDesktop / GetScreenContext / GetScreenSize)
#include "Server.h" // CONTEXT_OBJECT 定义(GetScreenContext 返回 CONTEXT_OBJECT* → context* 上转型) #include "Server.h" // CONTEXT_OBJECT 定义(GetScreenContext 返回 CONTEXT_OBJECT* → context* 上转型)
#include "LangManager.h" // _TR(审计日志标题语言映射) #include "LangManager.h" // _TR(审计日志标题语言映射)
#include "common/file_upload.h" // V2 文件传输(RecvFileChunkV2 / HandleFileCompleteV2 / 包结构)
#include <sstream> #include <sstream>
@@ -24,6 +25,11 @@
#endif #endif
#endif #endif
// P6 download_file 复用 V2 落盘接口,需 FileManagerDlg 同款密码哈希 / HMAC 参数
// (声明见 CPasswordDlg.h,避免在此引入重量级 MFC 头)。
std::string GetPwdHash();
std::string GetHMAC(int offset);
namespace { namespace {
// P2b 工具等待响应的超时(ms)。MCP 一次性请求:等待子连接回传进程/窗口列表。 // P2b 工具等待响应的超时(ms)。MCP 一次性请求:等待子连接回传进程/窗口列表。
@@ -117,6 +123,11 @@ std::string GetStringArg(const Json::Value& args, const char* key) {
return JsonStrField(args, key); return JsonStrField(args, key);
} }
// 读取可选整数入参(JSON number 或数字字符串);缺失/非法返回 false(不写 out)。
// 定义见文件后半段(随 P3 终端会话辅助一起),此处前置声明供早于定义的 exec_command /
// terminal_exec 等工具复用。
static bool GetIntArg(const Json::Value& args, const char* key, int& out);
// ========== P2b 辅助 ========== // ========== P2b 辅助 ==========
// 解析 id 入参(必填、纯数字)为 uint64;非法返回 false。 // 解析 id 入参(必填、纯数字)为 uint64;非法返回 false。
@@ -1142,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 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 BuildRemoteKeyboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
std::string BuildRemoteMouse(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); std::string BuildRemoteClipboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
// tools/list // tools/list
@@ -1392,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; result["tools"] = tools;
return BuildResult(id, result); return BuildResult(id, result);
} }
@@ -2051,11 +2078,8 @@ std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy
return BuildError(id, -32007, "Command not allowed by whitelist: " + command); return BuildError(id, -32007, "Command not allowed by whitelist: " + command);
int timeoutMs = kMcpToolTimeoutMs; int timeoutMs = kMcpToolTimeoutMs;
std::string t = GetStringArg(args, "timeout_ms"); int v = 0;
if (!t.empty() && IsDigits(t)) { if (GetIntArg(args, "timeout_ms", v) && v > 0 && v <= 600000) timeoutMs = v;
int v = atoi(t.c_str());
if (v > 0 && v <= 600000) timeoutMs = v;
}
std::string nonce = GenerateRandomToken().substr(0, 8); std::string nonce = GenerateRandomToken().substr(0, 8);
@@ -2426,11 +2450,8 @@ std::string BuildTerminalOpen(const Json::Value& id, const Json::Value& args, CM
return errJson; return errJson;
int timeoutMs = kMcpToolTimeoutMs; int timeoutMs = kMcpToolTimeoutMs;
std::string t = GetStringArg(args, "timeout_ms"); int v = 0;
if (!t.empty() && IsDigits(t)) { if (GetIntArg(args, "timeout_ms", v) && v > 0 && v <= 600000) timeoutMs = v;
int v = atoi(t.c_str());
if (v > 0 && v <= 600000) timeoutMs = v;
}
std::string sessionId = GenerateRandomToken(); std::string sessionId = GenerateRandomToken();
if (!mcp.BeginTermOpen(devId, sessionId)) if (!mcp.BeginTermOpen(devId, sessionId))
@@ -2498,11 +2519,8 @@ std::string BuildTerminalExec(const Json::Value& id, const Json::Value& args, CM
"Command contains & or | or a newline (breaks output capture); run chained/piped commands as separate terminal_exec calls"); "Command contains & or | or a newline (breaks output capture); run chained/piped commands as separate terminal_exec calls");
int timeoutMs = kMcpToolTimeoutMs; int timeoutMs = kMcpToolTimeoutMs;
std::string t = GetStringArg(args, "timeout_ms"); int v = 0;
if (!t.empty() && IsDigits(t)) { if (GetIntArg(args, "timeout_ms", v) && v > 0 && v <= 600000) timeoutMs = v;
int v = atoi(t.c_str());
if (v > 0 && v <= 600000) timeoutMs = v;
}
std::string nonce = GenerateRandomToken().substr(0, 8); std::string nonce = GenerateRandomToken().substr(0, 8);
@@ -2751,11 +2769,8 @@ std::string BuildRemoteOpen(const Json::Value& id, const Json::Value& args, CMy2
return BuildError(id, -32003, "Device busy: a remote desktop session is already active for this host"); return BuildError(id, -32003, "Device busy: a remote desktop session is already active for this host");
int timeoutMs = kMcpToolTimeoutMs; int timeoutMs = kMcpToolTimeoutMs;
std::string t = GetStringArg(args, "timeout_ms"); int v = 0;
if (!t.empty() && IsDigits(t)) { if (GetIntArg(args, "timeout_ms", v) && v > 0 && v <= 600000) timeoutMs = v;
int v = atoi(t.c_str());
if (v > 0 && v <= 600000) timeoutMs = v;
}
std::string sessionId = GenerateRandomToken(); std::string sessionId = GenerateRandomToken();
if (!mcp.BeginScreenCtrlOpen(devId, sessionId)) if (!mcp.BeginScreenCtrlOpen(devId, sessionId))
@@ -3523,6 +3538,264 @@ std::string BuildRemoteClipboard(const Json::Value& id, const Json::Value& args,
return BuildResult(id, result); 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 分派 // tools/call 分派
std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
const Json::Value& id = root["id"]; const Json::Value& id = root["id"];
@@ -3556,6 +3829,7 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
if (toolName == "remote_keyboard") return BuildRemoteKeyboard(id, args, parent); if (toolName == "remote_keyboard") return BuildRemoteKeyboard(id, args, parent);
if (toolName == "remote_mouse") return BuildRemoteMouse(id, args, parent); if (toolName == "remote_mouse") return BuildRemoteMouse(id, args, parent);
if (toolName == "remote_clipboard") return BuildRemoteClipboard(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, return BuildError(id, -32602,
"Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName)); "Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName));
@@ -3579,7 +3853,7 @@ static bool FindSentinel(const std::vector<BYTE>& buf, const std::string& nonce,
while (true) { while (true) {
size_t p = s.rfind(marker, from); size_t p = s.rfind(marker, from);
if (p == std::string::npos) return false; if (p == std::string::npos) return false;
bool lineStart = (p == 0) || (s[p - 1] == '\n'); bool lineStart = (p == 0) || (s[p - 1] == '\n') || (s[p - 1] == '\r');
size_t digitPos = p + marker.size(); size_t digitPos = p + marker.size();
if (lineStart && digitPos < s.size() && if (lineStart && digitPos < s.size() &&
(s[digitPos] == '0' || s[digitPos] == '1')) { (s[digitPos] == '0' || s[digitPos] == '1')) {
@@ -4080,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) { 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); std::lock_guard<std::mutex> lk(m_PendingMutex);
if (m_Pending.find(device_id) != m_Pending.end()) return false; // 设备忙 if (m_Pending.find(device_id) != m_Pending.end()) return false; // 设备忙
PendingRequest r; PendingRequest r;
@@ -4112,6 +4387,7 @@ void CMcpServer::ClearPending(uint64_t device_id) {
// ===== P2c:list_files / get_screenshot 扩展 ===== // ===== P2c:list_files / get_screenshot 扩展 =====
bool CMcpServer::BeginPending(uint64_t device_id, const std::string& tool, const std::string& path) { 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); std::lock_guard<std::mutex> lk(m_PendingMutex);
if (m_Pending.find(device_id) != m_Pending.end()) return false; // 设备忙 if (m_Pending.find(device_id) != m_Pending.end()) return false; // 设备忙
PendingRequest r; PendingRequest r;
@@ -4265,3 +4541,205 @@ std::string GenerateRandomToken() {
} }
return out; 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();
}
+67
View File
@@ -8,6 +8,7 @@
#include <mutex> #include <mutex>
#include <condition_variable> #include <condition_variable>
#include <map> #include <map>
#include <set>
#include <vector> #include <vector>
#include <cstdint> #include <cstdint>
#include <ctime> #include <ctime>
@@ -28,6 +29,14 @@
class CMy2015RemoteDlg; class CMy2015RemoteDlg;
class context; 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 校验。 // MCP (Model Context Protocol) 服务端:httplib 封装 + JSON-RPC 2.0 分发 + 静态 token 校验。
// 与 CWebService 平级、互不依赖;默认禁用,经「扩展 → MCP设置」开启后监听 // 与 CWebService 平级、互不依赖;默认禁用,经「扩展 → MCP设置」开启后监听
// (默认 127.0.0.1:6544,仅本机回环)。见 docs/Mcp_Design.md。 // (默认 127.0.0.1:6544,仅本机回环)。见 docs/Mcp_Design.md。
@@ -194,6 +203,40 @@ public:
// 屏幕子连接断开(OfflineProc 调用):擦除该子连接对应的会话 + 路由(幂等)。 // 屏幕子连接断开(OfflineProc 调用):擦除该子连接对应的会话 + 路由(幂等)。
void OnScreenControlClosed(context* subCtx); 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 后设置)。 // 安全配置(启动时由 CMy2015RemoteDlg 读 THIS_CFG 后设置)。
void SetReadonly(bool readonly) { m_readonly = readonly; } void SetReadonly(bool readonly) { m_readonly = readonly; }
void SetCmdWhitelist(const std::string& whitelist) { m_cmdWhitelist = whitelist; } void SetCmdWhitelist(const std::string& whitelist) { m_cmdWhitelist = whitelist; }
@@ -272,10 +315,34 @@ private:
std::map<uint64_t, ScreenCtrlSession> m_ScreenCtrlSessions; // device_id → 会话 std::map<uint64_t, ScreenCtrlSession> m_ScreenCtrlSessions; // device_id → 会话
std::map<context*, uint64_t> m_ScreenCtrlContextToDevice; // subCtx → device_id(OfflineProc 反查) 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; bool m_readonly = true;
std::string m_cmdWhitelist; std::string m_cmdWhitelist;
bool m_terminalEnabled = false; // 持久终端开关(默认关;要求 m_readonly=false) bool m_terminalEnabled = false; // 持久终端开关(默认关;要求 m_readonly=false)
bool m_remoteControlEnabled = false; // 远程控制开关(默认关;要求 m_readonly=false) bool m_remoteControlEnabled = false; // 远程控制开关(默认关;要求 m_readonly=false)
bool m_fileTransferEnabled = false; // 文件传输开关(默认关;download_file)
}; };
// 全局访问器(仿 WebService(),见 WebService.h 末尾) // 全局访问器(仿 WebService(),见 WebService.h 末尾)
+13 -2
View File
@@ -106,7 +106,7 @@ INT_PTR CMcpSettingsDlg::DoModal()
{ {
USES_CONVERSION; USES_CONVERSION;
CString title = _TR("MCP设置"); CString title = _TR("MCP设置");
BuildDialogTemplate(m_Template, T2CW(title), 320, 390); BuildDialogTemplate(m_Template, T2CW(title), 320, 420);
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data()); InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
return CDialog::DoModal(); return CDialog::DoModal();
} }
@@ -142,6 +142,9 @@ BOOL CMcpSettingsDlg::OnInitDialog()
m_btnRemoteControl.Create(_TR("启用远程控制(AI 操控桌面)"), m_btnRemoteControl.Create(_TR("启用远程控制(AI 操控桌面)"),
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
r0, this, IDC_MCP_REMOTECONTROL); 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_lblWhitelist.Create(_TR("命令白名单"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
m_editWhitelist.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | m_editWhitelist.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP |
ES_MULTILINE | ES_AUTOVSCROLL | ES_WANTRETURN | WS_VSCROLL, ES_MULTILINE | ES_AUTOVSCROLL | ES_WANTRETURN | WS_VSCROLL,
@@ -164,6 +167,7 @@ BOOL CMcpSettingsDlg::OnInitDialog()
m_btnReadonly.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnReadonly.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnTerminal.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnTerminal.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnRemoteControl.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_lblWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_editWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
@@ -179,6 +183,7 @@ BOOL CMcpSettingsDlg::OnInitDialog()
int readonly = THIS_CFG.GetInt("settings", "McpReadonly", 1); int readonly = THIS_CFG.GetInt("settings", "McpReadonly", 1);
int terminal = THIS_CFG.GetInt("settings", "McpTerminal", 0); int terminal = THIS_CFG.GetInt("settings", "McpTerminal", 0);
int remoteControl = THIS_CFG.GetInt("settings", "McpRemoteControl", 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", ""); std::string whitelist = THIS_CFG.GetStr("settings", "McpCmdWhitelist", "");
m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED); m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED);
@@ -188,6 +193,7 @@ BOOL CMcpSettingsDlg::OnInitDialog()
m_btnReadonly.SetCheck(readonly ? BST_CHECKED : BST_UNCHECKED); m_btnReadonly.SetCheck(readonly ? BST_CHECKED : BST_UNCHECKED);
m_btnTerminal.SetCheck(terminal ? BST_CHECKED : BST_UNCHECKED); m_btnTerminal.SetCheck(terminal ? BST_CHECKED : BST_UNCHECKED);
m_btnRemoteControl.SetCheck(remoteControl ? 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())); m_editWhitelist.SetWindowText(CString(WhitelistForDisplay(whitelist).c_str()));
@@ -206,6 +212,7 @@ void CMcpSettingsDlg::OnOK()
bool readonly = (m_btnReadonly.GetCheck() == BST_CHECKED); bool readonly = (m_btnReadonly.GetCheck() == BST_CHECKED);
bool terminal = (m_btnTerminal.GetCheck() == BST_CHECKED); bool terminal = (m_btnTerminal.GetCheck() == BST_CHECKED);
bool remoteControl = (m_btnRemoteControl.GetCheck() == BST_CHECKED); bool remoteControl = (m_btnRemoteControl.GetCheck() == BST_CHECKED);
bool fileTransfer = (m_btnFileTransfer.GetCheck() == BST_CHECKED);
// 端口校验:1-65535 // 端口校验:1-65535
int port = atoi(CT2A(sPort)); int port = atoi(CT2A(sPort));
@@ -231,13 +238,14 @@ void CMcpSettingsDlg::OnOK()
THIS_CFG.SetInt("settings", "McpReadonly", readonly ? 1 : 0); THIS_CFG.SetInt("settings", "McpReadonly", readonly ? 1 : 0);
THIS_CFG.SetInt("settings", "McpTerminal", terminal ? 1 : 0); THIS_CFG.SetInt("settings", "McpTerminal", terminal ? 1 : 0);
THIS_CFG.SetInt("settings", "McpRemoteControl", remoteControl ? 1 : 0); THIS_CFG.SetInt("settings", "McpRemoteControl", remoteControl ? 1 : 0);
THIS_CFG.SetInt("settings", "McpFileTransfer", fileTransfer ? 1 : 0);
std::string whitelist = CT2A(sWhitelist); std::string whitelist = CT2A(sWhitelist);
whitelist = NormalizeWhitelist(whitelist); whitelist = NormalizeWhitelist(whitelist);
THIS_CFG.SetStr("settings", "McpCmdWhitelist", whitelist); THIS_CFG.SetStr("settings", "McpCmdWhitelist", whitelist);
// 拆成两段可翻译的单行键,中间用 \r\n 连接(多行键无法在 INI 中表示) // 拆成两段可翻译的单行键,中间用 \r\n 连接(多行键无法在 INI 中表示)
MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") + MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") +
_TR("启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制的改动需重启程序生效。") + _T("\r\n") + _TR("启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制/文件传输的改动需重启程序生效。") + _T("\r\n") +
_TR("持久终端与远程控制仅在只读模式关闭时生效。"), _TR("持久终端与远程控制仅在只读模式关闭时生效。"),
_TR("提示"), MB_ICONINFORMATION); _TR("提示"), MB_ICONINFORMATION);
@@ -278,6 +286,9 @@ void CMcpSettingsDlg::LayoutControls(int cx, int cy)
m_btnRemoteControl.MoveWindow(margin, y, cx - margin * 2, 22); m_btnRemoteControl.MoveWindow(margin, y, cx - margin * 2, 22);
y += 30; y += 30;
m_btnFileTransfer.MoveWindow(margin, y, cx - margin * 2, 22);
y += 30;
const int whitelistH = 90; const int whitelistH = 90;
m_lblWhitelist.MoveWindow(margin, y, labelW, rowH); m_lblWhitelist.MoveWindow(margin, y, labelW, rowH);
m_editWhitelist.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, whitelistH); m_editWhitelist.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, whitelistH);
+2
View File
@@ -30,6 +30,7 @@ private:
IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀) IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀)
IDC_MCP_TERMINAL = 1007, // 「启用持久终端」复选框(全命令,无白名单,要求只读关) IDC_MCP_TERMINAL = 1007, // 「启用持久终端」复选框(全命令,无白名单,要求只读关)
IDC_MCP_REMOTECONTROL = 1008, // 「启用远程控制」复选框(AI 操控桌面,要求只读关) IDC_MCP_REMOTECONTROL = 1008, // 「启用远程控制」复选框(AI 操控桌面,要求只读关)
IDC_MCP_FILETRANSFER = 1009, // 「启用文件传输」复选框(download_file,不要求只读关)
}; };
CButton m_btnEnable; CButton m_btnEnable;
@@ -38,6 +39,7 @@ private:
CButton m_btnReadonly; CButton m_btnReadonly;
CButton m_btnTerminal; CButton m_btnTerminal;
CButton m_btnRemoteControl; CButton m_btnRemoteControl;
CButton m_btnFileTransfer;
CStatic m_lblWhitelist; CStatic m_lblWhitelist;
CEdit m_editWhitelist; CEdit m_editWhitelist;
CButton m_btnOK, m_btnCancel; CButton m_btnOK, m_btnCancel;