diff --git a/WxAgent.sln b/WxAgent.sln index 745b5d7..d88af38 100644 --- a/WxAgent.sln +++ b/WxAgent.sln @@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CE7C3E9D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Core.Tests", "tests\WxAgent.Core.Tests\WxAgent.Core.Tests.csproj", "{1F24FB68-6917-4984-89FC-5F9C64E2965D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Service", "src\WxAgent.Service\WxAgent.Service.csproj", "{8280C39E-328E-4321-8D96-238405B5F19D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Service.Tests", "tests\WxAgent.Service.Tests\WxAgent.Service.Tests.csproj", "{99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -40,11 +44,21 @@ Global {1F24FB68-6917-4984-89FC-5F9C64E2965D}.Debug|Any CPU.Build.0 = Debug|Any CPU {1F24FB68-6917-4984-89FC-5F9C64E2965D}.Release|Any CPU.ActiveCfg = Release|Any CPU {1F24FB68-6917-4984-89FC-5F9C64E2965D}.Release|Any CPU.Build.0 = Release|Any CPU + {8280C39E-328E-4321-8D96-238405B5F19D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8280C39E-328E-4321-8D96-238405B5F19D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8280C39E-328E-4321-8D96-238405B5F19D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8280C39E-328E-4321-8D96-238405B5F19D}.Release|Any CPU.Build.0 = Release|Any CPU + {99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {EDC68F82-2F67-40A5-B6CA-B4D26349E988} = {11E9B433-75E3-446D-B8FF-958A0F541BA5} {2552B68F-0F33-4B71-AE13-CF53ACD15D4D} = {11E9B433-75E3-446D-B8FF-958A0F541BA5} {70B4D2DF-1FDC-4C80-85A0-F03B1A979915} = {11E9B433-75E3-446D-B8FF-958A0F541BA5} {1F24FB68-6917-4984-89FC-5F9C64E2965D} = {CE7C3E9D-F9B0-4A30-AA6F-A645D75888D9} + {8280C39E-328E-4321-8D96-238405B5F19D} = {11E9B433-75E3-446D-B8FF-958A0F541BA5} + {99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB} = {CE7C3E9D-F9B0-4A30-AA6F-A645D75888D9} EndGlobalSection EndGlobal diff --git a/docs/WebUI-MCP-使用说明.md b/docs/WebUI-MCP-使用说明.md new file mode 100644 index 0000000..caaabf7 --- /dev/null +++ b/docs/WebUI-MCP-使用说明.md @@ -0,0 +1,40 @@ +# Web UI + MCP 使用说明 + +## 启动 + +1. 在已登录、未锁定的 Windows 用户会话中准备目录和凭据文件;不要通过 HTTP 生成 Token。 +2. `credentials.json` 只保存 SHA-256 大写十六进制摘要,例如: + +```json +[{"PrincipalId":"local-read","TokenSha256":"<64-hex-sha256>","Permissions":["read"],"AccountIds":[]}] +``` + +`AccountIds: []` 不授予任何显式数据库账号范围;需要联系人/群成员等账号范围调用时,必须填入已验证的 account fingerprint。凭据文件应使用当前用户 ACL,禁止提交仓库。 + +3. 复制 `docs/webui-mcp-config.example.json`,修改本机路径后启动: + +```powershell +WxAgent.Host.exe serve --config C:\Users\USERNAME\wx-agent\service.json +``` + +默认只监听 `127.0.0.1:5088`。外部监听必须同时设置 `AllowExternal=true`、明确 IP/端口和精确 `AllowedHosts`/`AllowedOrigins`;自行配置防火墙,服务不会自动开放端口。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。 + +浏览器访问 `/`,输入 Token 登录。HTTP/MCP 客户端使用: + +```text +Authorization: Bearer +``` + +MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ID、浏览器持久存储或日志中。事件流默认关闭;只有在固定测试会话、受控消息验证和恢复验收完成后才设置 `EnableListenerEvents=true`。真机消息验证最多发送 3 条。 + +## 凭据更换与撤销 + +原子替换凭据文件并保留相同 `PrincipalId` 可保留幂等记录;服务在每个请求、任务执行和事件批次重新读取凭据。旧 Token、Cookie、SSE/MCP 授权立即失效,不存在重叠窗口。变更后删除旧浏览器会话并重新登录。 + +## 只读边界 + +状态、账号、会话、摘要消息、联系人和任务查询通过 REST 或同一 MCP 工具访问。正文需 `content` 权限。当前写入、@所有人、管理和朋友圈能力保持 disabled;不会把入队、UI 点击或数据库指纹描述成已发送。 + +## 回滚 + +停止新服务并等待正在执行的 UI 操作结束,保留 `data/operations.sqlite` 和脱敏诊断;恢复旧版本发布目录后再启动。旧版本不会自动重放新版本未完成任务。删除临时 `artifacts` 目录前确认没有活动任务占用。 diff --git a/docs/WebUI-MCP-功能矩阵.md b/docs/WebUI-MCP-功能矩阵.md new file mode 100644 index 0000000..201c632 --- /dev/null +++ b/docs/WebUI-MCP-功能矩阵.md @@ -0,0 +1,24 @@ +# Web UI + MCP 功能矩阵 + +状态以 `GET /api/v1/capabilities` 为准;`implemented` 不代表 `validated`,数据库指纹也不代表当前 UI 账号绑定。 + +| 操作 | REST | MCP | 当前状态 | 说明 | +|---|---|---|---|---| +| 服务/微信诊断 | `GET /api/v1/status`、`/api/v1/diagnostics` | `agent_status`、`agent_diagnose` | Ready/环境依赖 | 脱敏;服务在线不等于微信可用,不执行恢复或 UI 写操作 | +| 能力清单 | `GET /api/v1/capabilities` | `agent_capabilities` | Ready | 各项 implemented/validated/enabled 分离 | +| 数据库账号发现 | `GET /api/v1/accounts` | `accounts_list` | 只读 | 仅返回指纹和脱敏 UI 信息,不返回密钥 | +| 可见会话 | `GET /api/v1/sessions`、`/search`、`/current`;POST `/open`、`/scroll` | `sessions_list`、`sessions_search`、`session_current` | 只读/导航 | 精确匹配拒绝猜测;open/scroll 需 manage 且当前仍待导航验收 | +| 可见消息 | `GET /api/v1/messages` | `messages_read` | 只读 | 默认摘要;`includeContent` 需要 content 权限 | +| 数据库消息/合并记录 | `GET /api/v1/db/messages`、`/api/v1/db/merged` | `db_messages`、`db_merged` | Implemented but disabled | 仅接受显式已验证账号 fingerprint;只读 SQLCipher,未提供通用 SQL | +| 任务查询/取消 | `/api/v1/operations/{id}` | `operation_get`/`operation_cancel` | Ready | 只允许任务所有者;取消不撤销已发生副作用 | +| 事件流 | `GET /api/v1/events` | 暂未注册 | Explicit opt-in | SSE 有界缓存、Last-Event-ID gap、Windows `ListenEventsAsync` 已接入;默认关闭,需受控真机验收后开启 | +| 文本/文件/卡片发送 | — | — | Disabled | 账号绑定、目标唯一性、真机后置验证未完成;绝不模拟成功 | +| 联系人/群管理、朋友圈 | — | — | Deferred/Disabled | 遵循 `docs/PENDING.md`,需单项授权和真机证据 | +| 任意 SQL/UI 菜单/shell | — | — | Unsupported | 不提供 | + +## 运行边界 + +- 通过 `WxAgent.Host serve --config ` 启动;默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`。 +- HTTP API、Streamable HTTP MCP (`/mcp`) 和浏览器登录共用 Bearer Token;Token 只放 `Authorization`,不放 URL。 +- 浏览器登录后仅保留短期 HttpOnly SameSite Cookie,写请求需 CSRF;普通日志不记录正文、Token、Cookie、密钥或完整 UI 树。 +- 明文 HTTP 不提供传输保密性,只适合可信隔离网络;不应直接暴露公网。 diff --git a/docs/WebUI-MCP-开发计划.md b/docs/WebUI-MCP-开发计划.md new file mode 100644 index 0000000..bc551f1 --- /dev/null +++ b/docs/WebUI-MCP-开发计划.md @@ -0,0 +1,417 @@ +# Web UI + MCP 服务开发计划 + +> 状态:规划,尚未实施或验收。用户已确认目标为“浏览器 Web UI + MCP 服务”。 +> 本次交付仅为开发计划,不启动服务、不执行微信写操作。 +> 范围变化:原第一阶段排除正式 UI;本文规划其后的控制面阶段。用户已确认 HTTP/Web UI 与 MCP 支持外部访问且共用 Token,不要求 HTTPS,不实现服务端人工审批;不改变 Windows Service、协议及数据库安全边界。 + +## 1. 目标与基线 + +在已有 C# 微信业务能力上增加浏览器控制台和 MCP 调用入口,实现“查看状态 → 选择账号/会话 → 发起操作 → 查询进度 → 验证结果 → 获取诊断”的闭环。 + +依据: + +- [C# 开发计划](WxAgent-CSharp-开发计划.md):总体目标、项目边界、自动化规则。 +- [PENDING](PENDING.md):最新功能边界、暂缓事项和未闭环问题;与早期计划冲突时以此为准。 +- `src/WxAgent.Host/Program.cs`:现有 CLI 入口、参数校验、JSON 输出与 smoke。 +- `src/WxAgent.Windows/WechatChatClient*.cs`:UI 操作及管理 API。 +- `src/WxAgent.Core`:业务模型、错误码、监听及只读数据相关逻辑。 +- `docs/validation/`:已记录的验收证据;实现存在不等于已通过真机验收。 + +### 1.1 当前事实 + +1. 当前主要交付是 Windows Host/CLI 和 C# 库,不把它们描述成已经可供浏览器调用的 HTTP/MCP 服务。 +2. CLI 已包含诊断、会话、消息、监听、数据库只读查询等入口;部分管理功能仅通过库 API 提供。 +3. 现有 UI 操作使用 `InterprocessCommandGate` 协调;服务化时复用并审查覆盖范围,不另建绕过它的点击通道。 +4. 联系人、群和群成员列表使用数据库读取,不增加 UI 长列表枚举回退。 +5. M6 未完成;500 条发送、多 DPI、长时监听及部分管理/朋友圈写路径仍属暂缓验收。 +6. 不实现“下一个未读会话”。语音 Beta、长文本自动分段、多附件批量等不因增加 UI 而自动恢复开发。 + +### 1.2 首版范围 + +- 本机或外部浏览器、HTTP 客户端与 MCP 客户端访问同一 Host;外部访问必须认证,HTTP 与 MCP 共用 Token、身份及权限。默认仍仅监听回环地址,外部 HTTP 监听须显式启用;不要求 HTTPS。 +- 一个交互式 Windows 用户会话、一个自动化控制目标;多数据库账号可显式选择,但不承诺多微信实例并行控制。 +- 完成诊断、会话、基础消息、受控文件、只读联系人/群成员、监听及任务中心。 +- 管理和朋友圈等风险功能按能力状态逐项接入,不默认开放未经真机验收的写操作。 +- 支持经 Token 认证的外部 HTTP 入口;明文传输不具备保密性,不作为安全公网部署方案。暂不做多租户、Windows Service、自动升级、批量营销或绕过已有确认门禁的破坏性操作。 + +## 2. 最小架构与运行方式 + +以下为实施建议,不代表已安装相应依赖。U0 阶段确认具体 SDK 版本与目标客户端兼容性。 + +```text +本机/外部浏览器 本机/外部 MCP 客户端 + │ 同源 HTTP + SSE │ Streamable HTTP + └──────────────┬───────────────────┘ + ▼ + WxAgent.Host(同一进程、默认回环,可显式外部监听) + 静态页面 / REST API / MCP 适配 + ▼ + 共享操作处理、权限、任务与错误映射 + ├── 只读数据库查询(有界并发) + └── 统一 UI 调度 → 现有跨进程门禁 + ▼ + FlaUI / Win32 / 微信 +``` + +### 2.1 技术与文件组织 + +- 继续 .NET 8、System.Text.Json、Microsoft.Extensions.Logging。 +- Host 增加 ASP.NET Core Minimal API 和静态文件托管;复用现有 CLI,不启动 CLI 子进程解析 stdout 作为正式服务接口。 +- Web UI 首版采用原生 HTML/CSS/JavaScript 模块、浏览器表单和 `fetch`,不先引入 SPA 框架。 +- MCP 使用维护中的官方 C# SDK,验证其 .NET 8 支持后锁定版本;不自行实现协议解析器。 +- 一个 Host 进程同时提供 REST 和 MCP。首版选 Streamable HTTP;stdio 只有明确客户端兼容需求时再补,不能额外启动独立 UI 写执行器。 +- 消息事件对浏览器使用 SSE;MCP 用显式订阅和有界事件拉取工具,不假设客户端都支持服务端主动通知。 + +建议新增位置: + +```text +src/WxAgent.Host/Api/ REST 路由和鉴权 +src/WxAgent.Host/Mcp/ MCP 工具薄适配 +src/WxAgent.Host/wwwroot/ 本地 Web UI +src/WxAgent.Core/ 必要的跨平台契约、校验和任务状态逻辑 +``` + +仅在实际代码需要时创建文件。Windows 类型留在 Windows/Host;不新建微服务、插件系统或通用仓储框架。 + +以上路由目录为暂定位置:当前 Host 为 `net8.0-windows`/`win-x64` 且引用 Windows 项目,不能仅凭替换业务处理器就宣称真实 HTTP/MCP 适配可在 Linux 测试。U0 必须先运行一个加载真实路由、鉴权中间件和 MCP schema 的 Linux 冒烟实验;据此选择最小跨平台适配程序集或经验证的多目标方案。跨平台适配不得引用 FlaUI/Windows 项目,Host 仅负责组合和注入 Windows 执行器;不得复制一套测试专用路由冒充集成测试。 + +### 2.2 生命周期与调度 + +- 新增计划命令 `WxAgent.Host serve`,在已登录、未锁定的微信用户会话运行;不是 Windows Service。 +- 同一桌面仅一个服务实例;端口/实例冲突明确失败,保留现有 CLI 跨进程互斥保护。 +- 点击、输入、滚动、导航以及依赖页面状态的读取统一排队;数据库纯读取不占用 UI 写队列。 +- 使用有界 `Channel` 承接请求,UI 执行遵守 COM/UIA 线程约束;整个复合动作持有执行所有权,步骤之间不允许另一请求切换页面。 +- 审查已有门禁获取位置,避免服务层和库层嵌套获取同一非重入门禁造成死锁。 +- 监听器只在短快照采集时占用 UI 调度,不持锁等待整个监听生命周期;不同目标冲突时排队或明确拒绝,不偷偷切换。 +- 关闭服务时停止接单、取消未执行任务、保存已有监听 checkpoint;重启不自动重放写任务。 + +## 3. 功能、页面与服务映射 + +以下 REST 和 MCP 名称为**拟新增契约**,不是现有接口。U0 输出最终逐项操作清单,包括真实方法签名、输入、输出、风险和证据。 + +| 页面/模块 | 现有依据 | 拟 REST / MCP | 首版操作及限制 | +| --- | --- | --- | --- | +| 总览/环境 | doctor、window-status、tray-status | `GET /api/v1/status` / `agent_status` | Host、微信版本、登录/锁屏、窗口、队列状态分开展示;服务在线不等于微信可用 | +| 诊断 | inspect-ui、diagnose、recover-ui | `POST /api/v1/operations` / `agent_diagnose`、`agent_recover_ui` | 导出脱敏诊断;恢复是会改变 UI 的显式操作,不随刷新执行 | +| 账号与数据库 | db status、scan、query | `/api/v1/accounts` / `db_accounts`、`db_scan`、`db_metadata` | 显式选账号;密钥扫描为本机管理员操作,默认不向 MCP 开放;不暴露通用 SQL | +| 会话 | session list/search/current/open/scroll | `/api/v1/sessions` / `sessions_list`、`sessions_search`、`session_open`、`sessions_scroll` | 显示可见列表边界;同名目标拒绝猜测;搜索/滚动按 UI 操作调度 | +| 消息阅读 | chat read/history/latest/locate | `/api/v1/messages` / `messages_read`、`messages_history`、`message_locate` | 标明 UI 可见快照、历史滚动范围与上限;旧指纹不当永久 ID | +| 消息发送 | chat send/reply-latest/mention、group at-all | `POST /api/v1/operations` / `message_send_text`、`message_reply`、`message_mention` | 固定目标、正文预览;引用绑定选定消息;@所有人显式确认;不自动重试 | +| 文件/图片/卡片 | send-file、send-image、send-url-card | `/api/v1/files` + operations / `message_send_file`、`message_send_image`、`message_send_url_card` | 上传后用 fileId 发送;单附件;URL 卡片不退化为文本;URL 卡片不强加原 API 没有的 CONFIRM | +| 监听 | chat monitor/listen | `/api/v1/subscriptions`、`/api/v1/events` / `listener_start`、`listener_stop`、`listener_events` | 启停、目标、续接、丢失区间、最后事件时间;不宣称离线必达 | +| 联系人/群成员 | db contacts、db group-members、管理读取 API | `/api/v1/contacts`、`/api/v1/groups` / `contacts_list`、`contact_get`、`groups_list`、`group_members` | 只读分页;稳定 ID;字段未知显示未知;已知群不标为最近活跃群 | +| 数据库消息/合并记录 | db messages、db merged | `/api/v1/db/messages`、`/api/v1/db/merged` / `db_messages`、`db_merged` | 指定账号/会话;递归内容受深度/条数限制;不承诺附件原文件下载 | +| 子窗口/导航 | C# 子窗口、导航 API | operations / `windows_list`、`window_open`、`window_close`、`navigation_switch` | 目标窗口须唯一;窗口关闭不是退出微信 | +| 消息动作/附件 | 管理及内容读取 API | operations / `message_forward`、`message_download`、`message_ocr`、`message_to_text`、`message_content` | 按实际能力逐项开放;独立 OCR 窗口无法归属时返回 ResultUnconfirmed | +| 联系人/群管理 | C# 管理 API | operations / 按编辑好友、建群、加成员、修改群信息分别建工具 | U6 条件交付;逐项补充专用对象真机验收,不开放任意菜单点击工具 | +| 朋友圈/低频动作 | C# Moments、拍一拍、语音 API | operations / 按读取、刷新、发布、点赞、评论分别建工具 | 读写分权;写操作 U6 条件交付;语音 Beta 默认禁用 | +| 任务/审计 | 新增服务能力 | `/api/v1/operations/{id}` / `operation_get`、`operation_cancel` | 结果、阶段、错误、关联 ID、确认和脱敏诊断;取消不等于撤销 | + +每个分页响应明确 `items/limit/offset/hasMore/nextOffset` 或实际可用游标。现有 offset 分页不是事务快照;数据变更时提示刷新,不声称全量一致。 + +## 4. 共享服务契约 + +### 4.1 能力清单 + +新增 `GET /api/v1/capabilities` 和 `agent_capabilities`,对每项操作返回: + +- 操作标识、输入输出版本、是否需 UI、是否有外部副作用。 +- `implemented`、`validated`、`enabled` 分开表示,并附证据位置/适用微信版本。 +- 是否需确认、所需权限、默认超时、限制、禁用原因。 +- `Unavailable`/`Experimental`/`Ready` 是展示状态,不能用“有方法”自动推导 Ready。 + +UI 根据能力显示禁用原因;服务端仍独立校验。MCP 不注册未实现工具,实验功能仅显式启用后可调用。 + +### 4.2 请求与结果 + +- 查询使用受限的分页与筛选;任何会产生微信副作用的动作禁止使用 GET。 +- 写操作请求包含 `operation`、`accountId`、明确 `target`、类型化 `arguments`、`timeoutSeconds` 和 `idempotencyKey`;不提供 approvalId。有外部副作用的操作必须提供幂等键。客户端在首次提交前生成键,双击及网络重试复用同一键,明确发起另一操作才换键。 +- 状态、联系人分页等短查询直接返回结果,不创建持久任务、不要求轮询;写操作及确实耗时的操作才返回任务 ID。直接返回不等于绕过调度,依赖 UI 页面状态的读取仍经过统一队列。 +- 账号数据库指纹仅证明数据归属;不能据此推断当前微信 UI 活跃账号。当前 `GetMyInfoAsync` 返回显示名/微信号等,不构成到数据库根目录指纹的可靠绑定。U0 必须验证绑定证据及实现路径,禁止按昵称、缓存 PID 或仅 page 1 HMAC 猜测关联;未形成可靠关联前禁用写能力。 +- 写前在取得 UI 执行所有权后验证账号绑定,复合动作在提交副作用前再次检查可观察身份。退出登录、账号切换、微信进程重启或身份不可判定时使绑定及待执行写任务失效,要求重新验证;无法可靠检测的场景保持禁用,不以人工选择数据库账号替代验证。 +- 返回 `operationId/correlationId/status/result/error`;错误包含稳定 `code`、安全的 `message`、`stage` 和明确的重试提示。 +- 保留现有 WxAgent 错误码;新增鉴权、队列、幂等冲突和游标失效错误时先补测试,再固定对外契约。 +- 接单返回 HTTP 202 不代表成功;MCP 同样返回任务 ID 和状态,不把“已入队”包装为“已发送”。 +- HTTP 建议:参数 400、未认证 401、无权 403、冲突 409、队列饱和 429、微信不可用 503;运行后的业务结果以任务终态为准。 +- MCP 协议参数错误与业务失败分开处理;已执行失败按 SDK 工具错误约定及结构化错误码返回,不伪装正常成功。 + +### 4.3 任务、超时与幂等 + +```text +Queued → Running → Succeeded / Failed / Cancelled / Unconfirmed +``` + +| 当前状态/事件 | 转换及约束 | +| --- | --- | +| Queued:取消 | Cancelled,不执行微信动作 | +| Queued:预算耗尽 | Failed / Timeout,不进入执行器 | +| Queued:权限撤销/绑定失效 | Failed / AuthorizationRevoked 或 AccountBindingInvalid,不执行微信动作 | +| Running:取消/超时/权限撤销 | 未产生副作用且能证明已停止时按取消/失败收尾;已提交或无法证明未提交时为 Unconfirmed,绝不释放所有权后让后台残留动作继续运行 | +| Host 重启 | Queued 写任务变 Cancelled,Running 写任务变 Unconfirmed;不重放 | + +- 无人工审批等待状态。默认预算建议查询 30 秒、写入 60 秒、扫描 120 秒,设置服务端上限;排队时间计入总预算。 +- 监听为独立订阅生命周期,不使用无限长 HTTP 请求充当普通写任务。 +- 尚未开始的任务可以确定取消;已点击发送/提交后取消或超时,若无法证明结果则为 `Unconfirmed`,不能声称撤销成功。 +- `ResultUnconfirmed` 映射为不确定终态,UI 提示人工核实且不显示默认重试按钮。 +- 相同 principalId、幂等键与相同业务参数返回已有任务;同键不同参数报冲突。HTTP/MCP 使用相同去重命名空间;对规范化后的操作、账号、目标及完整业务参数计算摘要,不将传输关联 ID 纳入比较。幂等键不等于微信 exactly-once 保证。 +- 原子创建任务和幂等记录并持久化成功后,才返回 HTTP 202/MCP 接单结果或将任务交给执行器;持久化失败不执行。Running 状态必须先持久化再开始副作用。记录不含消息正文/密钥;运行参数仅在受控内存中保留。 +- principalId 独立于 Token 值;更换 Token 保留身份与去重记录,重新登录不重置去重窗口。显式创建新身份不共享幂等窗口,不得用新身份重试结果不确定的旧操作。 +- 崩溃恢复按状态表处理;写任务不自动再发。队列无容量时拒绝接单,不返回虚假接单成功。 +- 首版建议队列上限 100、终态保留 24 小时/最多 10000 条;提前清理意味着去重窗口缩短,必须公开窗口并拒绝将其宣传为永久幂等。 +- 任务记录优先复用现有存储;无现成实现时使用小型 SQLite 任务表,不引入消息中间件。 + +## 5. 安全与隐私 + +### 5.1 本机与外部入口鉴权 + +1. 默认绑定 `127.0.0.1`/`::1`;允许显式配置非回环 IP、`0.0.0.0`/`::`、端口和允许的访问域名。外部 HTTP 监听必须先配置有效 Token,否则启动失败;不自动开放防火墙。不要求 HTTPS,不开发证书管理及反向代理适配。 +2. HTTP API 与 MCP 共用高熵 Token、principalId、权限和撤销机制,同一 Token 可以调用两种协议。直接客户端使用 `Authorization: Bearer `,不得将 Token 放入 URL、查询参数、日志或 MCP session ID。Token 首次生成/配置及授权在 Windows 主机本地完成,文件由当前用户 ACL 保护;不通过匿名网络接口生成或提权。 +3. 本机和外部 Web UI 统一输入 Token 登录,不实现配对码。只匿名提供无业务数据的登录页及静态资源;浏览器通过 POST 换取短期 `HttpOnly`、`SameSite=Strict` 会话 Cookie,随后清空输入,不把 Token 写入 localStorage/sessionStorage 或持久缓存。明文 HTTP 下 Cookie 不设置 Secure,因此不具备传输保密性。会话继承 Token 身份和权限;SSE 使用该 Cookie,写请求另校验 CSRF。API/MCP 无 Token 或有效派生会话时拒绝,错误/状态接口不得匿名泄露环境信息。 +4. Token 登录失败限流;校验配置的 Host 与精确 Origin 白名单,不设置通配 CORS。原生 MCP/HTTP 客户端可无 Origin,但仍须 Bearer 认证;有 Origin 则必须通过检查。本机专用入口仅接受直接回环连接,不信任客户端自报 Host、Origin 或 X-Forwarded-For 作为来源证明。 +5. 首版权限区分只读、内容读取、普通写、管理写和本机诊断管理;凭据默认只读。共享 Token 即共享身份,不能隔离共享者;需要隔离时配置不同身份的 Token。密钥扫描、保存等本机专用动作不能因外部 Token 有管理权限而开放。 +6. 更换或撤销 Token 立即使旧 Token、派生 Cookie 和 MCP 会话授权失效,关闭相关 SSE/事件流,不实现新旧 Token 重叠窗口;稳定身份和幂等记录保留。每次资源访问及每个事件输出批次检查授权;执行及实际提交副作用前重新检查。未执行任务失败且不执行;已提交任务保留真实/不确定结果,不承诺撤销微信动作。 + +**明文传输限制:** Token、Cookie、聊天内容和附件可被网络监听或中间人窃取/篡改;Token 认证不等于加密。外部 HTTP 仅建议用于可信隔离网络,界面和部署说明必须提示此风险,不宣称可安全直接暴露公网。 + +### 5.1.1 对象级权限 + +- operation、subscription、事件游标、fileId、artifactId 均绑定 principalId 和适用账号范围;ID 不透明不等于授权。查询、列表、取消、续接、内容展开及下载都检查归属与当前权限,任务结果中的正文仍需内容读取权限。 +- 普通任务、订阅和文件只允许所有者访问,不实现管理员跨身份读取/取消功能。 +- 撤销、账号切换及退出时清理相关浏览器内容、关闭旧订阅;内容响应设 `Cache-Control: no-store`。已交付客户端的数据无法追回,不宣称撤销能擦除第三方副本。 + +### 5.2 写入授权与现有确认门禁 + +- 不实现人工审批中心、审批记录、approvalId、AwaitingApproval 或等待另一人批准的流程。已启用操作在权限、参数、账号和目标校验通过后直接入队。 +- 普通发送由 UI 明确点击发送;HTTP/MCP 要求普通写权限和显式目标。管理写权限不自动启用未验收功能,也不解除 PENDING 暂缓项。 +- 已有要求 `CONFIRM` 的库操作仍需调用方在本次请求中明确提供对应确认参数,服务不得默认补入。该参数只表达本次动作确认,不是 Token、提权凭据或人工审批;参数纳入幂等摘要。UI 展示目标与影响后提交,不建立审批工作流。 +- @所有人等既定显式确认要求保留;任何确认均不能代替目标唯一性、账号绑定及写后结果检查。 + +### 5.3 内容、文件与数据库 + +- 服务默认返回摘要;经授权并显式请求正文才返回内容。浏览器切换账号/退出时清理内容,不将聊天内容默认写入持久缓存。 +- 消息、昵称、OCR 和 Markdown 都是非可信数据,用 textContent/安全渲染处理,禁止直接 innerHTML;不自动访问内容里的 URL。 +- MCP 返回的消息内容只是数据,不能作为执行工具、授权或改变系统策略的指令。 +- 上传建议首版单文件最多 50 MiB、临时区总量 500 MiB;以服务端最终配置为准并在 UI 展示。限制大小、类型、数量、保留时间,校验后以不透明 fileId 引用。 +- 拒绝外部任意绝对路径、UNC、`..`、符号链接/重解析点逃逸;发送前重新验证文件归属和摘要。浏览器下载只允许授权 artifactId,不提供任意本机文件读取。 +- 文件上传/下载不自动执行;临时产物 24 小时清理,任务占用时不误删;下载诊断包也需权限。 +- 数据库密钥仅服务端使用;扫描候选必须经 page 1 HMAC 校验后才允许使用/保存。仅本机显式管理动作可保存,MCP/UI 不返回完整密钥。 +- 数据库只读、query_only、有界固定查询;不提供原始 SQL、进程内存读取或密钥导出工具。 +- 日志只保留操作类型、脱敏目标摘要、耗时、错误码和 correlationId;Cookie、Token、消息正文、附件、完整 UI 树和密钥不得进入普通日志。 + +## 6. 开发阶段与验收门槛 + +阶段按 U0 → U1 → U2/U3 → U4 → U5 → U6 → U7 推进。U2/U3 可独立开发适配代码,但共用契约;不得并行操作同一微信桌面。 + +### U0:盘点和契约冻结(2–3 人日) + +开发步骤: + +1. 逐项核对 Program.cs、公开 C# API、PENDING 和验收记录,输出操作清单,不用旧里程碑状态替代现状。 +2. 标记 Ready、待真机验证、暂缓、未实现;列清 UI 页面、REST、MCP、真实方法和风险对应关系。 +3. 确认目标 MCP 客户端、协议/SDK 版本、首版浏览器;锁定本机与外部 HTTP 部署、共用 Token 和原生 Web UI 方案,验证目标 MCP 客户端支持非回环 HTTP 与 Bearer 配置;不支持时明确标记客户端不兼容,不绕过其安全限制。 +4. 定义 DTO、错误、任务状态、权限、确认策略、上传限制和内容披露规则。 +5. 验证数据库账号到当前 UI 账号的可靠绑定及失效检测;无可靠方案则写能力 No-Go,不阻塞只读基础版。 +6. 验证 Linux 能加载真实 HTTP/MCP 适配层的最小测试,再冻结项目边界;不能仅以 Core 替身测试通过代替。 + +验收: + +- 功能矩阵所有行均有真实入口或“未实现”说明;零虚构可用服务。 +- 每项写动作有目标判定、确认规则、超时/取消语义和后置成功条件。 +- SDK 可恢复/编译,目标 MCP 客户端可使用与 HTTP API 相同的 Token 经外部 HTTP 完成最小 initialize/list/call 实验。 +- 账号绑定有可验证证据,覆盖切换账号、重启及身份未知时拒绝写入;缺失则明确阻塞 U4 写能力。 +- Linux 真实适配冒烟覆盖至少一条路由、鉴权中间件和一个 MCP 工具 schema,不加载 Windows 实现。 +- 交付 `docs/WebUI-MCP-功能矩阵.md`,未经批准的暂缓项仍保持暂缓。 + +### U1:服务骨架与共享执行(4–6 人日) + +开发步骤: + +1. 增加 serve 分支和最小 HTTP Host,CLI 行为保持兼容。 +2. 提取必要共享处理逻辑,使 REST、MCP 不复制参数校验和微信操作实现。 +3. 接入有界队列、现有门禁、超时取消和任务记录,核查库 API 未受门禁保护的路径。 +4. 实现默认回环及显式外部 HTTP 监听、HTTP/MCP 共用 Token、统一浏览器 Token 登录、权限、CSRF、Host/Origin 检查和脱敏日志。 +5. 先接 status、capabilities、operations 查询和取消。 + +验收: + +- 原 Core 测试与全量 Release 构建通过;CLI 原命令 smoke 不退化。 +- 20 个并发模拟 UI 请求的最大执行并发数为 1;复合动作不交错;数据库读取不长期阻塞 UI。 +- 队列超过上限明确拒绝,不无限增长;排队超时后不得执行微信动作。 +- 未认证、错误 Origin/Host、无权限和跨站写请求均被拒绝;密钥与正文日志扫描为零泄漏。 +- 重复任务返回同一 ID;冲突参数拒绝;强制终止后不重放写操作。 +- 写请求缺幂等键被拒绝;同身份跨 HTTP/MCP 并发、接单响应丢失后重试、Token 轮换后重试均返回原任务;记录持久化失败无微信动作。 +- 排队超时、取消、重启恢复及持久化失败有状态测试;短查询直接返回且不产生持久任务,UI 查询仍串行调度。 +- 两个身份不能互读/取消任务、读取事件或下载产物,无管理员跨身份例外。更换或撤销 Token 后旧 Cookie/MCP/SSE 立即失效,未执行任务不再写入,已提交任务不虚报已撤销。 +- 非回环监听缺 Token 时启动失败;配置 Token 后可使用 HTTP,无 TLS 前置要求。HTTP/MCP 同 Token 得到相同身份和权限,缺失/错误 Token 均被拒绝;伪造转发头不能使用本机专用入口。 + +### U2:只读服务与 MCP 基础(3–5 人日) + +开发步骤: + +1. 接账号状态、会话、可见消息、数据库联系人/群成员和消息读取。 +2. 明确数据库账号与 UI 当前账号的不同含义;多账号未选择时报错。 +3. REST 与 MCP 共用处理函数,增加只读/内容权限和结果大小限制。 +4. 建立 MCP 初始化、工具列表、schema、调用、错误、取消的兼容测试。 + +验收: + +- 同一固定快照从 REST/MCP 返回相同业务字段和错误码(忽略关联 ID/时间)。 +- 联系人同名不同 ID 不合并;limit/offset 边界和末页正确;未知字段不虚构。 +- 未选多账号不猜测;未授权 includeContent 不泄露正文。 +- list/tools 不含未实现的可用工具;输入越界在触碰微信之前失败。 +- 所有列表有数量上限;旧游标/数据变更的限制在响应或页面可见。 + +### U3:Web UI 只读闭环(4–6 人日) + +开发步骤: + +1. 建总览、账号选择、会话/消息、联系人/群成员、诊断、任务中心页面。 +2. 实现统一 loading/empty/error/offline/unsupported 状态、分页和刷新。 +3. 加入正文显示开关、能力禁用说明、关联 ID 复制与脱敏诊断下载。 +4. 实现键盘导航、可读焦点、表单标签和自适应布局。 + +验收: + +- Edge/Chrome 目标版本在本机和外部均可完成“输入共用 Token → 状态 → 选账号 → 读取 → 分页 → 退出”。仅本机诊断管理动作在外部显示禁用。 +- 登录失败、Token 失效/撤销有明确提示,外部 HTTP 页面提示明文风险;浏览器持久存储、URL、历史和普通日志不含 Token,注销关闭事件流。 +- 刷新页面、服务离线、401、空列表、超时均有明确可恢复提示,不显示假数据或无限 loading。 +- 昵称/消息含 HTML、脚本或 Markdown 链接时不执行代码,不自动请求外部资源。 +- 1280×720 和 1920×1080 无关键控件遮挡;浏览器 100%/125%/150% 缩放可操作;这不替代微信 DPI 验收。 +- 全程键盘可选择目标、打开详情、关闭弹窗,焦点不丢失。 + +### U4:基础写入、文件和确认门禁(工期 U0 重估) + +开发步骤: + +1. 接文本、引用、单图片、单文件、URL 卡片以及明确支持的提及操作。 +2. 写前绑定账号/目标和消息引用,检测重名、旧快照与已有草稿,不能覆盖用户草稿。 +3. 复用现有操作的显式确认参数与权限检查;HTTP/MCP 校验后直接入队,不实现人工审批中心。 +4. 接上传、fileId、产物下载、限额及清理;实现写后确认与不确定状态展示。 + +验收: + +- 仅对白名单测试对象执行,每种首版发送类型至少成功 1 次并读回对应类型/内容摘要;发送请求不因点击、HTTP/MCP 重试而重复执行。 +- 前端快速双击、同幂等键并发、断连后重查均只产生一个逻辑任务。 +- 已有要求确认参数的操作缺少确认时无副作用,适配器不自动补 `CONFIRM`;确认不提升权限。相同幂等键下参数变更被拒绝;账号切换使待执行写任务失效,取得执行所有权后绑定不匹配则不发送。 +- 点击提交后故意中断确认阶段,返回 Unconfirmed,不自动重发。 +- 文件超限、扩展名伪装、路径穿越、非本用户 fileId 和过期产物被拒绝;合法文件发送并确认。 +- URL 卡片验收必须确认原生卡片,不能以发送 URL 文本冒充。 + +### U5:实时监听与恢复(4–6 人日) + +开发步骤: + +1. 接现有 MessageEvent/checkpoint,建立服务订阅生命周期和所有权。 +2. 浏览器 SSE 输出事件 ID、来源会话、恢复标记;MCP 分页拉取相同事件。 +3. 有界环形缓存与慢消费者隔离;游标过期返回 gap/resyncRequired,不能静默跳过。 +4. 浏览器断连重连只恢复读取;Host 重启的监听续接与写任务不重放分开处理。 + +验收: + +- 60 分钟受控测试,记录至少 20 条独立标记消息;在声明的可见/监听覆盖范围内计数与来源正确、0 重复。 +- 浏览器断连 30 秒且仍在缓存范围内,重连补齐事件;超过缓存范围明确报告缺口。 +- 一个慢浏览器/MCP 客户端不能阻塞 UI 执行或其他订阅;缓存数量不超过上限。 +- Token 撤销或权限收回后,现有 SSE/MCP 事件读取停止输出;另一身份不能凭窃取的订阅 ID/游标读取历史事件。 +- UI 导航与监听冲突不会将消息归属到错误会话;无法确定时明确失败。 +- 该短测不替代仍暂缓的 8/24/72 小时及多会话稳定性验收。 + +### U6:管理和高级功能(条件阶段,5–10 人日,不含暂缓等待) + +开发步骤: + +1. 按“只读详情 → 子窗口 → 消息动作 → 管理变更 → 朋友圈写入”逐项接入。 +2. 对照真实方法签名建立专用 DTO 和 MCP 工具,不开放任意 UI 菜单、脚本或 shell。 +3. 每项写能力先补专用测试对象的定位、确认及后置结果验证,再解除 capability 禁用。 +4. 合并记录、OCR、转文字、语音 Beta 显示真实限制;保持 PENDING 决定。 + +验收: + +- 每个解除禁用的动作分别具备成功、无权限、取消、歧义目标、结果不确定用例。 +- 联系人/群管理、朋友圈写入必须另获恢复暂缓验收的明确授权;没有授权则维持禁用并标记条件未满足。 +- 朋友圈刷新后旧指纹拒绝操作;管理弹窗不唯一时不点击;未知 OCR 结果不返回整窗拼接文本。 +- 语音 Beta、长列表提及、嵌套合并附件等未验证项不得显示正式可用。 +- U6 未完成不伪装全功能交付;可以发布明确标注功能范围的 U1–U5 基础版。 + +### U7:集成、部署与发布(3–5 人日) + +开发步骤: + +1. 完成 Core 契约测试、HTTP/MCP 一致性和浏览器端到端测试;UI 测试使用明确的 mock 数据,不计入真机通过数。 +2. Linux restore/test/build/publish 后上传 Windows,同一已登录用户会话启动 serve。 +3. 执行 doctor/inspect-ui/smoke 及本期实际接入功能回归,记录 Windows/微信/浏览器/MCP 客户端版本。 +4. 验证关闭、崩溃重启、凭据撤销、端口冲突、锁屏、微信退出及版本不支持路径。 +5. 从另一主机验证外部 HTTP Web UI、HTTP API 和 MCP:同一 Token 可访问,缺失/错误/撤销 Token 被拒绝,本机专用入口不可访问;验证 Host/Origin、限流和防火墙部署要求,不以 localhost 自测代替。 +6. 输出使用说明、HTTP/MCP 共用 Token 配置样例(只用占位值)、显式监听配置、明文风险提示、凭据更换、回滚步骤及本轮验收报告。 + +验收: + +- 发布包包含 Web 静态资源与 MCP 依赖;Windows 无全局 dotnet 也可运行。 +- 未锁定会话正常工作;锁屏/未登录时明确拒绝写入,不进入 Session 0,不绕过登录。 +- CLI、Web UI、MCP 并发提交时无 UI 步骤交错;原有 CLI 行为和安全门禁不退化。 +- 所有首版必选测试通过;未完成项附禁用状态、影响及证据,零已知越权、重复写入和隐私泄漏缺陷。 +- 回滚前停止新服务,保存必要任务记录;旧版本不得自动消费新版本未完成写任务。 + +## 7. 测试执行与证据模板 + +### 7.1 Linux 构建基线 + +```bash +dotnet restore WxAgent.sln -p:EnableWindowsTargeting=true +dotnet test tests/WxAgent.Core.Tests -c Release +dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true +dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \ + -p:EnableWindowsTargeting=true -p:PublishSingleFile=true -p:PublishTrimmed=false +``` + +新增 HTTP/MCP 测试必须在 Linux 加载生产使用的真实路由、鉴权和协议适配,仅 Windows 业务执行器使用纯逻辑替身;不得通过重写测试路由规避 Host 的 Windows 目标限制。按 U0 验证后的最小跨平台项目边界实施,不在 Linux 执行 FlaUI;Windows 会话绑定、部署监听和桌面行为留在 Windows。实施时将新测试命令纳入发布清单。 + +### 7.2 Windows 真机 + +沿用 AGENTS.md:主机 `10.1.1.101`、计算机 `DESKTOP-EGI7QCK`、用户 `rogee`,部署到 `C:\Users\Rogee\wx-agent`。优先 Windows MCP,上传/日志可用 `ssh rogee@10.1.1.101`。当前环境的 Windows MCP 是操作真机的工具,**不是本计划要开发的 WxAgent MCP 服务**。 + +每次发布至少执行: + +```powershell +WxAgent.Host doctor +WxAgent.Host inspect-ui --output artifacts/ui-tree.json +WxAgent.Host smoke +# 本计划实施后的新增入口,当前不能作为已有命令执行: +WxAgent.Host serve +``` + +- smoke 有发送副作用,测试对象和次数必须写入报告。 +- 消息测试仅使用“文件传输助手”“Hao 豪”“吉祥三宝”“消息测试专用群组”;@所有人仅在指定测试群。 +- 不为完成 UI/MCP 接入而操作真实联系人、真实群或朋友圈内容;已有暂缓写验收需另行授权。 +- 新增验收记录建议保存到 `docs/validation/WebUI-MCP-<日期>.md`,敏感原始产物不提交仓库。 + +### 7.3 单项验收记录 + +| 字段 | 必填内容 | +| --- | --- | +| 能力/用例 | 操作 ID、REST 路由、MCP 工具、UI 入口 | +| 环境 | 提交号、包版本、Windows/微信/浏览器/MCP 客户端版本、DPI、会话状态 | +| 前置条件 | 权限、账号绑定、目标别名、启用状态和既有确认参数要求 | +| 输入 | 脱敏参数、数量、边界及超时设置 | +| 预期 | HTTP/工具错误码、任务状态、可观察微信后置结果 | +| 实际 | correlationId、脱敏证据、耗时、重复/遗漏计数 | +| 结论 | 通过/失败/未执行/暂缓,不用“基本支持” | +| 限制 | 失败诊断、适用范围、是否阻塞对应 capability 启用 | + +## 8. 排期、交付和完成定义 + +单工程师原本机范围估算:U0–U5 加 U7 为 **25–39 人日**;U6 另计 **5–10 人日**。当前范围增加外部 HTTP、共用 Token、对象级授权及撤销验证,补入账号绑定和 Linux 真实适配技术验证;同时取消 HTTPS 要求、配对码、人工审批、Token 重叠轮换及管理员跨身份管理。旧总工期和阶段人日仅为历史参考,U0 完成后重新估算;不含用户暂缓等待、微信版本适配及全面压力测试。 + +基础版交付: + +- 可发布的 serve 入口、支持本机及外部 HTTP 的同源 Web UI、HTTP/MCP 共用 Token 与已验证连接配置。 +- 共享契约与能力矩阵、鉴权、现有确认门禁、任务和事件通道。 +- 首版功能的 Linux 自动测试和 Windows 真机证据。 +- 用户操作手册、诊断导出、凭据撤销及回滚说明。 + +最终完成必须同时满足: + +1. UI、REST、MCP 对同一业务操作使用同一校验、权限和调度路径。 +2. 每个标记 Ready 的能力有输入/输出、超时取消、稳定错误、测试及当前微信版本验收记录。 +3. 成功表示已满足明确后置条件;未确认结果不会误标成功,不盲目重试。 +4. 应用日志及未授权响应无内容/密钥泄漏、无未认证写入、无任意文件或 SQL 入口;外部业务访问通过共用 Token 或其有效派生会话认证,对象级授权、撤销及本机专用边界有验收证据。明文 HTTP 不保证传输保密性,不能将通过应用鉴权验收描述为网络防窃听验收通过。 +5. 未实现、暂缓与不支持项在文档、UI 和 MCP 能力信息中一致;不得将本次控制面交付描述为 M6 或全功能矩阵已完成。 diff --git a/docs/validation/WebUI-MCP-2026-09-07.md b/docs/validation/WebUI-MCP-2026-09-07.md new file mode 100644 index 0000000..50e4c98 --- /dev/null +++ b/docs/validation/WebUI-MCP-2026-09-07.md @@ -0,0 +1,49 @@ +# WebUI/MCP 验收记录(2026-09-07) + +## 环境 + +- Windows:`DESKTOP-EGI7QCK` / Windows 用户 `rogee` +- 微信版本:`4.1.13.63` +- 发布方式:Linux .NET 8 self-contained `win-x64` single-file,部署目录 `C:\Users\Rogee\wx-agent` +- 服务进程:曾在交互式用户会话启动(非 Session 0);最终验证配置恢复默认回环 `127.0.0.1:5088`,发布完成后保持停止 +- MCP SDK:`ModelContextProtocol.AspNetCore 2.2.0` + +## 已验证 + +| 能力 | 入口 | 结果 | +|---|---|---| +| 服务/微信诊断 | `GET /api/v1/status` | HTTP 200;`serviceOnline=true`、`wechatAvailable=true`、`sessionAvailable=true`、`windowFound=true`,错误为空 | +| 会话列表 | `GET /api/v1/sessions` | HTTP 200;返回可见会话,含 automationId | +| 会话当前/精确搜索 | `/api/v1/sessions/current`、`/search?exactOnly=true` | HTTP 200;当前会话和“文件传输助手”精确匹配均返回稳定 automationId | +| 账号发现 | `GET /api/v1/accounts` | HTTP 200;返回数据库指纹;UI 绑定保持 false | +| 联系人只读 | `GET /api/v1/contacts` | HTTP 200;返回稳定联系人 ID;未输出密钥 | +| 群成员只读 | `GET /api/v1/groups/{accountId}/{group}/members` | HTTP 200;显式账号范围与测试群 `消息测试专用群组`,返回稳定成员 ID | +| SSE 单帧 | `GET /api/v1/events` | HTTP 200;真实 `id/event/data` 帧,正文不进入事件;默认仍需显式 opt-in | + +| 可见消息摘要 | `GET /api/v1/messages` | HTTP 200;返回 fingerprint/type/summary,正文为 null | +| MCP | `/mcp` initialize、`tools/list` | HTTP 200;同一 Bearer Token;工具 schema 可加载 | +| Web 静态入口 | `/` | HTTP 200;静态资源随发布包部署 | + +## 安全/失败路径 + +- 无 Bearer Token:401。 +- 非 allowlist Host/Origin:403。 +- URL 查询中出现 token 参数:400,拒绝凭据泄漏路径。 +- 旧 Token 轮换后:旧 Bearer 与浏览器 Cookie 均 401;身份与幂等记录保留。 +- 写能力:保持 disabled;当前服务不模拟成功、不执行微信写入。 +- SSH 端口转发初次测试需要显式覆盖 Host 头;未将转发场景误判为外部网络验收。 +- `0.0.0.0` 外部监听配置已验证可加载并明确要求 `AllowExternal=true`;当前 Windows 防火墙未开放入站端口,因此未声称外部网络连通验收通过。 + +## 自动化证据 + +- `dotnet test tests/WxAgent.Core.Tests -c Release`:150 passed。 +- `dotnet test tests/WxAgent.Service.Tests -c Release`:11 passed。 +- `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true`:成功,0 warning/0 error。 +- self-contained `win-x64` publish:成功,包含 `wwwroot` 与 MCP 依赖。 + +## 未完成/不宣称 + +- 未执行发送、联系人/群管理、朋友圈、语音等写操作;`send-text`、`group-at-all` 和 deferred 项保持禁用。 +- 未完成 60 分钟/20 条消息监听、断线补齐和多客户端慢消费者真机验收;SSE 与服务端监听源已实现,默认配置 `EnableListenerEvents=false`。自动化高频探针已中止,不作为验收证据;后续真机消息验证上限 3 条。 +- 未完成外部主机直接访问验收(防火墙未开放);不将 HTTP 鉴权描述为网络加密。 +- 远程验证任务已停止并清理;不再自动发送测试消息,后续真机消息验证上限为 3 条。 diff --git a/docs/webui-mcp-config.example.json b/docs/webui-mcp-config.example.json new file mode 100644 index 0000000..88aae31 --- /dev/null +++ b/docs/webui-mcp-config.example.json @@ -0,0 +1,11 @@ +{ + "ListenUrl": "http://127.0.0.1:5088", + "AllowExternal": false, + "AllowedHosts": ["127.0.0.1:5088", "localhost:5088", "[::1]:5088"], + "AllowedOrigins": ["http://127.0.0.1:5088", "http://localhost:5088", "http://[::1]:5088"], + "CredentialFile": "C:/Users/USERNAME/wx-agent/credentials.json", + "DataDirectory": "C:/Users/USERNAME/wx-agent/data", + "QueueCapacity": 100, + "ListenerSession": "文件传输助手", + "EnableListenerEvents": false +} diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index 5904850..b85d88e 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -2,6 +2,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using WxAgent.Core; using WxAgent.Windows; +using WxAgent.Service; +using WxAgent.Host; var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; jsonOptions.Converters.Add(new JsonStringEnumConverter()); @@ -22,6 +24,20 @@ try return 0; } + if (args[0] == "serve") + { + ValidateOptions(args, 1, ["--config"], []); + var serviceOptions = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(GetRequiredOption(args, "--config"), shutdown.Token), ServiceHost.Json) + ?? throw new ArgumentException("A service configuration is required."); + await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(serviceOptions)); + await app.StartAsync(shutdown.Token); + try { await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token); } + catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { } + await app.StopAsync(CancellationToken.None); + return 0; + } + var defaultTimeoutSeconds = ValidateCommandLine(args); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(shutdown.Token); timeout.CancelAfter(TimeSpan.FromSeconds(GetTimeoutSeconds(args, defaultTimeoutSeconds))); @@ -987,6 +1003,7 @@ static int CountNodes(UiNodeSnapshot node) => 1 + node.Children.Sum(CountNodes); static void PrintHelp() => Console.WriteLine(""" WxAgent.Host commands: + serve --config doctor [--timeout 30] diagnose --output [--baseline ] [--timeout 60] inspect-ui --output [--window-title ] [--timeout 30] diff --git a/src/WxAgent.Host/WindowsAgentBackend.cs b/src/WxAgent.Host/WindowsAgentBackend.cs new file mode 100644 index 0000000..6386c57 --- /dev/null +++ b/src/WxAgent.Host/WindowsAgentBackend.cs @@ -0,0 +1,159 @@ +using WxAgent.Core; +using WxAgent.Service; +using WxAgent.Windows; + +namespace WxAgent.Host; + +internal sealed class WindowsAgentBackend(ServiceOptions options) : IAgentBackend, IAgentEventSource +{ + public IReadOnlyList<AgentCapability> Capabilities { get; } = + [ + new("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("accounts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("sessions-list", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("sessions-scroll", true, false, false, true, false, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"), + new("session-open", true, false, false, true, true, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"), + new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("db-messages", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.63"), + new("db-merged", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.63"), + new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("listener-events", true, options.EnableListenerEvents, options.EnableListenerEvents, true, false, "read", false, 30, + options.EnableListenerEvents ? null : "Listener events require explicit opt-in and recovery validation.", ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("send-text", true, false, false, true, true, "write", false, 30, + "Active UI/database account binding has not been verified for this service session.", []), + new("group-at-all", true, false, false, true, true, "write", true, 30, + "Service account binding and group-owner validation required.", []), + new("next-unread", false, false, false, true, false, "read", false, 30, + "Deferred by docs/PENDING.md.", []) + ]; + + public async IAsyncEnumerable<AgentEvent> ListenAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var checkpoint = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", "listener-checkpoint.json"); + while (!cancellationToken.IsCancellationRequested) + { + await foreach (var item in WechatChatClient.ListenEventsAsync(TimeSpan.FromMinutes(5), checkpoint, cancellationToken, + session: options.ListenerSession)) + { + yield return new AgentEvent(item.EventId, "ui-current", item.Session, + item.Kind.ToString(), item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt); + } + } + } + + public Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken cancellationToken) + { + var roots = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken); + // Database fingerprints are deliberately not merged with the current UI identity. + return Task.FromResult<IReadOnlyList<AccountInfo>>(roots.Select(root => new AccountInfo(root.Fingerprint, null, null, null, + root.Fingerprint, false)).ToArray()); + } + + public async Task<IReadOnlyList<SessionInfo>> SessionsAsync(CancellationToken cancellationToken) + { + var sessions = await WechatChatClient.ListVisibleSessionsAsync(cancellationToken); + return sessions.Select(s => new SessionInfo(s.Name, s.AutomationId, s.IsCurrent)).ToArray(); + } + + public async Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken cancellationToken) + { + var results = await WechatChatClient.SearchSessionsAsync(query, exactOnly, cancellationToken); + return results.Select(s => new SessionSearchInfo(s.Name, s.AutomationId, s.IsExactMatch)).ToArray(); + } + + public async Task<SessionInfo?> CurrentSessionAsync(CancellationToken cancellationToken) + { + var current = await WechatChatClient.GetCurrentSessionAsync(cancellationToken); + return current is null ? null : new SessionInfo(current.Name, current.AutomationId, true); + } + + public async Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken cancellationToken) + { + var matches = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken)).Where(s => s.AutomationId == automationId).ToArray(); + if (matches.Length != 1) throw new ServiceException(matches.Length == 0 ? "NotFound" : "AmbiguousTarget", 409, "AutomationId is not unique."); + await WechatChatClient.OpenSessionAsync(matches[0].Name, cancellationToken, automationId); + return new SessionInfo(matches[0].Name, matches[0].AutomationId, true); + } + + public async Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken) + { + var result = await WechatChatClient.ScrollSessionsAsync(direction == "up" ? WechatScrollDirection.Up : WechatScrollDirection.Down, pages, cancellationToken); + return new SessionViewportInfo(result.Scrolls, result.ViewportChanged, + result.Sessions.Select(s => new SessionInfo(s.Name, s.AutomationId, s.IsCurrent)).ToArray()); + } + + public async Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(session)) + { + var matches = await WechatChatClient.SearchSessionsAsync(session, exactOnly: true, cancellationToken: cancellationToken); + if (matches.Count != 1) throw new ServiceException(matches.Count == 0 ? "NotFound" : "AmbiguousTarget", 409, "Session name is not unique or not found."); + await WechatChatClient.OpenSessionAsync(matches[0].Name, cancellationToken, session); + } + var messages = await WechatChatClient.ReadVisibleAsync(cancellationToken); + return messages.Select(m => new MessageInfo(m.Fingerprint, m.Type.ToString(), m.Quote?.Sender, + m.Text.Length > 160 ? m.Text[..160] : m.Text, includeContent ? m.Text : null)).ToArray(); + } + + public async Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken) + { + var page = await WechatChatClient.GetContactsPageAsync(limit, offset, contains, groupsOnly, accountId, cancellationToken: cancellationToken); + var items = page.Contacts.Select(c => new ContactInfo(c.Username, c.DisplayName, c.Remark, null)).ToArray(); + return new Page<ContactInfo>(items, limit, offset, page.HasMore, page.NextOffset); + } + + public async Task<Page<GroupMemberInfo>> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken cancellationToken) + { + var page = await WechatChatClient.GetGroupMembersPageAsync(group, limit, offset, accountId, cancellationToken: cancellationToken); + var items = page.Members.Select(m => new GroupMemberInfo(m.MemberId, m.Username, m.DisplayName, m.IsOwner)).ToArray(); + return new Page<GroupMemberInfo>(items, limit, offset, page.HasMore, page.NextOffset); + } + + public async Task<IReadOnlyList<DatabaseMessageInfo>> DatabaseMessagesAsync(string accountId, string chatId, int limit, long? localId, CancellationToken cancellationToken) + { + var account = await DatabaseKeyStore.LoadAsync(null, cancellationToken); + var selected = account.SingleOrDefault(a => string.Equals(a.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No verified database account matches the selected fingerprint."); + var messages = await WechatMessageDbReader.ReadAsync(selected.AccountRootPath, selected.Databases, chatId, limit, cancellationToken, localId); + return messages.Select(m => new DatabaseMessageInfo(m.LocalId, m.ServerId, m.ChatId, m.SenderWxId, m.SenderName, m.Type, m.Content, m.Timestamp, m.IsSelf)).ToArray(); + } + + public async Task<MergedMessageInfo> DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken cancellationToken) + { + var account = await DatabaseKeyStore.LoadAsync(null, cancellationToken); + var selected = account.SingleOrDefault(a => string.Equals(a.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No verified database account matches the selected fingerprint."); + var merged = await WechatMessageDbReader.ReadMergedAsync(selected.AccountRootPath, selected.Databases, chatId, localId, cancellationToken); + var parent = merged.Parent; + var messages = merged.Record.Messages.Select(m => new MergedMessagePart(m.SenderName, m.Text, m.Timestamp, m.Path, m.DataType)).ToArray(); + return new MergedMessageInfo(merged.DatabaseRelativePath, + new DatabaseMessageInfo(parent.LocalId, parent.ServerId, parent.ChatId, parent.SenderWxId, parent.SenderName, parent.Type, parent.Content, parent.Timestamp, parent.IsSelf), + merged.Record.Title, merged.Record.Description, messages); + } + + public async Task<object> DiagnoseAsync(CancellationToken cancellationToken) + { + var result = await StatusAsync(cancellationToken); + return new { diagnostic = true, redacted = true, result }; + } + + public Task<object> StatusAsync(CancellationToken cancellationToken) + { + var report = WechatDoctor.Run(cancellationToken); + return Task.FromResult<object>(new + { + serviceOnline = true, + wechatAvailable = report.Errors.Count == 0, + sessionAvailable = report.UserInteractive && report.InputDesktopAvailable, + report.WindowFound, + errors = report.Errors.Select(e => e.ToString()), + wechatVersions = report.Processes.Select(p => p.Version).Where(v => v is not null).Distinct(), + activeAccountBound = false, + defaultReadOnly = true + }); + } +} diff --git a/src/WxAgent.Host/WxAgent.Host.csproj b/src/WxAgent.Host/WxAgent.Host.csproj index f51954a..8cd4719 100644 --- a/src/WxAgent.Host/WxAgent.Host.csproj +++ b/src/WxAgent.Host/WxAgent.Host.csproj @@ -15,5 +15,6 @@ </PropertyGroup> <ItemGroup> <ProjectReference Include="..\WxAgent.Windows\WxAgent.Windows.csproj" /> + <ProjectReference Include="..\WxAgent.Service\WxAgent.Service.csproj" /> </ItemGroup> </Project> diff --git a/src/WxAgent.Service/AgentService.cs b/src/WxAgent.Service/AgentService.cs new file mode 100644 index 0000000..19bbcce --- /dev/null +++ b/src/WxAgent.Service/AgentService.cs @@ -0,0 +1,125 @@ +using Microsoft.AspNetCore.Http; + +namespace WxAgent.Service; + +public sealed record AgentCapability(string Operation, bool Implemented, bool Validated, bool Enabled, + bool RequiresUi, bool HasSideEffects, string Permission, bool RequiresConfirmation, int TimeoutSeconds, + string? DisabledReason, string[] Evidence, string? WechatVersion = null, int ContractVersion = 1); + +// The only platform seam: Windows is composed by Host; route tests load this assembly unchanged. +public interface IAgentBackend +{ + IReadOnlyList<AgentCapability> Capabilities { get; } + Task<object> StatusAsync(CancellationToken cancellationToken); + Task<object> DiagnoseAsync(CancellationToken cancellationToken) => StatusAsync(cancellationToken); + Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<AccountInfo>>([]); + Task<IReadOnlyList<SessionInfo>> SessionsAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<SessionInfo>>([]); + Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<SessionSearchInfo>>([]); + Task<SessionInfo?> CurrentSessionAsync(CancellationToken cancellationToken) => Task.FromResult<SessionInfo?>(null); + Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Session opening is not available."); + Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken) => Task.FromResult(new SessionViewportInfo(0, false, [])); + Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<MessageInfo>>([]); + Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page<ContactInfo>([], limit, offset, false, null)); + Task<Page<GroupMemberInfo>> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page<GroupMemberInfo>([], limit, offset, false, null)); + Task<IReadOnlyList<DatabaseMessageInfo>> DatabaseMessagesAsync(string accountId, string chatId, int limit, long? localId, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<DatabaseMessageInfo>>([]); + Task<MergedMessageInfo> DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Merged database messages are not available."); +} + +public sealed class AgentService(IAgentBackend backend, ServiceSecurity security, IHttpContextAccessor contexts, OperationQueue operations, ArtifactStore artifacts) +{ + public ServiceIdentity Identity => contexts.HttpContext?.Items[typeof(ServiceIdentity)] as ServiceIdentity + ?? throw new ServiceException("Unauthorized", 401, "Authentication required."); + + internal void RequireEvents() => RequireCapability("listener-events"); + + private void RequireCapability(string operation) + { + var capability = backend.Capabilities.SingleOrDefault(c => c.Operation == operation) + ?? throw new ServiceException("Unsupported", 501, "Capability is not implemented."); + security.RequireCurrent(Identity, capability.Permission); + if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability is disabled."); + } + + public async Task<object> DiagnoseAsync(CancellationToken cancellationToken) + { RequireCapability("agent-diagnose"); return await backend.DiagnoseAsync(cancellationToken); } + + public async Task<object> StatusAsync(CancellationToken cancellationToken) + { + RequireCapability("agent-status"); + var result = await backend.StatusAsync(cancellationToken); + security.RequireCurrent(Identity, "read"); + return result; + } + + public async Task<Page<AccountInfo>> AccountsAsync(int limit, int offset, CancellationToken ct) + { RequireCapability("accounts-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.AccountsAsync(ct)).ToPage(limit, offset); } + + public async Task<Page<SessionInfo>> SessionsAsync(int limit, int offset, CancellationToken ct) + { RequireCapability("sessions-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.SessionsAsync(ct)).ToPage(limit, offset); } + + public async Task<Page<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, int limit, int offset, CancellationToken ct) + { RequireCapability("sessions-search"); ReadOnlyRequest.Page(limit, offset); if (string.IsNullOrWhiteSpace(query) || query.Length > 200) throw new ServiceException("InvalidRequest", 400, "query must be 1..200 characters."); return (await backend.SearchSessionsAsync(query, exactOnly, ct)).ToPage(limit, offset); } + + public async Task<SessionInfo> CurrentSessionAsync(CancellationToken ct) + { RequireCapability("session-current"); return await backend.CurrentSessionAsync(ct) ?? throw new ServiceException("NotFound", 404, "No current session."); } + + public async Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken ct) + { RequireCapability("session-open"); if (string.IsNullOrWhiteSpace(automationId) || automationId.Length > 512) throw new ServiceException("InvalidRequest", 400, "automationId is required and bounded."); return await backend.OpenSessionAsync(automationId, ct); } + + public async Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken ct) + { RequireCapability("sessions-scroll"); if (pages is < 1 or > 10 || direction is not ("up" or "down")) throw new ServiceException("InvalidRequest", 400, "direction must be up/down and pages must be 1..10."); return await backend.ScrollSessionsAsync(direction, pages, ct); } + + public async Task<Page<MessageInfo>> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) + { + RequireCapability("messages-read"); + var identity = security.RequireCurrent(Identity, includeContent ? "content" : "read"); + ReadOnlyRequest.Page(limit, offset); + var values = await backend.MessagesAsync(session, includeContent && identity.Allows("content"), ct); + return values.ToPage(limit, offset); + } + + public async Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken ct) + { + RequireCapability("contacts-list"); + var identity = security.RequireCurrent(Identity, "read"); + ReadOnlyRequest.Page(limit, offset); if (!string.IsNullOrWhiteSpace(accountId)) security.RequireCurrent(identity, "read", accountId); + return await backend.ContactsAsync(accountId, contains, groupsOnly, limit, offset, ct); + } + + public async Task<Page<GroupMemberInfo>> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken ct) + { + RequireCapability("group-members"); + var identity = security.RequireCurrent(Identity, "read", accountId); ReadOnlyRequest.Page(limit, offset); + return await backend.GroupMembersAsync(accountId, group, limit, offset, ct); + } + + public async Task<Page<DatabaseMessageInfo>> DatabaseMessagesAsync(string accountId, string chatId, int limit, int offset, long? localId, CancellationToken ct) + { + RequireCapability("db-messages"); security.RequireCurrent(Identity, "read", accountId); ReadOnlyRequest.Page(limit, offset); + if (string.IsNullOrWhiteSpace(chatId) || chatId.Length > 512) throw new ServiceException("InvalidRequest", 400, "chatId is required and bounded."); + return (await backend.DatabaseMessagesAsync(accountId, chatId, Math.Min(500, limit + offset), localId, ct)).ToPage(limit, offset); + } + + public async Task<MergedMessageInfo> DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken ct) + { + RequireCapability("db-merged"); security.RequireCurrent(Identity, "read", accountId); + if (localId <= 0) throw new ServiceException("InvalidRequest", 400, "localId must be positive."); + return await backend.DatabaseMergedAsync(accountId, chatId, localId, ct); + } + + public async Task<ArtifactInfo> UploadAsync(IFormFile file, CancellationToken ct) + { security.RequireCurrent(Identity, "write"); return await artifacts.SaveAsync(Identity.PrincipalId, file, ct); } + + public FileStream Download(string id) + { security.RequireCurrent(Identity, "content"); return artifacts.Open(Identity.PrincipalId, id); } + + public OperationRecord Operation(string id) => operations.Get(Identity, id); + public OperationRecord CancelOperation(string id) => operations.Cancel(Identity, id); + + public IReadOnlyList<AgentCapability> Capabilities() + { + var identity = security.RequireCurrent(Identity, "read"); + return backend.Capabilities.Select(c => identity.Allows(c.Permission) ? c : + c with { Enabled = false, DisabledReason = "Permission required." }).ToArray(); + } +} diff --git a/src/WxAgent.Service/AgentTools.cs b/src/WxAgent.Service/AgentTools.cs new file mode 100644 index 0000000..f6b8d56 --- /dev/null +++ b/src/WxAgent.Service/AgentTools.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace WxAgent.Service; + +[McpServerToolType] +public sealed class AgentTools(AgentService service) +{ + [McpServerTool(Name = "agent_status", ReadOnly = true), Description("Read authenticated agent and WeChat availability. Service online does not imply WeChat available.")] + public Task<CallToolResult> Status(CancellationToken cancellationToken) => + Result(() => service.StatusAsync(cancellationToken)); + + [McpServerTool(Name = "agent_capabilities", ReadOnly = true), Description("Read implemented, validated and enabled capabilities and their limits. Disabled capabilities are not callable tools.")] + public Task<CallToolResult> Capabilities() => Result(() => Task.FromResult<object>(service.Capabilities())); + + [McpServerTool(Name = "agent_diagnose", ReadOnly = true), Description("Run redacted diagnostics without changing the WeChat UI.")] + public Task<CallToolResult> Diagnose(CancellationToken cancellationToken = default) => Result(async () => await service.DiagnoseAsync(cancellationToken)); + + [McpServerTool(Name = "accounts_list", ReadOnly = true), Description("List explicit database accounts; a fingerprint does not prove current UI identity.")] + public Task<CallToolResult> Accounts(int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.AccountsAsync(limit, offset, cancellationToken)); + + [McpServerTool(Name = "sessions_list", ReadOnly = true), Description("List the visible WeChat sessions.")] + public Task<CallToolResult> Sessions(int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SessionsAsync(limit, offset, cancellationToken)); + + [McpServerTool(Name = "sessions_search", ReadOnly = true), Description("Search visible sessions; exact targeting is explicit and ambiguous matches are not guessed.")] + public Task<CallToolResult> SearchSessions(string query, bool exactOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.SearchSessionsAsync(query, exactOnly, limit, offset, cancellationToken)); + + [McpServerTool(Name = "session_current", ReadOnly = true), Description("Read the current visible WeChat session.")] + public Task<CallToolResult> CurrentSession(CancellationToken cancellationToken = default) => Result(async () => await service.CurrentSessionAsync(cancellationToken)); + + [McpServerTool(Name = "messages_read", ReadOnly = true), Description("Read bounded visible messages; content requires the content permission.")] + public Task<CallToolResult> Messages(string? session = null, int limit = 50, int offset = 0, bool includeContent = false, CancellationToken cancellationToken = default) => Result(async () => await service.MessagesAsync(session, limit, offset, includeContent, cancellationToken)); + + [McpServerTool(Name = "contacts_list", ReadOnly = true), Description("Read stable-ID contacts from the selected read-only database account.")] + public Task<CallToolResult> Contacts(string? accountId = null, string? contains = null, bool? groupsOnly = null, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.ContactsAsync(accountId, contains, groupsOnly, limit, offset, cancellationToken)); + + [McpServerTool(Name = "group_members", ReadOnly = true), Description("Read stable-ID members of an explicitly selected group and account.")] + public Task<CallToolResult> GroupMembers(string accountId, string group, int limit = 50, int offset = 0, CancellationToken cancellationToken = default) => Result(async () => await service.GroupMembersAsync(accountId, group, limit, offset, cancellationToken)); + + [McpServerTool(Name = "db_messages", ReadOnly = true), Description("Read bounded SQLCipher database messages for an explicitly verified account fingerprint; disabled until key scope is validated.")] + public Task<CallToolResult> DatabaseMessages(string accountId, string chatId, int limit = 50, int offset = 0, long? localId = null, CancellationToken cancellationToken = default) => Result(async () => await service.DatabaseMessagesAsync(accountId, chatId, limit, offset, localId, cancellationToken)); + + [McpServerTool(Name = "db_merged", ReadOnly = true), Description("Read one bounded merged database record for an explicitly verified account fingerprint; disabled until key scope is validated.")] + public Task<CallToolResult> DatabaseMerged(string accountId, string chatId, long localId, CancellationToken cancellationToken = default) => Result(async () => await service.DatabaseMergedAsync(accountId, chatId, localId, cancellationToken)); + + [McpServerTool(Name = "operation_get", ReadOnly = true), Description("Read one operation owned by the current identity; terminal writes are never replayed.")] + public Task<CallToolResult> Operation(string operationId) => Result(() => Task.FromResult<object>(service.Operation(operationId))); + + [McpServerTool(Name = "operation_cancel"), Description("Request cancellation of your operation. An in-flight write may remain Unconfirmed; cancellation does not undo a side effect.")] + public Task<CallToolResult> CancelOperation(string operationId) => Result(() => Task.FromResult<object>(service.CancelOperation(operationId))); + + internal static async Task<CallToolResult> Result(Func<Task<object>> action) + { + try + { + var value = await action(); + return new CallToolResult { Content = [new TextContentBlock { Text = JsonSerializer.Serialize(value, ServiceHost.Json) }] }; + } + catch (ServiceException e) + { + return new CallToolResult { IsError = true, Content = [new TextContentBlock + { Text = JsonSerializer.Serialize(new { error = new { e.Code, e.Message, stage = "validation", retry = false } }, ServiceHost.Json) }] }; + } + catch (OperationCanceledException) + { + return new CallToolResult { IsError = true, Content = [new TextContentBlock + { Text = JsonSerializer.Serialize(new { error = new { code = "Cancelled", message = "Operation cancelled.", stage = "execution", retry = true } }, ServiceHost.Json) }] }; + } + catch + { + return new CallToolResult { IsError = true, Content = [new TextContentBlock + { Text = JsonSerializer.Serialize(new { error = new { code = "InternalError", message = "Tool failed; use the HTTP correlation ID for diagnosis.", stage = "execution", retry = false } }, ServiceHost.Json) }] }; + } + } +} diff --git a/src/WxAgent.Service/ArtifactStore.cs b/src/WxAgent.Service/ArtifactStore.cs new file mode 100644 index 0000000..29ea54e --- /dev/null +++ b/src/WxAgent.Service/ArtifactStore.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; +using System.Text.Json; + +namespace WxAgent.Service; + +public sealed record ArtifactInfo(string ArtifactId, string PrincipalId, string FileName, string ContentType, long Length, string Sha256, DateTimeOffset ExpiresAt); + +public sealed class ArtifactStore(ServiceOptions options) +{ + private const long MaxBytes = 50L * 1024 * 1024; + private readonly string root = Path.Combine(options.DataDirectory, "artifacts"); + private static readonly HashSet<string> Types = new(StringComparer.OrdinalIgnoreCase) + { ".txt", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip" }; + + public async Task<ArtifactInfo> SaveAsync(string principalId, IFormFile file, CancellationToken ct) + { + if (file.Length is <= 0 or > MaxBytes) throw new ServiceException("FileTooLarge", 413, "Single file limit is 50 MiB."); + var extension = Path.GetExtension(file.FileName); + if (!Types.Contains(extension)) throw new ServiceException("FileTypeRejected", 415, "File type is not allowed."); + Directory.CreateDirectory(root); + var id = Guid.NewGuid().ToString("N"); + var path = Path.Combine(root, id + ".bin"); + var metadataPath = Path.Combine(root, id + ".json"); + try + { + await using var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + await using var input = file.OpenReadStream(); + var buffer = new byte[81920]; long total = 0; int read; + while ((read = await input.ReadAsync(buffer, ct)) != 0) + { + total += read; if (total > MaxBytes) throw new ServiceException("FileTooLarge", 413, "Single file limit is 50 MiB."); + await output.WriteAsync(buffer.AsMemory(0, read), ct); hash.AppendData(buffer, 0, read); + } + var info = new ArtifactInfo(id, principalId, Path.GetFileName(file.FileName), file.ContentType ?? "application/octet-stream", total, + Convert.ToHexString(hash.GetHashAndReset()), DateTimeOffset.UtcNow.AddHours(24)); + await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(info, ServiceHost.Json), ct); + return info; + } + catch { TryDelete(path); TryDelete(metadataPath); throw; } + } + + public ArtifactInfo Get(string principalId, string id) + { + if (!IsId(id)) throw new ServiceException("NotFound", 404, "Artifact not found."); + var info = Read(id); + if (info.PrincipalId != principalId) throw new ServiceException("NotFound", 404, "Artifact not found."); + if (info.ExpiresAt <= DateTimeOffset.UtcNow) { Delete(id); throw new ServiceException("Expired", 410, "Artifact expired."); } + return info; + } + + public FileStream Open(string principalId, string id) { Get(principalId, id); return new FileStream(Path.Combine(root, id + ".bin"), FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); } + public void Delete(string id) { if (IsId(id)) { TryDelete(Path.Combine(root, id + ".bin")); TryDelete(Path.Combine(root, id + ".json")); } } + private ArtifactInfo Read(string id) { try { return JsonSerializer.Deserialize<ArtifactInfo>(File.ReadAllText(Path.Combine(root, id + ".json")), ServiceHost.Json) ?? throw new InvalidDataException(); } catch { throw new ServiceException("NotFound", 404, "Artifact not found."); } } + private static bool IsId(string id) => id.Length == 32 && id.All(Uri.IsHexDigit); + private static void TryDelete(string path) { try { File.Delete(path); } catch { } } +} diff --git a/src/WxAgent.Service/DatabaseMessageContracts.cs b/src/WxAgent.Service/DatabaseMessageContracts.cs new file mode 100644 index 0000000..42032b7 --- /dev/null +++ b/src/WxAgent.Service/DatabaseMessageContracts.cs @@ -0,0 +1,7 @@ +namespace WxAgent.Service; + +public sealed record DatabaseMessageInfo(long LocalId, long ServerId, string ChatId, string? SenderId, string? SenderName, + long Type, string? Content, DateTimeOffset Timestamp, bool? IsSelf); +public sealed record MergedMessageInfo(string DatabaseRelativePath, DatabaseMessageInfo Parent, string? Title, + string? Description, IReadOnlyList<MergedMessagePart> Messages); +public sealed record MergedMessagePart(string? Sender, string? Content, DateTimeOffset? At, string? Path, int DataType); diff --git a/src/WxAgent.Service/DirectoryContracts.cs b/src/WxAgent.Service/DirectoryContracts.cs new file mode 100644 index 0000000..6e028de --- /dev/null +++ b/src/WxAgent.Service/DirectoryContracts.cs @@ -0,0 +1,4 @@ +namespace WxAgent.Service; + +public sealed record ContactInfo(string Id, string? DisplayName, string? Remark, string? AvatarUrl); +public sealed record GroupMemberInfo(long MemberId, string Id, string DisplayName, bool IsOwner); diff --git a/src/WxAgent.Service/EventHub.cs b/src/WxAgent.Service/EventHub.cs new file mode 100644 index 0000000..0e537fc --- /dev/null +++ b/src/WxAgent.Service/EventHub.cs @@ -0,0 +1,56 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace WxAgent.Service; + +public sealed record AgentEvent(string EventId, string AccountId, string? Session, string Kind, string Summary, DateTimeOffset At); + +public sealed class EventHub +{ + private readonly ConcurrentDictionary<string, Subscription> subscriptions = new(); + private readonly object gate = new(); + private readonly Queue<AgentEvent> history = new(); + private const int MaxHistory = 200; + + public (string SubscriptionId, IReadOnlyList<AgentEvent> Replay, bool Gap) Subscribe(string principal, string? after) + { + lock (gate) + { + var replay = after is null ? [] : history.SkipWhile(e => e.EventId != after).Skip(1).ToArray(); + var gap = after is not null && !history.Any(e => e.EventId == after); + var id = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)); + subscriptions[id] = new Subscription(principal); + return (id, replay, gap); + } + } + + public void Publish(string principal, AgentEvent value) + { + lock (gate) + { + history.Enqueue(value); + while (history.Count > MaxHistory) history.Dequeue(); + foreach (var subscription in subscriptions.Values.Where(s => s.Principal == principal)) + { + if (subscription.Channel.Writer.TryWrite(value)) continue; + // ponytail: one bounded channel per subscriber; if it overflows, emit an explicit resync marker. + subscription.Channel.Reader.TryRead(out _); + subscription.Channel.Writer.TryWrite(new AgentEvent( + Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)), + value.AccountId, value.Session, "gap", "resync required", DateTimeOffset.UtcNow)); + } + } + } + + public bool TryGet(string id, string principal, out Subscription subscription) => + subscriptions.TryGetValue(id, out subscription!) && subscription.Principal == principal; + + public void Remove(string id) => subscriptions.TryRemove(id, out _); + + public sealed class Subscription(string principal) + { + public string Principal { get; } = principal; + public Channel<AgentEvent> Channel { get; } = System.Threading.Channels.Channel.CreateBounded<AgentEvent>(new BoundedChannelOptions(100) + { FullMode = BoundedChannelFullMode.Wait, SingleReader = true, SingleWriter = false }); + } +} diff --git a/src/WxAgent.Service/EventPump.cs b/src/WxAgent.Service/EventPump.cs new file mode 100644 index 0000000..c220ed3 --- /dev/null +++ b/src/WxAgent.Service/EventPump.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Hosting; + +namespace WxAgent.Service; + +public interface IAgentEventSource +{ + IAsyncEnumerable<AgentEvent> ListenAsync(CancellationToken cancellationToken); +} + +public sealed class EventPump(IAgentBackend backend, EventHub hub, ServiceOptions options) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (backend is not IAgentEventSource source || backend.Capabilities.All(c => c.Operation != "listener-events" || !c.Enabled)) return; + while (!stoppingToken.IsCancellationRequested) + { + try + { + await foreach (var item in source.ListenAsync(stoppingToken)) + { + foreach (var credential in options.ReadCredentials()) + if (credential.AccountIds.Length == 0 || credential.AccountIds.Contains(item.AccountId, StringComparer.Ordinal)) + hub.Publish(credential.PrincipalId, item); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + catch (Exception) { await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); } + } + } +} diff --git a/src/WxAgent.Service/OperationQueue.cs b/src/WxAgent.Service/OperationQueue.cs new file mode 100644 index 0000000..f03a505 --- /dev/null +++ b/src/WxAgent.Service/OperationQueue.cs @@ -0,0 +1,133 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Channels; +using Microsoft.Extensions.Hosting; + +namespace WxAgent.Service; + +public sealed class OperationQueue(OperationStore store, ServiceOptions options, ServiceSecurity security) : BackgroundService +{ + private sealed record Work(OperationRecord Record, ServiceIdentity Identity, string Permission, + Func<CancellationToken, Task> Action, CancellationTokenSource Cancel); + private readonly Channel<Work> queue = Channel.CreateBounded<Work>(new BoundedChannelOptions(options.QueueCapacity) + { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); + private readonly ConcurrentDictionary<string, CancellationTokenSource> cancellations = new(); + private readonly object admissionGate = new(); + private bool stopping; + + public OperationRecord Submit(ServiceIdentity identity, string accountId, AgentCapability capability, + string? idempotencyKey, string canonicalParameters, Func<CancellationToken, Task> action) + { + security.RequireCurrent(identity, capability.Permission, accountId); + if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability unavailable."); + if (capability.TimeoutSeconds is < 1 or > 300) throw new ServiceException("InvalidRequest", 400, "Invalid execution budget."); + if (idempotencyKey is { Length: < 1 or > 128 } || (capability.HasSideEffects && string.IsNullOrWhiteSpace(idempotencyKey))) + throw new ServiceException("InvalidRequest", 400, "Side effects require a nonempty idempotency key of at most 128 characters."); + var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonicalParameters))); + lock (admissionGate) + { + if (stopping) throw new ServiceException("Unavailable", 503, "Agent is stopping."); + var (record, created) = store.Enqueue(identity.PrincipalId, accountId, capability.Operation, + idempotencyKey, digest, capability.HasSideEffects, TimeSpan.FromSeconds(capability.TimeoutSeconds), options.QueueCapacity); + if (!created) return record; + var cancel = new CancellationTokenSource(); + cancellations[record.Id] = cancel; + if (!queue.Writer.TryWrite(new Work(record, identity, capability.Permission, action, cancel))) + { + cancellations.TryRemove(record.Id, out _); + cancel.Dispose(); + store.Transition(record.Id, "Failed", "admission", "QueueFull"); + throw new ServiceException("QueueFull", 429, "Agent queue is full."); + } + return record; + } + } + + public OperationRecord Get(ServiceIdentity identity, string id) + { + security.RequireCurrent(identity, "read"); + var record = store.Get(id, identity.PrincipalId); + security.RequireCurrent(identity, "read", record.AccountId); + return record; + } + + public OperationRecord Cancel(ServiceIdentity identity, string id) + { + lock (admissionGate) + { + var record = Get(identity, id); + if (cancellations.TryGetValue(id, out var cancel)) cancel.Cancel(); + if (record.State == "Queued") store.Transition(id, "Cancelled", "queued", "Cancelled"); + } + return Get(identity, id); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await foreach (var work in queue.Reader.ReadAllAsync(stoppingToken)) + { + var started = false; + try + { + if (store.Get(work.Record.Id, work.Identity.PrincipalId).State != "Queued") continue; + var remaining = work.Record.ExpiresAt - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) throw new ServiceException("Timeout", 408, "Queue budget expired."); + using var budget = CancellationTokenSource.CreateLinkedTokenSource(work.Cancel.Token, stoppingToken); + budget.CancelAfter(remaining); + lock (admissionGate) + { + budget.Token.ThrowIfCancellationRequested(); + security.RequireCurrent(work.Identity, work.Permission, work.Record.AccountId); + store.Transition(work.Record.Id, "Running", "execution"); + started = true; + } + // Never release this slot with WaitAsync: the actual action must have stopped first. + await work.Action(budget.Token); + budget.Token.ThrowIfCancellationRequested(); + security.RequireCurrent(work.Identity, work.Permission, work.Record.AccountId); + store.Transition(work.Record.Id, "Succeeded", "complete"); + } + catch (Exception e) + { + var unknown = started && work.Record.HasSideEffects; + var cancelled = e is OperationCanceledException; + var error = e is ServiceException se ? se.Code : cancelled ? "Cancelled" : "ExecutionFailed"; + store.Transition(work.Record.Id, unknown ? "Unconfirmed" : cancelled ? "Cancelled" : "Failed", + started ? "execution" : "admission", error); + } + finally + { + lock (admissionGate) + { + cancellations.TryRemove(work.Record.Id, out _); + work.Cancel.Dispose(); + } + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } + finally + { + lock (admissionGate) + { + stopping = true; + queue.Writer.TryComplete(); + while (queue.Reader.TryRead(out var queued)) + { + store.Transition(queued.Record.Id, "Cancelled", "shutdown", "AgentStopping"); + cancellations.TryRemove(queued.Record.Id, out _); + queued.Cancel.Dispose(); + } + } + } + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + lock (admissionGate) { stopping = true; queue.Writer.TryComplete(); } + return base.StopAsync(cancellationToken); + } +} diff --git a/src/WxAgent.Service/OperationStore.cs b/src/WxAgent.Service/OperationStore.cs new file mode 100644 index 0000000..0a1f689 --- /dev/null +++ b/src/WxAgent.Service/OperationStore.cs @@ -0,0 +1,122 @@ +using Microsoft.Data.Sqlite; + +namespace WxAgent.Service; + +public sealed record OperationRecord(string Id, string PrincipalId, string AccountId, string Capability, + string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, + string? ErrorCode, bool HasSideEffects); + +public sealed class OperationStore : IDisposable +{ + private readonly SqliteConnection database; + private readonly object gate = new(); + + public OperationStore(ServiceOptions options) + { + Directory.CreateDirectory(options.DataDirectory); + database = new SqliteConnection(new SqliteConnectionStringBuilder + { DataSource = Path.Combine(options.DataDirectory, "operations.sqlite"), Mode = SqliteOpenMode.ReadWriteCreate }.ToString()); + database.Open(); + Execute(""" + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS operations ( + id TEXT PRIMARY KEY, principal TEXT NOT NULL, account TEXT NOT NULL, capability TEXT NOT NULL, + state TEXT NOT NULL, stage TEXT NOT NULL, correlation TEXT NOT NULL, created TEXT NOT NULL, + expires TEXT NOT NULL, error TEXT, side_effects INTEGER NOT NULL, idempotency TEXT, digest TEXT NOT NULL, + UNIQUE(principal, account, capability, idempotency)); + UPDATE operations SET state=CASE WHEN side_effects=1 THEN 'Unconfirmed' ELSE 'Failed' END, + stage='restart', error='AgentRestarted' WHERE state='Running'; + UPDATE operations SET state='Cancelled', stage='restart', error='AgentRestarted' WHERE state='Queued'; + """); + } + + public (OperationRecord Record, bool Created) Enqueue(string principal, string account, string capability, + string? idempotency, string digest, bool sideEffects, TimeSpan budget, int capacity) + { + lock (gate) + { + using var transaction = database.BeginTransaction(); + using var lookup = database.CreateCommand(); + lookup.Transaction = transaction; + lookup.CommandText = "SELECT *, digest FROM operations WHERE principal=$p AND account=$a AND capability=$c AND idempotency=$k"; + lookup.Parameters.AddWithValue("$p", principal); + lookup.Parameters.AddWithValue("$a", account); + lookup.Parameters.AddWithValue("$c", capability); + lookup.Parameters.AddWithValue("$k", (object?)idempotency ?? DBNull.Value); + using (var reader = lookup.ExecuteReader()) + { + if (reader.Read()) + { + if (reader.GetString(12) != digest) throw new ServiceException("IdempotencyConflict", 409, "Key already used for different parameters."); + return (Read(reader), false); + } + } + using var count = database.CreateCommand(); + count.Transaction = transaction; + count.CommandText = "SELECT COUNT(*) FROM operations WHERE state IN ('Queued','Running')"; + if (Convert.ToInt64(count.ExecuteScalar()) >= capacity) + throw new ServiceException("QueueFull", 429, "Agent queue is full."); + var now = DateTimeOffset.UtcNow; + var operation = new OperationRecord(Guid.NewGuid().ToString("N"), principal, account, capability, + "Queued", "queued", Guid.NewGuid().ToString("N"), now, now.Add(budget), null, sideEffects); + using var insert = database.CreateCommand(); + insert.Transaction = transaction; + insert.CommandText = "INSERT INTO operations VALUES ($id,$p,$a,$c,'Queued','queued',$correlation,$created,$expires,NULL,$effects,$key,$digest)"; + insert.Parameters.AddWithValue("$id", operation.Id); + insert.Parameters.AddWithValue("$p", principal); + insert.Parameters.AddWithValue("$a", account); + insert.Parameters.AddWithValue("$c", capability); + insert.Parameters.AddWithValue("$correlation", operation.CorrelationId); + insert.Parameters.AddWithValue("$created", now.ToString("O")); + insert.Parameters.AddWithValue("$expires", operation.ExpiresAt.ToString("O")); + insert.Parameters.AddWithValue("$effects", sideEffects ? 1 : 0); + insert.Parameters.AddWithValue("$key", (object?)idempotency ?? DBNull.Value); + insert.Parameters.AddWithValue("$digest", digest); + insert.ExecuteNonQuery(); + transaction.Commit(); + return (operation, true); + } + } + + public OperationRecord Get(string id, string principal) + { + lock (gate) + { + using var command = database.CreateCommand(); + command.CommandText = "SELECT * FROM operations WHERE id=$id AND principal=$principal"; + command.Parameters.AddWithValue("$id", id); + command.Parameters.AddWithValue("$principal", principal); + using var reader = command.ExecuteReader(); + if (!reader.Read()) throw new ServiceException("NotFound", 404, "Operation not found."); + return Read(reader); + } + } + + public void Transition(string id, string state, string stage, string? error = null) + { + lock (gate) + { + using var command = database.CreateCommand(); + command.CommandText = "UPDATE operations SET state=$state,stage=$stage,error=$error WHERE id=$id AND state IN ('Queued','Running')"; + command.Parameters.AddWithValue("$id", id); + command.Parameters.AddWithValue("$state", state); + command.Parameters.AddWithValue("$stage", stage); + command.Parameters.AddWithValue("$error", (object?)error ?? DBNull.Value); + command.ExecuteNonQuery(); + } + } + + private static OperationRecord Read(SqliteDataReader reader) => new(reader.GetString(0), reader.GetString(1), reader.GetString(2), + reader.GetString(3), reader.GetString(4), reader.GetString(5), reader.GetString(6), + DateTimeOffset.Parse(reader.GetString(7), System.Globalization.CultureInfo.InvariantCulture), + DateTimeOffset.Parse(reader.GetString(8), System.Globalization.CultureInfo.InvariantCulture), reader.IsDBNull(9) ? null : reader.GetString(9), reader.GetInt64(10) != 0); + + private void Execute(string sql) + { + using var command = database.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + public void Dispose() => database.Dispose(); +} diff --git a/src/WxAgent.Service/ReadOnlyContracts.cs b/src/WxAgent.Service/ReadOnlyContracts.cs new file mode 100644 index 0000000..564fd5f --- /dev/null +++ b/src/WxAgent.Service/ReadOnlyContracts.cs @@ -0,0 +1,26 @@ +namespace WxAgent.Service; + +public sealed record Page<T>(IReadOnlyList<T> Items, int Limit, int Offset, bool HasMore, int? NextOffset); +public sealed record AccountInfo(string AccountId, string? DisplayName, string? WechatId, string? Region, string DataFingerprint, bool IsUiBindingKnown); +public sealed record SessionInfo(string Name, string AutomationId, bool IsCurrent); +public sealed record MessageInfo(string Fingerprint, string Type, string? Sender, string? Summary, string? Content); +public sealed record ListRequest(int Limit = 50, int Offset = 0, bool IncludeContent = false, string? AccountId = null, string? Session = null); + +public sealed class ReadOnlyRequest +{ + public static (int Limit, int Offset) Page(int limit, int offset) + { + if (limit is < 1 or > 200 || offset < 0) throw new ServiceException("InvalidPagination", 400, "limit must be 1..200 and offset must be non-negative."); + return (limit, offset); + } +} + +public static class PageExtensions +{ + public static Page<T> ToPage<T>(this IReadOnlyList<T> values, int limit, int offset) + { + var items = values.Skip(offset).Take(limit).ToArray(); + var hasMore = offset + items.Length < values.Count; + return new Page<T>(items, limit, offset, hasMore, hasMore ? offset + items.Length : null); + } +} diff --git a/src/WxAgent.Service/ServiceHost.cs b/src/WxAgent.Service/ServiceHost.cs new file mode 100644 index 0000000..ec331ff --- /dev/null +++ b/src/WxAgent.Service/ServiceHost.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace WxAgent.Service; + +public static class ServiceHost +{ + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; + + public static WebApplication Build(ServiceOptions options, IAgentBackend backend, + Action<WebApplicationBuilder>? configure = null) + { + options.Validate(); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { Args = [], WebRootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot") }); + builder.WebHost.UseUrls(options.ListenUrl); + builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 1024 * 1024); + // No framework request logging: URLs, headers and backend exception messages may contain private data. + builder.Logging.ClearProviders(); + builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow); + builder.Services.AddSingleton(options); + builder.Services.AddSingleton(backend); + builder.Services.AddSingleton<ServiceSecurity>(); + builder.Services.AddSingleton<OperationStore>(); + builder.Services.AddSingleton<EventHub>(); + builder.Services.AddSingleton<ArtifactStore>(); + builder.Services.AddSingleton<OperationQueue>(); + builder.Services.AddHostedService(p => p.GetRequiredService<OperationQueue>()); + builder.Services.AddHostedService<EventPump>(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped<AgentService>(); + builder.Services.AddMcpServer().WithHttpTransport(o => o.Stateless = true).WithTools<AgentTools>(); + configure?.Invoke(builder); + builder.Services.AddRouting(); + var app = builder.Build(); + app.Use(async (context, next) => + { + var correlationId = Guid.NewGuid().ToString("N"); + context.Response.Headers["X-Correlation-Id"] = correlationId; + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"; + try + { + var security = context.RequestServices.GetRequiredService<ServiceSecurity>(); + security.ValidateSource(context); + // There are no tokens in query strings, including MCP initialization URLs. + if (context.Request.Query.Keys.Any(k => k.Contains("token", StringComparison.OrdinalIgnoreCase))) + throw new ServiceException("InvalidRequest", 400, "Credentials must not be supplied in URLs."); + var path = context.Request.Path.Value ?? "/"; + var isStatic = !path.StartsWith("/api/", StringComparison.Ordinal) && path != "/mcp"; + if (!(path == "/api/v1/login" && HttpMethods.IsPost(context.Request.Method)) && !isStatic) + context.Items[typeof(ServiceIdentity)] = security.AuthenticateRequest(context); + await next(context); + } + catch (Exception e) when (!context.Response.HasStarted) + { + var (code, status, message) = e switch + { + ServiceException se => (se.Code, se.StatusCode, se.Message), + BadHttpRequestException or JsonException => ("InvalidRequest", 400, "Invalid request."), + OperationCanceledException => ("Cancelled", 408, "Request cancelled or timed out."), + _ => ("InternalError", 500, "Request failed; use the correlation ID for diagnosis.") + }; + context.Response.StatusCode = status; + await context.Response.WriteAsJsonAsync(new { correlationId, error = new { code, message, stage = "request", retry = false } }); + } + }); + app.UseDefaultFiles(); + app.UseStaticFiles(); + app.MapPost("/api/v1/login", (HttpContext context, LoginRequest request, ServiceSecurity security) => security.Login(context, request.Token ?? "")); + app.MapPost("/api/v1/logout", (HttpContext context, ServiceSecurity security) => { security.Logout(context); return Results.NoContent(); }); + app.MapGet("/api/v1/status", (AgentService service, CancellationToken ct) => service.StatusAsync(ct)); + app.MapGet("/api/v1/diagnostics", (AgentService service, CancellationToken ct) => service.DiagnoseAsync(ct)); + app.MapGet("/api/v1/capabilities", (AgentService service) => service.Capabilities()); + app.MapGet("/api/v1/accounts", (int? limit, int? offset, AgentService service, CancellationToken ct) => service.AccountsAsync(limit ?? 50, offset ?? 0, ct)); + app.MapGet("/api/v1/sessions", (int? limit, int? offset, AgentService service, CancellationToken ct) => service.SessionsAsync(limit ?? 50, offset ?? 0, ct)); + app.MapGet("/api/v1/sessions/search", (string query, bool? exactOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SearchSessionsAsync(query, exactOnly ?? false, limit ?? 50, offset ?? 0, ct)); + app.MapGet("/api/v1/sessions/current", (AgentService service, CancellationToken ct) => service.CurrentSessionAsync(ct)); + app.MapPost("/api/v1/sessions/scroll", (ScrollRequest request, AgentService service, CancellationToken ct) => service.ScrollSessionsAsync(request.Direction, request.Pages, ct)); + app.MapPost("/api/v1/sessions/open", (OpenSessionRequest request, AgentService service, CancellationToken ct) => service.OpenSessionAsync(request.AutomationId, ct)); + app.MapGet("/api/v1/messages", (string? session, int? limit, int? offset, bool? includeContent, AgentService service, CancellationToken ct) => service.MessagesAsync(session, limit ?? 50, offset ?? 0, includeContent ?? false, ct)); + app.MapGet("/api/v1/contacts", (string? accountId, string? contains, bool? groupsOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.ContactsAsync(accountId, contains, groupsOnly, limit ?? 50, offset ?? 0, ct)); + app.MapGet("/api/v1/groups/{accountId}/{group}/members", (string accountId, string group, int? limit, int? offset, AgentService service, CancellationToken ct) => service.GroupMembersAsync(accountId, group, limit ?? 50, offset ?? 0, ct)); + app.MapGet("/api/v1/db/messages", (string accountId, string chatId, int? limit, int? offset, long? localId, AgentService service, CancellationToken ct) => service.DatabaseMessagesAsync(accountId, chatId, limit ?? 50, offset ?? 0, localId, ct)); + app.MapGet("/api/v1/db/merged", (string accountId, string chatId, long localId, AgentService service, CancellationToken ct) => service.DatabaseMergedAsync(accountId, chatId, localId, ct)); + app.MapGet("/api/v1/events", StreamEvents); + app.MapPost("/api/v1/files", async (HttpContext context, AgentService service, CancellationToken ct) => + { + if (!context.Request.HasFormContentType) throw new ServiceException("InvalidRequest", 400, "multipart/form-data is required."); + var form = await context.Request.ReadFormAsync(ct); + if (form.Files.Count != 1) throw new ServiceException("InvalidRequest", 400, "Exactly one file is required."); + return Results.Ok(await service.UploadAsync(form.Files[0], ct)); + }); + app.MapGet("/api/v1/files/{id}", (string id, AgentService service) => Results.File(service.Download(id), "application/octet-stream")); + app.MapGet("/api/v1/operations/{id}", (string id, AgentService service) => service.Operation(id)); + app.MapPost("/api/v1/operations/{id}/cancel", (string id, AgentService service) => service.CancelOperation(id)); + app.MapMcp("/mcp"); + return app; + } + + private static async Task StreamEvents(HttpContext context, EventHub hub, ServiceSecurity security, AgentService service) + { + var identity = context.Items[typeof(ServiceIdentity)] as ServiceIdentity + ?? throw new ServiceException("Unauthorized", 401, "Authentication required."); + service.RequireEvents(); + var after = context.Request.Headers["Last-Event-ID"].ToString(); + var (id, replay, gap) = hub.Subscribe(identity.PrincipalId, string.IsNullOrWhiteSpace(after) ? null : after); + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-store"; + await context.Response.StartAsync(context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + if (!hub.TryGet(id, identity.PrincipalId, out var subscription)) + throw new ServiceException("SubscriptionUnavailable", 503, "Could not create event subscription."); + try + { + if (gap) await WriteEvent(context, "gap", new { resyncRequired = true }); + foreach (var item in replay) await WriteEvent(context, "message", item); + if (replay.Count != 0) await context.Response.Body.FlushAsync(context.RequestAborted); + while (!context.RequestAborted.IsCancellationRequested) + { + using var wake = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + wake.CancelAfter(TimeSpan.FromSeconds(15)); + bool available; + try { available = await subscription.Channel.Reader.WaitToReadAsync(wake.Token); } + catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested) + { service.RequireEvents(); continue; } + if (!available) break; + security.RequireCurrent(identity, "read"); + while (subscription.Channel.Reader.TryRead(out var item)) + await WriteEvent(context, item.Kind == "gap" ? "gap" : "message", item); + await context.Response.Body.FlushAsync(context.RequestAborted); + } + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { } + catch (ServiceException) when (context.Response.HasStarted) { } + finally { hub.Remove(id); } + } + + private static async Task WriteEvent(HttpContext context, string name, object value) + { + var id = value is AgentEvent e ? e.EventId : "control"; + await context.Response.WriteAsync($"id: {id}\nevent: {name}\ndata: {JsonSerializer.Serialize(value, Json)}\n\n", context.RequestAborted); + } + + public sealed record LoginRequest(string Token); + public sealed record ScrollRequest(string Direction, int Pages = 1); + public sealed record OpenSessionRequest(string AutomationId); +} diff --git a/src/WxAgent.Service/ServiceOptions.cs b/src/WxAgent.Service/ServiceOptions.cs new file mode 100644 index 0000000..296ae01 --- /dev/null +++ b/src/WxAgent.Service/ServiceOptions.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; + +namespace WxAgent.Service; + +public sealed record ServiceCredential(string PrincipalId, string TokenSha256, string[] Permissions, string[] AccountIds); + +public sealed class ServiceOptions +{ + public string ListenUrl { get; init; } = "http://127.0.0.1:5088"; + public bool AllowExternal { get; init; } + public string[] AllowedHosts { get; init; } = ["127.0.0.1:5088", "localhost:5088", "[::1]:5088"]; + public string[] AllowedOrigins { get; init; } = ["http://127.0.0.1:5088", "http://localhost:5088", "http://[::1]:5088"]; + public required string CredentialFile { get; init; } + public required string DataDirectory { get; init; } + public int QueueCapacity { get; init; } = 100; + public string ListenerSession { get; init; } = "文件传输助手"; + public bool EnableListenerEvents { get; init; } + + public void Validate() + { + if (!Uri.TryCreate(ListenUrl, UriKind.Absolute, out var uri) || uri.Scheme != "http" || + uri.AbsolutePath != "/" || uri.Query.Length != 0 || uri.Fragment.Length != 0 || uri.UserInfo.Length != 0 || + !IPAddress.TryParse(uri.Host.Trim('[', ']'), out var address)) + throw new ArgumentException("ListenUrl must be an explicit HTTP IP address and port."); + if (!IPAddress.IsLoopback(address) && !AllowExternal) + throw new ArgumentException("External HTTP requires AllowExternal; use a trusted isolated network."); + if (AllowedHosts.Length == 0 || AllowedOrigins.Length == 0 || + AllowedHosts.Any(h => h.Contains('*') || h.Contains('/')) || + AllowedOrigins.Any(o => !Uri.TryCreate(o, UriKind.Absolute, out var origin) || + origin.Scheme != "http" || origin.GetLeftPart(UriPartial.Authority) != o)) + throw new ArgumentException("Explicit Host and exact Origin allowlists are required."); + if (QueueCapacity is < 1 or > 100) throw new ArgumentOutOfRangeException(nameof(QueueCapacity)); + if (string.IsNullOrWhiteSpace(ListenerSession) || ListenerSession.Length > 200) throw new ArgumentException("ListenerSession must be a bounded nonempty name."); + _ = ReadCredentials(); + } + + // Reread on every authorization boundary: rotation has no overlap or stale cache. + public ServiceCredential[] ReadCredentials() + { + var credentials = System.Text.Json.JsonSerializer.Deserialize<ServiceCredential[]>(File.ReadAllText(CredentialFile)) + ?? throw new InvalidDataException("No credentials configured."); + if (credentials.Length is < 1 or > 100 || credentials.Any(c => + c is null || string.IsNullOrWhiteSpace(c.PrincipalId) || c.TokenSha256 is null || c.TokenSha256.Length != 64 || + !c.TokenSha256.All(Uri.IsHexDigit) || c.Permissions is null || c.AccountIds is null || + c.Permissions.Any(p => p is not ("read" or "content" or "write" or "manage" or "local-admin")) || + c.AccountIds.Any(string.IsNullOrWhiteSpace)) || + credentials.Select(c => c.PrincipalId).Distinct(StringComparer.Ordinal).Count() != credentials.Length || + credentials.Select(c => c.TokenSha256.ToUpperInvariant()).Distinct().Count() != credentials.Length) + throw new InvalidDataException("Invalid credential configuration."); + return credentials; + } + + public static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); +} + +public sealed record ServiceIdentity(string PrincipalId, string CredentialHash, string[] Permissions, string[] AccountIds) +{ + public bool Allows(string permission) => Permissions.Contains(permission, StringComparer.Ordinal); + public bool AllowsAccount(string accountId) => AccountIds.Contains(accountId, StringComparer.Ordinal); +} + +public sealed class ServiceException(string code, int statusCode, string message) : Exception(message) +{ + public string Code { get; } = code; + public int StatusCode { get; } = statusCode; +} diff --git a/src/WxAgent.Service/ServiceSecurity.cs b/src/WxAgent.Service/ServiceSecurity.cs new file mode 100644 index 0000000..246fbed --- /dev/null +++ b/src/WxAgent.Service/ServiceSecurity.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; + +namespace WxAgent.Service; + +public sealed class ServiceSecurity(ServiceOptions options) +{ + private sealed record BrowserSession(ServiceIdentity Identity, string Csrf, DateTimeOffset Expires); + private readonly ConcurrentDictionary<string, BrowserSession> sessions = new(); + private readonly object loginGate = new(); + private DateTimeOffset loginWindow = DateTimeOffset.UtcNow; + private int loginAttempts; + public const string CookieName = "wxagent-session"; + + public ServiceIdentity? AuthenticateToken(string token) + { + if (token.Length is < 43 or > 256) return null; + var hash = ServiceOptions.HashToken(token); + return ReadCredentialsSafely().Where(c => EqualHash(c.TokenSha256, hash)) + .Select(c => new ServiceIdentity(c.PrincipalId, c.TokenSha256, c.Permissions, c.AccountIds)).SingleOrDefault(); + } + + public ServiceIdentity RequireCurrent(ServiceIdentity original, string? permission = null, string? accountId = null) + { + var credential = ReadCredentialsSafely().SingleOrDefault(c => c.PrincipalId == original.PrincipalId && + EqualHash(c.TokenSha256, original.CredentialHash)); + if (credential is null) throw new ServiceException("AuthorizationRevoked", 401, "Credential expired or revoked."); + var current = new ServiceIdentity(credential.PrincipalId, credential.TokenSha256, credential.Permissions, credential.AccountIds); + if (permission is not null && !current.Allows(permission)) + throw new ServiceException("Forbidden", 403, "Permission required."); + if (accountId is not null && !current.AllowsAccount(accountId)) + throw new ServiceException("Forbidden", 403, "Account access denied."); + return current; + } + + private ServiceCredential[] ReadCredentialsSafely() + { + try { return options.ReadCredentials(); } + catch (Exception e) when (e is IOException or InvalidDataException or UnauthorizedAccessException or System.Text.Json.JsonException or ArgumentException) + { return []; } // Fail closed during invalid or incomplete local rotation. + } + + private static bool EqualHash(string a, string b) => CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(a), Convert.FromHexString(b)); + + public void ValidateSource(HttpContext context) + { + if (!options.AllowedHosts.Contains(context.Request.Host.Value, StringComparer.OrdinalIgnoreCase)) + throw new ServiceException("InvalidHost", 403, "Host not allowed."); + var origin = context.Request.Headers.Origin; + if (origin.Count != 0 && (origin.Count != 1 || !options.AllowedOrigins.Contains(origin[0], StringComparer.Ordinal))) + throw new ServiceException("InvalidOrigin", 403, "Origin not allowed."); + } + + public static void RequireLocal(HttpContext context) + { + if (context.Connection.RemoteIpAddress is not { } address || !IPAddress.IsLoopback(address)) + throw new ServiceException("LocalOnly", 403, "This action requires a direct loopback connection."); + } + + public ServiceIdentity AuthenticateRequest(HttpContext context) + { + if (context.Request.Headers.Authorization.Count != 0) + { + var header = context.Request.Headers.Authorization.ToString(); + if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) && AuthenticateToken(header[7..]) is { } identity) + return identity; + throw new ServiceException("Unauthorized", 401, "Valid Bearer credential required."); + } + if (!context.Request.Cookies.TryGetValue(CookieName, out var id) || !sessions.TryGetValue(id, out var session)) + throw new ServiceException("Unauthorized", 401, "Login required."); + if (session.Expires <= DateTimeOffset.UtcNow) + { + sessions.TryRemove(id, out _); + throw new ServiceException("Unauthorized", 401, "Session expired."); + } + var current = RequireCurrent(session.Identity); + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) && + (context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf || + !options.AllowedOrigins.Contains(context.Request.Headers.Origin.ToString(), StringComparer.Ordinal))) + throw new ServiceException("CsrfRejected", 403, "CSRF token and same-origin request required."); + return current; + } + + public object Login(HttpContext context, string token) + { + // ponytail: process-wide login throttle; per-IP quotas only if legitimate shared use needs them. + lock (loginGate) + { + if (DateTimeOffset.UtcNow - loginWindow > TimeSpan.FromMinutes(1)) + { loginWindow = DateTimeOffset.UtcNow; loginAttempts = 0; } + if (++loginAttempts > 10) throw new ServiceException("RateLimited", 429, "Wait before attempting login again."); + } + if (!options.AllowedOrigins.Contains(context.Request.Headers.Origin.ToString(), StringComparer.Ordinal)) + throw new ServiceException("InvalidOrigin", 403, "Login requires an allowed Origin."); + var identity = AuthenticateToken(token) ?? throw new ServiceException("Unauthorized", 401, "Invalid credential."); + foreach (var entry in sessions.Where(s => s.Value.Expires <= DateTimeOffset.UtcNow)) sessions.TryRemove(entry.Key, out _); + if (sessions.Count >= 100) throw new ServiceException("RateLimited", 429, "Browser session limit reached."); + var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + var csrf = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + var expires = DateTimeOffset.UtcNow.AddMinutes(30); + sessions[id] = new BrowserSession(identity, csrf, expires); + context.Response.Cookies.Append(CookieName, id, new CookieOptions + { HttpOnly = true, SameSite = SameSiteMode.Strict, Secure = false, Path = "/", MaxAge = TimeSpan.FromMinutes(30), IsEssential = true }); + return new { identity.PrincipalId, identity.Permissions, identity.AccountIds, csrfToken = csrf, expires }; + } + + public void Logout(HttpContext context) + { + if (context.Request.Cookies.TryGetValue(CookieName, out var id)) sessions.TryRemove(id, out _); + context.Response.Cookies.Delete(CookieName, new CookieOptions { Path = "/" }); + } +} diff --git a/src/WxAgent.Service/SessionContracts.cs b/src/WxAgent.Service/SessionContracts.cs new file mode 100644 index 0000000..5d52c31 --- /dev/null +++ b/src/WxAgent.Service/SessionContracts.cs @@ -0,0 +1,4 @@ +namespace WxAgent.Service; + +public sealed record SessionSearchInfo(string Name, string AutomationId, bool IsExactMatch); +public sealed record SessionViewportInfo(int ScrolledPages, bool Changed, IReadOnlyList<SessionInfo> Sessions); diff --git a/src/WxAgent.Service/WxAgent.Service.csproj b/src/WxAgent.Service/WxAgent.Service.csproj new file mode 100644 index 0000000..8667976 --- /dev/null +++ b/src/WxAgent.Service/WxAgent.Service.csproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + <ItemGroup> + <FrameworkReference Include="Microsoft.AspNetCore.App" /> + <ProjectReference Include="../WxAgent.Core/WxAgent.Core.csproj" /> + <PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.2.0" /> + <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.28" /> + </ItemGroup> + <ItemGroup> + <Content Include="wwwroot/**/*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + </ItemGroup> +</Project> diff --git a/src/WxAgent.Service/wwwroot/app.js b/src/WxAgent.Service/wwwroot/app.js new file mode 100644 index 0000000..e7c38ff --- /dev/null +++ b/src/WxAgent.Service/wwwroot/app.js @@ -0,0 +1,7 @@ +const $=id=>document.getElementById(id);let csrf=''; +function showError(e){$('error').textContent=e.message||'请求失败'} +async function api(path,init={}){const r=await fetch(path,{...init,headers:{'Accept':'application/json',...(init.headers||{})}});if(!r.ok){let x={};try{x=await r.json()}catch{}if(r.status===401){$('app').hidden=true;$('login').hidden=false}throw new Error(x.error?.message||`HTTP ${r.status}`)}return r.status===204?null:r.json()} +function rows(id,items,format){$(id).replaceChildren(...items.map(x=>{const d=document.createElement('div');d.className='row';d.textContent=format(x);return d}))} +async function refresh(){try{const [s,c,a,se,co,m]=await Promise.all([api('/api/v1/status'),api('/api/v1/capabilities'),api('/api/v1/accounts?limit=50'),api('/api/v1/sessions?limit=50'),api('/api/v1/contacts?limit=50'),api(`/api/v1/messages?limit=50&includeContent=${$('content').checked}`)]);$('status').textContent=JSON.stringify(s,null,2);rows('capabilities',c,x=>`${x.operation} — ${x.enabled?'可用':'禁用'}${x.disabledReason?`:${x.disabledReason}`:''}`);rows('accounts',a.items,x=>`${x.accountId} ${x.displayName||''}(数据库指纹不代表 UI 绑定)`);rows('sessions',se.items,x=>`${x.name}${x.isCurrent?'(当前)':''}`);rows('contacts',co.items,x=>`${x.displayName||'[未知]'} — ${x.id}`);rows('messages',m.items,x=>`${x.type} ${x.sender||''}: ${x.content??x.summary??'[正文未授权]'}`)}catch(e){showError(e)}} +$('loginForm').addEventListener('submit',async e=>{e.preventDefault();try{const x=await api('/api/v1/login',{method:'POST',headers:{'Content-Type':'application/json','Origin':location.origin},body:JSON.stringify({token:$('token').value})});csrf=x.csrfToken;$('token').value='';$('login').hidden=true;$('app').hidden=false;await refresh()}catch(e){showError(e)}}); +$('refresh').onclick=refresh;$('content').onchange=refresh;$('lookup').onclick=async()=>{try{$('operation').textContent=JSON.stringify(await api('/api/v1/operations/'+encodeURIComponent($('operationId').value)),null,2)}catch(e){showError(e)}};$('logout').onclick=async()=>{try{await api('/api/v1/logout',{method:'POST',headers:{'X-CSRF-Token':csrf,'Origin':location.origin}})}finally{$('app').hidden=true;$('login').hidden=false}}; diff --git a/src/WxAgent.Service/wwwroot/index.html b/src/WxAgent.Service/wwwroot/index.html new file mode 100644 index 0000000..bcd6033 --- /dev/null +++ b/src/WxAgent.Service/wwwroot/index.html @@ -0,0 +1,6 @@ +<!doctype html> +<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WxAgent +

WxAgent 控制台

HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。

+

登录

+
diff --git a/src/WxAgent.Service/wwwroot/styles.css b/src/WxAgent.Service/wwwroot/styles.css new file mode 100644 index 0000000..10cac92 --- /dev/null +++ b/src/WxAgent.Service/wwwroot/styles.css @@ -0,0 +1 @@ +*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#18202a;font:16px system-ui,sans-serif}main{max-width:1100px;margin:auto;padding:24px}section{background:white;border:1px solid #d9dee7;border-radius:8px;padding:18px;margin:16px 0}label{display:block;margin:10px 0}input{max-width:100%;padding:9px;border:1px solid #9aa6b2;border-radius:4px}button{padding:9px 14px;margin:5px;border:0;border-radius:4px;background:#1459c7;color:white;cursor:pointer}button:focus,input:focus{outline:3px solid #ffbf47;outline-offset:2px}.warning{padding:12px;background:#fff3cd;border:1px solid #e0b400}.error{color:#a00;min-height:1.4em}pre{white-space:pre-wrap;overflow:auto;background:#111827;color:#e5e7eb;padding:12px;border-radius:4px}.row{padding:8px;border-bottom:1px solid #eee}.disabled{color:#777}h1{margin-top:0}@media(max-width:600px){main{padding:12px}} diff --git a/tests/WxAgent.Service.Tests/ArtifactStoreTests.cs b/tests/WxAgent.Service.Tests/ArtifactStoreTests.cs new file mode 100644 index 0000000..6cc1168 --- /dev/null +++ b/tests/WxAgent.Service.Tests/ArtifactStoreTests.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Http; +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class ArtifactStoreTests +{ + [Fact] + public async Task ArtifactIdsAreOpaqueOwnedBoundedAndPathSafe() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var options = new ServiceOptions { DataDirectory = dir, CredentialFile = "unused" }; + try + { + var store = new ArtifactStore(options); + await using var body = new MemoryStream("safe"u8.ToArray()); + var file = new FormFile(body, 0, body.Length, "file", "note.txt") { Headers = new HeaderDictionary(), ContentType = "text/plain" }; + var info = await store.SaveAsync("alice", file, default); + Assert.Matches("^[0-9a-f]{32}$", info.ArtifactId); + Assert.Equal("alice", store.Get("alice", info.ArtifactId).PrincipalId); + Assert.Throws(() => store.Get("bob", info.ArtifactId)); + await using var opened = store.Open("alice", info.ArtifactId); using var reader = new StreamReader(opened); + Assert.Equal("safe", await reader.ReadToEndAsync()); + Assert.Throws(() => store.Get("alice", "../secrets")); + await using var badBody = new MemoryStream("x"u8.ToArray()); + var bad = new FormFile(badBody, 0, badBody.Length, "file", "payload.exe") { Headers = new HeaderDictionary(), ContentType = "application/octet-stream" }; + Assert.Equal("FileTypeRejected", (await Assert.ThrowsAsync(() => store.SaveAsync("alice", bad, default))).Code); + } + finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } + } +} diff --git a/tests/WxAgent.Service.Tests/EventHubTests.cs b/tests/WxAgent.Service.Tests/EventHubTests.cs new file mode 100644 index 0000000..8eb9524 --- /dev/null +++ b/tests/WxAgent.Service.Tests/EventHubTests.cs @@ -0,0 +1,35 @@ +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class EventHubTests +{ + [Fact] + public async Task ReplayAndGapAreExplicitAndSubscriptionsAreOwnerScoped() + { + var hub = new EventHub(); + var first = new AgentEvent("one", "account", "chat", "message", "summary", DateTimeOffset.UtcNow); + hub.Publish("alice", first); + var replay = hub.Subscribe("alice", "one"); + Assert.Empty(replay.Replay); Assert.False(replay.Gap); + var gap = hub.Subscribe("alice", "gone"); + Assert.True(gap.Gap); + Assert.False(hub.TryGet(replay.SubscriptionId, "bob", out _)); + hub.Publish("alice", new AgentEvent("two", "account", "chat", "message", "summary", DateTimeOffset.UtcNow)); + Assert.True(hub.TryGet(replay.SubscriptionId, "alice", out var subscription)); + Assert.True(await subscription.Channel.Reader.WaitToReadAsync()); + } + + [Fact] + public void SlowConsumerGetsAnExplicitGapInsteadOfSilentDrop() + { + var hub = new EventHub(); + var subscription = hub.Subscribe("alice", null); + for (var i = 0; i < 102; i++) hub.Publish("alice", new AgentEvent(i.ToString(), "account", "chat", "message", "summary", DateTimeOffset.UtcNow)); + Assert.True(hub.TryGet(subscription.SubscriptionId, "alice", out var current)); + var foundGap = false; + while (current.Channel.Reader.TryRead(out var item)) foundGap |= item.Kind == "gap"; + Assert.True(foundGap); + } +} diff --git a/tests/WxAgent.Service.Tests/EventPumpTests.cs b/tests/WxAgent.Service.Tests/EventPumpTests.cs new file mode 100644 index 0000000..3c06b3d --- /dev/null +++ b/tests/WxAgent.Service.Tests/EventPumpTests.cs @@ -0,0 +1,41 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class EventPumpTests +{ + private sealed class SourceBackend : IAgentBackend, IAgentEventSource + { + public IReadOnlyList Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])]; + public Task StatusAsync(CancellationToken ct) => Task.FromResult(new { ok = true }); + public async IAsyncEnumerable ListenAsync([EnumeratorCancellation] CancellationToken ct) + { yield return new AgentEvent("evt", "ui-current", "session", "message", "summary", DateTimeOffset.UtcNow); await Task.Delay(Timeout.Infinite, ct); } + } + + [Fact] + public async Task PumpForwardsOnlyToAuthorizedPrincipals() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var options = new ServiceOptions { DataDirectory = dir, CredentialFile = Path.Combine(dir, "credentials.json") }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] + { + new ServiceCredential("alice", new string('A', 64), ["read"], []), + new ServiceCredential("scoped", new string('B', 64), ["read"], ["other-account"]) + })); + try + { + var hub = new EventHub(); var all = hub.Subscribe("alice", null); var scoped = hub.Subscribe("scoped", null); + using var pump = new EventPump(new SourceBackend(), hub, options); + await pump.StartAsync(default); + var ct = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!hub.TryGet(all.SubscriptionId, "alice", out var alice) || !alice.Channel.Reader.TryRead(out _)) + await Task.Delay(10, ct.Token); + Assert.False(hub.TryGet(scoped.SubscriptionId, "scoped", out var no) && no.Channel.Reader.TryRead(out _)); + await pump.StopAsync(default); + } + finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } + } +} diff --git a/tests/WxAgent.Service.Tests/OperationQueueTests.cs b/tests/WxAgent.Service.Tests/OperationQueueTests.cs new file mode 100644 index 0000000..9b5687b --- /dev/null +++ b/tests/WxAgent.Service.Tests/OperationQueueTests.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class OperationQueueTests +{ + [Fact] + public async Task QueueCancelsBeforeExecutionRevokesAtExecutionAndNeverOverlapsTimedOutAction() + { + var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var options = new ServiceOptions { DataDirectory = directory, CredentialFile = Path.Combine(directory, "auth.json") }; + var hash = ServiceOptions.HashToken(new string('A', 43)); + var identity = new ServiceIdentity("alice", hash, ["read", "write"], ["account"]); + void Credentials(string value) => File.WriteAllText(options.CredentialFile, + JsonSerializer.Serialize(new[] { new ServiceCredential("alice", value, identity.Permissions, identity.AccountIds) })); + Credentials(hash); + var capability = new AgentCapability("send", true, true, true, true, true, "write", false, 30, null, []); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + using var store = new OperationStore(options); + using var queue = new OperationQueue(store, options, new ServiceSecurity(options)); + await queue.StartAsync(default); + var first = queue.Submit(identity, "account", capability, "first", "one", async _ => { entered.SetResult(); await release.Task; }); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var calls = 0; + var second = queue.Submit(identity, "account", capability, "second", "two", _ => { Interlocked.Increment(ref calls); return Task.CompletedTask; }); + Assert.Equal("Cancelled", queue.Cancel(identity, second.Id).State); + var third = queue.Submit(identity, "account", capability, "third", "three", _ => { Interlocked.Increment(ref calls); return Task.CompletedTask; }); + queue.Cancel(identity, first.Id); + // Cancel is cooperative: the first action still owns the lane until it really exits. + Assert.Equal("Running", store.Get(first.Id, "alice").State); + Assert.Equal("Queued", store.Get(third.Id, "alice").State); + Credentials(ServiceOptions.HashToken(new string('B', 43))); + release.SetResult(); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (store.Get(third.Id, "alice").State == "Queued") await Task.Delay(10, deadline.Token); + Assert.Equal(0, calls); + Assert.Equal("Unconfirmed", store.Get(first.Id, "alice").State); + Assert.Equal("AuthorizationRevoked", store.Get(third.Id, "alice").ErrorCode); + await queue.StopAsync(default); + } + finally { release.TrySetResult(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(directory, true); } + } +} diff --git a/tests/WxAgent.Service.Tests/OperationStoreTests.cs b/tests/WxAgent.Service.Tests/OperationStoreTests.cs new file mode 100644 index 0000000..b20326f --- /dev/null +++ b/tests/WxAgent.Service.Tests/OperationStoreTests.cs @@ -0,0 +1,46 @@ +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class OperationStoreTests +{ + [Fact] + public void DurableIdempotencyOwnershipCapacityAndRestartNeverReplay() + { + var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var options = new ServiceOptions { DataDirectory = directory, CredentialFile = "unused" }; + try + { + string runningId; + string queuedId; + using (var store = new OperationStore(options)) + { + var first = store.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2); + runningId = first.Record.Id; + Assert.True(first.Created); + var again = store.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2); + Assert.False(again.Created); + Assert.Equal(runningId, again.Record.Id); + Assert.Equal("IdempotencyConflict", Assert.Throws(() => + store.Enqueue("alice", "account", "send-text", "one", "different", true, TimeSpan.FromMinutes(1), 2)).Code); + Assert.Equal("NotFound", Assert.Throws(() => store.Get(runningId, "bob")).Code); + queuedId = store.Enqueue("bob", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2).Record.Id; + Assert.Equal("QueueFull", Assert.Throws(() => + store.Enqueue("alice", "account", "send-text", "two", "digest", true, TimeSpan.FromMinutes(1), 2)).Code); + store.Transition(runningId, "Running", "executing"); + } + using (var restarted = new OperationStore(options)) + { + Assert.Equal("Unconfirmed", restarted.Get(runningId, "alice").State); + Assert.Equal("Cancelled", restarted.Get(queuedId, "bob").State); + var again = restarted.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2); + Assert.False(again.Created); + Assert.Equal("Unconfirmed", again.Record.State); + restarted.Transition(runningId, "Succeeded", "late-completion"); + Assert.Equal("Unconfirmed", restarted.Get(runningId, "alice").State); + } + } + finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(directory, true); } + } +} diff --git a/tests/WxAgent.Service.Tests/ServiceBoundaryTests.cs b/tests/WxAgent.Service.Tests/ServiceBoundaryTests.cs new file mode 100644 index 0000000..f745dd1 --- /dev/null +++ b/tests/WxAgent.Service.Tests/ServiceBoundaryTests.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.TestHost; +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class ServiceBoundaryTests +{ + private sealed class Backend : IAgentBackend + { + public IReadOnlyList Capabilities => + [ + new("agent-status", true, true, true, false, false, "read", false, 30, null, []), + new("accounts-list", true, true, true, false, false, "read", false, 30, null, []), + new("sessions-list", true, true, true, true, false, "read", false, 30, null, []), + new("messages-read", true, true, true, true, false, "read", false, 30, null, []), + new("contacts-list", true, true, true, false, false, "read", false, 30, null, []) + ]; + public Task StatusAsync(CancellationToken ct) => Task.FromResult(new { ok = true }); + public Task> AccountsAsync(CancellationToken ct) => Task.FromResult>([new("account-1", null, null, null, "fingerprint", false)]); + } + + [Fact] + public async Task StaticUiAndBoundedReadOnlyPagesAreRealProductionRoutes() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var token = new string('A', 43); + var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("p", ServiceOptions.HashToken(token), ["read"], []) })); + await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer()); + try + { + await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088"); + var page = await client.GetAsync("/"); Assert.Equal(HttpStatusCode.OK, page.StatusCode); Assert.Contains("WxAgent", await page.Content.ReadAsStringAsync()); + Assert.True(page.Headers.Contains("X-Content-Type-Options")); Assert.True(page.Headers.Contains("Content-Security-Policy")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var accounts = await client.GetFromJsonAsync("/api/v1/accounts?limit=1&offset=0"); + Assert.Equal(1, accounts.GetProperty("items").GetArrayLength()); Assert.False(accounts.GetProperty("hasMore").GetBoolean()); + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/accounts?limit=201")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/accounts?offset=-1")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/status?token=leak")).StatusCode); + } + finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } + } + + [Fact] + public void ExternalBindingRequiresExplicitOptIn() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var options = new ServiceOptions { ListenUrl = "http://192.0.2.10:5088", CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; + File.WriteAllText(options.CredentialFile, "[]"); + try { Assert.Throws(() => options.Validate()); } + finally { Directory.Delete(dir, true); } + } +} diff --git a/tests/WxAgent.Service.Tests/ServiceTests.cs b/tests/WxAgent.Service.Tests/ServiceTests.cs new file mode 100644 index 0000000..f38532b --- /dev/null +++ b/tests/WxAgent.Service.Tests/ServiceTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.TestHost; +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class ServiceTests +{ + private sealed class Backend : IAgentBackend + { + public IReadOnlyList Capabilities => + [ + new("agent-status", true, true, true, false, false, "read", false, 30, null, []), + new("accounts-list", true, true, true, false, false, "read", false, 30, null, []), + new("sessions-list", true, true, true, false, false, "read", false, 30, null, []), + new("messages-read", true, true, true, true, false, "read", false, 30, null, []), + new("contacts-list", true, true, true, false, false, "read", false, 30, null, []), + new("listener-events", true, true, true, true, false, "read", false, 30, null, []) + ]; + public Task StatusAsync(CancellationToken cancellationToken) => Task.FromResult(new { online = true, wechatAvailable = false }); + } + + [Fact] + public async Task RealRoutesAndMcpShareCredentialsAndRejectRevokedBrowserSession() + { + var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var token = new string('A', 43); + var options = new ServiceOptions { CredentialFile = Path.Combine(directory, "credentials.json"), DataDirectory = directory }; + void Rotate(string value) => File.WriteAllText(options.CredentialFile, + JsonSerializer.Serialize(new[] { new ServiceCredential("alice", ServiceOptions.HashToken(value), ["read"], []) })); + Rotate(token); + await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer()); + try + { + await app.StartAsync(); + using var client = app.GetTestClient(); + client.BaseAddress = new Uri("http://localhost:5088"); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/status")).StatusCode); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode); + client.DefaultRequestHeaders.Add("Origin", "http://evil.invalid"); + Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode); + client.DefaultRequestHeaders.Remove("Origin"); + client.DefaultRequestHeaders.Host = "evil.invalid"; + Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode); + client.DefaultRequestHeaders.Host = null; + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.Accept.ParseAdd("text/event-stream"); + using var initialize = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 1, method = "initialize", @params = new { protocolVersion = "2025-11-25", capabilities = new { }, clientInfo = new { name = "wxagent-integration", version = "1" } } }); + Assert.Equal(HttpStatusCode.OK, initialize.StatusCode); + using var list = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 2, method = "tools/list" }); + var schema = await list.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, list.StatusCode); + Assert.Contains("agent_status", schema); + Assert.Contains("inputSchema", schema); + using var call = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 3, method = "tools/call", @params = new { name = "agent_status", arguments = new { } } }); + Assert.Equal(HttpStatusCode.OK, call.StatusCode); + Assert.Contains("wechatAvailable", await call.Content.ReadAsStringAsync()); + client.DefaultRequestHeaders.Authorization = null; + client.DefaultRequestHeaders.Add("Origin", "http://localhost:5088"); + using var login = await client.PostAsJsonAsync("/api/v1/login", new { token }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + var cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; + var result = await login.Content.ReadFromJsonAsync(); + client.DefaultRequestHeaders.Add("Cookie", cookie); + Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync("/api/v1/logout", null)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode); + Rotate(new string('B', 43)); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/status")).StatusCode); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 4, method = "tools/list" })).StatusCode); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", new string('B', 43)); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode); + } + finally { await app.StopAsync(); Directory.Delete(directory, true); } + } +} diff --git a/tests/WxAgent.Service.Tests/SessionTests.cs b/tests/WxAgent.Service.Tests/SessionTests.cs new file mode 100644 index 0000000..bc7bdfd --- /dev/null +++ b/tests/WxAgent.Service.Tests/SessionTests.cs @@ -0,0 +1,43 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Net.Http.Json; +using Microsoft.AspNetCore.TestHost; +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class SessionTests +{ + private sealed class Backend : IAgentBackend + { + public IReadOnlyList Capabilities => + [ + new("sessions-search", true, true, true, true, false, "read", false, 30, null, []), + new("session-current", true, true, true, true, false, "read", false, 30, null, []), + new("sessions-scroll", true, false, false, true, false, "manage", false, 30, "not validated", []) + ]; + public Task StatusAsync(CancellationToken ct) => Task.FromResult(new { ok = true }); + public Task> SearchSessionsAsync(string query, bool exactOnly, CancellationToken ct) => + Task.FromResult>([new("测试", "stable-1", true)]); + public Task CurrentSessionAsync(CancellationToken ct) => Task.FromResult(new("测试", "stable-1", true)); + } + + [Fact] + public async Task SearchCurrentAndDisabledNavigationHaveExplicitContracts() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var token = new string('A', 43); var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("p", ServiceOptions.HashToken(token), ["read"], []) })); + await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer()); + try + { + await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var current = await client.GetFromJsonAsync("/api/v1/sessions/current"); Assert.Equal("stable-1", current!.AutomationId); + var search = await client.GetFromJsonAsync("/api/v1/sessions/search?query=x&exactOnly=true"); Assert.Equal("stable-1", search.GetProperty("items")[0].GetProperty("automationId").GetString()); + var scroll = await client.PostAsJsonAsync("/api/v1/sessions/scroll", new { direction = "down", pages = 1 }); Assert.Equal(HttpStatusCode.Forbidden, scroll.StatusCode); + } + finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } + } +} diff --git a/tests/WxAgent.Service.Tests/SseTests.cs b/tests/WxAgent.Service.Tests/SseTests.cs new file mode 100644 index 0000000..744bcaf --- /dev/null +++ b/tests/WxAgent.Service.Tests/SseTests.cs @@ -0,0 +1,45 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using WxAgent.Service; +using Xunit; + +namespace WxAgent.Service.Tests; + +public sealed class SseTests +{ + private sealed class Backend : IAgentBackend + { + public IReadOnlyList Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])]; + public Task StatusAsync(CancellationToken ct) => Task.FromResult(new { ok = true }); + } + + [Fact] + public async Task EventStreamUsesRealSseFramesAndRedactsMessageContent() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var token = new string('A', 43); + var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("alice", ServiceOptions.HashToken(token), ["read"], []) })); + await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer()); + try + { + await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await client.GetAsync("/api/v1/events", HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(200, (int)response.StatusCode); + var hub = app.Services.GetRequiredService(); + hub.Publish("alice", new AgentEvent("evt-1", "ui-current", "test", "message", "Text", DateTimeOffset.UtcNow)); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var reader = new StreamReader(await response.Content.ReadAsStreamAsync(timeout.Token)); + Assert.Equal("id: evt-1", await reader.ReadLineAsync(timeout.Token)); + Assert.Equal("event: message", await reader.ReadLineAsync(timeout.Token)); + var data = await reader.ReadLineAsync(timeout.Token); + Assert.StartsWith("data: ", data); + Assert.DoesNotContain("content", data, StringComparison.OrdinalIgnoreCase); + Assert.Equal(string.Empty, await reader.ReadLineAsync(timeout.Token)); + } + finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } + } +} diff --git a/tests/WxAgent.Service.Tests/WxAgent.Service.Tests.csproj b/tests/WxAgent.Service.Tests/WxAgent.Service.Tests.csproj new file mode 100644 index 0000000..399d42d --- /dev/null +++ b/tests/WxAgent.Service.Tests/WxAgent.Service.Tests.csproj @@ -0,0 +1,10 @@ + + net8.0enableenablefalsetrue + + + + + + + +