Compare commits
17
Commits
8bd6f3b60a
...
v1.3.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b41b27bfe4 | ||
|
|
6bcd593b6d | ||
|
|
5c77f5ce14 | ||
|
|
ac2855198b | ||
|
|
52b4afb8cf | ||
|
|
35dc2d89a0 | ||
|
|
d99122b25f | ||
|
|
d55d40e7a2 | ||
|
|
25a6e2d07a | ||
|
|
18259b8526 | ||
|
|
f04d892ac8 | ||
|
|
c844ba7614 | ||
|
|
09018e2b4d | ||
|
|
6045baadb8 | ||
|
|
84e3565de1 | ||
|
|
70dbb6b255 | ||
|
|
0ddbc1aced |
No files matched your search
@@ -378,6 +378,34 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.3.9 (2026.9.2)
|
||||
|
||||
**MCP 服务端集成(AI 远程管理)& 远程控制与文件传输闭环 & 主控增强**
|
||||
|
||||
**新功能:**
|
||||
- **MCP 服务端集成**:本地 `127.0.0.1:6544` 暴露 JSON-RPC 2.0 接口,Bearer Token 鉴权,「扩展 > MCP 设置」对话框配置启用 / 端口 / 绑定 / Token;23 个工具覆盖「观测 → 执行 → 控制 → 传输」全链路,默认关闭、分级开关(只读 / 白名单 / 终端 / 远程控制 / 文件传输)全面审计
|
||||
- **MCP 观测类工具(只读)**:search_hosts、get_host_detail、list_processes、list_windows、get_activity_history、list_files、get_screenshot(可选 max_width)、list_services、get_client_log、list_registry、get_audit_log;单飞请求注册表 20s 超时 + 编码修正(clientType / clientID 定位)
|
||||
- **MCP 远程终端工具**:exec_command 一次性执行 + terminal_open / terminal_exec / terminal_close 持久终端(cwd / 环境保留,空闲 300s 清扫),哨兵判定与 timeout_ms 解析修复
|
||||
- **MCP 远程控制工具**:remote_open / remote_close 会话 + remote_keyboard(key_down / up / press / type)、remote_mouse(move / click / drag / scroll)、remote_clipboard 注入;与人工 Web 查看双向互斥,会话状态机 busy / closed 加固
|
||||
- **MCP 文件传输工具**:download_file(V2 协议 + SHA-256 校验)、upload_file(主连接上传),客户端侧 `COMMAND_SEND_FILE_V2` 懒初始化 `InitFileUpload` 修复上传截断
|
||||
- **客户端活动历史**:记录活跃窗口历史(上限 500 条、跳过 <5s),主机「客户端管理」子菜单新增「活动历史」对话框,Unicode 编辑控件正确渲染 UTF-8 标题
|
||||
- **日志右键菜单三连**:消息日志右键新增「在线通知」「拉黑 IP」「解封 IP」,IPv4 提取即时生效
|
||||
- **列表排序记忆**:主机列表、进程 / 窗口列表排序偏好持久化(`list\*Sort` 注册表键),下次启动默认沿用
|
||||
|
||||
**改进:**
|
||||
- 重复连接 standby 处理:重复登录暂存 standby 待旧连接超时 / 断线后晋升,避免循环重连与误报离线
|
||||
- 远程桌面 / 虚拟屏幕对话框标题显示主机备注而非 IP(无备注回退 IP)
|
||||
- 主机列表三态排序(升序 → 降序 → 恢复默认,默认序按在线时间重建)
|
||||
- 在线主机右键三个菜单项新增图标(位图数组 63→66)
|
||||
|
||||
**Bug 修复:**
|
||||
- 客户端屏幕控制批解析错误:记录数是 7 的倍数时误判 MSG32 致注入错乱(`%48==0` 优先)
|
||||
- 心跳延迟更新 use-after-free:跨 PostMessage 持有 IoRefCount + memcpy 显式截断
|
||||
- ONLINELIST_PRIVILEGE 枚举错位损坏历史数据库(权限列移至 16)
|
||||
- 远程桌面光标跨对话框污染(改 `OnSetCursor` 逐窗口路由)
|
||||
- 客户端日志对话框 EDIT 上限停止刷新(`SetLimitText(0)`)
|
||||
- 窗口管理器视图检查读取陈旧状态、进程列表架构列排序无效、FRP / NAT 下窗口标题乱码
|
||||
|
||||
### v1.3.8 (2026.8.3)
|
||||
|
||||
**SYSTEM 权限客户端 & 主控可用性增强 & Bug 修复**
|
||||
|
||||
@@ -378,6 +378,34 @@ Valid : 2026-02-01 to 2028-02-01
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.3.9 (2026.9.2)
|
||||
|
||||
**MCP Server Integration (AI Remote Management) & Remote Control / File Transfer Loop & Master Enhancements**
|
||||
|
||||
**New features:**
|
||||
- **MCP server integration**: local JSON-RPC 2.0 endpoint on `127.0.0.1:6544` with Bearer token auth, configured via "Extensions > MCP Settings"; 23 tools spanning observe → execute → control → transfer, disabled by default with per-tier gates (read-only / whitelist / terminal / remote control / file transfer) and full audit logging
|
||||
- **MCP read-only observation tools**: search_hosts, get_host_detail, list_processes, list_windows, get_activity_history, list_files, get_screenshot (optional max_width), list_services, get_client_log, list_registry, get_audit_log; single-flight pending registry with 20s timeout, plus encoding fixes (clientType / clientID resolution)
|
||||
- **MCP remote terminal tools**: one-shot exec_command plus persistent terminal_open / terminal_exec / terminal_close (cwd and environment preserved, 300s idle sweep); sentinel detection and timeout_ms parsing fixes
|
||||
- **MCP remote control tools**: remote_open / remote_close sessions plus remote_keyboard (key_down / up / press / type), remote_mouse (move / click / drag / scroll), and remote_clipboard injection; two-way mutual exclusion with human Web viewing and a hardened busy/closed session state machine
|
||||
- **MCP file transfer tools**: download_file (V2 protocol + SHA-256 verification) and upload_file (main-connection upload); client-side lazy `InitFileUpload` in `COMMAND_SEND_FILE_V2` fixes truncated uploads
|
||||
- **Client activity history**: records active-window history (capped at 500 entries, skips <5s); new "Activity History" item under the host's Client Management menu with a Unicode edit control for correct UTF-8 title rendering
|
||||
- **Log context-menu trio**: right-click "Online Notify", "Ban IP", and "Unban IP" on the message log, with IPv4 extraction applied immediately
|
||||
- **Sort preference memory**: host list and process/window list sort preferences persisted (`list\*Sort` registry keys), restored on next launch
|
||||
|
||||
**Improvements:**
|
||||
- Duplicate connections parked as standby and promoted after the old connection times out / disconnects, avoiding reconnect loops and false offline notifications
|
||||
- Remote desktop and virtual screen dialog titles show the host remark instead of the IP (falling back to IP)
|
||||
- Three-state host list sorting (ascending → descending → restore default, default rebuilt by online time)
|
||||
- Icons added to three online-host context menu items (bitmap array 63→66)
|
||||
|
||||
**Bug fixes:**
|
||||
- Client screen-control batch misparse: record count that is a multiple of 7 was misclassified as MSG32, garbling injected input (`%48==0` checked first)
|
||||
- Heartbeat deferred-update use-after-free: IoRefCount held across PostMessage plus explicit memcpy truncation
|
||||
- ONLINELIST_PRIVILEGE enum shift corrupted the history host database (column moved to 16)
|
||||
- Remote desktop cursor cross-contamination across dialogs (routed via `OnSetCursor` per window)
|
||||
- Client log dialog stopped refreshing past the EDIT control limit (`SetLimitText(0)`)
|
||||
- Window manager view check reading stale state, process-list architecture column sort no-op, and FRP/NAT window-title mojibake
|
||||
|
||||
### v1.3.8 (2026.8.3)
|
||||
|
||||
**SYSTEM-level Client & Master Usability Enhancements & Bug Fixes**
|
||||
|
||||
@@ -378,6 +378,34 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
||||
|
||||
## 更新日誌
|
||||
|
||||
### v1.3.9 (2026.9.2)
|
||||
|
||||
**MCP 伺服端整合(AI 遠端管理)& 遠端控制與檔案傳輸閉環 & 主控增強**
|
||||
|
||||
**新功能:**
|
||||
- **MCP 伺服端整合**:本地 `127.0.0.1:6544` 暴露 JSON-RPC 2.0 介面,Bearer Token 鑑權,「擴充 > MCP 設定」對話框設定啟用 / 連接埠 / 綁定 / Token;23 個工具覆蓋「觀測 → 執行 → 控制 → 傳輸」全鏈路,預設關閉、分級開關(唯讀 / 白名單 / 終端 / 遠端控制 / 檔案傳輸)全面稽核
|
||||
- **MCP 觀測類工具(唯讀)**:search_hosts、get_host_detail、list_processes、list_windows、get_activity_history、list_files、get_screenshot(可選 max_width)、list_services、get_client_log、list_registry、get_audit_log;單飛請求登錄表 20s 逾時 + 編碼修正(clientType / clientID 定位)
|
||||
- **MCP 遠端終端工具**:exec_command 一次性執行 + terminal_open / terminal_exec / terminal_close 持久終端(cwd / 環境保留,閒置 300s 清掃),哨兵判定與 timeout_ms 解析修復
|
||||
- **MCP 遠端控制工具**:remote_open / remote_close 工作階段 + remote_keyboard(key_down / up / press / type)、remote_mouse(move / click / drag / scroll)、remote_clipboard 注入;與人工 Web 查看雙向互斥,工作階段狀態機 busy / closed 加固
|
||||
- **MCP 檔案傳輸工具**:download_file(V2 協定 + SHA-256 校驗)、upload_file(主連線上傳),用戶端側 `COMMAND_SEND_FILE_V2` 懶初始化 `InitFileUpload` 修復上傳截斷
|
||||
- **用戶端活動歷史**:記錄活躍視窗歷史(上限 500 條、跳過 <5s),主機「用戶端管理」子選單新增「活動歷史」對話框,Unicode 編輯控件正確渲染 UTF-8 標題
|
||||
- **訊息右鍵選單三連**:訊息記錄右鍵新增「線上通知」「封鎖 IP」「解封 IP」,IPv4 提取即時生效
|
||||
- **列表排序記憶**:主機列表、處理程序 / 視窗列表排序偏好持久化(`list\*Sort` 登錄檔鍵),下次啟動預設沿用
|
||||
|
||||
**改進:**
|
||||
- 重複連線 standby 處理:重複登入暫存 standby 待舊連線逾時 / 斷線後晉升,避免循環重連與誤報離線
|
||||
- 遠端桌面 / 虛擬螢幕對話框標題顯示主機備註而非 IP(無備註回退 IP)
|
||||
- 主機列表三態排序(升冪 → 降冪 → 恢復預設,預設序按上線時間重建)
|
||||
- 線上主機右鍵三個選單項目新增圖示(位圖陣列 63→66)
|
||||
|
||||
**Bug 修復:**
|
||||
- 用戶端螢幕控制批次解析錯誤:記錄數是 7 的倍數時誤判 MSG32 致注入錯亂(`%48==0` 優先)
|
||||
- 心跳延遲更新 use-after-free:跨 PostMessage 持有 IoRefCount + memcpy 顯式截斷
|
||||
- ONLINELIST_PRIVILEGE 列舉錯位損壞歷史資料庫(權限列移至 16)
|
||||
- 遠端桌面游標跨對話框污染(改 `OnSetCursor` 逐視窗路由)
|
||||
- 用戶端記錄對話框 EDIT 上限停止刷新(`SetLimitText(0)`)
|
||||
- 視窗管理員檢視檢查讀取陳舊狀態、處理程序列表架構欄排序無效、FRP / NAT 下視窗標題亂碼
|
||||
|
||||
### v1.3.8 (2026.8.3)
|
||||
|
||||
**SYSTEM 權限用戶端 & 主控可用性增強 & Bug 修復**
|
||||
|
||||
@@ -1379,6 +1379,13 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_SEND_FILE_V2: {
|
||||
// 主连接接收 V2 上传:确保文件传输模块已初始化(RecvFileChunkV2 依赖 g_status==1)。
|
||||
// static 标志保证进程内只初始化一次,不随主连接重连反复 Init/Uninit。
|
||||
static bool s_v2RecvInited = false;
|
||||
if (!s_v2RecvInited) {
|
||||
InitFileUpload({}, m_LoginMsg, m_LoginSignature, 64, 50, Logf);
|
||||
s_v2RecvInited = true;
|
||||
}
|
||||
// C2C/V2 文件接收(RecvFileChunkV2 内部会打印进度)
|
||||
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn,
|
||||
nullptr, m_hash, m_hmac, m_MyClientID);
|
||||
|
||||
@@ -1726,11 +1726,15 @@ bool IsExtendedKey(WPARAM vKey)
|
||||
|
||||
VOID CScreenManager::ProcessCommand(LPBYTE szBuffer, ULONG ulLength)
|
||||
{
|
||||
// 记录大小判定:现代控制端(本服务端)统一发 48 字节 MSG64;28 字节 MSG32 仅为兼容
|
||||
// 老 32 位控制端。二者长度的最小公倍数是 336(=7×48=12×28),批量注入(如 MCP 远程
|
||||
// 控制的 type/拖拽)时若先判 %28,会把 7 的整数倍条 MSG64 误判成 MSG32,字段错位导致
|
||||
// 输入错乱甚至吞掉按键;故先判 %48,仅当不整除 48 才回落到 28。
|
||||
int msgSize = sizeof(MSG64);
|
||||
if (ulLength % 28 == 0) // 32位控制端发过来的消息
|
||||
msgSize = 28;
|
||||
else if (ulLength % 48 == 0) // 64位控制端发过来的消息
|
||||
if (ulLength % 48 == 0) // 64位控制端(现代服务端)发过来的消息
|
||||
msgSize = 48;
|
||||
else if (ulLength % 28 == 0) // 32位控制端发过来的消息(兼容)
|
||||
msgSize = 28;
|
||||
else return; // 数据包不合法
|
||||
|
||||
// 命令个数
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@ IDR_WAVE WAVE "Res\\msg.wav"
|
||||
//
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION 1,0,3,8
|
||||
FILEVERSION 1,0,3,9
|
||||
PRODUCTVERSION 1,0,0,1
|
||||
FILEFLAGSMASK 0x3fL
|
||||
#ifdef _DEBUG
|
||||
@@ -106,7 +106,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "CompanyName", "FUCK THE UNIVERSE"
|
||||
VALUE "FileDescription", "A GHOST"
|
||||
VALUE "FileVersion", "1.0.3.8"
|
||||
VALUE "FileVersion", "1.0.3.9"
|
||||
VALUE "InternalName", "ServerDll.dll"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2019-2026"
|
||||
VALUE "OriginalFilename", "ServerDll.dll"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,396 @@
|
||||
# YAMA MCP 文件传输功能设计(download_file / upload_file)
|
||||
|
||||
> **状态**:设计定稿(经专家审查修订,结论见 §13)。`download_file`(P1)已实施并验收;`upload_file`(P2)已实施(详见 §7,评审修订见 §13.1)。`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` 既有;客户端零改动(upload 方向例外:`COMMAND_SEND_FILE_V2` 需懒初始化文件模块,见 §7.8)。
|
||||
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)
|
||||
|
||||
### 7.1 工具 Schema
|
||||
|
||||
```
|
||||
input: {
|
||||
id: string (必填, 目标主机 id)
|
||||
local_path: string (必填, 主控本机文件或目录绝对路径; 目录递归上传)
|
||||
remote_dir: string (必填, 远程主机保存目录; 不存在会自动创建)
|
||||
overwrite: boolean (可选, 默认 false; true 覆盖同名文件, false 跳过)
|
||||
timeout_ms: integer (可选, 默认 600000, 上限 3600000)
|
||||
}
|
||||
output: {
|
||||
files: [{ path: string, size: integer, sha256: string }] // 已发送文件(远程完整路径);P2 sha256 恒为空串(见 §7.5)
|
||||
total_bytes: integer
|
||||
skipped: integer
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 方向与连接(与 download 的关键差异)
|
||||
|
||||
| 维度 | download_file(P1) | upload_file(P2) |
|
||||
|---|---|---|
|
||||
| 数据方向 | 远程客户端 → 主控 | 主控 → 远程客户端 |
|
||||
| 发送方 | 客户端(`UploadToRemoteV2` → `FileBatchTransferWorkerV2`) | 主控(`FileBatchTransferWorkerV2`) |
|
||||
| 连接 | 客户端**新开一条鉴权流式子连接** | 复用**主连接**(`ctx->Send2Client`),无子连接 |
|
||||
| 完成信号 | 客户端发 `COMMAND_FILE_COMPLETE_V2`,服务端 `OnFileCompleteV2` 校验计数 | 服务端**作为发送方**自己发 `COMMAND_FILE_COMPLETE_V2`(SHA-256),客户端 `HandleFileCompleteV2` 校验 |
|
||||
| MessageHandle 改动 | 2 处守卫分支 + `OnDriveList` 扩展 | **零改动**(客户端 `KernelManager.cpp:1381` 接收,但需懒初始化文件模块,见 §7.8) |
|
||||
|
||||
> **修正旧稿(§13.1 评审)**:旧稿写「只需等客户端回 `COMMAND_FILE_COMPLETE_V2`」是错的。V2 协议里 COMPLETE 包恒由**发送方**发、接收方校验,**无接收方→发送方 ACK**。upload 的服务端是发送方,故它**发** COMPLETE 而非「等」。这也意味着 upload 比 download 更简单——无需路由外来流式子连接、无需 `OnFileCompleteV2` 计数。
|
||||
|
||||
### 7.3 时序
|
||||
|
||||
```
|
||||
MCP工具线程 主连接 (ctx→Send2Client) 客户端 KernelManager
|
||||
1. 校验 McpFileTransfer=1 && McpReadonly=0(§8)
|
||||
2. CollectLocalFiles({local_path}) 收集本机文件+目录项(目录项在前、子项随后)
|
||||
3. (overwrite=false) list_files 预检 remote_dir 一层 → 得已存在顶层名 → skip 列表
|
||||
4. BeginFileUpload(id) 登记会话(单设备单传输互斥标记,§6.5)
|
||||
5. 同步驱动(工具线程内):FileBatchTransferWorkerV2(files, remote_dir, cb, f, hash, hmac, opts)
|
||||
6. ── COMMAND_SEND_FILE_V2 分块流 ───► RecvFileChunkV2 落盘
|
||||
7. ── COMMAND_FILE_COMPLETE_V2(SHA256)► HandleFileCompleteV2 校验
|
||||
8. worker 同步返回 → ClearFileTransfer → 返回 files/total_bytes/skipped
|
||||
```
|
||||
|
||||
### 7.4 服务端改动
|
||||
|
||||
1. **`McpServer.h`**:新增 `BeginFileUpload(id)`,登记 `FileTransferSession{tool="upload_file", startAt}` 作为**单设备单传输互斥标记**(与 `m_Pending`/`m_FileXferSessions` 互斥,见 §6.5)。upload 走主连接、工具线程**同步**驱动 `FileBatchTransferWorkerV2`,会话**无** `fmSubCtx`/`streamSubCtx`/`fileEntries`/`done` 等字段(`ClearFileTransfer` 对空指针安全)。
|
||||
2. **`McpServer.cpp`**:
|
||||
- `BuildUploadFileInputSchema/OutputSchema` + `BuildUploadFile(...)`(`tools/call` 分派);
|
||||
- 无头回调 `UploadSendChunkHeadless(user, chunk, data, size)`:经 `UploadCallbackData{parent,clientID,deadline}` 里 `parent->FindHost(clientID)` 定位 ctx → `ctx->Send2Client(data,size)`;客户端离线或整体超时返回 false 中止(镜像 GUI `SendFileChunkToClientV2`,去掉 `dlg` 进度,见 `2015RemoteDlg.cpp:7424-7459`);
|
||||
- 收集:本地 `CollectLocalFiles` 递归(`common/file_upload.cpp:38` 的 `ExpandDirectories` 未在 `file_upload.h` 导出,故在 McpServer.cpp 本地同构实现,目录项在前、子项随后);
|
||||
- overwrite 预检:复用 `list_files` 的 `COMMAND_LIST_DRIVE→TOKEN_DRIVE_LIST→COMMAND_LIST_FILES→TOKEN_FILE_LIST` 机制列 `remote_dir` **一层**(顶层名,不递归),过滤同名 → `skipped`;
|
||||
- 驱动:`FileBatchTransferWorkerV2(files, remote_dir, &cb, headlessCallback, nullptr, GetPwdHash(), GetHMAC(100), opts)`;`opts.transferID=GenerateTransferID()`、`srcClientID=0`、`dstClientID=clientID`、`enableResume=false`;
|
||||
- 完成判定:`result==0 && FindHost(clientID)!=nullptr`(镜像 GUI `SendFilesToClientV2Internal` 末尾)。
|
||||
3. **`2015RemoteDlg.cpp`(MessageHandle)**:**零改动**。
|
||||
4. **`McpSettingsDlg.cpp/.h`**:零改动(`McpFileTransfer` 已存在,§8 复用)。
|
||||
|
||||
### 7.5 完整性边界(诚实声明)
|
||||
|
||||
- V2 无接收方→发送方 ACK:客户端 `HandleFileCompleteV2` 校验失败只打日志(`RecvFileChunkV2` 返回 8,`KernelManager.cpp:1381` 不回传主控)。
|
||||
- **P2 的 `output.files[].sha256` 恒为空串**:`FileBatchTransferWorkerV2` 内部会为每个文件自算 SHA-256 并写入 `COMMAND_FILE_COMPLETE_V2` 包发给客户端,但该值**不回传调用方**;服务端侧又无导出的 SHA-256 工具函数可自行复算。故 P2 输出不填 sha256(延后 P3:worker 回传哈希,或服务端引入 SHA-256 工具)。传输完整性仍依赖客户端本地 `HandleFileCompleteV2` 校验(与 GUI upload / C2C 同权,属 V2 协议既有边界,非 MCP 引入)。
|
||||
- 若未来需要「接收方验真回执」,需新增反向 ACK 包(协议扩展,进 P3)。
|
||||
|
||||
### 7.6 overwrite 语义
|
||||
|
||||
- `overwrite=false`(默认):发送前用 `list_files` 预检 `remote_dir` **一层**(顶层名,不递归),本地顶层项名已存在者整体跳过(单文件精确、目录整体跳过),文件条目计入 `skipped`(目录项不计)。编码 UTF-8→ANSI(936),与 `list_files`/`download_file` 一致。
|
||||
- `overwrite=true`:全量发送(客户端 `RecvFileChunkV2` 覆盖写)。
|
||||
- 代价:一次预检往返;对称于 download 的「本地同名跳过」语义。
|
||||
|
||||
### 7.7 安全
|
||||
|
||||
- 门槛 `McpFileTransfer=1 && McpReadonly=0`(§8):upload 写远程盘,复用「允许写」主开关。
|
||||
- `remote_dir` 路径规范化(防 `..` 穿越到预期目录之外)。`local_path` 是主控本机路径(信任本地文件系统),主要风险是 AI 误推敏感文件/覆盖远程关键文件——由 `McpReadonly=0` + 审计兜底(与 `terminal_*`/`remote_*` 同款「开关+审计」,不加目录黑名单,见 §12.1)。
|
||||
|
||||
### 7.8 客户端改动(实施期修正,评审见 §13.1 U7)
|
||||
|
||||
`RecvFileChunkV2` 依赖全局 `g_status==1`(`SimplePlugins/file_upload.cpp:2812` `if (!g_status) return -1;`),但客户端**主连接此前从不初始化文件传输模块**——`InitFileUpload` 只在 `FileManager`/`ScreenManager` 构造里成对调用(下载/屏幕方向),上传走主连接时 `g_status==0`,每个 chunk 都被 `RecvFileChunkV2` 直接丢弃(表现为落盘 0 字节 / 截断)。
|
||||
|
||||
修法:在 `client/KernelManager.cpp` 的 `COMMAND_SEND_FILE_V2` 分支加**懒初始化**(进程内仅一次):
|
||||
|
||||
```cpp
|
||||
static bool s_v2RecvInited = false;
|
||||
if (!s_v2RecvInited) {
|
||||
InitFileUpload({}, m_LoginMsg, m_LoginSignature, 64, 50, Logf);
|
||||
s_v2RecvInited = true;
|
||||
}
|
||||
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn, nullptr, m_hash, m_hmac, m_MyClientID);
|
||||
```
|
||||
|
||||
要点(独立评审结论,均为安全):
|
||||
|
||||
- 与 `FileManager.cpp:40` 的 Init 参数**逐字节一致**(`{}`, `m_LoginMsg`, `m_LoginSignature`, 64, 50, `Logf`),不新增路径。
|
||||
- `static` 保证进程内只初始化一次,主连接重连(`ClientDll.cpp:660/665` 反复 `SAFE_DELETE`+`new CKernelManager`)不反复 Init/Uninit;`~CKernelManager` 保持原样**不** `UninitFileUpload`,故 `g_threadCount` 从 1 起永不归 0,`g_status` 恒为 1。
|
||||
- `InitFileUpload` 幂等(`g_fileStatesMtx` + `g_threadCount` 引用计数,二次调用 `g_threadCount>1` 早退)、非阻塞(仅置标志 + 派生 detach 线程)、license 校验在启动期 `licenseInit()` 后必过、`verifyMessage` 对空签名 `return false`(`license.cpp:244`)不会越界。
|
||||
- 影响面:主连接 `OnReceive` 无 V1 `COMMAND_SEND_FILE` 分支,`g_status=1` 只作用于 `RecvFileChunkV2`;`FileManager`/`ScreenManager` 的成对 Init/Uninit 只是在 1↔2 间震荡,语义不变。
|
||||
|
||||
---
|
||||
|
||||
## 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)`server/2015Remote/McpServer.h` | `FileTransferSession.tool` 增 `"upload_file"`;新增 `BeginFileUpload(id)`(单设备单传输互斥标记) |
|
||||
| (P2)`server/2015Remote/McpServer.cpp` | `upload_file` schema/实现:无头回调 `UploadSendChunkHeadless` + `CollectLocalFiles` 收集 + overwrite 预检(复用 `list_files` 链路)+ 驱动 `FileBatchTransferWorkerV2`(复用主连接,无子连接路由) |
|
||||
| (P2)`client/KernelManager.cpp` | `COMMAND_SEND_FILE_V2` 分支加懒初始化 `InitFileUpload`(`g_status` 由 0 置 1,见 §7.8) |
|
||||
|
||||
**客户端**:`UploadToRemoteV2` / `RecvFileChunkV2` / `FileBatchTransferWorkerV2` 均已存在、未改动;仅 `COMMAND_SEND_FILE_V2` 分支新增懒初始化(见 §7.8)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 分阶段实施与回滚
|
||||
|
||||
- **P1**:`download_file` + `McpFileTransfer` 开关 + 路由分支。可独立合入、独立验收(真实主机拖回一个目录,SHA-256 与 `certutil -hashfile` 比对一致)。
|
||||
- **P2**:`upload_file`(§7,主连接复用 + 发送方驱动,`MessageHandle` 零改动)。可独立合入、独立验收(推一个目录到真实主机,SHA-256 与源文件 `certutil -hashfile` 比对一致)。
|
||||
- **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` 同规则)。
|
||||
- 断线收尾:传输中客户端掉线 → 会话擦除 + 半成品删除,无句柄/内存泄漏。
|
||||
- **上传(P2)**:单文件 / 目录(含中文名、深层嵌套)上传;`remote_dir` 不存在自动创建;SHA-256 与源文件 `certutil -hashfile` 比对一致。
|
||||
- 上传 `overwrite=false` 同名跳过(`skipped` 计数);`overwrite=true` 覆盖。
|
||||
- 上传 `remote_dir` 含 `..` 逃逸被拒;大文件(>2GB)超时与断线;并发上传/上传-下载同主机返回 `-32003`。
|
||||
- 上传编码:GBK 中文文件名往返无乱码。
|
||||
|
||||
---
|
||||
|
||||
## 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 项问题均已在正文对应章节修正,无阻塞项,**定稿**。
|
||||
|
||||
### 13.1 upload_file 评审记录(P2,实施前)
|
||||
|
||||
核对了 `2015RemoteDlg.cpp` 的 `SendFilesToClientV2Internal`/`SendFileChunkToClientV2`(GUI upload 发送方)、`SimplePlugins/file_upload.cpp` 的 `FileBatchTransferWorkerV2`/`RecvFileChunkV2`、`client/KernelManager.cpp:1381` 的接收分支,结论:
|
||||
|
||||
| # | 审查发现 | 结论 |
|
||||
|---|---|---|
|
||||
| U1 | 旧稿「服务端等客户端回 `COMMAND_FILE_COMPLETE_V2`」方向写反 | COMPLETE 恒由**发送方**发、接收方 `HandleFileCompleteV2` 校验,无 ACK 回传。upload 服务端是发送方 → **发** COMPLETE,不是「等」 |
|
||||
| U2 | upload 走**主连接**(`ctx->Send2Client`),不像 download 要客户端新开鉴权流式子连接 | `MessageHandle` 零改动;无需 `IsFileTransferPending` 路由守卫、无需 `OnFileCompleteV2` 收包计数 |
|
||||
| U3 | 复用 `FileBatchTransferWorkerV2` 时回调需无头版 | 镜像 GUI `SendFileChunkToClientV2` 去掉 `dlg` 进度,`m_parent->FindHost(clientID)` 定位 ctx;离线返回 false 中止 |
|
||||
| U4 | 完成判定无接收方回执 | `result==0 && FindHost(clientID)!=nullptr`(与 GUI `SendFilesToClientV2Internal` 末尾一致);`output.sha256` 恒为空串,非接收方验真(见 §7.5) |
|
||||
| U5 | `overwrite=false` 需预知远程同名文件 | 复用 `list_files` 的 `COMMAND_LIST_DRIVE→TOKEN_DRIVE_LIST` 预检 `remote_dir`,过滤同名进 `skipped` |
|
||||
| U6 | 本地文件收集 | `common/file_upload.cpp:38` `ExpandDirectories` 未在 `file_upload.h` 导出,改为 McpServer.cpp 本地 `CollectLocalFiles` 同构实现(目录项在前、子项随后) |
|
||||
| U7 | 实施验证发现:客户端主连接 `g_status==0`,`RecvFileChunkV2` 逐 chunk `return -1`(上传落盘 0 字节/截断) | 客户端 `COMMAND_SEND_FILE_V2` 分支加懒初始化(§7.8):`static` 一次 + 永久引用计数、析构不 `Uninit`;与 `FileManager.cpp:40` 参数一致,独立评审确认无稳定性风险 |
|
||||
|
||||
**判定**:7 项均已写入 §7 对应小节,无阻塞项,**定稿**。
|
||||
@@ -0,0 +1,484 @@
|
||||
# MCP 远程控制(Remote Control)设计文档
|
||||
|
||||
> 版本:v1(设计基线) 状态:待开发 关联:`docs/Mcp_Design.md`、`docs/Mcp_Terminal_Design.md`
|
||||
> 定位:本文件是「AI 远程控制一台 Windows 客户端(像人一样点鼠标、敲键盘、看画面完成任务)」功能的**唯一参照设计**,后续开发一律以本文为准,改需求先改本文。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 问题
|
||||
|
||||
现有 MCP 工具只能做「文本/命令行」层面的操作(`exec_command` 一次性命令、`terminal_*` 持久终端、`list_files`/`get_screenshot` 等只读)。很多真实任务只能在 **GUI 上完成**:点按钮、填表单、拖拽窗口、操作只有图形界面的软件。
|
||||
|
||||
AI 无法「看懂」视频流。把 25fps 的远程画面直接丢给视觉模型是行不通的——每秒 25 张、每张数十万像素,海量且绝大部分帧对决策无用。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
提供一组 MCP 工具,让 AI 以**「截图 → 观察 → 决策 → 注入单个输入动作 → 等待 → 重复」**的闭环驱动远程桌面:
|
||||
|
||||
- **Observe(看)**:按需取一帧解码后的位图(JPEG),按视觉模型能力缩放到合适尺寸。
|
||||
- **Act(动)**:注入鼠标/键盘事件,复用现有远程桌面的输入注入链路。
|
||||
- **Decide(想)**:由 AI/宿主(Claude Code / 任何 MCP 客户端)在工具返回结果之上编排循环——**YAMA 只提供原语,不提供黑盒 `perform_task`**(与终端设计同一哲学)。
|
||||
|
||||
### 1.3 关键设计决策(已确认)
|
||||
|
||||
| # | 决策 | 理由 |
|
||||
|---|---|---|
|
||||
| D1 | **截图驱动,非视频流** | 每次决策一张图,3–8s/步;视觉模型天然面向静态图 |
|
||||
| D2 | **原语 + AI 编排,非黑盒任务** | 可控、可审、可中断;AI 在循环外可插判断/确认 |
|
||||
| D3 | **归一化坐标(0..1)** | 彻底规避「截图缩放 vs 注入物理像素」的比例错位(最佳实践第一大坑) |
|
||||
| D4 | **复用现有注入链路** | `COMMAND_SCREEN_CONTROL` + `MSG64` + 客户端 `SendInput` 已成熟 |
|
||||
| D5 | **Observe 复用 `get_screenshot`** | 主连接按需抓 JPEG,无需常驻子连接,最省 |
|
||||
| D6 | **独立开关 + 全程审计** | 与终端同级:`McpRemoteControl=1 && McpReadonly=0`,默认关 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 行业最佳实践借鉴
|
||||
|
||||
本节是「科学设计」的依据,逐条落到下文对应章节。主要参考 Anthropic 官方 Computer Use / Browser Use 最佳实践与参考实现(见文末「参考」)。
|
||||
|
||||
| 最佳实践 | 本设计落地 |
|
||||
|---|---|
|
||||
| 视觉模型是「规划者+眼睛」,宿主是「手」;工具调用=动作,返回=截图 | §3 循环归属:YAMA=手,AI=规划者 |
|
||||
| **客户端先自行缩图**,绝不让模型 API 静默缩放(否则坐标系统性偏移) | §5:客户端按 `max_width` 缩图后 JPEG 回传,模型见到的就是声明尺寸 |
|
||||
| 声明 `display_width/height` 必须 == 实际发送的图尺寸 | §7:归一化坐标,服务器侧映射物理像素,不依赖声明尺寸 |
|
||||
| 动作原语集合(move/click/drag/scroll/type/key/…) | §4:`remote_mouse`/`remote_keyboard` 的动作枚举对齐 |
|
||||
| 文本指令放在截图**之前**(内容顺序) | §8/宿主侧建议:由 MCP 客户端保证,本文仅记录 |
|
||||
| 缩图上限:长边 ~1568px、总像素 ~1.15MP(Claude 4.6 系);推荐 1280×720 | §5.3:`max_width` 默认建议 1280,可配 |
|
||||
| 输入注入 vs 高危动作:不可逆操作应人工确认(human-in-the-loop) | §9.4:审计 + 宿主确认,YAMA 不做每动作确认(防延迟) |
|
||||
| 提示注入是首要风险(模型处理不可信 UI 文本) | §9.5:全审计 + 只读关才可用 + 建议宿主隔离 |
|
||||
| 长会话上下文管理(滚动缓冲/压缩) | §3:宿主职责,本文提示 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
```
|
||||
┌──────────────────── 宿主(Claude Code / MCP 客户端)────────────────────┐
|
||||
│ 系统提示(含远程控制约定) + 任务文本 │
|
||||
│ ↓ │
|
||||
│ ┌──────────────────────── 智能体循环 ─────────────────────────┐ │
|
||||
│ │ get_screenshot(max_width) ──→ 视觉模型看图 ──→ 决策 ──→ │ │
|
||||
│ │ remote_mouse/remote_keyboard(归一化坐标/文本) ──→ 等待0.5–2s │ │
|
||||
│ │ 循环,直到任务完成或模型判定需用户介入 │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────┬───────────────────────────────────────┘
|
||||
│ JSON-RPC over HTTP POST /mcp (Bearer)
|
||||
┌───────────────────────────────▼───────────────────────────────────────┐
|
||||
│ YAMA 服务器(McpServer.cpp) │
|
||||
│ · get_screenshot:COMMAND_SCREEN_PREVIEW_REQ → 客户端缩图 JPEG │
|
||||
│ · remote_open:COMMAND_SCREEN_SPY → 建立屏幕子连接(控制会话) │
|
||||
│ · remote_mouse/remote_keyboard:归一化→物理像素 → MSG64 → │
|
||||
│ COMMAND_SCREEN_CONTROL(经屏幕子连接) │
|
||||
└───────────────────────────────┬───────────────────────────────────────┘
|
||||
│ 主连接(控制)+ 屏幕子连接(注入)
|
||||
┌───────────────────────────────▼───────────────────────────────────────┐
|
||||
│ 客户端(ClientDll / ScreenManager / ScreenPreview) │
|
||||
│ · CaptureAndEncodePreview:抓主屏 + StretchBlt 缩图 + JPEG │
|
||||
│ · ScreenManager.ProcessCommand:解码 MSG64 → SendInput │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**核心事实(已核实,见 §13 源码索引)**:
|
||||
|
||||
- **Observe** 走**主连接**、无子连接、无需控制模式:`COMMAND_SCREEN_PREVIEW_REQ`(247) → 客户端抓主屏缩图 JPEG → `TOKEN_SCREEN_PREVIEW_RSP`(248)。现有 `get_screenshot` 已完整实现。
|
||||
- **Act(注入)必须走屏幕子连接**:`COMMAND_SCREEN_CONTROL`(20) 只能经 `COMMAND_SCREEN_SPY`(16) 建立的子连接发送(`hub.go` 注释:input events MUST go through the sub-connection)。故需要一个 `remote_open` 建立/持有该子连接。
|
||||
- 服务器端同时维护一张**原始 BGRA 32bpp DIB**(`m_BitmapData_Full`),但 MCP 的 `get_screenshot` **不使用**它——它拿的是客户端已编码的 JPEG。两者坐标空间不同,见 §7。
|
||||
|
||||
---
|
||||
|
||||
## 4. 工具集设计
|
||||
|
||||
### 4.1 工具清单
|
||||
|
||||
| 工具 | 作用 | 入参(required 标注) | 出参 |
|
||||
|---|---|---|---|
|
||||
| `get_screenshot`(**已有,复用**) | Observe | `id`;可选 `max_width`(64..1920) | JPEG 图片(见 §5) |
|
||||
| `remote_open` | 建立控制会话(屏幕子连接) | `id`;可选 `timeout_ms` | `session_id`(32hex)、`screen_w`/`screen_h`(物理虚拟桌面像素) |
|
||||
| `remote_close` | 关闭会话 | `id`、`session_id` | `closed`(bool) |
|
||||
| `remote_mouse` | 注入鼠标 | `id`、`session_id`、`action`、`x`、`y`(+ 按 action 需 `x2`/`y2`/`button`/`delta`/`clicks`) | `{}`(成功即空对象) |
|
||||
| `remote_keyboard` | 注入键盘 | `id`、`session_id`、`action`(+ 按 action 需 `text`/`key`/`modifiers`) | `{}` |
|
||||
|
||||
> 命名对齐现有 `terminal_*`(open/exec/close),Observe 沿用 `get_screenshot` 避免重复造轮子。鼠标/键盘各一个工具、用 `action` 枚举区分具体动作——既对齐 Anthropic 的 `computer` 工具动作集,又贴合现有 `WebService::HandleMouse/HandleKey` 的「鼠标/键盘分家」实现。
|
||||
|
||||
### 4.2 `remote_mouse` 动作枚举
|
||||
|
||||
| action | 参数 | 语义 |
|
||||
|---|---|---|
|
||||
| `move` | `x`,`y` | 移动光标(不按键) |
|
||||
| `down` | `x`,`y`,`button`(left/middle/right) | 按下 |
|
||||
| `up` | `x`,`y`,`button` | 抬起 |
|
||||
| `click` | `x`,`y`,`button`(默认 left)、`clicks`(1/2/3,默认 1) | 单击/双击/三击 |
|
||||
| `right_click` | `x`,`y` | 右键(`click` 的便捷别名) |
|
||||
| `middle_click` | `x`,`y` | 中键 |
|
||||
| `drag` | `x`,`y`,`x2`,`y2`,`button`(默认 left) | 从 (x,y) 拖到 (x2,y2) |
|
||||
| `scroll` | `x`,`y`,`delta`(+/- 滚动量)、`button`(可选,垂直默认) | 滚轮 |
|
||||
|
||||
### 4.3 `remote_keyboard` 动作枚举
|
||||
|
||||
| action | 参数 | 语义 |
|
||||
|---|---|---|
|
||||
| `type` | `text` | 输入一串文本(字符级映射,非粘贴) |
|
||||
| `key_down` | `key`、`modifiers`(可选) | 按下组合键(如 `Ctrl`+`C`) |
|
||||
| `key_up` | `key`、`modifiers`(可选) | 抬起 |
|
||||
| `key_press` | `key`、`modifiers`(可选) | 按下并抬起(最常用) |
|
||||
|
||||
`key` 取值对齐 Windows 虚拟键名(`VK_*` 去掉前缀,如 `ENTER`/`TAB`/`F5`/`LEFT`)与常见修饰键 `CTRL`/`ALT`/`SHIFT`/`WIN`。`type` 文本中的字符经客户端 `VkKeyScan`/Unicode 注入映射为键盘事件(非剪贴板粘贴,保证焦点内输入)。
|
||||
|
||||
### 4.4 可选/扩展工具(`remote_clipboard`、`remote_focus_window` 为 §17 代聊实验所需,建议随阶段一落地)
|
||||
|
||||
- `remote_clipboard`:写远程剪贴板(UTF-8→Unicode),非 ASCII 文本输入的关键路径;复用 `COMMAND_C2C_TEXT`(90) 的 UTF-8 逻辑或新增 UTF-8 设剪贴板命令(§6.4)。
|
||||
- `remote_focus_window`:`COMMAND_SCREEN_WINDOW`(153) / 客户端 `SetForegroundWindow`,把焦点给到指定窗口(配合 `list_windows` 的 `hwnd`);代聊第一步。
|
||||
- `remote_cursor_position`:读当前光标(`TOKEN_NEXTSCREEN` 帧头已带 `POINT cursor`)。
|
||||
- `remote_screenshot`(会话内/窗口抓屏):从子连接 DIB 服务端缩图,或按窗口抓取(`COMMAND_SCREEN_WINDOW`),供高分辨率读聊天文字与多显示器场景(§12 阶段二、§17)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 屏幕捕获与编码(Observe)
|
||||
|
||||
### 5.1 现有链路(复用)
|
||||
|
||||
`get_screenshot`(`McpServer.cpp` `BuildGetScreenshot`,~1597)已实现:
|
||||
|
||||
1. `ParseHostIdArg` → `FindMainContext` → 能力门 `ctx->SupportsScreenPreview()`(`CLIENT_CAP_SCREEN_PREVIEW`,不支持 → `-32005`)。
|
||||
2. `BeginPending(devId,"get_screenshot")` 设备级互斥(忙 → `-32003`)。
|
||||
3. `reqId = NextPreviewReqId()`;`SetPendingReqId(devId, reqId)`(stale-drop 机制)。
|
||||
4. `parent->ChooseScreenPreviewParams(...)` 按 RTT/FRP 自适应缩图档位 + `max_width` 覆盖(clamp 64..1920)。
|
||||
5. `SendScreenPreviewRequest` → `COMMAND_SCREEN_PREVIEW_REQ`。
|
||||
6. `WaitPending` 收 `[ScreenPreviewRspHeader][JPEG]`,校验 `status==OK && format==JPEG`。
|
||||
7. base64 JPEG 返回:`structuredContent.image = {mimeType:"image/jpeg", width, height, bytes}`,`content=[{type:"image", data:<base64>, mimeType:"image/jpeg"}]`。
|
||||
|
||||
客户端 `CaptureAndEncodePreview`(`client/ScreenPreview.cpp:107`)抓**主屏** + `StretchBlt`(HALFTONE) 缩图 + GDI+ `Bitmap::Save` JPEG——**位图永不离开客户端,只回传 JPEG**。
|
||||
|
||||
### 5.2 缩放策略(对齐最佳实践 D5)
|
||||
|
||||
- 缩图在**客户端**完成(`max_width` 长边约束),服务器/MCP 宿主**不再二次缩放**,模型看到的就是声明尺寸——避免「模型 API 静默缩放导致坐标漂移」这个头号坑。
|
||||
- `max_width` 由宿主按视觉模型能力显式传入;YAMA 提供**默认建议 1280**(长边),上限维持 1920。
|
||||
|
||||
### 5.3 推荐参数
|
||||
|
||||
| 项 | 建议值 | 说明 |
|
||||
|---|---|---|
|
||||
| `max_width`(长边) | 1280(默认)/ 1568(上限) | 1280×720 ≈ 80% 像素预算、训练常见分辨率 |
|
||||
| 编码 | JPEG,quality ~70–85 | 客户端 `jpegQuality` 已有,按 `ChooseScreenPreviewParams` 自适应 |
|
||||
| 返回 | base64 `image/jpeg` | 已实现 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 输入注入(Act)
|
||||
|
||||
### 6.1 协议(复用,已核实)
|
||||
|
||||
- **包格式**:`[cmd:1][MSG64:48]`,可批量重复(`commands.h` `COMMAND_SCREEN_CONTROL=20`,`MSG64` 1581–1623)。
|
||||
- **MSG64 布局**:`{uint64 hwnd, message, wParam, lParam, time; POINT pt;}` = 48 字节(`_WIN64` 下 `MYMSG` 即 `MSG`;客户端亦接受 28 字节 `MSG32`,由 `ulLength % 28/48` 判定)。
|
||||
- **客户端解码**:`ScreenManager.cpp:1175` → `ProcessCommand(1727)` → `SendInput`:
|
||||
- 鼠标:坐标取 `LOWORD/HIWORD(lParam)`,`WM_MOUSEMOVE`→`MOUSEEVENTF_MOVE`,`WM_LBUTTONDOWN/UP`→`LEFTDOWN/LEFTUP`,`WM_LBUTTONDBLCLK`→额外 `LEFTDOWN`,`WM_MBUTTONDOWN/UP`,`WM_MOUSEWHEEL`→`MOUSEEVENTF_WHEEL`(`mouseData=GET_WHEEL_DELTA_WPARAM`)。
|
||||
- 键盘:`wVk=(WORD)wParam`,`wScan=(lParam>>16)&0xFF`,`KEYEVENTF_EXTENDEDKEY` 由 `(lParam>>24)&1`。
|
||||
|
||||
### 6.2 服务端可复用实现
|
||||
|
||||
`WebService::HandleMouse`(`WebService.cpp:773-865`)与 `HandleKey`(`867-952`)已从 JSON(`type/x/y/button/delta`;`keyCode/down/altKey`)构造 `MSG64` 并 `[COMMAND_SCREEN_CONTROL][MSG64]` 发送。其中 `HandleKey` 已正确处理 `lParam`:repeat=1、`MapVirtualKey` 扫瞄码、扩展键表、Alt 上下文位 29、keyup 位 30–31。
|
||||
|
||||
**结论**:`remote_mouse`/`remote_keyboard` 是这两个函数的 MCP 薄封装——先归一化→物理像素,再套用其 `MSG64` 构造逻辑。**不要新写注入协议**。
|
||||
|
||||
### 6.3 子连接前置条件(关键约束)
|
||||
|
||||
注入**必须**经屏幕子连接:
|
||||
|
||||
1. 主连接发 `COMMAND_SCREEN_SPY`(16)(载荷 `{cmd, USING_DXGI|2, ALGORITHM_*, MultiScreen}`,见 `2015RemoteDlg.cpp:8443-8470`)→ 客户端开子连接(`ClientDll.cpp:89-94`)。
|
||||
2. 客户端回 `TOKEN_BITMAPINFO`(含物理 `BITMAPINFOHEADER`:`biWidth/biHeight`)——**这是归一化坐标映射所需的物理分辨率来源**。
|
||||
3. 服务器发 `COMMAND_NEXT`(30) 开始流/允许控制。
|
||||
4. `GetScreenContext(device_id)`(`WebService.cpp:2084`)从 `m_ScreenContexts` 取该子连接上下文。
|
||||
|
||||
> 实现提示:上述 1–4 的建立/持有逻辑**耦合在 `CScreenSpyDlg`(隐藏对话框)里**——子连接、帧 DIB、`TOKEN_BITMAPINFO` 处理都由它完成。MCP 的 `remote_open` 应复用 `WebService::StartRemoteDesktop`(1725) 的「隐藏对话框」路径,而非另造无对话框持有者(§13.2)。
|
||||
|
||||
`remote_open` 即上述 1–4 的封装;`screen_w/screen_h` 取自 `TOKEN_BITMAPINFO`。
|
||||
|
||||
### 6.4 文本输入:ASCII 直注 vs 非 ASCII 走剪贴板
|
||||
|
||||
`remote_keyboard` 的 `type` 走物理键事件(`wVk`/`wScan` → `SendInput`),只能可靠注入 ASCII/ANSI 字符——**中文等非 ASCII 文本无法靠模拟键盘键入**(需 IME 组合,键事件层面做不到)。
|
||||
|
||||
非 ASCII 文本的可靠路径是**剪贴板 + 粘贴**:
|
||||
|
||||
- 服务器设远程剪贴板命令 `COMMAND_SCREEN_SET_CLIPBOARD`(25),包 `[cmd:1][text:N]`,经屏幕子连接发送;客户端 `UpdateClientClipboard`(`ScreenManager.cpp:1223/1599`)落到剪贴板。
|
||||
- 随后 `remote_keyboard` 注入 `Ctrl+V` 粘贴、`Enter` 发送。
|
||||
- **编码注意(已核实)**:`UpdateClientClipboard` 用 `SetClipboardData(CF_TEXT,...)`——ANSI/GBK 格式(`CF_TEXT`,非 `CF_UNICODETEXT`)。MVP 下中文可经 `ToAnsi(utf8, 936)` 转 GBK 后直发(与终端命令同模式);但 emoji/非 GBK 字符会丢、部分新式聊天软件粘贴偏好 Unicode。更稳做法是复用 `COMMAND_C2C_TEXT`(90) 的 UTF-8→Unicode→`CF_UNICODETEXT` 逻辑(`KernelManager.cpp:1573-1587`,且已内置「设完剪贴板后自动模拟 Ctrl+V」),或新增一个 UTF-8 设剪贴板变体。见 §17 实验案例。
|
||||
|
||||
---
|
||||
|
||||
## 7. 坐标约定与映射
|
||||
|
||||
### 7.1 归一化坐标(D3)
|
||||
|
||||
- AI 在**所有鼠标动作**里输出 **0..1 浮点**:`x = 目标横向比例,y = 目标纵向比例`,相对**它看到的那张截图**。
|
||||
- 服务器侧映射:`phys_x = round(norm_x * screen_w)`、`phys_y = round(norm_y * screen_h)`,其中 `screen_w/h` 是 `remote_open` 时从 `TOKEN_BITMAPINFO` 得到的**物理捕获分辨率**。
|
||||
- 好处:无论 `max_width` 怎么缩、显示器多大,AI 永远只说「在图的 40%/60% 处点一下」,比例错位风险为零。
|
||||
|
||||
### 7.2 坐标空间一致性(最重要正确性点)
|
||||
|
||||
Observe(`get_screenshot` 抓**主屏**)与 Act(`SendInput` 绝对坐标在**虚拟桌面**空间)默认不同源。**阶段一只支持单显示器**:主屏 == 虚拟桌面,两者重合,映射严格正确。
|
||||
|
||||
多显示器(`MultiScreen`)在**阶段二**处理:届时 Observe 必须改抓**整个虚拟桌面**(而非主屏),`screen_w/h` 用虚拟桌面尺寸,归一化坐标才继续成立。**在阶段二落地前,`remote_open` 对多显示器配置应返回显式错误或降级为主屏**(见 §12)。
|
||||
|
||||
### 7.3 边界与钳制
|
||||
|
||||
- 归一化值越界(`<0` 或 `>1`)→ 钳制到 `[0,1]` 或返回 `-32602`(建议钳制,模型偶发 `1.0001`)。
|
||||
- `phys_x/y` 不得越过 `screen_w/h`,注入前 `clamp(0, screen_w-1)`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 控制会话状态机
|
||||
|
||||
### 8.1 生命周期
|
||||
|
||||
```
|
||||
remote_open ──► 建立子连接 ──► [remote_mouse/remote_keyboard × N + get_screenshot × M]
|
||||
│ │
|
||||
└──(busy 互斥 / idle 回收 / 断线清理)──────────┴──► remote_close(幂等)
|
||||
```
|
||||
|
||||
### 8.2 状态与不变量(镜像 `docs/Mcp_Terminal_Design.md` §并发)
|
||||
|
||||
- **单设备单控制会话**:`remote_open` 对已存在会话 → `-32003`(忙)。
|
||||
- **互斥**:同一设备上,MCP 远程控制会话与**人类远程桌面观看**(`m_ScreenContexts` 已被占用)互斥——`remote_open` 发现已有屏幕子连接 → `-32003`,避免 AI 向人类正在观看/控制的画面注入。`remote_mouse/keyboard` 要求 `session_id` 匹配且会话未关闭。
|
||||
- **busy 标志**:一条注入在飞时 `busy=true`,保证等待线程是唯一擦除者(沿用终端的 `m_TermMutex`+`busy` 模式;远程控制建议独立 `m_ScreenCtrlMutex`/`m_ScreenCtrlCv`)。
|
||||
- **idle 回收**:会话闲置超时(默认 300s)自动关闭并 `CancelIO`(镜像 `SweepIdleTerminals`)。
|
||||
- **断线清理**:`OfflineProc`/`OnTerminalClosed` 同位置加 `McpServer().OnScreenControlClosed(subCtx)`,防悬空 `subCtx` 被连接池复用(复刻终端设计里发现的第 2 个修复点)。
|
||||
- **锁外 `CancelIO`**:所有取消动作在锁外执行(终端的 P2 教训)。
|
||||
|
||||
### 8.3 并发(P5 教训)
|
||||
|
||||
`httplib` 多线程并发处理请求;`remote_mouse` 会高频调用。会话状态一律 `m_ScreenCtrlMutex` 串行化,**不要假定请求串行**。高频注入可加**节流**(沿用 `SendScaledMouseMessage` 的鼠标移动节流思路,`ScreenSpyDlg.cpp:2728-2743`)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 安全模型
|
||||
|
||||
延续既有「可控、可关、可审、可隔离」原则(与终端同级):
|
||||
|
||||
### 9.1 开关与门控
|
||||
|
||||
- 新增 `McpRemoteControl`(默认 **0=关**)。
|
||||
- `remote_*` 生效条件:`McpRemoteControl=1 && McpReadonly=0`,否则 `-32006`。
|
||||
- `get_screenshot` 是只读工具,不受此门控,仍仅受现有能力门(供纯观察场景)。
|
||||
|
||||
### 9.2 全程审计
|
||||
|
||||
每条 `remote_open/close/mouse/keyboard` 调用 → `PostMessageA(WM_SHOWERRORMSG, ...)` → `m_MessageLog` → `get_audit_log` 可查(复用 `McpServer.cpp:2050` 的审计模式,标题 `MCP远程控制`,标记「不可关闭」)。审计内容含:设备 id、动作、归一化坐标与换算后的物理坐标、session_id、时间戳。
|
||||
|
||||
### 9.3 会话级鉴权
|
||||
|
||||
- `session_id` 由 `GenerateRandomToken()`(32hex),每次 `remote_open` 独立随机,防串用(镜像终端 P6)。
|
||||
- 注入工具校验 `session_id` 归属与设备匹配,不匹配 → `-32002`。
|
||||
|
||||
### 9.4 不可逆操作与人工确认
|
||||
|
||||
- YAMA **不做逐动作确认**(会引入每步 0.5s+ 延迟,破坏闭环节奏)。
|
||||
- 高危动作(提交表单、删除、付款、改系统设置)的确认由**宿主/AI 编排层**负责(Claude Code 的权限系统 / 系统提示约定「不可逆操作前暂停询问用户」)。本文记录此分工;YAMA 提供审计留痕兜底。
|
||||
|
||||
### 9.5 提示注入(Prompt Injection)
|
||||
|
||||
视觉模型会读到客户端屏幕上的**不可信 UI 文本**,存在注入风险。缓解:远程控制本身即高危能力(默认关 + 只读关才开 + 全审计);建议宿主将远程控制会话隔离在独立权限域,并对「屏幕文本里的指令」保持怀疑。YAMA 侧不做内容分类(无意义且加延迟),记录即可。
|
||||
|
||||
---
|
||||
|
||||
## 10. 错误码
|
||||
|
||||
沿用现有约定(`BuildError(id, code, msg)`):
|
||||
|
||||
| code | 含义 | 触发 |
|
||||
|---|---|---|
|
||||
| -32602 | 参数非法 | action 未知、必填参数缺失、`max_width` 越界、归一化值格式错 |
|
||||
| -32000 | 通用失败 | 未分类错误 |
|
||||
| -32001 | 超时 | `remote_open` 等子连接/`get_screenshot` 等帧超时 |
|
||||
| -32002 | 设备/会话不存在 | `id` 无在线主机、`session_id` 不存在或不匹配 |
|
||||
| -32003 | 设备/会话忙 | 已有控制会话、人类远程桌面占用、`get_screenshot` 在飞 |
|
||||
| -32004 | 发送失败 | 注入命令发送失败 |
|
||||
| -32005 | 能力不支持 | 客户端无 `CLIENT_CAP_SCREEN_PREVIEW`(Observe)/ 不支持屏幕控制(Act) |
|
||||
| -32006 | 被开关禁用 | `McpRemoteControl=0` 或 `McpReadonly=1` |
|
||||
| -32008 | 非法/被拒参数 | 多显示器未支持时的 `remote_open` 降级拒绝(阶段一) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 配置项
|
||||
|
||||
新增 1 项(`THIS_CFG` `settings` 节,键名沿用蛇形):
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `McpRemoteControl` | `0` | 远程控制开关;要求 `McpReadonly=0` 才生效 |
|
||||
|
||||
其余沿用:`McpEnabled`/`McpPort`/`McpBind`/`McpToken`/`McpReadonly`/`McpTerminal`/`McpCmdWhitelist`。改动需重启生效(与终端一致)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 已知限制与后续扩展
|
||||
|
||||
### 12.1 阶段一(本期)
|
||||
|
||||
- **单显示器**:Observe 抓主屏,坐标空间与虚拟桌面重合;多显示器 `remote_open` 显式报错(`-32008`)。
|
||||
- 注入基于**绝对坐标** `SendInput`,要求客户端处于可注入的桌面会话(已登录、非锁屏/非 UAC 安全桌面);锁屏/UAC 提示场景注入无效(客户端 `SendInput` 限制),设计文档记录为已知限制。
|
||||
- **代聊实验使能项**:窗口抓屏(`COMMAND_SCREEN_WINDOW`)、UTF-8 剪贴板、窗口聚焦随阶段一落地(§17)。
|
||||
|
||||
### 12.2 阶段二(后续)
|
||||
|
||||
1. **多显示器虚拟桌面捕获**:`get_screenshot` 增加虚拟桌面抓屏,或新增 `remote_screenshot` 从子连接 DIB 服务端缩图(复用 `BmpToJpeg`/`CacheThumbnail` GDI+ 路径),使 Observe/Act 同源。
|
||||
2. **窗口定向**:`remote_focus_window`(复用 `COMMAND_SCREEN_WINDOW` + 客户端 `SetForegroundWindow`)。
|
||||
3. **光标读取 / 剪贴板**。
|
||||
4. **服务端缩图**:从 `m_BitmapData_Full`(BGRA DIB)直接按 API 上限缩图编码,进一步省客户端往返。
|
||||
|
||||
---
|
||||
|
||||
## 13. 改动清单(供实现参照)
|
||||
|
||||
> 行号以当前树为准(实现前请 grep 复核)。新增/改动尽量「复用」而非「重写」。
|
||||
|
||||
### 13.1 `server/2015Remote/McpServer.h`
|
||||
|
||||
- 新增 `struct ScreenCtrlSession`(仿 `TermSession`):`sessionId`、`deviceId`、`busy`、`started`、`closed`、`lastActiveAt`、`screenW/screenH`(来自 `TOKEN_BITMAPINFO`)、`subCtx` 引用。
|
||||
- 新增方法:`SetRemoteControlEnabled/IsRemoteControlEnabled`、`BeginScreenCtrlOpen/…/CloseScreenCtrlSession/SweepIdleScreenCtrl`、`OnScreenControlClosed(context*)`、`ResolveScreenCtrlSessionHost`。
|
||||
- 新增成员:`bool m_remoteControlEnabled=false;` 及 `std::mutex m_ScreenCtrlMutex; std::condition_variable m_ScreenCtrlCv; std::map<context*,uint64_t> m_ScreenCtrlContextToDevice; std::map<uint64_t,ScreenCtrlSession> m_ScreenCtrlSessions;`(或复用 WebService 的 `m_ScreenContexts` 生命周期,见下)。
|
||||
|
||||
### 13.2 `server/2015Remote/McpServer.cpp`
|
||||
|
||||
- 新增 4 个 schema 构建器 + 3 个 handler:`BuildRemoteOpen/Close/Mouse/Keyboard`(镜像 `BuildTerminalOpen` 等,`BuildToolsListResult` ~1278 后加条目,`BuildToolsCall` ~2124 加分派)。
|
||||
- `remote_open`:`SweepIdleScreenCtrl` → `ResolveScreenCtrlSessionHost` → 已有会话 `-32003` → `GenerateRandomToken` → **复用 Web 远程桌面的会话建立路径 `WebService::StartRemoteDesktop`(1725)**(发 `COMMAND_SCREEN_SPY` → 以 `SW_HIDE` 打开隐藏 `CScreenSpyDlg` 持有子连接 → 客户端子连接到达 → `RegisterScreenContext` 记入 `m_ScreenContexts`)→ 等 `TOKEN_BITMAPINFO` 取 `screenW/H` → `COMMAND_NEXT` → 审计 → 返回 `{session_id, screen_w, screen_h}`。**注意:屏幕子连接/帧 DIB/`TOKEN_BITMAPINFO` 处理都与 `CScreenSpyDlg` 耦合,不要另造无对话框持有者——沿 Web 的隐藏对话框路径是低风险正解。**
|
||||
- `remote_mouse/keyboard`:`ResolveScreenCtrlSessionHost` → 校验 `session_id`/`busy` → 归一化→物理像素(`round(norm * screenW/H)` + clamp)→ **复用 `WebService::HandleMouse/HandleKey` 的 `MSG64` 构造**(或抽取成共享 helper `BuildMouseMsg64/BuildKeyMsg64`,避免 MCP 与 Web 两处重复)→ `subCtx->Send2Client([COMMAND_SCREEN_CONTROL][MSG64])` → 审计。
|
||||
- `remote_close`:幂等 `CloseScreenCtrlSession`(不存在 → `{closed:true}`;sid 不匹配 → `-32002`)→ 锁外 `CancelIO` → 审计。
|
||||
- `OnScreenControlClosed`:镜像终端修复点——空闲会话直接擦路由+会话,busy 会话唤醒等待线程由其清理。
|
||||
- `SweepIdleScreenCtrl`:镜像 `SweepIdleTerminals`(`difftime` 防时钟回拨)。
|
||||
|
||||
### 13.3 `server/2015Remote/McpSettingsDlg.h/.cpp`
|
||||
|
||||
- 新增复选框 `IDC_MCP_REMOTECONTROL = 1008`,文案 `_TR("启用远程控制(AI 操控桌面)")`;回填/落盘 `McpRemoteControl`;对话框高度 +30。
|
||||
|
||||
### 13.4 `server/2015Remote/2015RemoteDlg.cpp`
|
||||
|
||||
- 启动读配置处加 `McpServer().SetRemoteControlEnabled(THIS_CFG.GetInt("settings","McpRemoteControl",0)!=0);`。
|
||||
- `OfflineProc` 加 `if (McpServer().IsRunning() && McpServer().IsScreenCtrlContext(ContextObject)) McpServer().OnScreenControlClosed(ContextObject);`(复刻终端修复点)。
|
||||
|
||||
### 13.5 语言文件
|
||||
|
||||
- 沿用终端约定:MCP 相关文案本就在 `lang/*.ini` 无条目(`_TR` 对未命中键原样返回中文)。若日后翻译,用 **GBK 工具**改 `lang/{en_US,zh_TW}.ini`(ANSI/GBK,**不能用 Write/Edit 直接写**)。
|
||||
|
||||
---
|
||||
|
||||
## 14. 关键点(实现时务必遵守)
|
||||
|
||||
- **P1(坐标一致性,最重要)**:归一化坐标映射所用 `screenW/H` 必须与**当前截图来源的分辨率**一致。阶段一单屏下 `TOKEN_BITMAPINFO` 的主屏尺寸 == 截图主屏物理尺寸,才成立;任何「用虚拟桌面尺寸映射主屏截图」都会造成系统性偏移(最佳实践第一大坑)。
|
||||
- **P2(注入走子连接)**:`COMMAND_SCREEN_CONTROL` **必须**经 `GetScreenContext` 取得的子连接发送,主连接无效。
|
||||
- **P3(锁外 CancelIO)**:所有 `CancelIO` 在 `m_ScreenCtrlMutex` 之外(终端 P2 教训)。
|
||||
- **P4(复用勿重写)**:`MSG64` 构造逻辑复用 `WebService::HandleMouse/HandleKey`;理想做法抽共享 helper,否则未来两处漂移。
|
||||
- **P5(并发串行化)**:`remote_mouse` 高频并发,状态全部走锁;勿假定请求串行(终端 P5 教训)。
|
||||
- **P6(session_id 独立随机 + 互斥)**:会话 token 用 `GenerateRandomToken`;单设备单会话;与人类远程桌面观看互斥(`-32003`)。
|
||||
- **P7(非黑盒)**:不实现 `perform_task`;循环归宿主/AI,YAMA 只出原语(D2)。
|
||||
|
||||
---
|
||||
|
||||
## 15. 验证(端到端,127.0.0.1:6544,Bearer `a2176213eddcf0acfdb285940ec4eb2a`)
|
||||
|
||||
前置:设置开 `McpEnabled=1`、`McpReadonly=0`、`McpRemoteControl=1`;目标 Windows 客户端在线、已登录桌面。`id` 取 `list_online_hosts` 的十进制主机 id。
|
||||
|
||||
```bash
|
||||
B='Authorization: Bearer a2176213eddcf0acfdb285940ec4eb2a'
|
||||
U=http://127.0.0.1:6544/mcp
|
||||
|
||||
# 1) tools/list 应出现 remote_open/close/mouse/keyboard 四个工具
|
||||
curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
||||
|
||||
# 2) 打开会话 → session_id + screen_w/h
|
||||
curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"remote_open","arguments":{"id":"<HOSTID>"}}}'
|
||||
|
||||
# 3) Observe:截图(复用)
|
||||
curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_screenshot","arguments":{"id":"<HOSTID>","max_width":1280}}}'
|
||||
|
||||
# 4) Act:移动 + 单击(归一化坐标,0.5,0.5 = 屏幕中心)
|
||||
curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"remote_mouse","arguments":{"id":"<HOSTID>","session_id":"<SID>","action":"move","x":0.5,"y":0.5}}}'
|
||||
curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"remote_mouse","arguments":{"id":"<HOSTID>","session_id":"<SID>","action":"click","x":0.5,"y":0.5,"button":"left"}}}'
|
||||
|
||||
# 5) 键盘:按下并抬起 Ctrl+Esc 或输入文本
|
||||
curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"remote_keyboard","arguments":{"id":"<HOSTID>","session_id":"<SID>","action":"key_press","key":"WIN"}}}'
|
||||
|
||||
# 6) 负例:未开会话/错误 sid → -32002;已开会话再 open → -32003;只读/开关关 → -32006
|
||||
# 7) 关闭 + 幂等关闭
|
||||
curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"remote_close","arguments":{"id":"<HOSTID>","session_id":"<SID>"}}}'
|
||||
|
||||
# 8) 审计链
|
||||
curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"get_audit_log","arguments":{}}}'
|
||||
```
|
||||
|
||||
人工核对:注入后目标机光标/焦点确实变化;审计列表出现标题「MCP远程控制」的 open/close/mouse/keyboard 条目;闲置 >300s 后旧 `session_id` 调用 → `-32002`。编译由你以 VS2019(MSBuild v143)完成;我侧仅代码审查(工具集 v142,无法在此构建)。
|
||||
|
||||
---
|
||||
|
||||
## 16. 涉及文件
|
||||
|
||||
- `server/2015Remote/McpServer.h` / `.cpp`(会话状态机 + schema + handler + 分派)
|
||||
- `server/2015Remote/McpSettingsDlg.h` / `.cpp`(新开关)
|
||||
- `server/2015Remote/2015RemoteDlg.cpp`(启动读配置 + `OfflineProc` 清理钩子)
|
||||
- `common/commands.h`(**只读参照**:`COMMAND_SCREEN_SPY`/`COMMAND_SCREEN_CONTROL`/`MSG64`/`COMMAND_SCREEN_PREVIEW_REQ` 等,无需改)
|
||||
- `server/2015Remote/WebService.cpp`(**复用参照**:`HandleMouse`/`HandleKey`/`GetScreenContext`)
|
||||
- `lang/{en_US,zh_TW}.ini`(可选,GBK 工具改)
|
||||
|
||||
---
|
||||
|
||||
## 17. 实验案例:AI 代聊(chat-on-behalf)
|
||||
|
||||
> 目标:以「AI 通过远程桌面,在聊天软件里代表你与朋友聊天」为第一个实验用例,验证整条「看 → 想 → 动 → 确认」链路。低风险、目标清晰、信号明确(有新消息 / 已发出)。
|
||||
|
||||
### 17.1 闭环
|
||||
|
||||
1. `list_windows` 找到聊天窗口(`hwnd`/`title`)。
|
||||
2. `remote_focus_window`(或 `remote_mouse` 点击输入框)聚焦聊天窗。
|
||||
3. 抓**聊天窗口**高分辨率截图 → 视觉模型读出朋友最新消息。
|
||||
4. 模型生成回复。
|
||||
5. `remote_clipboard` 把回复写入远程剪贴板(UTF-8)。
|
||||
6. `remote_keyboard` 注入 `Ctrl+V`(粘贴)+ `Enter`(发送)。
|
||||
7. 再截图确认已发出,等待下一条。
|
||||
|
||||
### 17.2 两个已验证的关键依赖
|
||||
|
||||
- **中文输入(走剪贴板,已解决)**:见 §6.4。`COMMAND_SCREEN_SET_CLIPBOARD`(25) 为 ANSI/GBK(`CF_TEXT`),中文可经 `ToAnsi(utf8, 936)` 直发跑通 MVP;要支持 emoji/完整 Unicode,复用 `COMMAND_C2C_TEXT`(90) 的 UTF-8→`CF_UNICODETEXT` 逻辑或新增 UTF-8 变体。客户端 `KernelManager.cpp:1590` 已内置「设剪贴板后自动模拟 Ctrl+V」。
|
||||
- **读消息(需窗口抓屏)**:`get_screenshot` 抓整屏,缩到 1280 后聊天文字过小、OCR 易错。用 `COMMAND_SCREEN_WINDOW`(153) 按窗口抓取聊天窗(更高有效分辨率),或对该区域用更高 `max_width`。
|
||||
|
||||
### 17.3 使能清单(相对现有代码的增量)
|
||||
|
||||
| 能力 | 状态 | 复用点 |
|
||||
|---|---|---|
|
||||
| `get_screenshot` / `list_windows` | ✅ 已有 | — |
|
||||
| 剪贴板写入 | ⚠️ 客户端已有(GBK 直发可跑通),缺 MCP 封装 | `COMMAND_SCREEN_SET_CLIPBOARD`(25) / `COMMAND_C2C_TEXT`(90) |
|
||||
| `remote_open` / `remote_close`(屏幕子连接) | ❌ 待建 | `COMMAND_SCREEN_SPY`(16) 建立逻辑 `2015RemoteDlg.cpp:8443` |
|
||||
| `remote_mouse` / `remote_keyboard` | ❌ 待建 | `WebService::HandleMouse/HandleKey` |
|
||||
| `remote_focus_window` | ❌ 待建 | `COMMAND_SCREEN_WINDOW`(153) |
|
||||
| `remote_clipboard` | ❌ 待建 | 见 §6.4 |
|
||||
|
||||
### 17.4 最小 MVP 实施顺序(按依赖)
|
||||
|
||||
1. `remote_open` / `remote_close`(屏幕子连接 + `TOKEN_BITMAPINFO` 取分辨率)。
|
||||
2. `remote_keyboard`(先只做 `key_press`/`type` + 修饰键,够发 `Ctrl+V`/`Enter`)。
|
||||
3. `remote_mouse`(`click`/`move`,用于点输入框;聚焦可用点击替代)。
|
||||
4. `remote_clipboard`(先 GBK 直发跑通,再上 UTF-8 变体)。
|
||||
5. `remote_focus_window`(或用步骤 3 的点击替代,二选一即可跑通)。
|
||||
|
||||
> 1→2→3 已足以发出一条消息(点输入框 + 粘贴 + 回车);4 是中文关键路径;5 是体验优化,可后置。
|
||||
|
||||
### 17.5 实验注意事项
|
||||
|
||||
- 目标机必须**已登录且未锁屏**(锁屏/UAC 安全桌面下抓屏与注入均失效,§12.1)。
|
||||
- 「代聊」会让朋友误以为是真人在聊——实验前建议告知对方;避免让 AI 在承诺/隐私等话术上自由发挥(§9.4 的人工确认原则同样适用)。
|
||||
- 新消息检测先用「轮询截图」即可(MVP 接受),后续用未读角标/窗口通知优化(§17.6)。
|
||||
|
||||
### 17.6 后续优化(非 MVP)
|
||||
|
||||
- 新消息触发:从「定时截图」改为「未读角标/窗口通知驱动」,大幅省 token。
|
||||
- 窗口定向抓屏(`COMMAND_SCREEN_WINDOW`)并入 `remote_screenshot`。
|
||||
- 会话历史:AI 保留最近几轮聊天上下文,避免回复脱节(宿主侧滚动缓冲,§3)。
|
||||
|
||||
---
|
||||
|
||||
## 参考(行业最佳实践)
|
||||
|
||||
- Anthropic《Best practices for computer and browser use with Claude》— https://claude.com/fr/blog/best-practices-for-computer-and-browser-use-with-claude
|
||||
- Anthropic 参考实现 `computer.py`(动作枚举、缩放、坐标换算)— https://github.com/anthropics/claude-quickstarts/blob/main/computer-use-demo/computer_use_demo/tools/computer.py
|
||||
- 《How Claude Computer Use Works: Architecture Internals》— https://callsphere.ai/blog/how-claude-computer-use-works-architecture-internals
|
||||
@@ -0,0 +1,471 @@
|
||||
# MCP 远程命令与持久终端 — 设计与实现
|
||||
|
||||
> 本文档覆盖 MCP 侧两类**命令执行**能力:
|
||||
> 1. **一次性命令 `exec_command`**(P3a)—— 每次新建一个 shell、跑一条命令、读完即关。
|
||||
> 2. **持久终端 `terminal_open` / `terminal_exec` / `terminal_close`**(P4)—— 同一主机**一个** shell 会话,跨命令保持 cwd 与环境变量。
|
||||
>
|
||||
> 阅读顺序建议:先读 `docs/Mcp_Design.md`(Phase 1:HTTP 协议、token 校验、配置、`list_online_hosts`)与 `docs/Mcp_Phase2_Design.md`(模式 A/A′/B、工具路线图、P2a–P3a 概要),再读本文档的**实现级细节**。本文档是后续人员 / 大模型接手与排查问题的主参考。
|
||||
|
||||
---
|
||||
|
||||
## 1. 术语与总览
|
||||
|
||||
| 术语 | 含义 |
|
||||
|---|---|
|
||||
| **一次性命令** | `exec_command`:无状态,每次独立 shell,命令结束即 `CancelIO` 关闭子链接。 |
|
||||
| **持久终端** | `terminal_open`→`terminal_exec`×N→`terminal_close`:有状态,cwd/env 跨命令保持。 |
|
||||
| **哨兵(sentinel)** | 包装在命令尾部的一段随机标记,用于在输出流里定位「命令真实退出码」并截断输出。 |
|
||||
| **子链接(sub-connection / `context*`)** | 客户端为 shell 建立的独立网络连接,`context` 是其服务端句柄。 |
|
||||
| **主连接(`FindMainContext`)** | 客户端与控制端的常驻长连接,用于下发 `COMMAND_*`。 |
|
||||
| **isPty** | `true`=ConPTY(UTF-8,`cp=CP_UTF8`);`false`=老 cmd 管道(GBK,`cp=936`)。 |
|
||||
|
||||
两类能力**共享同一套底层机制**(终端链路、编码、哨兵、状态机 map、审计),区别只在**生命周期**(一次性 vs 保持)与**安全门**(白名单 vs 无白名单 + 独立开关)。这也是为什么它们会互相排他(见 §6.3)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 共享基础设施
|
||||
|
||||
### 2.1 客户端终端链路(两种能力通用)
|
||||
|
||||
无论一次性还是持久,shell 的建立过程完全相同:
|
||||
|
||||
```
|
||||
服务端 客户端
|
||||
│ COMMAND_SHELL(主连接) ──► 客户端创建 shell 子连接
|
||||
│ ◄── TOKEN_SHELL_START 或 TOKEN_TERMINAL_START(子连接)
|
||||
│ ↓ MessageHandle 顶部判定 IsTermPending → 交给 RegisterTerminalContext 接管
|
||||
│ COMMAND_NEXT(+ 若 PTY 先发 CMD_TERMINAL_RESIZE 80x24)──► 客户端启动 shell 输出回流
|
||||
│ ◄── 持续 shell 输出(TOKEN_TERMINAL_DATA 之类,经 IsTerminalContext 路由到 OnTerminalData)
|
||||
│ ◄── TOKEN_TERMINAL_CLOSE(shell 进程退出)→ OnTerminalClosed
|
||||
```
|
||||
|
||||
关键点:
|
||||
- **`COMMAND_NEXT` 不能漏发**——客户端读线程靠它才启动输出回流,漏发会导致 shell 在跑但输出永不送回(`RegisterTerminalContext` 内的注释明确强调)。
|
||||
- **PTY 需先告知初始尺寸 80×24**,否则 TUI 尺寸错乱。
|
||||
- `TOKEN_SHELL_START` 与 `TOKEN_TERMINAL_START` 二选一:老 `ShellManager`(cmd 管道)回前者,ConPTY 回后者;服务端在 `MessageHandle` 里据此判定 `isPty`。
|
||||
|
||||
### 2.2 编码
|
||||
|
||||
| isPty | 底层 | 服务端 cp |
|
||||
|---|---|---|
|
||||
| `true` | ConPTY | `CP_UTF8` |
|
||||
| `false` | 老 cmd 管道 | `936`(GBK) |
|
||||
|
||||
- **发送方向**:命令/哨兵行先 UTF-8 组好,再 `ToAnsi(line, cp)` 转成客户端编码,追加 `"\r\n"`。
|
||||
- **接收方向**:原始字节 `ToUtf8(raw, cp)` 转回 UTF-8。
|
||||
- 哨兵是纯 ASCII,两种编码下字节一致,编码无关。
|
||||
|
||||
### 2.3 哨兵机制
|
||||
|
||||
命令被包装成一行复合命令:
|
||||
|
||||
```
|
||||
[@echo off & ] <cmd> 2>&1 && echo __MCP_DONE_<nonce>__0 || echo __MCP_DONE_<nonce>__1
|
||||
```
|
||||
|
||||
- `@echo off & ` **仅 ConPTY 需要**(抑制 ConPTY 把输入整行回显)。老 `ShellManager` 已自行跳过回显,加 `@echo off` 反而破坏它的回显跳过逻辑。
|
||||
- `&&` / `||` 控制操作符取**命令真实退出码**(`%errorlevel%` 在复合句中解析期展开、已过期,故不能用 `%errorlevel%`)。
|
||||
- `<nonce>` 为 8 位随机 hex(`GenerateRandomToken().substr(0,8)`),保证命令真实输出不可能恰好包含完整哨兵串。
|
||||
- `2>&1` 让 stderr 也走哨兵链,stdout/stderr 都能被捕获且退出码正确。
|
||||
|
||||
**`FindSentinel`(检测)** 用 `rfind` **从后往前**找,且要求哨兵在**行首**(`p==0` 或前一个字节是 `\n`),后跟 `0` 或 `1`:
|
||||
|
||||
```cpp
|
||||
size_t from = npos;
|
||||
while (true) {
|
||||
size_t p = s.rfind(marker, from);
|
||||
bool lineStart = (p == 0) || (s[p-1] == '\n');
|
||||
if (lineStart && s[p+marker.size()] 是 '0' 或 '1') { exitCode=...; pos=p; return true; }
|
||||
from = p - 1;
|
||||
}
|
||||
```
|
||||
|
||||
为什么要「从后往前 + 行首」:ConPTY 会把**整条命令行回显**进输出流,回显里的哨兵字样嵌在 `&& echo ... || echo ...` 中间、前面是空格(非行首)。若用 `rfind` 直取,回显包先于真实输出到达时会命中回显里的 `__1` 提前结束、截断真实输出。行首校验能排除回显行。老 `ShellManager` 无回显,输出里只有真实哨兵(仍在行首),逻辑一致。
|
||||
|
||||
### 2.4 输出清洗管线
|
||||
|
||||
从原始字节到返回 `stdout` 字符串,固定五步(`CleanTerminalOutput`,一次性命令为内联等价逻辑):
|
||||
|
||||
```
|
||||
raw 字节
|
||||
→ ToUtf8(raw, cp) 按 2.2 编码解码
|
||||
→ StripAnsi(...) 剥 CSI/OSC/单字节 ESC,去彩色码
|
||||
→ CRLF → LF "\r\n" 归一为 "\n"
|
||||
→ StripEchoedCommand(stdout, sentLine) 剔 ConPTY 输入行回显(见下)
|
||||
→ TrimRight(...) 去尾部空白
|
||||
```
|
||||
|
||||
**`StripEchoedCommand`(回显剔除)**:ConPTY 的 `ENABLE_ECHO_INPUT` 会在 `@echo off` 生效**之前**把整条输入命令行回显进输出流。修复方法是在**服务端**把「刚发送的、含唯一 nonce 的完整命令行」(`SendTerminalCommandLine` 的返回值)从输出里整行 `find` + 剔除:
|
||||
|
||||
```cpp
|
||||
static std::string StripEchoedCommand(const std::string& s, const std::string& echoedLine) {
|
||||
if (echoedLine.empty() || s.empty()) return s;
|
||||
size_t p = s.find(echoedLine);
|
||||
if (p == npos) return s;
|
||||
size_t q = p + echoedLine.size();
|
||||
if (q < s.size() && s[q] == '\n') ++q; // CRLF 已归一为 \n
|
||||
return s.substr(0, p) + s.substr(q);
|
||||
}
|
||||
```
|
||||
|
||||
- 因 `echoedLine` 含随机 nonce,命令真实输出不可能恰好相同 → 不会误删合法输出。
|
||||
- 老 `ShellManager` 无回显,`find` 不到该行 → 无操作,安全。
|
||||
|
||||
### 2.5 状态机与互斥(两类能力共用)
|
||||
|
||||
```cpp
|
||||
struct TermSession {
|
||||
bool started; // false=已发 COMMAND_SHELL 待 START;true=已接管
|
||||
context* subCtx; // shell 子连接上下文
|
||||
bool isPty; // ConPTY(UTF-8) / cmd 管道(GBK)
|
||||
std::string command; // 原始 UTF-8 命令(审计用)
|
||||
std::string nonce; // 哨兵随机串
|
||||
std::vector<BYTE> data; // 收集的原始 shell 输出
|
||||
size_t sentPos; // 哨兵在 data 中的位置
|
||||
int exitCode; // 0/1/-1
|
||||
bool done; // 哨兵命中
|
||||
bool closed; // TOKEN_TERMINAL_CLOSE(进程退出)
|
||||
// ── 以下为持久终端新增 ──
|
||||
std::string sessionId; // 持久会话 token(一次性 exec 为空)
|
||||
bool persistent; // 是否持久会话
|
||||
bool busy; // 一条命令在飞(terminal_exec 复位 → WaitTermCommand 返回)
|
||||
time_t lastActiveAt; // idle 回收用(秒)
|
||||
};
|
||||
|
||||
std::mutex m_TermMutex; // 保护下面两个 map
|
||||
std::condition_variable m_TermCv;
|
||||
std::map<uint64_t, TermSession> m_TermSessions; // device_id → 会话
|
||||
std::map<context*, uint64_t> m_TermContextToDevice; // subCtx → device_id(顶部路由)
|
||||
```
|
||||
|
||||
- `m_TermContextToDevice` 是**反向索引**:`MessageHandle` 顶部用 `IsTerminalContext(subCtx)` 判定「这个子链接的输出是否该路由到 MCP 的 `OnTerminalData`,而不是打开 MFC 终端对话框」。
|
||||
- `httplib` 默认多线程处理请求 → **多个 `tools/call` 会并发**。所有会话状态都靠 `m_TermMutex` + `busy` 串行化,**不要假定请求串行**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 一次性命令 `exec_command`
|
||||
|
||||
### 3.1 安全门(顺序)
|
||||
|
||||
```
|
||||
ParseHostIdArg → -32602 参数非法
|
||||
FindMainContext → -32002 主机不存在/离线
|
||||
clientType==LNX/MAC → -32005 仅 Windows
|
||||
command 为空 → -32602
|
||||
mcp.IsReadonly() → -32006 只读模式默认开,需 McpReadonly=0
|
||||
含 &|<>^ 五元字符 → -32008 防注入绕过白名单
|
||||
含控制字符(\r\n 等 <0x20) → -32008 防换行拆分绕过白名单
|
||||
白名单前缀校验 → -32007 不在白名单
|
||||
```
|
||||
|
||||
- **白名单**:`IsCommandAllowed` 做「trim + ASCII 小写后前缀匹配」,且前缀后须为空/空格/制表符(防 `dirx` 误配 `dir`)。配置为空时回落到内置 `kDefaultCmdWhitelist`:
|
||||
```
|
||||
dir,type,cd,chdir,ver,hostname,whoami,where,tasklist,systeminfo,ipconfig,netstat,
|
||||
path,set,find,findstr,reg query,sc query,
|
||||
ping,tracert,nslookup,getmac,driverquery,query,gpresult,
|
||||
arp -a,route print,schtasks /query
|
||||
```
|
||||
- **元字符门 `&|<>^`** 是**安全门**(拒绝 shell 注入/命令拼接),比持久终端的 `&|` 更严。
|
||||
- **控制字符门**:同时拒绝所有 `<0x20` 的控制字符(`\r`/`\n` 等)。否则 `dir \n rd /s /q ...` 这种带内嵌换行的命令会通过「前缀 `dir` + 空格」的白名单校验,却在 `cmd.exe` 里被换行拆成两条独立命令、执行任意后续行——彻底绕过只读白名单。这是 2026-08-24 独立审查发现并修复的**安全漏洞**。
|
||||
|
||||
### 3.2 流程
|
||||
|
||||
```
|
||||
BeginTermPending(devId, command, nonce) → false 则 -32003(该 host 已有终端会话)
|
||||
ctx->Send2Client(COMMAND_SHELL) → 失败 ClearTermPending + -32004
|
||||
WaitTerminalReady(devId, subCtx, isPty, timeoutMs)
|
||||
→ 超时 -32001(内部已自清理)
|
||||
组哨兵行 Send2Client (见 §2.3)
|
||||
WaitTerminalDone(devId, raw, exitCode, closed, timeoutMs)
|
||||
→ 超时 -32001(内部已自清理 + 调用方 CancelIO)
|
||||
subCtx->CancelIO() ← 一次性:读完即关子链接,结束 shell
|
||||
清洗输出(§2.4)
|
||||
审计:PostMessageA(WM_SHOWERRORMSG, ..., "MCP命令执行")
|
||||
返回 { stdout, exit_code }
|
||||
```
|
||||
|
||||
### 3.3 `WaitTerminalDone` 三态(一次性专用)
|
||||
|
||||
- **哨兵命中(done)**:`out` = `data[0..sentPos)`(截断到哨兵前),`exitCode=0/1`,**擦除会话+路由**,返回 true。
|
||||
- **进程退出(closed)**:`out` = 全部 `data`(无哨兵),`exitCode=-1`,擦除会话+路由,返回 true。
|
||||
- **超时**:擦除会话+路由,返回 false(调用方 `CancelIO`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 持久终端 `terminal_open` / `terminal_exec` / `terminal_close`
|
||||
|
||||
### 4.1 工具契约
|
||||
|
||||
| 工具 | 入参(required) | 出参 |
|
||||
|---|---|---|
|
||||
| `terminal_open` | `id`(+`timeout_ms`) | `session_id`(32hex)、`is_pty`(bool) |
|
||||
| `terminal_exec` | `id`、`session_id`、`command`(+`timeout_ms`) | `stdout`、`exit_code` |
|
||||
| `terminal_close` | `id`、`session_id` | `closed`(bool,恒 true) |
|
||||
|
||||
`session_id` 由服务端 `GenerateRandomToken()` 生成(32 hex / 128 bit)。
|
||||
|
||||
### 4.2 共享前置 `ResolveTerminalSessionHost`
|
||||
|
||||
`terminal_open` / `terminal_exec` 复用(`terminal_close` 为幂等内联版,见 §4.5):
|
||||
|
||||
```
|
||||
IsTerminalEnabled() && !IsReadonly() → -32006(需 McpTerminal=1 且 McpReadonly=0)
|
||||
ParseHostIdArg → -32602
|
||||
FindMainContext → -32002 主机不存在/离线
|
||||
clientType==LNX/MAC → -32005 仅 Windows
|
||||
```
|
||||
|
||||
### 4.3 `terminal_open`
|
||||
|
||||
```
|
||||
SweepIdleTerminals(300) ← 先回收闲置会话
|
||||
ResolveTerminalSessionHost
|
||||
sessionId = GenerateRandomToken()
|
||||
BeginTermOpen(devId, sessionId) → false 则 -32003(单设备单终端)
|
||||
ctx->Send2Client(COMMAND_SHELL) → 失败 ClearTermPending + -32004
|
||||
WaitTerminalReady(...) → 超时 -32001(自清理)
|
||||
审计 "term-open"
|
||||
返回 { session_id, is_pty }
|
||||
```
|
||||
|
||||
**关键**:`WaitTerminalReady` 成功后**不擦除会话**(与一次性相反)——会话要留给后续 `terminal_exec`。此时 `started=true`、`busy=false`,shell 空闲运行。
|
||||
|
||||
### 4.4 `terminal_exec`
|
||||
|
||||
```
|
||||
SweepIdleTerminals(300)
|
||||
ResolveTerminalSessionHost
|
||||
session_id 为空 → -32602
|
||||
command 为空 → -32602
|
||||
command 含 & 或 | → -32008(正确性门,见 §4.6)
|
||||
nonce = 随机 8 hex
|
||||
BeginTermCommand(devId, sessionId, command, nonce, subCtx, isPty)
|
||||
→ false 则 -32002(不存在/不匹配/未就绪/忙碌/已关)
|
||||
sentLine = SendTerminalCommandLine(subCtx, isPty, command, nonce)
|
||||
WaitTermCommand(devId, raw, exitCode, closed, timeoutMs)
|
||||
→ 超时:subCtx->CancelIO() + -32001
|
||||
→ closed:subCtx->CancelIO()(会话已被清理,仅对称无害)
|
||||
stdoutStr = CleanTerminalOutput(raw, cp, sentLine)
|
||||
审计 "term-exec [sessionId]: command"
|
||||
返回 { stdout, exit_code }
|
||||
```
|
||||
|
||||
**`WaitTermCommand` 三态(持久专用,与一次性不同)**:
|
||||
|
||||
- **哨兵命中(done)**:`busy=false`、刷新 `lastActiveAt`、`out` 截到 `sentPos`、**保留会话**,返回 true。← 这是「持久」的核心:命令完成后 shell 不关。
|
||||
- **进程退出(closed)**:`out`=全部 `data`、`exitCode=-1`、**擦除会话+路由**,返回 true。
|
||||
- **超时**:擦除会话+路由,返回 false(调用方 `CancelIO`)。
|
||||
|
||||
### 4.5 `terminal_close`
|
||||
|
||||
```
|
||||
SweepIdleTerminals(300)
|
||||
IsTerminalEnabled() && !IsReadonly() → -32006
|
||||
ParseHostIdArg → -32602
|
||||
session_id 为空 → -32602
|
||||
CloseTermSession(devId, sessionId)
|
||||
r==2(sessionId 不匹配) → -32002(防拼错静默泄漏真会话)
|
||||
审计 "term-close"
|
||||
返回 { closed: true }
|
||||
```
|
||||
|
||||
`CloseTermSession` 返回值语义:**0**=已关闭;**1**=会话不存在(幂等,仍返回 `{closed:true}`);**2**=sessionId 不匹配(报错)。
|
||||
|
||||
### 4.6 正确性门 `&|`(非安全门)
|
||||
|
||||
`terminal_exec` **拒绝含 `&` 或 `|` 的命令**(`find_first_of("&|")`,含 `&&`/`||`/`|`),报 `-32008`。原因:哨兵包装 `cmd 2>&1 && echo ... || echo ...` 假定命令是「扁平」的,命令内含 `&`/`|` 会与包装的控制操作符合流、导致退出码失真/输出归属不清。
|
||||
|
||||
- 这是**正确性限制,不是白名单**——持久终端本就不设白名单。
|
||||
- 同样拒绝 `<0x20` 的控制字符(`\r`/`\n`):换行会把命令拆成多行、只有末行被哨兵包装,导致退出码与输出归属失真。
|
||||
- `> < ^`(重定向 / 转义)**允许**:不破坏哨兵链。AI 把链式/管道命令拆成多条 `terminal_exec` 调用即可(cwd/env 保持,等价)。
|
||||
|
||||
对比:一次性 `exec_command` 因有白名单,其元字符门更严(`&|<>^` 五元字符全拒,§3.1)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 状态机方法明细
|
||||
|
||||
### 5.1 一次性命令专用
|
||||
|
||||
| 方法 | 作用 |
|
||||
|---|---|
|
||||
| `BeginTermPending(devId, cmd, nonce)` | 锁内登记会话;已有会话则 false(单设备单终端)。 |
|
||||
| `WaitTerminalReady(devId, subCtx, isPty, timeout)` | 等 `started`;超时**擦除**并返回 false。成功输出 `subCtx`/`isPty`,**不擦**。 |
|
||||
| `WaitTerminalDone(...)` | 等 `done||closed`;三态见 §3.3。 |
|
||||
| `ClearTermPending(devId)` | 发送失败等提前退出路径的清理。 |
|
||||
| `IsTermPending(devId)` | `MessageHandle` 判定是否有待接管的终端会话。 |
|
||||
| `RegisterTerminalContext(devId, subCtx, isPty)` | 子连接就绪接管:置 `started/subCtx/isPty`、登记反向索引、发 `COMMAND_NEXT`(+PTY resize)、`notify_all`。 |
|
||||
| `IsTerminalContext(subCtx)` | `MessageHandle` 顶部路由判定。 |
|
||||
| `OnTerminalData(subCtx, data, len)` | 追加输出、`FindSentinel` 命中置 `done` + `notify`;`done||closed` 时忽略迟到数据。 |
|
||||
| `OnTerminalClosed(subCtx)` | 置 `closed`;空闲持久会话直接擦;`notify_all` 唤醒 busy 等待线程。 |
|
||||
|
||||
### 5.2 持久终端专用
|
||||
|
||||
| 方法 | 作用 |
|
||||
|---|---|
|
||||
| `BeginTermOpen(devId, sessionId)` | 锁内建持久会话(`persistent=true`、`lastActiveAt=now`);已有会话 false。 |
|
||||
| `BeginTermCommand(devId, sid, cmd, nonce, subCtx, isPty)` | 锁内校验「persistent && sid 匹配 && started && !busy && !closed」→ 置 `busy=true`、**原子复位** `data/sentPos/exitCode/done/closed`、更新 `command/nonce/lastActiveAt`。 |
|
||||
| `WaitTermCommand(...)` | 等 `done||closed`;三态见 §4.4。 |
|
||||
| `CloseTermSession(devId, sid)` | 不存在→1;sid 不匹配→2;`busy` 时仅置 `closed`+notify(交等待线程清理,防双重擦除);非 busy 直接擦+锁外 `CancelIO`。 |
|
||||
| `SweepIdleTerminals(idleTimeoutSec)` | 回收 `persistent && !busy && difftime(now,lastActiveAt)>idle` 的会话;收集后锁外 `CancelIO`。 |
|
||||
|
||||
### 5.3 `OnTerminalClosed` 的泄漏修复
|
||||
|
||||
`OnTerminalClosed` 有**两个入口**:
|
||||
|
||||
1. `MessageHandle` 的 `TOKEN_TERMINAL_CLOSE` 分支(客户端 shell 优雅退出时发 token)。
|
||||
2. `OfflineProc`(`2015RemoteDlg.cpp`)——连接**骤然断开**(网络断/客户端崩溃,无 token)时,与 `WebService().OnTerminalClosed` 并列调用 `McpServer().OnTerminalClosed`。此入口是 2026-08-24 独立审查发现缺失并补齐的:否则骤断的持久终端会话会在 `m_TermSessions`/`m_TermContextToDevice` 里留悬空 `subCtx`,连接池复用地地址后会误路由新连接、甚至 `CancelIO` 掉无关连接。
|
||||
|
||||
shell 进程退出时,若是一个**空闲持久会话**(`persistent && !busy && started`),没有等待线程会清理它,会泄漏死 `subCtx`。故:
|
||||
|
||||
```cpp
|
||||
s.closed = true;
|
||||
if (s.persistent && !s.busy && s.started) {
|
||||
m_TermContextToDevice.erase(subCtx); // 空闲持久会话:直接擦(无需 CancelIO,已死)
|
||||
m_TermSessions.erase(sit);
|
||||
}
|
||||
m_TermCv.notify_all(); // busy 场景唤醒等待线程由其清理;一次性 exec 路径不变
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 并发模型与不变量
|
||||
|
||||
### 6.1 关键不变量
|
||||
|
||||
- **`CancelIO` 一律在 `m_TermMutex` 之外**(所有路径:`BuildExecCommand`、`BuildTerminalExec`、`CloseTermSession`、`SweepIdleTerminals`)。这是硬约束——`CancelIO` 可能阻塞/回调,锁内调用会死锁。
|
||||
- **`wait_for` 期间不持有会被他人擦除的迭代器**:等待线程(`WaitTermCommand`)持 `it`,但**只有它自己**会在超时/closed 路径擦除该会话;`terminal_close`/`sweep` 因 `busy` 标志而**不会**擦除「正在等待的命令」的会话。这就是 `busy` 标志的核心作用。
|
||||
- **单设备单终端**:`terminal_*` 与 `exec_command` 因**共享 `m_TermSessions`** 而互斥(双向 `-32003`)。`BeginTermPending` 与 `BeginTermOpen` 都做「已有会话则 false」。
|
||||
|
||||
### 6.2 `busy` 标志生命周期
|
||||
|
||||
```
|
||||
terminal_exec: BeginTermCommand 置 busy=true
|
||||
Send2Client
|
||||
WaitTermCommand 返回时:
|
||||
done 路径 → busy=false(保留会话)
|
||||
closed 路径 → 会话被擦除
|
||||
超时路径 → 会话被擦除
|
||||
terminal_close(会话忙时)→ 仅置 closed=true + notify,不擦,交等待线程清理
|
||||
```
|
||||
|
||||
### 6.3 互斥矩阵
|
||||
|
||||
| | `exec_command` | `terminal_*` | `list_files`/`get_screenshot` 等 |
|
||||
|---|---|---|---|
|
||||
| `exec_command` | 互斥(-32003) | 互斥(-32003) | 无关(各用 `m_Pending`) |
|
||||
| `terminal_*` | 互斥 | 单设备单终端 | 无关 |
|
||||
|
||||
> Web 终端与 MCP 终端各用独立 map,理论上可同设备并存(既有现象,非本次范围)。
|
||||
|
||||
### 6.4 idle 回收语义
|
||||
|
||||
`SweepIdleTerminals(300)`:`lastActiveAt` 在 `BeginTermOpen` / `BeginTermCommand` **(命令开始时)**更新,**命令完成后不刷新**。因此「两次命令间隔 < 300s」的活跃会话安全;「一次长命令 + 长时间无新命令」的会话会在最后一次命令开始 300s 后被回收。回收是非惰性的——每次 `terminal_*` 入口先 sweep,所以空闲会话不会在无请求时被后台主动关闭(但也不占后台定时器资源)。用 `difftime` 比较防时钟回拨下溢。
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全模型
|
||||
|
||||
| 维度 | `exec_command` | 持久终端 |
|
||||
|---|---|---|
|
||||
| 开关 | `McpReadonly=0` | `McpTerminal=1` **且** `McpReadonly=0`(默认 `McpTerminal=0`) |
|
||||
| 白名单 | 有(内置/自定义前缀白名单) | **无**(完整 shell,可写命令、重定向、改环境) |
|
||||
| 元字符门 | `&|<>^` + 控制字符全拒(安全门) | `&|` + 换行拒(正确性门) |
|
||||
| 审计 | `WM_SHOWERRORMSG` 标题「MCP命令执行」 | 标题「MCP持久终端」(open/exec/close 各一条) |
|
||||
| 生命周期 | 命令结束即关 | 会话保持,闲置 300s 回收,`terminal_close` 显式关 |
|
||||
| 会话隔离 | 单设备单终端 | 单设备单终端 + `session_id` 校验防串用 |
|
||||
|
||||
审计实现:`PostMessageA(WM_SHOWERRORMSG, new CString(...), new CString(_TR("...标题...")))` → 进入服务端 `m_MessageLog`,`get_audit_log` 可查。审计不可关闭。
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置项
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `McpEnabled` | 0 | MCP 总开关 |
|
||||
| `McpPort` | 6544 | 监听端口 |
|
||||
| `McpBind` | 127.0.0.1 | 绑定地址(默认仅回环) |
|
||||
| `McpToken` | (空→运行时兜底) | Bearer token |
|
||||
| `McpReadonly` | 1 | 只读模式(禁 `exec_command` 与持久终端) |
|
||||
| `McpCmdWhitelist` | (空→内置白名单) | `exec_command` 白名单,逗号分隔 |
|
||||
| `McpTerminal` | 0 | 持久终端开关(要求 `McpReadonly=0`) |
|
||||
|
||||
**存储位置**:Release 版存**注册表** `HKCU\Software\YAMA\settings`(**不是** `settings.ini`;`settings.ini` 仅 Debug 版读取)。改动需**重启程序**生效。
|
||||
|
||||
**启动读取**(`2015RemoteDlg.cpp`):
|
||||
```cpp
|
||||
McpServer().SetReadonly(THIS_CFG.GetInt("settings", "McpReadonly", 1) != 0);
|
||||
McpServer().SetCmdWhitelist(THIS_CFG.GetStr("settings", "McpCmdWhitelist", ""));
|
||||
McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0);
|
||||
```
|
||||
|
||||
**工具可见性**:`terminal_*` 三个工具在 `tools/list` 里**仅在** `IsTerminalEnabled() && !IsReadonly()` 时出现(门控在 `BuildToolsListResult`)。故默认配置下 `tools/list` 看不到它们。
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误码
|
||||
|
||||
| 码 | 含义 | 触发点 |
|
||||
|---|---|---|
|
||||
| `-32001` | 超时 | `WaitTerminalReady`/`WaitTermCommand`/`WaitPending` 等超时 |
|
||||
| `-32002` | 主机/会话不存在 | `FindMainContext` 失败、`session_id` 不匹配/未就绪/已关 |
|
||||
| `-32003` | 设备忙 | 单设备单终端冲突、同 host 已有挂起请求 |
|
||||
| `-32004` | 发送失败 | `Send2Client` 失败 |
|
||||
| `-32005` | 非 Windows | LNX/MAC 客户端 |
|
||||
| `-32006` | 已禁用/只读 | `IsReadonly()` 或 `!IsTerminalEnabled()` |
|
||||
| `-32007` | 白名单拒绝 | `IsCommandAllowed` false |
|
||||
| `-32008` | 含禁用字符 | `exec_command` 的 `&|<>^`;`terminal_exec` 的 `&|` |
|
||||
| `-32602` | 参数非法 | `ParseHostIdArg` 失败、缺 required 参数 |
|
||||
| `-32700` | JSON 解析错误 | 请求体非合法 JSON-RPC |
|
||||
|
||||
---
|
||||
|
||||
## 10. 排查指南(供后续人员/大模型)
|
||||
|
||||
### 10.1 `exec_command` 返回 `-32001` 超时
|
||||
|
||||
- 先看是不是**目标机环境问题**而非代码 bug:`nslookup`/`where` 在部分机器会因 DNS/文件系统异常稳定超时(见项目记忆)。裸 `nslookup <host>` 卡默认重试 20~40s > MCP 超时;`where cmd.exe` 在特定机器卡 PATH 解析。
|
||||
- 服务端行为正确:超时返回 `-32001` 并清理终端会话,不挂起/崩溃。
|
||||
|
||||
### 10.2 `terminal_*` 在 `tools/list` 里看不到
|
||||
|
||||
- 检查 `McpTerminal=1` **且** `McpReadonly=0`。任一不满足,工具不出现且调用返回 `-32006`。
|
||||
- 改配置后需**重启程序**(注册表配置启动时读)。
|
||||
|
||||
### 10.3 `terminal_exec` 返回 `-32002`
|
||||
|
||||
- 会话已被回收(闲置 >300s)、shell 已退出、或 `session_id` 拼错。重新 `terminal_open`。
|
||||
- `terminal_close` 的 `-32002` 特指 **session_id 不匹配**(防串用)。
|
||||
|
||||
### 10.4 `-32003` 设备忙
|
||||
|
||||
- 单设备单终端:同 host 已有终端会话(持久或一次性)在飞。等它结束或 `terminal_close`。
|
||||
- 紧接 `terminal_close`(会话 busy 时)后立刻 `terminal_open` 可能偶发 `-32003`:busy 会话的关闭是「标记 closed + 交等待线程清理」,清理完成前有极短窗口。重试一次即可,非死锁。
|
||||
|
||||
### 10.5 stdout 里出现命令本身 / 哨兵
|
||||
|
||||
- 命令本身回显 = ConPTY 输入回显,已由 `StripEchoedCommand` 服务端剔除(§2.4)。若仍出现,检查是否该行因编码(非 PTY 老管道的 GBK)与 `echoedLine` 字节不一致而 `find` 不到——老管道本应无回显。
|
||||
- 哨兵 `__MCP_DONE_...` 出现 = 哨兵截断/清洗异常,通常意味着命令含 `&|` 破坏了控制操作符链(持久终端会提前 `-32008` 拒绝,一次性会 `-32008` 拒绝五元字符)。
|
||||
|
||||
### 10.6 中文/乱码
|
||||
|
||||
- 输出乱码:检查 `isPty` 与 `cp` 是否匹配(PTY=UTF-8,老管道=GBK)。
|
||||
- **`get_audit_log` 中文正确**:`BuildGetAuditLog` 用 `ToUtf8(type/time/msg, 936)` 把 GBK 转 UTF-8,jsoncpp 输出 `\uXXXX` 转义,客户端解析后中文正常(已实测 `MCP持久终端`/`MCP命令执行`/`操作成功`/`主机上线` 等均无乱码)。
|
||||
|
||||
### 10.7 会话泄漏 / 子链接不关
|
||||
|
||||
- 三个擦除点:`WaitTermCommand`(closed/超时)、`CloseTermSession`、`SweepIdleTerminals`;以及 `OnTerminalClosed` 对**空闲持久会话**的直接擦除(§5.3)。排查时确认 shell 退出路径 `OnTerminalClosed` 是否命中、`COMMAND_NEXT` 是否漏发导致输出永不回流。
|
||||
|
||||
---
|
||||
|
||||
## 11. 关键源文件索引
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `server/2015Remote/McpServer.h` | `TermSession` 结构、`BeginTermOpen/Command/WaitTermCommand/CloseTermSession/SweepIdleTerminals` 声明、`m_terminalEnabled` |
|
||||
| `server/2015Remote/McpServer.cpp` | `BuildExecCommand`(~1946)、`BuildTerminalOpen/Exec/Close`(~2345)、状态机方法(~2660)、`FindSentinel`(~2569)、`StripEchoedCommand`(~1935)、`SendTerminalCommandLine`(~2192)、`CleanTerminalOutput`(~2206)、`ResolveTerminalSessionHost`(~2160) |
|
||||
| `server/2015Remote/McpSettingsDlg.h/.cpp` | `IDC_MCP_TERMINAL` 复选框、回填/落盘 `McpTerminal` |
|
||||
| `server/2015Remote/2015RemoteDlg.cpp` | 启动读 `McpTerminal` → `SetTerminalEnabled` |
|
||||
|
||||
> 构建由 VS2019(MSBuild v143)完成;项目工具集 v142,无法在外部环境构建。
|
||||
Binary file not shown.
@@ -1689,8 +1689,12 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
|
||||
std::string msg = startTime;
|
||||
msg += "|" + std::to_string(ContextObject->GetClientID());
|
||||
auto signature = signMessage("", (BYTE*)msg.c_str(), msg.length());
|
||||
ASSERT(signature.size() <= sizeof(copy.Signature));
|
||||
memcpy(copy.Signature, signature.data(), signature.size());
|
||||
// 防御性边界保护:signMessage 正常返回 64 字节,但 ASSERT 在 Release 下是空操作,
|
||||
// 若签名意外超长,无界 memcpy 会越界写 MasterSettings.Signature[64]。这里强制截断。
|
||||
size_t sigLen = signature.size();
|
||||
if (sigLen > sizeof(copy.Signature))
|
||||
sigLen = sizeof(copy.Signature);
|
||||
memcpy(copy.Signature, signature.data(), sigLen);
|
||||
LeaveCriticalSection(&m_cs);
|
||||
Mprintf("主机[%s]上线: %s[%s][%s]\n", v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(),
|
||||
std::to_string(id).c_str(), loc, groupName.c_str());
|
||||
@@ -2190,6 +2194,15 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
||||
|
||||
McpServer().SetParentDlg(this);
|
||||
McpServer().SetToken(mcpToken);
|
||||
// 安全配置:只读默认 1(exec_command 默认禁用);白名单空则用内置只读前缀。
|
||||
McpServer().SetReadonly(THIS_CFG.GetInt("settings", "McpReadonly", 1) != 0);
|
||||
McpServer().SetCmdWhitelist(THIS_CFG.GetStr("settings", "McpCmdWhitelist", ""));
|
||||
// 持久终端开关:默认关;要求只读关(McpReadonly=0)才生效(工具列表/分派双重门控)。
|
||||
McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0);
|
||||
// 远程控制开关:默认关;要求只读关(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 {
|
||||
@@ -4033,8 +4046,13 @@ void CMy2015RemoteDlg::SortByColumn(int nColumn)
|
||||
if (ColumnToDataSlot(nColumn) < 0) return;
|
||||
|
||||
if (nColumn == m_nSortColumn) {
|
||||
// 如果点击的是同一列,切换排序顺序
|
||||
m_bSortAscending = !m_bSortAscending;
|
||||
// 同一列三态循环:升序 -> 降序 -> 恢复默认顺序 -> 升序 ...
|
||||
if (m_bSortAscending) {
|
||||
m_bSortAscending = false; // 升序 -> 降序
|
||||
} else {
|
||||
m_nSortColumn = -1; // 降序 -> 恢复默认(按上线先后)
|
||||
m_bSortAscending = true;
|
||||
}
|
||||
} else {
|
||||
// 否则,切换到新列并设置为升序
|
||||
m_nSortColumn = nColumn;
|
||||
@@ -4054,22 +4072,29 @@ void CMy2015RemoteDlg::SortByColumn(int nColumn)
|
||||
|
||||
void CMy2015RemoteDlg::ApplySort()
|
||||
{
|
||||
// 无有效排序偏好或列不合法(缩略图/越界)时保持列表原始顺序
|
||||
// 虚拟列表:对数据源进行排序,调用方需持有 m_cs
|
||||
if (m_nSortColumn <= COL_THUMBNAIL || m_nSortColumn >= g_Column_Count_Online) {
|
||||
return;
|
||||
// 无有效排序偏好(缩略图/越界/已清零):恢复默认顺序。
|
||||
// 默认顺序 = 上线先后 = 上线时间(OnlineTime)升序 = 在线时长(GetAliveTime)降序。
|
||||
std::sort(m_HostList.begin(), m_HostList.end(),
|
||||
[](context* a, context* b) {
|
||||
uint64_t ta = a ? a->GetAliveTime() : 0;
|
||||
uint64_t tb = b ? b->GetAliveTime() : 0;
|
||||
return ta > tb;
|
||||
});
|
||||
} else {
|
||||
// 按指定列排序(列号映射到底层数据槽)
|
||||
int slot = ColumnToDataSlot(m_nSortColumn);
|
||||
bool asc = m_bSortAscending;
|
||||
std::sort(m_HostList.begin(), m_HostList.end(),
|
||||
[slot, asc](context* a, context* b) {
|
||||
CString s1 = a ? a->GetClientData(slot) : "";
|
||||
CString s2 = b ? b->GetClientData(slot) : "";
|
||||
int result = s1.Compare(s2);
|
||||
return asc ? (result < 0) : (result > 0);
|
||||
});
|
||||
}
|
||||
|
||||
// 虚拟列表:对数据源进行排序(列号映射到底层数据槽),调用方需持有 m_cs
|
||||
int slot = ColumnToDataSlot(m_nSortColumn);
|
||||
bool asc = m_bSortAscending;
|
||||
std::sort(m_HostList.begin(), m_HostList.end(),
|
||||
[slot, asc](context* a, context* b) {
|
||||
CString s1 = a ? a->GetClientData(slot) : "";
|
||||
CString s2 = b ? b->GetClientData(slot) : "";
|
||||
int result = s1.Compare(s2);
|
||||
return asc ? (result < 0) : (result > 0);
|
||||
});
|
||||
|
||||
// 排序后 clientID -> 索引映射已改变,需重建
|
||||
m_ClientIndex.clear();
|
||||
for (size_t i = 0; i < m_HostList.size(); ++i) {
|
||||
@@ -4100,7 +4125,10 @@ void CMy2015RemoteDlg::LoadSortPreference()
|
||||
|
||||
void CMy2015RemoteDlg::SaveSortPreference()
|
||||
{
|
||||
std::string val = std::to_string(m_nSortColumn) + "," + (m_bSortAscending ? "1" : "0");
|
||||
// 清零(恢复默认顺序)时写入空值,等价于"无偏好",下次启动走 LoadSortPreference 的空分支
|
||||
std::string val = (m_nSortColumn >= 0)
|
||||
? std::to_string(m_nSortColumn) + "," + (m_bSortAscending ? "1" : "0")
|
||||
: "";
|
||||
THIS_CFG.SetStr("list", "OnlineListSort", val);
|
||||
}
|
||||
|
||||
@@ -5155,6 +5183,17 @@ BOOL CALLBACK CMy2015RemoteDlg::OfflineProc(CONTEXT_OBJECT* ContextObject)
|
||||
WebService().OnTerminalClosed(ContextObject);
|
||||
}
|
||||
|
||||
// MCP 终端(一次性 exec / 持久终端)的 shell 子上下文断开:同步清理会话,避免悬空 subCtx
|
||||
// 被连接池复用时误路由新连接(与 WebService 清理同理,需在 RemoveFromHostList 之前)。
|
||||
if (McpServer().IsRunning() && McpServer().IsTerminalContext(ContextObject)) {
|
||||
McpServer().OnTerminalClosed(ContextObject);
|
||||
}
|
||||
|
||||
// MCP 远程控制的屏幕子连接断开:同步清理会话,避免悬空 subCtx 被连接池复用误路由。
|
||||
if (McpServer().IsRunning() && McpServer().IsScreenCtrlContext(ContextObject)) {
|
||||
McpServer().OnScreenControlClosed(ContextObject);
|
||||
}
|
||||
|
||||
SOCKET nSocket = ContextObject->sClientSocket;
|
||||
|
||||
CDialogBase* p = (CDialogBase*)ContextObject->hDlg;
|
||||
@@ -5505,6 +5544,18 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== MCP exec_command 的 shell 子上下文:被 MCP 接管时,数据走 OnTerminalData =====
|
||||
// 与上方 Web 终端同构:MCP 接管的 shell 子上下文不开 MFC 对话框,hDlg 为 NULL,
|
||||
// 每个数据包都落到 MessageHandle 顶部分支,转发给 McpServer 做哨兵检测。
|
||||
if (McpServer().IsTerminalContext(ContextObject)) {
|
||||
if (len == 1 && cmd == TOKEN_TERMINAL_CLOSE) {
|
||||
McpServer().OnTerminalClosed(ContextObject);
|
||||
} else {
|
||||
McpServer().OnTerminalData(ContextObject, szBuffer, len);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 【L】:主机上下线和授权
|
||||
// 【x】:对话框相关功能
|
||||
switch (cmd) {
|
||||
@@ -5794,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) {
|
||||
@@ -6054,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);
|
||||
@@ -6234,7 +6300,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
case TOKEN_HEARTBEAT:
|
||||
case 137: // 心跳【L】
|
||||
ContextObject->HeartbeatRecvMs.store(GetUnixMs(), std::memory_order_relaxed);
|
||||
g_2015RemoteDlg->PostMessageA(WM_UPDATE_ACTIVEWND, 0, (LPARAM)ContextObject);
|
||||
// 为延迟消息占一个引用计数:RemoveStaleContext 会等 IoRefCount==0 才回收对象,
|
||||
// 否则这里经消息队列传出的裸指针在 UI 线程消费时可能已被替补抖动回收复用(use-after-free)。
|
||||
// UpdateUserEvent 处理完后配对 fetch_sub 释放。
|
||||
ContextObject->IoRefCount.fetch_add(1);
|
||||
if (!g_2015RemoteDlg->PostMessageA(WM_UPDATE_ACTIVEWND, 0, (LPARAM)ContextObject)) {
|
||||
ContextObject->IoRefCount.fetch_sub(1); // 投递失败,退回引用,避免泄漏
|
||||
}
|
||||
break;
|
||||
case TOKEN_SCREEN_PREVIEW_RSP: {
|
||||
// 屏幕预览响应:把整个包 + 来源 clientId 一并堆分配,转给主线程处理
|
||||
@@ -6381,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;
|
||||
@@ -6408,6 +6487,11 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
// hDlg 留 NULL:后续数据继续走 MessageHandle 顶部的 IsTerminalContext 分支
|
||||
break;
|
||||
}
|
||||
// MCP exec_command 触发的 shell 子上下文:交给 McpServer 接管(老 cmd 管道,GBK)。
|
||||
if (McpServer().IsTermPending(devId)) {
|
||||
McpServer().RegisterTerminalContext(devId, ContextObject, /*isPty*/false);
|
||||
break;
|
||||
}
|
||||
g_2015RemoteDlg->SendMessage(WM_OPENSHELLDIALOG, 0, (LPARAM)ContextObject);
|
||||
break;
|
||||
}
|
||||
@@ -6418,6 +6502,11 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
WebService().RegisterTerminalContext(devId, ContextObject, /*isPty*/true);
|
||||
break;
|
||||
}
|
||||
// MCP exec_command 触发的 PTY 子上下文:交给 McpServer 接管(ConPTY,UTF-8)。
|
||||
if (McpServer().IsTermPending(devId)) {
|
||||
McpServer().RegisterTerminalContext(devId, ContextObject, /*isPty*/true);
|
||||
break;
|
||||
}
|
||||
|
||||
// 三个前置条件,缺任何一个都回退到经典终端,并把原因贴到信息列表。
|
||||
// SYSTEM 场景:WebView2 不支持 LocalSystem token,会出现"窗口能弹但页面空白",
|
||||
@@ -6776,8 +6865,12 @@ bool CMy2015RemoteDlg::PromoteStandby(uint64_t clientID)
|
||||
std::string msg = startTime.GetString();
|
||||
msg += "|" + std::to_string(clientID);
|
||||
auto signature = signMessage("", (BYTE*)msg.c_str(), msg.length());
|
||||
ASSERT(signature.size() <= sizeof(copy.Signature));
|
||||
memcpy(copy.Signature, signature.data(), signature.size());
|
||||
// 防御性边界保护:signMessage 正常返回 64 字节,但 ASSERT 在 Release 下是空操作,
|
||||
// 若签名意外超长,无界 memcpy 会越界写 MasterSettings.Signature[64]。这里强制截断。
|
||||
size_t sigLen = signature.size();
|
||||
if (sigLen > sizeof(copy.Signature))
|
||||
sigLen = sizeof(copy.Signature);
|
||||
memcpy(copy.Signature, signature.data(), sigLen);
|
||||
SendMasterSettings(standby, copy);
|
||||
return true;
|
||||
}
|
||||
@@ -6834,7 +6927,12 @@ LRESULT CMy2015RemoteDlg::OnUserOfflineMsg(WPARAM wParam, LPARAM lParam)
|
||||
LRESULT CMy2015RemoteDlg::UpdateUserEvent(WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
CONTEXT_OBJECT* ctx = (CONTEXT_OBJECT*)lParam;
|
||||
UpdateActiveWindow(ctx);
|
||||
if (ctx) {
|
||||
UpdateActiveWindow(ctx);
|
||||
// 释放 TOKEN_HEARTBEAT 投递延迟消息时占用的引用计数(与那里的 fetch_add 配对)。
|
||||
// 必须在 UpdateActiveWindow 完全返回后再释放,保证整个处理期间对象不被回收。
|
||||
ctx->IoRefCount.fetch_sub(1);
|
||||
}
|
||||
|
||||
return S_OK;
|
||||
}
|
||||
@@ -7171,7 +7269,14 @@ void CMy2015RemoteDlg::UpdateActiveWindow(CONTEXT_OBJECT* ctx)
|
||||
HeartbeatACK ack = { hb.Time, (char)authStatus, (char)isTrail };
|
||||
if (authorized) {
|
||||
std::string authorization = isV2 ? LoadLicenseAuthorization(hb.SN) : BuildV1Authorization(hb.SN, true);
|
||||
memcpy(ack.Authorization, authorization.c_str(), authorization.length());
|
||||
// 防御性边界保护:正常 authorization 约 150~200 字节;一旦异常超长,无界 memcpy
|
||||
// 会越界写 HeartbeatACK.Authorization[200] 并踩栈金丝雀(0xC0000409)。
|
||||
// Release 下强制截断并保证 NUL 结尾。
|
||||
size_t authLen = authorization.length();
|
||||
if (authLen >= sizeof(ack.Authorization))
|
||||
authLen = sizeof(ack.Authorization) - 1;
|
||||
memcpy(ack.Authorization, authorization.c_str(), authLen);
|
||||
ack.Authorization[authLen] = '\0';
|
||||
}
|
||||
// 在 send 前一刻填进处理耗时(毫秒)。GetUnixMs 底层是 chrono::system_clock,
|
||||
// 在 VS2019+ MSVC 上精度亚微秒(截断到 ms),两次作差误差 ≤ 1ms,能准确捕获
|
||||
|
||||
@@ -173,7 +173,7 @@ protected:
|
||||
DECLARE_MESSAGE_MAP()
|
||||
public:
|
||||
void SortByColumn(int nColumn);
|
||||
void ApplySort(); // 按偏好重排 m_HostList(调用方需持有 m_cs)
|
||||
void ApplySort(); // 按偏好重排 m_HostList;无偏好时恢复默认顺序(上线先后)(调用方需持有 m_cs)
|
||||
void LoadSortPreference();
|
||||
void SaveSortPreference();
|
||||
afx_msg VOID OnHdnItemclickList(NMHDR* pNMHDR, LRESULT* pResult);
|
||||
|
||||
@@ -518,6 +518,7 @@
|
||||
<Image Include="res\Bitmap\AuthGen.bmp" />
|
||||
<Image Include="res\Bitmap\authorize.bmp" />
|
||||
<Image Include="res\Bitmap\Backup.bmp" />
|
||||
<Image Include="res\Bitmap\BrowseWnd.bmp" />
|
||||
<Image Include="res\Bitmap\CancelShare.bmp" />
|
||||
<Image Include="res\Bitmap\ClientLog.bmp" />
|
||||
<Image Include="res\Bitmap\Forbidden.bmp" />
|
||||
@@ -534,6 +535,7 @@
|
||||
<Image Include="res\Bitmap\GrayDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\Help.bmp" />
|
||||
<Image Include="res\Bitmap\History.bmp" />
|
||||
<Image Include="res\Bitmap\HistoryActivity.bmp" />
|
||||
<Image Include="res\Bitmap\HostProxy.bmp" />
|
||||
<Image Include="res\Bitmap\Import.bmp" />
|
||||
<Image Include="res\Bitmap\ImportLicense.bmp" />
|
||||
@@ -552,6 +554,7 @@
|
||||
<Image Include="res\Bitmap\PEEdit.bmp" />
|
||||
<Image Include="res\Bitmap\Plugin.bmp" />
|
||||
<Image Include="res\Bitmap\PluginConfig.bmp" />
|
||||
<Image Include="res\Bitmap\PortProxyAuto.bmp" />
|
||||
<Image Include="res\Bitmap\PortProxyStd.bmp" />
|
||||
<Image Include="res\Bitmap\PrivateScreen.bmp" />
|
||||
<Image Include="res\Bitmap\proxy.bmp" />
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
<ClCompile Include="McpServer.cpp" />
|
||||
<ClCompile Include="McpSettingsDlg.cpp" />
|
||||
<ClCompile Include="CClientLog.cpp" />
|
||||
<ClCompile Include="CActivityDialog.cpp" />
|
||||
<ClCompile Include="LogSearchBar.cpp" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="..\..\client\Audio.h" />
|
||||
@@ -196,6 +198,8 @@
|
||||
<ClInclude Include="McpServer.h" />
|
||||
<ClInclude Include="McpSettingsDlg.h" />
|
||||
<ClInclude Include="CClientLog.h" />
|
||||
<ClInclude Include="CActivityDialog.h" />
|
||||
<ClInclude Include="LogSearchBar.h" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ResourceCompile Include="2015Remote.rc" />
|
||||
@@ -338,5 +342,9 @@
|
||||
<Image Include="res\Bitmap\Debug.bmp" />
|
||||
<Image Include="res\ClientLog.ico" />
|
||||
<Image Include="res\Bitmap\ClientLog.bmp" />
|
||||
<Image Include="res\Bitmap\Forbidden.bmp" />
|
||||
<Image Include="res\Bitmap\HistoryActivity.bmp" />
|
||||
<Image Include="res\Bitmap\BrowseWnd.bmp" />
|
||||
<Image Include="res\Bitmap\PortProxyAuto.bmp" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,8 +8,10 @@
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
|
||||
// httplib 与 Windows 头部的 min/max 宏冲突,按 file_server.h 的既有约定处理。
|
||||
#undef min
|
||||
@@ -27,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。
|
||||
@@ -98,6 +108,146 @@ public:
|
||||
bool WaitPendingRegistry(uint64_t device_id, std::vector<BYTE>& subkeys,
|
||||
std::vector<BYTE>& values, int timeoutMs);
|
||||
|
||||
// ===== P3:exec_command(Windows 一次性远程命令,复刻 Web 终端链路)=====
|
||||
// 流程:工具线程 BeginTermPending → 主连接下发 COMMAND_SHELL → 客户端 shell 子连接回
|
||||
// TOKEN_TERMINAL_START / TOKEN_SHELL_START → RegisterTerminalContext 接管 → 发
|
||||
// COMMAND_NEXT(+PTY resize) 启动输出回流 → 工具线程发哨兵命令行 → OnTerminalData 收集
|
||||
// 并做哨兵检测 → WaitTerminalDone 取 stdout + exit_code → CancelIO 关子链接。
|
||||
// 单设备单终端(MVP):BeginTermPending 命中已有会话即拒绝,避免同设备并发归属歧义。
|
||||
|
||||
// 该 host 是否已有待接管的终端会话(MessageHandle TOKEN_*_START 分支调用)。
|
||||
bool IsTermPending(uint64_t device_id);
|
||||
|
||||
// 终端子连接就绪:接管(登记 subCtx/isPty、清 pending、发 COMMAND_NEXT 唤醒回流)。
|
||||
void RegisterTerminalContext(uint64_t device_id, context* subCtx, bool isPty);
|
||||
|
||||
// 终端子连接是否已被 MCP 接管(MessageHandle 顶部据此把 shell 输出路由到 OnTerminalData)。
|
||||
bool IsTerminalContext(context* subCtx);
|
||||
|
||||
// 追加 shell 输出并做哨兵检测(命中置 done 并唤醒等待者)。
|
||||
void OnTerminalData(context* subCtx, const BYTE* data, ULONG len);
|
||||
|
||||
// shell 进程退出(TOKEN_TERMINAL_CLOSE)。
|
||||
void OnTerminalClosed(context* subCtx);
|
||||
|
||||
// 工具线程:登记终端挂起(false = 该 host 已有终端会话)。
|
||||
bool BeginTermPending(uint64_t device_id, const std::string& command, const std::string& nonce);
|
||||
|
||||
// 工具线程:等待子连接回 START 并接管(true = 已接管,输出 subCtx/isPty)。
|
||||
bool WaitTerminalReady(uint64_t device_id, context*& subCtx, bool& isPty, int timeoutMs);
|
||||
|
||||
// 工具线程:等待输出收集完成(哨兵命中 / 进程退出 / 超时)。
|
||||
// 成功 true:out=截断到哨兵前的原始字节、exitCode=0/1(进程退出无哨兵时 -1)、closed=是否进程退出。
|
||||
bool WaitTerminalDone(uint64_t device_id, std::vector<BYTE>& out, int& exitCode, bool& closed, int timeoutMs);
|
||||
|
||||
// 工具线程:清理终端挂起(发送失败等提前退出路径)。
|
||||
void ClearTermPending(uint64_t device_id);
|
||||
|
||||
// ===== P4:持久远程终端(terminal_open / terminal_exec / terminal_close)=====
|
||||
void SetTerminalEnabled(bool enabled) { m_terminalEnabled = enabled; }
|
||||
bool IsTerminalEnabled() const { return m_terminalEnabled; }
|
||||
|
||||
// 登记持久终端挂起(复用单设备单终端约束;sessionId 由调用方生成)。
|
||||
bool BeginTermOpen(uint64_t device_id, const std::string& sessionId);
|
||||
|
||||
// 复位单条命令字段并置 busy;输出 subCtx/isPty。false = 不存在/不匹配/未就绪/忙碌/已关。
|
||||
bool BeginTermCommand(uint64_t device_id, const std::string& sessionId,
|
||||
const std::string& command, const std::string& nonce,
|
||||
context*& subCtx, bool& isPty);
|
||||
|
||||
// 等待本条命令完成(done || closed || 超时)。done=保持会话;closed/超时=清理会话+路由。
|
||||
bool WaitTermCommand(uint64_t device_id, std::vector<BYTE>& out, int& exitCode,
|
||||
bool& closed, int timeoutMs);
|
||||
|
||||
// 关闭持久终端(校验 sessionId;CancelIO + 清理路由/会话)。
|
||||
// 返回:0=已关闭;1=会话不存在(幂等);2=sessionId 不匹配(调用方报错)。
|
||||
int CloseTermSession(uint64_t device_id, const std::string& sessionId);
|
||||
|
||||
// idle 回收:关闭 lastActiveAt 超时且非 busy 的持久会话。返回回收数量。
|
||||
int SweepIdleTerminals(time_t idleTimeoutSec);
|
||||
|
||||
// ===== P5:MCP 远程控制(remote_open / remote_close / remote_mouse / remote_keyboard)=====
|
||||
// 屏幕子连接由 WebService::StartRemoteDesktop 建立的隐藏 CScreenSpyDlg 持有(复用 Web
|
||||
// 路径,见 docs/Mcp_RemoteControl_Design.md §6.3);会话仅记录逻辑状态 + 子连接指针。
|
||||
// 注入走该子连接(COMMAND_SCREEN_CONTROL),与终端一样单设备单会话。
|
||||
void SetRemoteControlEnabled(bool enabled) { m_remoteControlEnabled = enabled; }
|
||||
bool IsRemoteControlEnabled() const { return m_remoteControlEnabled; }
|
||||
|
||||
// 该 host 的屏幕子连接是否被 MCP 远程控制会话持有(OfflineProc 反查用)。
|
||||
bool IsScreenCtrlContext(context* subCtx);
|
||||
|
||||
// 登记控制会话(单设备单会话;sessionId 由调用方生成)。false = 该设备已有会话。
|
||||
bool BeginScreenCtrlOpen(uint64_t device_id, const std::string& sessionId);
|
||||
|
||||
// 子连接就绪(HasActiveSession && 分辨率到达)后调用:填 subCtx/分辨率并置 started。
|
||||
// false = 会话已被并发关闭(调用方应放弃本次 open)。
|
||||
bool MarkScreenCtrlReady(uint64_t device_id, const std::string& sessionId,
|
||||
context* subCtx, int screenW, int screenH);
|
||||
|
||||
// 注入前登记(校验 sessionId/started/!busy/!closed)。输出 subCtx/screenW/H。
|
||||
// 注入期间 busy=true,保证 close/sweep/断线不与注入并发擦会话(镜像终端 busy 模式,
|
||||
// 评审发现 #6)。返回:0=ok;1=不存在/不匹配/未就绪/已断(-32002);2=忙(-32003)。
|
||||
int BeginScreenCtrlAction(uint64_t device_id, const std::string& sessionId,
|
||||
context*& subCtx, int& screenW, int& screenH);
|
||||
|
||||
// 注入后复位 busy;若注入期间屏幕子连接已断(closed),擦会话+路由(防泄漏)。
|
||||
void EndScreenCtrlAction(uint64_t device_id, const std::string& sessionId);
|
||||
|
||||
// 关闭控制会话(校验 sessionId;擦路由 + 会话)。屏幕子连接的关闭由调用方锁外执行
|
||||
// (CloseWebRemoteDesktopByClientID)。返回 0=已关;1=不存在(幂等);2=sessionId 不匹配。
|
||||
int CloseScreenCtrlSession(uint64_t device_id, const std::string& sessionId);
|
||||
|
||||
// idle 回收:关闭 lastActiveAt 超时的控制会话,并锁外关闭其隐藏对话框。返回回收数量。
|
||||
int SweepIdleScreenCtrl(time_t idleTimeoutSec);
|
||||
|
||||
// 屏幕子连接断开(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);
|
||||
|
||||
// 登记上传会话(false = 该 host 已有文件会话或一次性挂起请求)。upload 走主连接、
|
||||
// 工具线程同步驱动 FileBatchTransferWorkerV2,会话仅为单设备单传输互斥标记
|
||||
// (无 fmSubCtx/streamSubCtx,ClearFileTransfer 对空指针安全)。
|
||||
bool BeginFileUpload(uint64_t device_id);
|
||||
|
||||
// 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; }
|
||||
bool IsReadonly() const { return m_readonly; }
|
||||
const std::string& GetCmdWhitelist() const { return m_cmdWhitelist; }
|
||||
|
||||
private:
|
||||
CMcpServer();
|
||||
~CMcpServer();
|
||||
@@ -131,6 +281,73 @@ private:
|
||||
|
||||
// get_screenshot 的 reqId 发生器(16 位,跳过 0)。与 MFC 预览的 m_PreviewReqId 各自独立计数。
|
||||
std::atomic<uint16_t> m_PreviewReqId{1};
|
||||
|
||||
// ===== P3:exec_command 终端会话(受 m_TermMutex 保护)=====
|
||||
struct TermSession {
|
||||
bool started = false; // false=已发 COMMAND_SHELL 待 START;true=已接管
|
||||
context* subCtx = nullptr; // shell 子连接上下文
|
||||
bool isPty = false; // true=ConPTY(UTF-8);false=老 cmd 管道(GBK)
|
||||
std::string command; // 原始 UTF-8 命令(审计用)
|
||||
std::string nonce; // 哨兵随机串
|
||||
std::vector<BYTE> data; // 收集的原始 shell 输出
|
||||
size_t sentPos = 0; // 哨兵在 data 中的位置
|
||||
int exitCode = -1;
|
||||
bool done = false; // 哨兵命中
|
||||
bool closed = false; // TOKEN_TERMINAL_CLOSE(进程退出)
|
||||
// ===== 新增(持久终端)=====
|
||||
std::string sessionId; // 持久会话 token(一次性 exec 为空)
|
||||
bool persistent = false;
|
||||
bool busy = false; // 一条命令在飞(terminal_exec 复位→WaitTermCommand 返回)
|
||||
time_t lastActiveAt = 0; // idle 回收用(秒)
|
||||
};
|
||||
std::mutex m_TermMutex;
|
||||
std::condition_variable m_TermCv;
|
||||
std::map<uint64_t, TermSession> m_TermSessions; // device_id → 会话
|
||||
std::map<context*, uint64_t> m_TermContextToDevice; // subCtx → device_id(顶部路由)
|
||||
|
||||
// ===== P5:远程控制会话(受 m_ScreenCtrlMutex 保护;单设备单会话)=====
|
||||
struct ScreenCtrlSession {
|
||||
std::string sessionId; // 会话 token(每次 open 独立随机)
|
||||
context* subCtx = nullptr; // 屏幕子连接上下文(就绪后填;用于注入)
|
||||
bool started = false; // false=子连接建立中;true=已就绪
|
||||
bool busy = false; // 一条注入在飞(BeginScreenCtrlAction→End 之间)
|
||||
bool closed = false; // 注入期间屏幕子连接已断(由注入线程收尾)
|
||||
time_t lastActiveAt = 0; // idle 回收用(秒)
|
||||
int screenW = 0; // 物理捕获分辨率(来自 TOKEN_BITMAPINFO)
|
||||
int screenH = 0;
|
||||
};
|
||||
std::mutex m_ScreenCtrlMutex;
|
||||
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" / "upload_file"
|
||||
std::string localDir; // download:本机保存目录(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 末尾)
|
||||
|
||||
@@ -46,6 +46,52 @@ void BuildDialogTemplate(std::vector<BYTE>& out, LPCWSTR caption, short cx, shor
|
||||
while (out.size() % 4) out.push_back(0); // DWORD align
|
||||
}
|
||||
|
||||
// 把白名单文本规范化为「逗号分隔、逐项去首尾空白、去空项」的存储格式。
|
||||
// 换行视作分隔符(与逗号等价),便于多行输入。
|
||||
std::string NormalizeWhitelist(const std::string& raw)
|
||||
{
|
||||
std::string norm;
|
||||
norm.reserve(raw.size());
|
||||
for (char ch : raw)
|
||||
norm += (ch == '\r' || ch == '\n') ? ',' : ch;
|
||||
|
||||
std::vector<std::string> items;
|
||||
size_t start = 0;
|
||||
while (start < norm.size()) {
|
||||
size_t comma = norm.find(',', start);
|
||||
std::string item = norm.substr(start, comma == std::string::npos ? std::string::npos : comma - start);
|
||||
size_t b = item.find_first_not_of(" \t\r\n");
|
||||
if (b != std::string::npos) {
|
||||
size_t e = item.find_last_not_of(" \t\r\n");
|
||||
item = item.substr(b, e - b + 1);
|
||||
} else {
|
||||
item.clear();
|
||||
}
|
||||
if (!item.empty()) items.push_back(item);
|
||||
if (comma == std::string::npos) break;
|
||||
start = comma + 1;
|
||||
}
|
||||
|
||||
std::string out;
|
||||
for (size_t i = 0; i < items.size(); ++i) {
|
||||
if (i) out += ",";
|
||||
out += items[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 存储格式(逗号分隔)→ 多行展示(每行一条)。
|
||||
std::string WhitelistForDisplay(const std::string& stored)
|
||||
{
|
||||
std::string disp;
|
||||
disp.reserve(stored.size());
|
||||
for (char ch : stored) {
|
||||
if (ch == ',') disp += "\r\n";
|
||||
else disp += ch;
|
||||
}
|
||||
return disp;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BEGIN_MESSAGE_MAP(CMcpSettingsDlg, CDialog)
|
||||
@@ -60,7 +106,7 @@ INT_PTR CMcpSettingsDlg::DoModal()
|
||||
{
|
||||
USES_CONVERSION;
|
||||
CString title = _TR("MCP设置");
|
||||
BuildDialogTemplate(m_Template, T2CW(title), 320, 150);
|
||||
BuildDialogTemplate(m_Template, T2CW(title), 320, 420);
|
||||
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
|
||||
return CDialog::DoModal();
|
||||
}
|
||||
@@ -87,6 +133,22 @@ BOOL CMcpSettingsDlg::OnInitDialog()
|
||||
m_lblToken.Create(_TR("Token"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
|
||||
m_editToken.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL,
|
||||
r0, this, IDC_MCP_TOKEN);
|
||||
m_btnReadonly.Create(_TR("只读模式(禁命令执行)"),
|
||||
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
|
||||
r0, this, IDC_MCP_READONLY);
|
||||
m_btnTerminal.Create(_TR("启用持久终端(全命令,无白名单)"),
|
||||
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
|
||||
r0, this, IDC_MCP_TERMINAL);
|
||||
m_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,
|
||||
r0, this, IDC_MCP_WHITELIST);
|
||||
m_btnOK.Create(_TR("确定"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON,
|
||||
r0, this, IDOK);
|
||||
m_btnCancel.Create(_TR("取消"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
|
||||
@@ -102,6 +164,12 @@ BOOL CMcpSettingsDlg::OnInitDialog()
|
||||
m_editBind.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||
m_lblToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||
m_editToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||
m_btnReadonly.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||
m_btnTerminal.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||
m_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));
|
||||
m_btnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||
}
|
||||
@@ -112,11 +180,22 @@ BOOL CMcpSettingsDlg::OnInitDialog()
|
||||
std::string bind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
|
||||
std::string tok = THIS_CFG.GetStr("settings", "McpToken", "");
|
||||
if (tok.empty()) tok = GenerateRandomToken();
|
||||
int readonly = THIS_CFG.GetInt("settings", "McpReadonly", 1);
|
||||
int terminal = THIS_CFG.GetInt("settings", "McpTerminal", 0);
|
||||
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);
|
||||
m_editPort.SetWindowText(CString(std::to_string(port).c_str()));
|
||||
m_editBind.SetWindowText(CString(bind.c_str()));
|
||||
m_editToken.SetWindowText(CString(tok.c_str()));
|
||||
m_btnReadonly.SetCheck(readonly ? BST_CHECKED : BST_UNCHECKED);
|
||||
m_btnTerminal.SetCheck(terminal ? BST_CHECKED : BST_UNCHECKED);
|
||||
m_btnRemoteControl.SetCheck(remoteControl ? BST_CHECKED : BST_UNCHECKED);
|
||||
m_btnFileTransfer.SetCheck(fileTransfer ? BST_CHECKED : BST_UNCHECKED);
|
||||
// 白名单存储为逗号分隔,展示为每行一条。
|
||||
m_editWhitelist.SetWindowText(CString(WhitelistForDisplay(whitelist).c_str()));
|
||||
|
||||
LayoutControls(cli.Width(), cli.Height());
|
||||
return TRUE;
|
||||
@@ -124,11 +203,16 @@ BOOL CMcpSettingsDlg::OnInitDialog()
|
||||
|
||||
void CMcpSettingsDlg::OnOK()
|
||||
{
|
||||
CString sPort, sBind, sToken;
|
||||
CString sPort, sBind, sToken, sWhitelist;
|
||||
m_editPort.GetWindowText(sPort);
|
||||
m_editBind.GetWindowText(sBind);
|
||||
m_editToken.GetWindowText(sToken);
|
||||
m_editWhitelist.GetWindowText(sWhitelist);
|
||||
bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED);
|
||||
bool readonly = (m_btnReadonly.GetCheck() == BST_CHECKED);
|
||||
bool terminal = (m_btnTerminal.GetCheck() == BST_CHECKED);
|
||||
bool remoteControl = (m_btnRemoteControl.GetCheck() == BST_CHECKED);
|
||||
bool fileTransfer = (m_btnFileTransfer.GetCheck() == BST_CHECKED);
|
||||
|
||||
// 端口校验:1-65535
|
||||
int port = atoi(CT2A(sPort));
|
||||
@@ -151,10 +235,18 @@ void CMcpSettingsDlg::OnOK()
|
||||
THIS_CFG.SetInt("settings", "McpPort", port);
|
||||
THIS_CFG.SetStr("settings", "McpBind", bind);
|
||||
THIS_CFG.SetStr("settings", "McpToken", token);
|
||||
THIS_CFG.SetInt("settings", "McpReadonly", readonly ? 1 : 0);
|
||||
THIS_CFG.SetInt("settings", "McpTerminal", terminal ? 1 : 0);
|
||||
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 的改动需重启程序生效。"),
|
||||
_TR("启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制/文件传输的改动需重启程序生效。") + _T("\r\n") +
|
||||
_TR("持久终端与远程控制仅在只读模式关闭时生效。"),
|
||||
_TR("提示"), MB_ICONINFORMATION);
|
||||
|
||||
CDialog::OnOK();
|
||||
@@ -185,7 +277,34 @@ void CMcpSettingsDlg::LayoutControls(int cx, int cy)
|
||||
m_editToken.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, rowH);
|
||||
y += rowH + gap;
|
||||
|
||||
int bottomY = cy - margin - btnH;
|
||||
m_btnCancel.MoveWindow(cx - margin - btnW, bottomY, btnW, btnH);
|
||||
m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, bottomY, btnW, btnH);
|
||||
m_btnReadonly.MoveWindow(margin, y, cx - margin * 2, 22);
|
||||
y += 30;
|
||||
|
||||
m_btnTerminal.MoveWindow(margin, y, cx - margin * 2, 22);
|
||||
y += 30;
|
||||
|
||||
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);
|
||||
y += whitelistH + gap;
|
||||
|
||||
// 确定 / 取消按钮紧跟白名单下方,不再锚定到底部,避免白名单与按钮之间出现大段空白。
|
||||
m_btnCancel.MoveWindow(cx - margin - btnW, y, btnW, btnH);
|
||||
m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, y, btnW, btnH);
|
||||
y += btnH + margin;
|
||||
|
||||
// 收缩对话框到内容实际高度并重新居中,消除底部空白。
|
||||
if (y < cy) {
|
||||
CRect wnd;
|
||||
GetWindowRect(&wnd);
|
||||
SetWindowPos(NULL, 0, 0, wnd.Width(), y + (wnd.Height() - cy),
|
||||
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
CenterWindow();
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,22 @@ private:
|
||||
IDC_MCP_PORT = 1002, // 端口编辑框
|
||||
IDC_MCP_BIND = 1003, // 绑定地址编辑框
|
||||
IDC_MCP_TOKEN = 1004, // Token 编辑框
|
||||
IDC_MCP_READONLY = 1005, // 「只读模式」复选框(默认勾选,禁 exec_command)
|
||||
IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀)
|
||||
IDC_MCP_TERMINAL = 1007, // 「启用持久终端」复选框(全命令,无白名单,要求只读关)
|
||||
IDC_MCP_REMOTECONTROL = 1008, // 「启用远程控制」复选框(AI 操控桌面,要求只读关)
|
||||
IDC_MCP_FILETRANSFER = 1009, // 「启用文件传输」复选框(download_file,不要求只读关)
|
||||
};
|
||||
|
||||
CButton m_btnEnable;
|
||||
CStatic m_lblPort, m_lblBind, m_lblToken;
|
||||
CEdit m_editPort, m_editBind, m_editToken;
|
||||
CButton m_btnReadonly;
|
||||
CButton m_btnTerminal;
|
||||
CButton m_btnRemoteControl;
|
||||
CButton m_btnFileTransfer;
|
||||
CStatic m_lblWhitelist;
|
||||
CEdit m_editWhitelist;
|
||||
CButton m_btnOK, m_btnCancel;
|
||||
|
||||
std::vector<BYTE> m_Template; // 内存 DLGTEMPLATE 字节
|
||||
|
||||
@@ -541,6 +541,7 @@ void CSystemDlg::OnWlistHide()
|
||||
lpMsgBuf[0]=CMD_WINDOW_TEST; //窗口处理数据头
|
||||
DWORD hwnd = data->ID; //得到窗口的句柄一同发送
|
||||
pListCtrl->SetItemText(nItem,2,"hidden"); //注意这时将列表中的显示状态为"隐藏"
|
||||
data->Data[2] = "hidden"; // 同步 ItemData,避免查看/排序读到旧状态
|
||||
//这样在删除列表条目时就不删除该项了 如果删除该项窗口句柄会丢失 就永远也不能显示了
|
||||
memcpy(lpMsgBuf+1,&hwnd,sizeof(DWORD)); //得到窗口的句柄一同发送
|
||||
DWORD dHow=SW_HIDE; //窗口处理参数 0
|
||||
@@ -564,6 +565,7 @@ void CSystemDlg::OnWlistRecover()
|
||||
lpMsgBuf[0]= CMD_WINDOW_TEST;
|
||||
DWORD hwnd = data->ID;
|
||||
pListCtrl->SetItemText(nItem,2,"normal");
|
||||
data->Data[2] = "normal"; // 同步 ItemData,避免查看/排序读到旧状态
|
||||
memcpy(lpMsgBuf+1,&hwnd,sizeof(DWORD));
|
||||
DWORD dHow=SW_NORMAL;
|
||||
memcpy(lpMsgBuf+1+sizeof(hwnd),&dHow,sizeof(DWORD));
|
||||
@@ -586,6 +588,7 @@ void CSystemDlg::OnWlistMax()
|
||||
lpMsgBuf[0]= CMD_WINDOW_TEST;
|
||||
DWORD hwnd = data->ID;
|
||||
pListCtrl->SetItemText(nItem,2,"maximized");
|
||||
data->Data[2] = "maximized"; // 同步 ItemData,避免查看/排序读到旧状态
|
||||
memcpy(lpMsgBuf+1,&hwnd,sizeof(DWORD));
|
||||
DWORD dHow=SW_MAXIMIZE;
|
||||
memcpy(lpMsgBuf+1+sizeof(hwnd),&dHow,sizeof(DWORD));
|
||||
@@ -608,6 +611,7 @@ void CSystemDlg::OnWlistMin()
|
||||
lpMsgBuf[0]= CMD_WINDOW_TEST;
|
||||
DWORD hwnd = data->ID;
|
||||
pListCtrl->SetItemText(nItem,2,"minimized");
|
||||
data->Data[2] = "minimized"; // 同步 ItemData,避免查看/排序读到旧状态
|
||||
memcpy(lpMsgBuf+1,&hwnd,sizeof(DWORD));
|
||||
DWORD dHow=SW_MINIMIZE;
|
||||
memcpy(lpMsgBuf+1+sizeof(hwnd),&dHow,sizeof(DWORD));
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
// 程序版本号 [建议格式: X.Y.Z]
|
||||
// 影响:关于对话框、标题栏
|
||||
#define BRAND_VERSION "1.3.8"
|
||||
#define BRAND_VERSION "1.3.9"
|
||||
|
||||
// 启动画面名称 [建议大写,更有 Logo 感]
|
||||
// 影响:启动画面 Logo 文字(大号艺术字体渲染)
|
||||
|
||||
@@ -643,6 +643,15 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
|
||||
}
|
||||
}
|
||||
|
||||
// Bidirectional mutex (direction 2, §8.2): reject human Web viewing while an MCP
|
||||
// remote control session owns this host's screen sub-connection (SetMcpTriggered on
|
||||
// remote_open, cleared on session teardown), so AI input cannot be injected into a
|
||||
// picture a human is watching/controlling.
|
||||
if (IsMcpTriggered(device_id)) {
|
||||
SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Device busy: remote control session active"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check max clients per device
|
||||
int current_count = GetWebClientCount(device_id);
|
||||
if (current_count >= m_nMaxClientsPerDevice) {
|
||||
@@ -799,34 +808,18 @@ void CWebService::HandleMouse(void* ws_ptr, const std::string& msg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build MSG64 structure
|
||||
MSG64 msg64;
|
||||
memset(&msg64, 0, sizeof(MSG64));
|
||||
msg64.pt.x = x;
|
||||
msg64.pt.y = y;
|
||||
msg64.lParam = MAKELPARAM(x, y);
|
||||
msg64.time = GetTickCount();
|
||||
// Build MSG64 structure(共享 helper,见 WebService.h;与 MCP remote_mouse 复用)
|
||||
MSG64 msg64 = BuildMouseMsg64(x, y, 0, 0); // 默认 WM_NULL;未知 button 时仍发送(保持原行为)
|
||||
|
||||
// Map type and button to Windows message
|
||||
if (type == "down") {
|
||||
if (button == 0) {
|
||||
msg64.message = WM_LBUTTONDOWN;
|
||||
msg64.wParam = MK_LBUTTON;
|
||||
} else if (button == 1) {
|
||||
msg64.message = WM_MBUTTONDOWN;
|
||||
msg64.wParam = MK_MBUTTON;
|
||||
} else if (button == 2) {
|
||||
msg64.message = WM_RBUTTONDOWN;
|
||||
msg64.wParam = MK_RBUTTON;
|
||||
}
|
||||
if (button == 0) { msg64.message = WM_LBUTTONDOWN; msg64.wParam = MK_LBUTTON; }
|
||||
else if (button == 1) { msg64.message = WM_MBUTTONDOWN; msg64.wParam = MK_MBUTTON; }
|
||||
else if (button == 2) { msg64.message = WM_RBUTTONDOWN; msg64.wParam = MK_RBUTTON; }
|
||||
} else if (type == "up") {
|
||||
if (button == 0) {
|
||||
msg64.message = WM_LBUTTONUP;
|
||||
} else if (button == 1) {
|
||||
msg64.message = WM_MBUTTONUP;
|
||||
} else if (button == 2) {
|
||||
msg64.message = WM_RBUTTONUP;
|
||||
}
|
||||
if (button == 0) { msg64.message = WM_LBUTTONUP; }
|
||||
else if (button == 1) { msg64.message = WM_MBUTTONUP; }
|
||||
else if (button == 2) { msg64.message = WM_RBUTTONUP; }
|
||||
} else if (type == "move") {
|
||||
msg64.message = WM_MOUSEMOVE;
|
||||
} else if (type == "wheel") {
|
||||
@@ -845,13 +838,8 @@ void CWebService::HandleMouse(void* ws_ptr, const std::string& msg) {
|
||||
if (clientType != GetClientType(CLIENT_TYPE_MACOS) && clientType != "macOS") {
|
||||
return; // Skip dblclick for non-macOS clients
|
||||
}
|
||||
if (button == 0) {
|
||||
msg64.message = WM_LBUTTONDBLCLK;
|
||||
msg64.wParam = MK_LBUTTON;
|
||||
} else if (button == 2) {
|
||||
msg64.message = WM_RBUTTONDBLCLK;
|
||||
msg64.wParam = MK_RBUTTON;
|
||||
}
|
||||
if (button == 0) { msg64.message = WM_LBUTTONDBLCLK; msg64.wParam = MK_LBUTTON; }
|
||||
else if (button == 2) { msg64.message = WM_RBUTTONDBLCLK; msg64.wParam = MK_RBUTTON; }
|
||||
} else {
|
||||
return; // Unknown type
|
||||
}
|
||||
@@ -907,41 +895,8 @@ void CWebService::HandleKey(void* ws_ptr, const std::string& msg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build MSG64 structure
|
||||
MSG64 msg64;
|
||||
memset(&msg64, 0, sizeof(MSG64));
|
||||
|
||||
// Use WM_SYSKEYDOWN/UP for Alt combinations (same as MFC version)
|
||||
if (altKey) {
|
||||
msg64.message = isDown ? WM_SYSKEYDOWN : WM_SYSKEYUP;
|
||||
} else {
|
||||
msg64.message = isDown ? WM_KEYDOWN : WM_KEYUP;
|
||||
}
|
||||
msg64.wParam = keyCode;
|
||||
msg64.time = GetTickCount();
|
||||
|
||||
// Build lParam for keyboard message:
|
||||
// bits 0-15: repeat count (1)
|
||||
// bits 16-23: scan code
|
||||
// bit 24: extended key flag
|
||||
// bit 29: context code (1 if Alt is pressed)
|
||||
// bit 30: previous key state (1 for keyup)
|
||||
// bit 31: transition state (1 for keyup)
|
||||
UINT scanCode = MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
|
||||
|
||||
// Extended keys: arrows, insert, delete, home, end, page up/down, numpad enter, etc.
|
||||
bool isExtended = (keyCode >= VK_PRIOR && keyCode <= VK_DOWN) || // Page Up/Down, End, Home, Arrows
|
||||
keyCode == VK_INSERT || keyCode == VK_DELETE ||
|
||||
keyCode == VK_NUMLOCK || keyCode == VK_RCONTROL || keyCode == VK_RMENU ||
|
||||
keyCode == VK_APPS;
|
||||
|
||||
LPARAM lParam = 1; // repeat count = 1
|
||||
lParam |= (scanCode & 0xFF) << 16;
|
||||
if (isExtended) lParam |= (1 << 24);
|
||||
if (altKey) lParam |= (1 << 29); // context code for Alt
|
||||
if (!isDown) lParam |= (3UL << 30); // bit 30 and 31 set for key up
|
||||
|
||||
msg64.lParam = lParam;
|
||||
// Build MSG64 structure(共享 helper,见 WebService.h;与 MCP remote_keyboard 复用)
|
||||
MSG64 msg64 = BuildKeyMsg64(keyCode, isDown, altKey);
|
||||
|
||||
// Send command to device
|
||||
const int length = sizeof(MSG64) + 1;
|
||||
@@ -1670,6 +1625,17 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig
|
||||
}
|
||||
}
|
||||
|
||||
bool CWebService::GetScreenSize(uint64_t device_id, int& width, int& height) {
|
||||
width = 0;
|
||||
height = 0;
|
||||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||||
auto it = m_DeviceCache.find(device_id);
|
||||
if (it == m_DeviceCache.end()) return false;
|
||||
width = it->second->screen_width;
|
||||
height = it->second->screen_height;
|
||||
return width > 0 && height > 0;
|
||||
}
|
||||
|
||||
void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) {
|
||||
if (m_bStopping) return;
|
||||
|
||||
@@ -2193,6 +2159,21 @@ void CWebService::ClearMfcTriggered(uint64_t device_id) {
|
||||
m_MfcTriggeredDevices.erase(device_id);
|
||||
}
|
||||
|
||||
void CWebService::SetMcpTriggered(uint64_t device_id) {
|
||||
std::lock_guard<std::mutex> lock(m_McpTriggeredMutex);
|
||||
m_McpTriggeredDevices.insert(device_id);
|
||||
}
|
||||
|
||||
bool CWebService::IsMcpTriggered(uint64_t device_id) {
|
||||
std::lock_guard<std::mutex> lock(m_McpTriggeredMutex);
|
||||
return m_McpTriggeredDevices.find(device_id) != m_McpTriggeredDevices.end();
|
||||
}
|
||||
|
||||
void CWebService::ClearMcpTriggered(uint64_t device_id) {
|
||||
std::lock_guard<std::mutex> lock(m_McpTriggeredMutex);
|
||||
m_McpTriggeredDevices.erase(device_id);
|
||||
}
|
||||
|
||||
bool CWebService::HasActiveSession(uint64_t device_id) {
|
||||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||||
return m_ScreenContexts.find(device_id) != m_ScreenContexts.end();
|
||||
|
||||
@@ -13,11 +13,56 @@
|
||||
#include <random>
|
||||
#include <ctime>
|
||||
|
||||
#include "common/commands.h" // MSG64 / COMMAND_SCREEN_CONTROL:共享键盘注入 helper(BuildKeyMsg64)
|
||||
|
||||
// Forward declarations
|
||||
class context;
|
||||
class CMy2015RemoteDlg;
|
||||
class CONTEXT_OBJECT;
|
||||
|
||||
// 构造单个键盘注入 MSG64(复用 WebService::HandleKey 的 lParam 逻辑,抽取为共享 helper,
|
||||
// 供 Web 与 MCP 远程控制两处使用,避免漂移,见 docs/Mcp_RemoteControl_Design.md §6.2)。
|
||||
// keyCode:虚拟键码;isDown:按下/抬起;isSysKey:true 用 WM_SYSKEYDOWN/UP 并置 Alt 上下文位。
|
||||
inline MSG64 BuildKeyMsg64(int keyCode, bool isDown, bool isSysKey) {
|
||||
MSG64 msg64;
|
||||
memset(&msg64, 0, sizeof(MSG64));
|
||||
|
||||
if (isSysKey) msg64.message = isDown ? WM_SYSKEYDOWN : WM_SYSKEYUP;
|
||||
else msg64.message = isDown ? WM_KEYDOWN : WM_KEYUP;
|
||||
msg64.wParam = (uint64_t)keyCode;
|
||||
msg64.time = GetTickCount();
|
||||
|
||||
UINT scanCode = MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
|
||||
bool isExtended = (keyCode >= VK_PRIOR && keyCode <= VK_DOWN) ||
|
||||
keyCode == VK_INSERT || keyCode == VK_DELETE ||
|
||||
keyCode == VK_NUMLOCK || keyCode == VK_RCONTROL || keyCode == VK_RMENU ||
|
||||
keyCode == VK_APPS;
|
||||
|
||||
LPARAM lParam = 1; // repeat count = 1
|
||||
lParam |= (scanCode & 0xFF) << 16;
|
||||
if (isExtended) lParam |= (1 << 24);
|
||||
if (isSysKey) lParam |= (1 << 29); // context code for Alt
|
||||
if (!isDown) lParam |= (3UL << 30); // bit 30/31 for key up
|
||||
|
||||
msg64.lParam = (uint64_t)lParam;
|
||||
return msg64;
|
||||
}
|
||||
|
||||
// 构造单个鼠标注入 MSG64(复用 WebService::HandleMouse 的基座字段逻辑,抽取为共享 helper,
|
||||
// 供 Web 与 MCP 远程控制两处使用,见 docs/Mcp_RemoteControl_Design.md §6.2)。
|
||||
// x/y 为物理像素坐标(已由调用方从归一化坐标映射);message/wParam 由调用方按动作设定。
|
||||
inline MSG64 BuildMouseMsg64(int x, int y, UINT message, uint64_t wParam) {
|
||||
MSG64 msg64;
|
||||
memset(&msg64, 0, sizeof(MSG64));
|
||||
msg64.pt.x = x;
|
||||
msg64.pt.y = y;
|
||||
msg64.lParam = MAKELPARAM(x, y);
|
||||
msg64.time = GetTickCount();
|
||||
msg64.message = message;
|
||||
msg64.wParam = wParam;
|
||||
return msg64;
|
||||
}
|
||||
|
||||
// Web client state
|
||||
struct WebClient {
|
||||
std::string token;
|
||||
@@ -104,6 +149,10 @@ public:
|
||||
// Resolution change notification
|
||||
void NotifyResolutionChange(uint64_t device_id, int width, int height, bool top_down = false, const std::string& client_type = "");
|
||||
|
||||
// 读回 NotifyResolutionChange 缓存的物理捕获分辨率(来自 TOKEN_BITMAPINFO)。
|
||||
// 尚未收到分辨率时返回 false 并置 w/h 为 0。MCP 远程控制(remote_open)用它映射归一化坐标。
|
||||
bool GetScreenSize(uint64_t device_id, int& width, int& height);
|
||||
|
||||
// Audio enable/disable notification — pushes current state to all web
|
||||
// clients watching this device and caches it for newcomers.
|
||||
void NotifyAudioState(uint64_t device_id, bool enabled);
|
||||
@@ -250,6 +299,12 @@ public:
|
||||
bool IsMfcTriggered(uint64_t device_id);
|
||||
void ClearMfcTriggered(uint64_t device_id);
|
||||
|
||||
// MCP trigger management - an MCP remote control session owns the screen
|
||||
// sub-connection and blocks human Web viewing (bidirectional mutex, §8.2).
|
||||
void SetMcpTriggered(uint64_t device_id);
|
||||
bool IsMcpTriggered(uint64_t device_id);
|
||||
void ClearMcpTriggered(uint64_t device_id);
|
||||
|
||||
// Check if a remote desktop session already exists for device
|
||||
bool HasActiveSession(uint64_t device_id);
|
||||
|
||||
@@ -295,6 +350,10 @@ private:
|
||||
std::set<uint64_t> m_MfcTriggeredDevices;
|
||||
std::mutex m_MfcTriggeredMutex;
|
||||
|
||||
// MCP triggered devices: screen sub-connection owned by an MCP remote control session
|
||||
std::set<uint64_t> m_McpTriggeredDevices;
|
||||
std::mutex m_McpTriggeredMutex;
|
||||
|
||||
// Web 终端会话状态
|
||||
struct WebTermSession {
|
||||
void* ws_ptr; // browser WebSocket
|
||||
|
||||
@@ -1987,4 +1987,15 @@ MCP
|
||||
端口需为 1-65535 的数字=Port must be a number between 1 and 65535
|
||||
Token 不能为空=Token cannot be empty
|
||||
MCP 设置已保存。=MCP settings saved.
|
||||
启用/端口/绑定地址/Token 的改动需重启程序生效。=Changes to enable/port/bind address/token take effect after restart.
|
||||
启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制/文件传输的改动需重启程序生效。=Changes to enable/port/bind address/token/read-only/whitelist/persistent terminal/remote control/file transfer take effect after restart.
|
||||
只读模式(禁命令执行)=Read-only mode (disable command execution)
|
||||
命令白名单=Command whitelist
|
||||
MCP命令执行=MCP command execution
|
||||
启用持久终端(全命令,无白名单)=Enable persistent terminal (full commands, no whitelist)
|
||||
持久终端与远程控制仅在只读模式关闭时生效。=Persistent terminal and remote control take effect only when read-only mode is off.
|
||||
MCP持久终端=MCP persistent terminal
|
||||
MCP远程控制=MCP remote control
|
||||
MCP文件传输=MCP file transfer
|
||||
Token=Token
|
||||
启用远程控制(AI 操控桌面)=Enable remote control (AI controls the desktop)
|
||||
启用文件传输(download_file)=Enable file transfer (download_file)
|
||||
@@ -1978,4 +1978,15 @@ MCP
|
||||
端口需为 1-65535 的数字=連接埠需為 1-65535 的數字
|
||||
Token 不能为空=Token 不能為空
|
||||
MCP 设置已保存。=MCP 設定已儲存。
|
||||
启用/端口/绑定地址/Token 的改动需重启程序生效。=啟用/連接埠/綁定位址/Token 的變更需重新啟動程式後生效。
|
||||
启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制/文件传输的改动需重启程序生效。=啟用/連接埠/綁定位址/Token/唯讀/白名單/持久終端/遠端控制/檔案傳輸的變更需重新啟動程式後生效。
|
||||
只读模式(禁命令执行)=唯讀模式(禁命令執行)
|
||||
命令白名单=命令白名單
|
||||
MCP命令执行=MCP命令執行
|
||||
启用持久终端(全命令,无白名单)=啟用持久終端(完整命令,無白名單)
|
||||
持久终端与远程控制仅在只读模式关闭时生效。=持久終端與遠端控制僅在唯讀模式關閉時生效。
|
||||
MCP持久终端=MCP持久終端
|
||||
MCP远程控制=MCP遠端控制
|
||||
MCP文件传输=MCP檔案傳輸
|
||||
Token=Token
|
||||
启用远程控制(AI 操控桌面)=啟用遠端控制(AI 操控桌面)
|
||||
启用文件传输(download_file)=啟用檔案傳輸(download_file)
|
||||
Reference in new issue
Block a user