feat: add HTTP and MCP service
This commit is contained in:
+14
@@ -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
|
||||
|
||||
@@ -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` 目录前确认没有活动任务占用。
|
||||
@@ -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 Token;Token 只放 `Authorization`,不放 URL。
|
||||
- 浏览器登录后仅保留短期 HttpOnly SameSite Cookie,写请求需 CSRF;普通日志不记录正文、Token、Cookie、密钥或完整 UI 树。
|
||||
- 明文 HTTP 不提供传输保密性,只适合可信隔离网络;不应直接暴露公网。
|
||||
@@ -0,0 +1,417 @@
|
||||
# Web UI + MCP 服务开发计划
|
||||
|
||||
> 状态:规划,尚未实施或验收。用户已确认目标为“浏览器 Web UI + MCP 服务”。
|
||||
> 本次交付仅为开发计划,不启动服务、不执行微信写操作。
|
||||
> 范围变化:原第一阶段排除正式 UI;本文规划其后的控制面阶段。用户已确认 HTTP/Web UI 与 MCP 支持外部访问且共用 Token,不要求 HTTPS,不实现服务端人工审批;不改变 Windows Service、协议及数据库安全边界。
|
||||
|
||||
## 1. 目标与基线
|
||||
|
||||
在已有 C# 微信业务能力上增加浏览器控制台和 MCP 调用入口,实现“查看状态 → 选择账号/会话 → 发起操作 → 查询进度 → 验证结果 → 获取诊断”的闭环。
|
||||
|
||||
依据:
|
||||
|
||||
- [C# 开发计划](WxAgent-CSharp-开发计划.md):总体目标、项目边界、自动化规则。
|
||||
- [PENDING](PENDING.md):最新功能边界、暂缓事项和未闭环问题;与早期计划冲突时以此为准。
|
||||
- `src/WxAgent.Host/Program.cs`:现有 CLI 入口、参数校验、JSON 输出与 smoke。
|
||||
- `src/WxAgent.Windows/WechatChatClient*.cs`:UI 操作及管理 API。
|
||||
- `src/WxAgent.Core`:业务模型、错误码、监听及只读数据相关逻辑。
|
||||
- `docs/validation/`:已记录的验收证据;实现存在不等于已通过真机验收。
|
||||
|
||||
### 1.1 当前事实
|
||||
|
||||
1. 当前主要交付是 Windows Host/CLI 和 C# 库,不把它们描述成已经可供浏览器调用的 HTTP/MCP 服务。
|
||||
2. CLI 已包含诊断、会话、消息、监听、数据库只读查询等入口;部分管理功能仅通过库 API 提供。
|
||||
3. 现有 UI 操作使用 `InterprocessCommandGate` 协调;服务化时复用并审查覆盖范围,不另建绕过它的点击通道。
|
||||
4. 联系人、群和群成员列表使用数据库读取,不增加 UI 长列表枚举回退。
|
||||
5. M6 未完成;500 条发送、多 DPI、长时监听及部分管理/朋友圈写路径仍属暂缓验收。
|
||||
6. 不实现“下一个未读会话”。语音 Beta、长文本自动分段、多附件批量等不因增加 UI 而自动恢复开发。
|
||||
|
||||
### 1.2 首版范围
|
||||
|
||||
- 本机或外部浏览器、HTTP 客户端与 MCP 客户端访问同一 Host;外部访问必须认证,HTTP 与 MCP 共用 Token、身份及权限。默认仍仅监听回环地址,外部 HTTP 监听须显式启用;不要求 HTTPS。
|
||||
- 一个交互式 Windows 用户会话、一个自动化控制目标;多数据库账号可显式选择,但不承诺多微信实例并行控制。
|
||||
- 完成诊断、会话、基础消息、受控文件、只读联系人/群成员、监听及任务中心。
|
||||
- 管理和朋友圈等风险功能按能力状态逐项接入,不默认开放未经真机验收的写操作。
|
||||
- 支持经 Token 认证的外部 HTTP 入口;明文传输不具备保密性,不作为安全公网部署方案。暂不做多租户、Windows Service、自动升级、批量营销或绕过已有确认门禁的破坏性操作。
|
||||
|
||||
## 2. 最小架构与运行方式
|
||||
|
||||
以下为实施建议,不代表已安装相应依赖。U0 阶段确认具体 SDK 版本与目标客户端兼容性。
|
||||
|
||||
```text
|
||||
本机/外部浏览器 本机/外部 MCP 客户端
|
||||
│ 同源 HTTP + SSE │ Streamable HTTP
|
||||
└──────────────┬───────────────────┘
|
||||
▼
|
||||
WxAgent.Host(同一进程、默认回环,可显式外部监听)
|
||||
静态页面 / REST API / MCP 适配
|
||||
▼
|
||||
共享操作处理、权限、任务与错误映射
|
||||
├── 只读数据库查询(有界并发)
|
||||
└── 统一 UI 调度 → 现有跨进程门禁
|
||||
▼
|
||||
FlaUI / Win32 / 微信
|
||||
```
|
||||
|
||||
### 2.1 技术与文件组织
|
||||
|
||||
- 继续 .NET 8、System.Text.Json、Microsoft.Extensions.Logging。
|
||||
- Host 增加 ASP.NET Core Minimal API 和静态文件托管;复用现有 CLI,不启动 CLI 子进程解析 stdout 作为正式服务接口。
|
||||
- Web UI 首版采用原生 HTML/CSS/JavaScript 模块、浏览器表单和 `fetch`,不先引入 SPA 框架。
|
||||
- MCP 使用维护中的官方 C# SDK,验证其 .NET 8 支持后锁定版本;不自行实现协议解析器。
|
||||
- 一个 Host 进程同时提供 REST 和 MCP。首版选 Streamable HTTP;stdio 只有明确客户端兼容需求时再补,不能额外启动独立 UI 写执行器。
|
||||
- 消息事件对浏览器使用 SSE;MCP 用显式订阅和有界事件拉取工具,不假设客户端都支持服务端主动通知。
|
||||
|
||||
建议新增位置:
|
||||
|
||||
```text
|
||||
src/WxAgent.Host/Api/ REST 路由和鉴权
|
||||
src/WxAgent.Host/Mcp/ MCP 工具薄适配
|
||||
src/WxAgent.Host/wwwroot/ 本地 Web UI
|
||||
src/WxAgent.Core/ 必要的跨平台契约、校验和任务状态逻辑
|
||||
```
|
||||
|
||||
仅在实际代码需要时创建文件。Windows 类型留在 Windows/Host;不新建微服务、插件系统或通用仓储框架。
|
||||
|
||||
以上路由目录为暂定位置:当前 Host 为 `net8.0-windows`/`win-x64` 且引用 Windows 项目,不能仅凭替换业务处理器就宣称真实 HTTP/MCP 适配可在 Linux 测试。U0 必须先运行一个加载真实路由、鉴权中间件和 MCP schema 的 Linux 冒烟实验;据此选择最小跨平台适配程序集或经验证的多目标方案。跨平台适配不得引用 FlaUI/Windows 项目,Host 仅负责组合和注入 Windows 执行器;不得复制一套测试专用路由冒充集成测试。
|
||||
|
||||
### 2.2 生命周期与调度
|
||||
|
||||
- 新增计划命令 `WxAgent.Host serve`,在已登录、未锁定的微信用户会话运行;不是 Windows Service。
|
||||
- 同一桌面仅一个服务实例;端口/实例冲突明确失败,保留现有 CLI 跨进程互斥保护。
|
||||
- 点击、输入、滚动、导航以及依赖页面状态的读取统一排队;数据库纯读取不占用 UI 写队列。
|
||||
- 使用有界 `Channel<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 写任务变 Cancelled,Running 写任务变 Unconfirmed;不重放 |
|
||||
|
||||
- 无人工审批等待状态。默认预算建议查询 30 秒、写入 60 秒、扫描 120 秒,设置服务端上限;排队时间计入总预算。
|
||||
- 监听为独立订阅生命周期,不使用无限长 HTTP 请求充当普通写任务。
|
||||
- 尚未开始的任务可以确定取消;已点击发送/提交后取消或超时,若无法证明结果则为 `Unconfirmed`,不能声称撤销成功。
|
||||
- `ResultUnconfirmed` 映射为不确定终态,UI 提示人工核实且不显示默认重试按钮。
|
||||
- 相同 principalId、幂等键与相同业务参数返回已有任务;同键不同参数报冲突。HTTP/MCP 使用相同去重命名空间;对规范化后的操作、账号、目标及完整业务参数计算摘要,不将传输关联 ID 纳入比较。幂等键不等于微信 exactly-once 保证。
|
||||
- 原子创建任务和幂等记录并持久化成功后,才返回 HTTP 202/MCP 接单结果或将任务交给执行器;持久化失败不执行。Running 状态必须先持久化再开始副作用。记录不含消息正文/密钥;运行参数仅在受控内存中保留。
|
||||
- principalId 独立于 Token 值;更换 Token 保留身份与去重记录,重新登录不重置去重窗口。显式创建新身份不共享幂等窗口,不得用新身份重试结果不确定的旧操作。
|
||||
- 崩溃恢复按状态表处理;写任务不自动再发。队列无容量时拒绝接单,不返回虚假接单成功。
|
||||
- 首版建议队列上限 100、终态保留 24 小时/最多 10000 条;提前清理意味着去重窗口缩短,必须公开窗口并拒绝将其宣传为永久幂等。
|
||||
- 任务记录优先复用现有存储;无现成实现时使用小型 SQLite 任务表,不引入消息中间件。
|
||||
|
||||
## 5. 安全与隐私
|
||||
|
||||
### 5.1 本机与外部入口鉴权
|
||||
|
||||
1. 默认绑定 `127.0.0.1`/`::1`;允许显式配置非回环 IP、`0.0.0.0`/`::`、端口和允许的访问域名。外部 HTTP 监听必须先配置有效 Token,否则启动失败;不自动开放防火墙。不要求 HTTPS,不开发证书管理及反向代理适配。
|
||||
2. HTTP API 与 MCP 共用高熵 Token、principalId、权限和撤销机制,同一 Token 可以调用两种协议。直接客户端使用 `Authorization: Bearer <TOKEN>`,不得将 Token 放入 URL、查询参数、日志或 MCP session ID。Token 首次生成/配置及授权在 Windows 主机本地完成,文件由当前用户 ACL 保护;不通过匿名网络接口生成或提权。
|
||||
3. 本机和外部 Web UI 统一输入 Token 登录,不实现配对码。只匿名提供无业务数据的登录页及静态资源;浏览器通过 POST 换取短期 `HttpOnly`、`SameSite=Strict` 会话 Cookie,随后清空输入,不把 Token 写入 localStorage/sessionStorage 或持久缓存。明文 HTTP 下 Cookie 不设置 Secure,因此不具备传输保密性。会话继承 Token 身份和权限;SSE 使用该 Cookie,写请求另校验 CSRF。API/MCP 无 Token 或有效派生会话时拒绝,错误/状态接口不得匿名泄露环境信息。
|
||||
4. Token 登录失败限流;校验配置的 Host 与精确 Origin 白名单,不设置通配 CORS。原生 MCP/HTTP 客户端可无 Origin,但仍须 Bearer 认证;有 Origin 则必须通过检查。本机专用入口仅接受直接回环连接,不信任客户端自报 Host、Origin 或 X-Forwarded-For 作为来源证明。
|
||||
5. 首版权限区分只读、内容读取、普通写、管理写和本机诊断管理;凭据默认只读。共享 Token 即共享身份,不能隔离共享者;需要隔离时配置不同身份的 Token。密钥扫描、保存等本机专用动作不能因外部 Token 有管理权限而开放。
|
||||
6. 更换或撤销 Token 立即使旧 Token、派生 Cookie 和 MCP 会话授权失效,关闭相关 SSE/事件流,不实现新旧 Token 重叠窗口;稳定身份和幂等记录保留。每次资源访问及每个事件输出批次检查授权;执行及实际提交副作用前重新检查。未执行任务失败且不执行;已提交任务保留真实/不确定结果,不承诺撤销微信动作。
|
||||
|
||||
**明文传输限制:** Token、Cookie、聊天内容和附件可被网络监听或中间人窃取/篡改;Token 认证不等于加密。外部 HTTP 仅建议用于可信隔离网络,界面和部署说明必须提示此风险,不宣称可安全直接暴露公网。
|
||||
|
||||
### 5.1.1 对象级权限
|
||||
|
||||
- operation、subscription、事件游标、fileId、artifactId 均绑定 principalId 和适用账号范围;ID 不透明不等于授权。查询、列表、取消、续接、内容展开及下载都检查归属与当前权限,任务结果中的正文仍需内容读取权限。
|
||||
- 普通任务、订阅和文件只允许所有者访问,不实现管理员跨身份读取/取消功能。
|
||||
- 撤销、账号切换及退出时清理相关浏览器内容、关闭旧订阅;内容响应设 `Cache-Control: no-store`。已交付客户端的数据无法追回,不宣称撤销能擦除第三方副本。
|
||||
|
||||
### 5.2 写入授权与现有确认门禁
|
||||
|
||||
- 不实现人工审批中心、审批记录、approvalId、AwaitingApproval 或等待另一人批准的流程。已启用操作在权限、参数、账号和目标校验通过后直接入队。
|
||||
- 普通发送由 UI 明确点击发送;HTTP/MCP 要求普通写权限和显式目标。管理写权限不自动启用未验收功能,也不解除 PENDING 暂缓项。
|
||||
- 已有要求 `CONFIRM` 的库操作仍需调用方在本次请求中明确提供对应确认参数,服务不得默认补入。该参数只表达本次动作确认,不是 Token、提权凭据或人工审批;参数纳入幂等摘要。UI 展示目标与影响后提交,不建立审批工作流。
|
||||
- @所有人等既定显式确认要求保留;任何确认均不能代替目标唯一性、账号绑定及写后结果检查。
|
||||
|
||||
### 5.3 内容、文件与数据库
|
||||
|
||||
- 服务默认返回摘要;经授权并显式请求正文才返回内容。浏览器切换账号/退出时清理内容,不将聊天内容默认写入持久缓存。
|
||||
- 消息、昵称、OCR 和 Markdown 都是非可信数据,用 textContent/安全渲染处理,禁止直接 innerHTML;不自动访问内容里的 URL。
|
||||
- MCP 返回的消息内容只是数据,不能作为执行工具、授权或改变系统策略的指令。
|
||||
- 上传建议首版单文件最多 50 MiB、临时区总量 500 MiB;以服务端最终配置为准并在 UI 展示。限制大小、类型、数量、保留时间,校验后以不透明 fileId 引用。
|
||||
- 拒绝外部任意绝对路径、UNC、`..`、符号链接/重解析点逃逸;发送前重新验证文件归属和摘要。浏览器下载只允许授权 artifactId,不提供任意本机文件读取。
|
||||
- 文件上传/下载不自动执行;临时产物 24 小时清理,任务占用时不误删;下载诊断包也需权限。
|
||||
- 数据库密钥仅服务端使用;扫描候选必须经 page 1 HMAC 校验后才允许使用/保存。仅本机显式管理动作可保存,MCP/UI 不返回完整密钥。
|
||||
- 数据库只读、query_only、有界固定查询;不提供原始 SQL、进程内存读取或密钥导出工具。
|
||||
- 日志只保留操作类型、脱敏目标摘要、耗时、错误码和 correlationId;Cookie、Token、消息正文、附件、完整 UI 树和密钥不得进入普通日志。
|
||||
|
||||
## 6. 开发阶段与验收门槛
|
||||
|
||||
阶段按 U0 → U1 → U2/U3 → U4 → U5 → U6 → U7 推进。U2/U3 可独立开发适配代码,但共用契约;不得并行操作同一微信桌面。
|
||||
|
||||
### U0:盘点和契约冻结(2–3 人日)
|
||||
|
||||
开发步骤:
|
||||
|
||||
1. 逐项核对 Program.cs、公开 C# API、PENDING 和验收记录,输出操作清单,不用旧里程碑状态替代现状。
|
||||
2. 标记 Ready、待真机验证、暂缓、未实现;列清 UI 页面、REST、MCP、真实方法和风险对应关系。
|
||||
3. 确认目标 MCP 客户端、协议/SDK 版本、首版浏览器;锁定本机与外部 HTTP 部署、共用 Token 和原生 Web UI 方案,验证目标 MCP 客户端支持非回环 HTTP 与 Bearer 配置;不支持时明确标记客户端不兼容,不绕过其安全限制。
|
||||
4. 定义 DTO、错误、任务状态、权限、确认策略、上传限制和内容披露规则。
|
||||
5. 验证数据库账号到当前 UI 账号的可靠绑定及失效检测;无可靠方案则写能力 No-Go,不阻塞只读基础版。
|
||||
6. 验证 Linux 能加载真实 HTTP/MCP 适配层的最小测试,再冻结项目边界;不能仅以 Core 替身测试通过代替。
|
||||
|
||||
验收:
|
||||
|
||||
- 功能矩阵所有行均有真实入口或“未实现”说明;零虚构可用服务。
|
||||
- 每项写动作有目标判定、确认规则、超时/取消语义和后置成功条件。
|
||||
- SDK 可恢复/编译,目标 MCP 客户端可使用与 HTTP API 相同的 Token 经外部 HTTP 完成最小 initialize/list/call 实验。
|
||||
- 账号绑定有可验证证据,覆盖切换账号、重启及身份未知时拒绝写入;缺失则明确阻塞 U4 写能力。
|
||||
- Linux 真实适配冒烟覆盖至少一条路由、鉴权中间件和一个 MCP 工具 schema,不加载 Windows 实现。
|
||||
- 交付 `docs/WebUI-MCP-功能矩阵.md`,未经批准的暂缓项仍保持暂缓。
|
||||
|
||||
### U1:服务骨架与共享执行(4–6 人日)
|
||||
|
||||
开发步骤:
|
||||
|
||||
1. 增加 serve 分支和最小 HTTP Host,CLI 行为保持兼容。
|
||||
2. 提取必要共享处理逻辑,使 REST、MCP 不复制参数校验和微信操作实现。
|
||||
3. 接入有界队列、现有门禁、超时取消和任务记录,核查库 API 未受门禁保护的路径。
|
||||
4. 实现默认回环及显式外部 HTTP 监听、HTTP/MCP 共用 Token、统一浏览器 Token 登录、权限、CSRF、Host/Origin 检查和脱敏日志。
|
||||
5. 先接 status、capabilities、operations 查询和取消。
|
||||
|
||||
验收:
|
||||
|
||||
- 原 Core 测试与全量 Release 构建通过;CLI 原命令 smoke 不退化。
|
||||
- 20 个并发模拟 UI 请求的最大执行并发数为 1;复合动作不交错;数据库读取不长期阻塞 UI。
|
||||
- 队列超过上限明确拒绝,不无限增长;排队超时后不得执行微信动作。
|
||||
- 未认证、错误 Origin/Host、无权限和跨站写请求均被拒绝;密钥与正文日志扫描为零泄漏。
|
||||
- 重复任务返回同一 ID;冲突参数拒绝;强制终止后不重放写操作。
|
||||
- 写请求缺幂等键被拒绝;同身份跨 HTTP/MCP 并发、接单响应丢失后重试、Token 轮换后重试均返回原任务;记录持久化失败无微信动作。
|
||||
- 排队超时、取消、重启恢复及持久化失败有状态测试;短查询直接返回且不产生持久任务,UI 查询仍串行调度。
|
||||
- 两个身份不能互读/取消任务、读取事件或下载产物,无管理员跨身份例外。更换或撤销 Token 后旧 Cookie/MCP/SSE 立即失效,未执行任务不再写入,已提交任务不虚报已撤销。
|
||||
- 非回环监听缺 Token 时启动失败;配置 Token 后可使用 HTTP,无 TLS 前置要求。HTTP/MCP 同 Token 得到相同身份和权限,缺失/错误 Token 均被拒绝;伪造转发头不能使用本机专用入口。
|
||||
|
||||
### U2:只读服务与 MCP 基础(3–5 人日)
|
||||
|
||||
开发步骤:
|
||||
|
||||
1. 接账号状态、会话、可见消息、数据库联系人/群成员和消息读取。
|
||||
2. 明确数据库账号与 UI 当前账号的不同含义;多账号未选择时报错。
|
||||
3. REST 与 MCP 共用处理函数,增加只读/内容权限和结果大小限制。
|
||||
4. 建立 MCP 初始化、工具列表、schema、调用、错误、取消的兼容测试。
|
||||
|
||||
验收:
|
||||
|
||||
- 同一固定快照从 REST/MCP 返回相同业务字段和错误码(忽略关联 ID/时间)。
|
||||
- 联系人同名不同 ID 不合并;limit/offset 边界和末页正确;未知字段不虚构。
|
||||
- 未选多账号不猜测;未授权 includeContent 不泄露正文。
|
||||
- list/tools 不含未实现的可用工具;输入越界在触碰微信之前失败。
|
||||
- 所有列表有数量上限;旧游标/数据变更的限制在响应或页面可见。
|
||||
|
||||
### U3:Web UI 只读闭环(4–6 人日)
|
||||
|
||||
开发步骤:
|
||||
|
||||
1. 建总览、账号选择、会话/消息、联系人/群成员、诊断、任务中心页面。
|
||||
2. 实现统一 loading/empty/error/offline/unsupported 状态、分页和刷新。
|
||||
3. 加入正文显示开关、能力禁用说明、关联 ID 复制与脱敏诊断下载。
|
||||
4. 实现键盘导航、可读焦点、表单标签和自适应布局。
|
||||
|
||||
验收:
|
||||
|
||||
- Edge/Chrome 目标版本在本机和外部均可完成“输入共用 Token → 状态 → 选账号 → 读取 → 分页 → 退出”。仅本机诊断管理动作在外部显示禁用。
|
||||
- 登录失败、Token 失效/撤销有明确提示,外部 HTTP 页面提示明文风险;浏览器持久存储、URL、历史和普通日志不含 Token,注销关闭事件流。
|
||||
- 刷新页面、服务离线、401、空列表、超时均有明确可恢复提示,不显示假数据或无限 loading。
|
||||
- 昵称/消息含 HTML、脚本或 Markdown 链接时不执行代码,不自动请求外部资源。
|
||||
- 1280×720 和 1920×1080 无关键控件遮挡;浏览器 100%/125%/150% 缩放可操作;这不替代微信 DPI 验收。
|
||||
- 全程键盘可选择目标、打开详情、关闭弹窗,焦点不丢失。
|
||||
|
||||
### U4:基础写入、文件和确认门禁(工期 U0 重估)
|
||||
|
||||
开发步骤:
|
||||
|
||||
1. 接文本、引用、单图片、单文件、URL 卡片以及明确支持的提及操作。
|
||||
2. 写前绑定账号/目标和消息引用,检测重名、旧快照与已有草稿,不能覆盖用户草稿。
|
||||
3. 复用现有操作的显式确认参数与权限检查;HTTP/MCP 校验后直接入队,不实现人工审批中心。
|
||||
4. 接上传、fileId、产物下载、限额及清理;实现写后确认与不确定状态展示。
|
||||
|
||||
验收:
|
||||
|
||||
- 仅对白名单测试对象执行,每种首版发送类型至少成功 1 次并读回对应类型/内容摘要;发送请求不因点击、HTTP/MCP 重试而重复执行。
|
||||
- 前端快速双击、同幂等键并发、断连后重查均只产生一个逻辑任务。
|
||||
- 已有要求确认参数的操作缺少确认时无副作用,适配器不自动补 `CONFIRM`;确认不提升权限。相同幂等键下参数变更被拒绝;账号切换使待执行写任务失效,取得执行所有权后绑定不匹配则不发送。
|
||||
- 点击提交后故意中断确认阶段,返回 Unconfirmed,不自动重发。
|
||||
- 文件超限、扩展名伪装、路径穿越、非本用户 fileId 和过期产物被拒绝;合法文件发送并确认。
|
||||
- URL 卡片验收必须确认原生卡片,不能以发送 URL 文本冒充。
|
||||
|
||||
### U5:实时监听与恢复(4–6 人日)
|
||||
|
||||
开发步骤:
|
||||
|
||||
1. 接现有 MessageEvent/checkpoint,建立服务订阅生命周期和所有权。
|
||||
2. 浏览器 SSE 输出事件 ID、来源会话、恢复标记;MCP 分页拉取相同事件。
|
||||
3. 有界环形缓存与慢消费者隔离;游标过期返回 gap/resyncRequired,不能静默跳过。
|
||||
4. 浏览器断连重连只恢复读取;Host 重启的监听续接与写任务不重放分开处理。
|
||||
|
||||
验收:
|
||||
|
||||
- 60 分钟受控测试,记录至少 20 条独立标记消息;在声明的可见/监听覆盖范围内计数与来源正确、0 重复。
|
||||
- 浏览器断连 30 秒且仍在缓存范围内,重连补齐事件;超过缓存范围明确报告缺口。
|
||||
- 一个慢浏览器/MCP 客户端不能阻塞 UI 执行或其他订阅;缓存数量不超过上限。
|
||||
- Token 撤销或权限收回后,现有 SSE/MCP 事件读取停止输出;另一身份不能凭窃取的订阅 ID/游标读取历史事件。
|
||||
- UI 导航与监听冲突不会将消息归属到错误会话;无法确定时明确失败。
|
||||
- 该短测不替代仍暂缓的 8/24/72 小时及多会话稳定性验收。
|
||||
|
||||
### U6:管理和高级功能(条件阶段,5–10 人日,不含暂缓等待)
|
||||
|
||||
开发步骤:
|
||||
|
||||
1. 按“只读详情 → 子窗口 → 消息动作 → 管理变更 → 朋友圈写入”逐项接入。
|
||||
2. 对照真实方法签名建立专用 DTO 和 MCP 工具,不开放任意 UI 菜单、脚本或 shell。
|
||||
3. 每项写能力先补专用测试对象的定位、确认及后置结果验证,再解除 capability 禁用。
|
||||
4. 合并记录、OCR、转文字、语音 Beta 显示真实限制;保持 PENDING 决定。
|
||||
|
||||
验收:
|
||||
|
||||
- 每个解除禁用的动作分别具备成功、无权限、取消、歧义目标、结果不确定用例。
|
||||
- 联系人/群管理、朋友圈写入必须另获恢复暂缓验收的明确授权;没有授权则维持禁用并标记条件未满足。
|
||||
- 朋友圈刷新后旧指纹拒绝操作;管理弹窗不唯一时不点击;未知 OCR 结果不返回整窗拼接文本。
|
||||
- 语音 Beta、长列表提及、嵌套合并附件等未验证项不得显示正式可用。
|
||||
- U6 未完成不伪装全功能交付;可以发布明确标注功能范围的 U1–U5 基础版。
|
||||
|
||||
### U7:集成、部署与发布(3–5 人日)
|
||||
|
||||
开发步骤:
|
||||
|
||||
1. 完成 Core 契约测试、HTTP/MCP 一致性和浏览器端到端测试;UI 测试使用明确的 mock 数据,不计入真机通过数。
|
||||
2. Linux restore/test/build/publish 后上传 Windows,同一已登录用户会话启动 serve。
|
||||
3. 执行 doctor/inspect-ui/smoke 及本期实际接入功能回归,记录 Windows/微信/浏览器/MCP 客户端版本。
|
||||
4. 验证关闭、崩溃重启、凭据撤销、端口冲突、锁屏、微信退出及版本不支持路径。
|
||||
5. 从另一主机验证外部 HTTP Web UI、HTTP API 和 MCP:同一 Token 可访问,缺失/错误/撤销 Token 被拒绝,本机专用入口不可访问;验证 Host/Origin、限流和防火墙部署要求,不以 localhost 自测代替。
|
||||
6. 输出使用说明、HTTP/MCP 共用 Token 配置样例(只用占位值)、显式监听配置、明文风险提示、凭据更换、回滚步骤及本轮验收报告。
|
||||
|
||||
验收:
|
||||
|
||||
- 发布包包含 Web 静态资源与 MCP 依赖;Windows 无全局 dotnet 也可运行。
|
||||
- 未锁定会话正常工作;锁屏/未登录时明确拒绝写入,不进入 Session 0,不绕过登录。
|
||||
- CLI、Web UI、MCP 并发提交时无 UI 步骤交错;原有 CLI 行为和安全门禁不退化。
|
||||
- 所有首版必选测试通过;未完成项附禁用状态、影响及证据,零已知越权、重复写入和隐私泄漏缺陷。
|
||||
- 回滚前停止新服务,保存必要任务记录;旧版本不得自动消费新版本未完成写任务。
|
||||
|
||||
## 7. 测试执行与证据模板
|
||||
|
||||
### 7.1 Linux 构建基线
|
||||
|
||||
```bash
|
||||
dotnet restore WxAgent.sln -p:EnableWindowsTargeting=true
|
||||
dotnet test tests/WxAgent.Core.Tests -c Release
|
||||
dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true
|
||||
dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \
|
||||
-p:EnableWindowsTargeting=true -p:PublishSingleFile=true -p:PublishTrimmed=false
|
||||
```
|
||||
|
||||
新增 HTTP/MCP 测试必须在 Linux 加载生产使用的真实路由、鉴权和协议适配,仅 Windows 业务执行器使用纯逻辑替身;不得通过重写测试路由规避 Host 的 Windows 目标限制。按 U0 验证后的最小跨平台项目边界实施,不在 Linux 执行 FlaUI;Windows 会话绑定、部署监听和桌面行为留在 Windows。实施时将新测试命令纳入发布清单。
|
||||
|
||||
### 7.2 Windows 真机
|
||||
|
||||
沿用 AGENTS.md:主机 `10.1.1.101`、计算机 `DESKTOP-EGI7QCK`、用户 `rogee`,部署到 `C:\Users\Rogee\wx-agent`。优先 Windows MCP,上传/日志可用 `ssh rogee@10.1.1.101`。当前环境的 Windows MCP 是操作真机的工具,**不是本计划要开发的 WxAgent MCP 服务**。
|
||||
|
||||
每次发布至少执行:
|
||||
|
||||
```powershell
|
||||
WxAgent.Host doctor
|
||||
WxAgent.Host inspect-ui --output artifacts/ui-tree.json
|
||||
WxAgent.Host smoke
|
||||
# 本计划实施后的新增入口,当前不能作为已有命令执行:
|
||||
WxAgent.Host serve
|
||||
```
|
||||
|
||||
- smoke 有发送副作用,测试对象和次数必须写入报告。
|
||||
- 消息测试仅使用“文件传输助手”“Hao 豪”“吉祥三宝”“消息测试专用群组”;@所有人仅在指定测试群。
|
||||
- 不为完成 UI/MCP 接入而操作真实联系人、真实群或朋友圈内容;已有暂缓写验收需另行授权。
|
||||
- 新增验收记录建议保存到 `docs/validation/WebUI-MCP-<日期>.md`,敏感原始产物不提交仓库。
|
||||
|
||||
### 7.3 单项验收记录
|
||||
|
||||
| 字段 | 必填内容 |
|
||||
| --- | --- |
|
||||
| 能力/用例 | 操作 ID、REST 路由、MCP 工具、UI 入口 |
|
||||
| 环境 | 提交号、包版本、Windows/微信/浏览器/MCP 客户端版本、DPI、会话状态 |
|
||||
| 前置条件 | 权限、账号绑定、目标别名、启用状态和既有确认参数要求 |
|
||||
| 输入 | 脱敏参数、数量、边界及超时设置 |
|
||||
| 预期 | HTTP/工具错误码、任务状态、可观察微信后置结果 |
|
||||
| 实际 | correlationId、脱敏证据、耗时、重复/遗漏计数 |
|
||||
| 结论 | 通过/失败/未执行/暂缓,不用“基本支持” |
|
||||
| 限制 | 失败诊断、适用范围、是否阻塞对应 capability 启用 |
|
||||
|
||||
## 8. 排期、交付和完成定义
|
||||
|
||||
单工程师原本机范围估算:U0–U5 加 U7 为 **25–39 人日**;U6 另计 **5–10 人日**。当前范围增加外部 HTTP、共用 Token、对象级授权及撤销验证,补入账号绑定和 Linux 真实适配技术验证;同时取消 HTTPS 要求、配对码、人工审批、Token 重叠轮换及管理员跨身份管理。旧总工期和阶段人日仅为历史参考,U0 完成后重新估算;不含用户暂缓等待、微信版本适配及全面压力测试。
|
||||
|
||||
基础版交付:
|
||||
|
||||
- 可发布的 serve 入口、支持本机及外部 HTTP 的同源 Web UI、HTTP/MCP 共用 Token 与已验证连接配置。
|
||||
- 共享契约与能力矩阵、鉴权、现有确认门禁、任务和事件通道。
|
||||
- 首版功能的 Linux 自动测试和 Windows 真机证据。
|
||||
- 用户操作手册、诊断导出、凭据撤销及回滚说明。
|
||||
|
||||
最终完成必须同时满足:
|
||||
|
||||
1. UI、REST、MCP 对同一业务操作使用同一校验、权限和调度路径。
|
||||
2. 每个标记 Ready 的能力有输入/输出、超时取消、稳定错误、测试及当前微信版本验收记录。
|
||||
3. 成功表示已满足明确后置条件;未确认结果不会误标成功,不盲目重试。
|
||||
4. 应用日志及未授权响应无内容/密钥泄漏、无未认证写入、无任意文件或 SQL 入口;外部业务访问通过共用 Token 或其有效派生会话认证,对象级授权、撤销及本机专用边界有验收证据。明文 HTTP 不保证传输保密性,不能将通过应用鉴权验收描述为网络防窃听验收通过。
|
||||
5. 未实现、暂缓与不支持项在文档、UI 和 MCP 能力信息中一致;不得将本次控制面交付描述为 M6 或全功能矩阵已完成。
|
||||
@@ -0,0 +1,49 @@
|
||||
# WebUI/MCP 验收记录(2026-09-07)
|
||||
|
||||
## 环境
|
||||
|
||||
- Windows:`DESKTOP-EGI7QCK` / Windows 用户 `rogee`
|
||||
- 微信版本:`4.1.13.63`
|
||||
- 发布方式:Linux .NET 8 self-contained `win-x64` single-file,部署目录 `C:\Users\Rogee\wx-agent`
|
||||
- 服务进程:曾在交互式用户会话启动(非 Session 0);最终验证配置恢复默认回环 `127.0.0.1:5088`,发布完成后保持停止
|
||||
- MCP SDK:`ModelContextProtocol.AspNetCore 2.2.0`
|
||||
|
||||
## 已验证
|
||||
|
||||
| 能力 | 入口 | 结果 |
|
||||
|---|---|---|
|
||||
| 服务/微信诊断 | `GET /api/v1/status` | HTTP 200;`serviceOnline=true`、`wechatAvailable=true`、`sessionAvailable=true`、`windowFound=true`,错误为空 |
|
||||
| 会话列表 | `GET /api/v1/sessions` | HTTP 200;返回可见会话,含 automationId |
|
||||
| 会话当前/精确搜索 | `/api/v1/sessions/current`、`/search?exactOnly=true` | HTTP 200;当前会话和“文件传输助手”精确匹配均返回稳定 automationId |
|
||||
| 账号发现 | `GET /api/v1/accounts` | HTTP 200;返回数据库指纹;UI 绑定保持 false |
|
||||
| 联系人只读 | `GET /api/v1/contacts` | HTTP 200;返回稳定联系人 ID;未输出密钥 |
|
||||
| 群成员只读 | `GET /api/v1/groups/{accountId}/{group}/members` | HTTP 200;显式账号范围与测试群 `消息测试专用群组`,返回稳定成员 ID |
|
||||
| SSE 单帧 | `GET /api/v1/events` | HTTP 200;真实 `id/event/data` 帧,正文不进入事件;默认仍需显式 opt-in |
|
||||
|
||||
| 可见消息摘要 | `GET /api/v1/messages` | HTTP 200;返回 fingerprint/type/summary,正文为 null |
|
||||
| MCP | `/mcp` initialize、`tools/list` | HTTP 200;同一 Bearer Token;工具 schema 可加载 |
|
||||
| Web 静态入口 | `/` | HTTP 200;静态资源随发布包部署 |
|
||||
|
||||
## 安全/失败路径
|
||||
|
||||
- 无 Bearer Token:401。
|
||||
- 非 allowlist Host/Origin:403。
|
||||
- URL 查询中出现 token 参数:400,拒绝凭据泄漏路径。
|
||||
- 旧 Token 轮换后:旧 Bearer 与浏览器 Cookie 均 401;身份与幂等记录保留。
|
||||
- 写能力:保持 disabled;当前服务不模拟成功、不执行微信写入。
|
||||
- SSH 端口转发初次测试需要显式覆盖 Host 头;未将转发场景误判为外部网络验收。
|
||||
- `0.0.0.0` 外部监听配置已验证可加载并明确要求 `AllowExternal=true`;当前 Windows 防火墙未开放入站端口,因此未声称外部网络连通验收通过。
|
||||
|
||||
## 自动化证据
|
||||
|
||||
- `dotnet test tests/WxAgent.Core.Tests -c Release`:150 passed。
|
||||
- `dotnet test tests/WxAgent.Service.Tests -c Release`:11 passed。
|
||||
- `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true`:成功,0 warning/0 error。
|
||||
- self-contained `win-x64` publish:成功,包含 `wwwroot` 与 MCP 依赖。
|
||||
|
||||
## 未完成/不宣称
|
||||
|
||||
- 未执行发送、联系人/群管理、朋友圈、语音等写操作;`send-text`、`group-at-all` 和 deferred 项保持禁用。
|
||||
- 未完成 60 分钟/20 条消息监听、断线补齐和多客户端慢消费者真机验收;SSE 与服务端监听源已实现,默认配置 `EnableListenerEvents=false`。自动化高频探针已中止,不作为验收证据;后续真机消息验证上限 3 条。
|
||||
- 未完成外部主机直接访问验收(防火墙未开放);不将 HTTP 鉴权描述为网络加密。
|
||||
- 远程验证任务已停止并清理;不再自动发送测试消息,后续真机消息验证上限为 3 条。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,6 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WxAgent.Windows\WxAgent.Windows.csproj" />
|
||||
<ProjectReference Include="..\WxAgent.Service\WxAgent.Service.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) }] };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = "/" });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}};
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user