feat: add HTTP and MCP service

This commit is contained in:
2026-09-07 19:07:16 +08:00
parent 6077abd1ad
commit d660c3b9e6
37 changed files with 2186 additions and 0 deletions
+14
View File
@@ -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
+40
View File
@@ -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 <TOKEN>
```
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` 目录前确认没有活动任务占用。
+24
View File
@@ -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 <file>` 启动;默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`
- HTTP API、Streamable HTTP MCP (`/mcp`) 和浏览器登录共用 Bearer TokenToken 只放 `Authorization`,不放 URL。
- 浏览器登录后仅保留短期 HttpOnly SameSite Cookie,写请求需 CSRF;普通日志不记录正文、Token、Cookie、密钥或完整 UI 树。
- 明文 HTTP 不提供传输保密性,只适合可信隔离网络;不应直接暴露公网。
+417
View File
@@ -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 HTTPstdio 只有明确客户端兼容需求时再补,不能额外启动独立 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<T>` 承接请求,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 写任务变 CancelledRunning 写任务变 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>`,不得将 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、进程内存读取或密钥导出工具。
- 日志只保留操作类型、脱敏目标摘要、耗时、错误码和 correlationIdCookie、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 HostCLI 行为保持兼容。
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 不含未实现的可用工具;输入越界在触碰微信之前失败。
- 所有列表有数量上限;旧游标/数据变更的限制在响应或页面可见。
### U3Web UI 只读闭环(46 人日)
开发步骤:
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 为 **2539 人日**U6 另计 **510 人日**。当前范围增加外部 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 或全功能矩阵已完成。
+49
View File
@@ -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 Token401。
- 非 allowlist Host/Origin403。
- 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 条。
+11
View File
@@ -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
}
+17
View File
@@ -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<ServiceOptions>(
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 <service.json>
doctor [--timeout 30]
diagnose --output <dir> [--baseline <ui-tree.json>] [--timeout 60]
inspect-ui --output <path> [--window-title <title>] [--timeout 30]
+159
View File
@@ -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
});
}
}
+1
View File
@@ -15,5 +15,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\WxAgent.Windows\WxAgent.Windows.csproj" />
<ProjectReference Include="..\WxAgent.Service\WxAgent.Service.csproj" />
</ItemGroup>
</Project>
+125
View File
@@ -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();
}
}
+77
View File
@@ -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) }] };
}
}
}
+58
View File
@@ -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 { } }
}
@@ -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);
@@ -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);
+56
View File
@@ -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 });
}
}
+30
View File
@@ -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); }
}
}
}
+133
View File
@@ -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);
}
}
+122
View File
@@ -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();
}
+26
View File
@@ -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);
}
}
+156
View File
@@ -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);
}
+68
View File
@@ -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;
}
+115
View File
@@ -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 = "/" });
}
}
+4
View File
@@ -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);
@@ -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>
+7
View File
@@ -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}};
+6
View File
@@ -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</title><link rel="stylesheet" href="styles.css"></head>
<body><main><h1>WxAgent 控制台</h1><p class="warning">HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。</p>
<section id="login"><h2>登录</h2><form id="loginForm"><label>Token <input id="token" type="password" autocomplete="off" required></label><button>登录</button></form></section>
<section id="app" hidden><div class="toolbar"><button id="refresh">刷新状态</button><button id="logout">退出</button></div><pre id="status" role="status"></pre>
<h2>能力</h2><div id="capabilities"></div><h2>账号</h2><div id="accounts"></div><h2>会话</h2><div id="sessions"></div><h2>联系人/群</h2><div id="contacts"></div><h2>任务</h2><label>Operation ID <input id="operationId" autocomplete="off"><button id="lookup">查询</button></label><pre id="operation"></pre><h2>可见消息</h2><label><input id="content" type="checkbox"> 显示正文(需要 content 权限)</label><div id="messages"></div><p id="error" class="error" role="alert"></p></section></main><script type="module" src="app.js"></script></body></html>
+1
View File
@@ -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}}
@@ -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<ServiceException>(() => 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<ServiceException>(() => 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<ServiceException>(() => store.SaveAsync("alice", bad, default))).Code);
}
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
}
@@ -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);
}
}
@@ -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<AgentCapability> Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public async IAsyncEnumerable<AgentEvent> 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); }
}
}
@@ -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); }
}
}
@@ -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<ServiceException>(() =>
store.Enqueue("alice", "account", "send-text", "one", "different", true, TimeSpan.FromMinutes(1), 2)).Code);
Assert.Equal("NotFound", Assert.Throws<ServiceException>(() => 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<ServiceException>(() =>
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); }
}
}
@@ -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<AgentCapability> 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<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<AccountInfo>>([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<JsonElement>("/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<ArgumentException>(() => options.Validate()); }
finally { Directory.Delete(dir, true); }
}
}
@@ -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<AgentCapability> 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<object> StatusAsync(CancellationToken cancellationToken) => Task.FromResult<object>(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<JsonElement>();
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); }
}
}
@@ -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<AgentCapability> 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<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SessionSearchInfo>>([new("测试", "stable-1", true)]);
public Task<SessionInfo?> CurrentSessionAsync(CancellationToken ct) => Task.FromResult<SessionInfo?>(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<SessionInfo>("/api/v1/sessions/current"); Assert.Equal("stable-1", current!.AutomationId);
var search = await client.GetFromJsonAsync<JsonElement>("/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); }
}
}
+45
View File
@@ -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<AgentCapability> Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(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<EventHub>();
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); }
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.28" />
<ProjectReference Include="../../src/WxAgent.Service/WxAgent.Service.csproj" />
</ItemGroup>
</Project>