diff --git a/AGENTS.md b/AGENTS.md index 0e2060d..302e9d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - 使用 C#/.NET 独立实现 wxautox4 商业版的业务功能,只兼容功能,不兼容其 Python API。 - 当前阶段实现 Desktop Agent、CLI 和业务层,不实现 Windows Service、正式 Web/UI、授权和自动升级。 - 仅通过 Windows UI Automation、Win32 和正常桌面交互实现;允许为本地数据库只读访问而读取 `Weixin.exe` 内存并进行 SQLCipher 解密;禁止协议破解、DLL 注入、进程内存修改、数据库写入、登录或风控绕过。 +- Agent 安装并成功连接控制面即完成节点及数据同步授权,不再要求额外用户确认;连接授权仅覆盖已验证账号的规范化只读数据,仍禁止上传原始微信数据库、密钥和未脱敏内容。 - 开发前先阅读 `docs/WxAgent-CSharp-开发计划.md`,功能范围、里程碑和参考对象以该文档为准。 ## 技术约束 diff --git a/control-plane/account_store.go b/control-plane/account_store.go index 89cfbde..b1567b1 100644 --- a/control-plane/account_store.go +++ b/control-plane/account_store.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "sync" "time" @@ -738,10 +739,13 @@ func (s *AccountStore) QueryConversations(ctx context.Context, limit int, cursor if err != nil { return nil, err } + allChats := slices.ContainsFunc(scopes, func(scope ReportingScope) bool { + return scope.ChatID == "*" && scopeAllows(scopes, "*", "conversations") + }) allowedChats := make([]string, 0, len(scopes)) seenChats := make(map[string]struct{}, len(scopes)) for _, scope := range scopes { - if !scopeAllows(scopes, scope.ChatID, "conversations") { + if allChats || !scopeAllows(scopes, scope.ChatID, "conversations") { continue } if _, exists := seenChats[scope.ChatID]; exists { @@ -750,15 +754,18 @@ func (s *AccountStore) QueryConversations(ctx context.Context, limit int, cursor seenChats[scope.ChatID] = struct{}{} allowedChats = append(allowedChats, scope.ChatID) } - if len(allowedChats) == 0 { + if !allChats && len(allowedChats) == 0 { return nil, ErrAccountNotAuthorized } const sortExpression = "COALESCE(last_activity_at, observed_at)" - query := `SELECT chat_id, chat_type, title, last_activity_at, source, observed_at, directory_state FROM conversations WHERE chat_id IN (` + strings.TrimSuffix(strings.Repeat("?,", len(allowedChats)), ",") + ")" + query := `SELECT chat_id, chat_type, title, last_activity_at, source, observed_at, directory_state FROM conversations` args := make([]any, 0, len(allowedChats)+4) - for _, chatID := range allowedChats { - args = append(args, chatID) + if !allChats { + query += ` WHERE chat_id IN (` + strings.TrimSuffix(strings.Repeat("?,", len(allowedChats)), ",") + ")" + for _, chatID := range allowedChats { + args = append(args, chatID) + } } if cursor != nil { query += " AND (" + sortExpression + " < ? OR (" + sortExpression + " = ? AND chat_id > ?))" @@ -1101,7 +1108,7 @@ func authorizeBatch(batch IngestBatch, scopes []ReportingScope) error { func scopeAllows(scopes []ReportingScope, chatID, dataType string) bool { now := time.Now().UTC() for _, scope := range scopes { - if scope.ChatID != chatID || (scope.ExpiresAt != nil && !scope.ExpiresAt.After(now)) { + if scope.ChatID != "*" && scope.ChatID != chatID || (scope.ExpiresAt != nil && !scope.ExpiresAt.After(now)) { continue } if scope.DataType == "*" || scope.DataType == "read" || scope.DataType == dataType { diff --git a/control-plane/account_store_test.go b/control-plane/account_store_test.go index fbee226..4549a42 100644 --- a/control-plane/account_store_test.go +++ b/control-plane/account_store_test.go @@ -273,6 +273,44 @@ func TestAccountStoreMaintenanceCapacityAndRevocation(t *testing.T) { } } +func TestAccountStoreConnectionWildcardScopeAuthorizesAllChats(t *testing.T) { + ctx := context.Background() + manager, err := OpenAccountStoreManager(t.TempDir(), AccountStoreManagerOptions{}) + if err != nil { + t.Fatal(err) + } + defer manager.Close() + store, err := manager.RegisterAccount(ctx, AccountRegistration{ + AccountID: "account-a", StableIdentity: "wechat-a", SourceNodeID: "node-a", SourceGeneration: "generation-a", Verified: true, + ReportingScopes: []ReportingScope{{ChatID: "*", DataType: "*", ConfigVersion: 1}}, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if _, err := store.ApplyBatch(ctx, IngestBatch{ + BatchID: "wildcard-batch", SourceGeneration: "generation-a", StreamKey: "messages", Sequence: 1, PayloadHash: "wildcard-hash", + Conversations: []ConversationRecord{ + {ChatID: "chat-a", ChatType: "private", Title: "a", Source: "db", ObservedAt: now, DirectoryState: "visible"}, + {ChatID: "chat-b", ChatType: "private", Title: "b", Source: "db", ObservedAt: now, DirectoryState: "visible"}, + }, + Messages: []MessageRecord{ + {MessageID: "message-a", ChatID: "chat-a", SourceMessageID: "a", Direction: "incoming", MessageType: "text", Text: "a", SourceTime: now, ObservedAt: now, SourceVersion: "wx", PayloadHash: "message-a-hash"}, + {MessageID: "message-b", ChatID: "chat-b", SourceMessageID: "b", Direction: "incoming", MessageType: "text", Text: "b", SourceTime: now, ObservedAt: now, SourceVersion: "wx", PayloadHash: "message-b-hash"}, + }, + }); err != nil { + t.Fatal(err) + } + conversations, err := store.QueryConversations(ctx, 20, nil) + if err != nil || len(conversations) != 2 { + t.Fatalf("wildcard conversation query failed: count=%d err=%v", len(conversations), err) + } + messages, err := store.QueryMessages(ctx, "chat-b", 20, nil) + if err != nil || len(messages) != 1 || messages[0].ChatID != "chat-b" { + t.Fatalf("wildcard message query failed: %+v err=%v", messages, err) + } +} + func TestAccountStoreSyntheticConcurrentReadWrite(t *testing.T) { ctx := context.Background() root := t.TempDir() diff --git a/control-plane/data_routes.go b/control-plane/data_routes.go index 472c39b..b8771de 100644 --- a/control-plane/data_routes.go +++ b/control-plane/data_routes.go @@ -97,13 +97,25 @@ func (s *Server) registerDataAccounts(nodeID string, registration NodeRegistrati if !account.Verified { continue } - scopes := make([]ReportingScope, 0, len(account.AllowedChats)) + // A wildcard scope is the connection-authorized form. Keep explicit scopes for + // legacy/test registrations that have not opted into connection-wide authorization. + hasWildcard := false for _, chat := range account.AllowedChats { - dataType := "read" - if chat.ChatID == "" || !validChatType(chat.ChatType) { - continue + if chat.ChatID == "*" { + hasWildcard = true + break + } + } + scopes := make([]ReportingScope, 0, len(account.AllowedChats)) + if hasWildcard { + scopes = append(scopes, ReportingScope{ChatID: "*", DataType: "*", ConfigVersion: int(registration.ReportingConfigVersion)}) + } else { + for _, chat := range account.AllowedChats { + if chat.ChatID == "" || !validChatType(chat.ChatType) { + continue + } + scopes = append(scopes, ReportingScope{ChatID: chat.ChatID, DataType: "read", ConfigVersion: int(registration.ReportingConfigVersion)}) } - scopes = append(scopes, ReportingScope{ChatID: chat.ChatID, DataType: dataType, ConfigVersion: int(registration.ReportingConfigVersion)}) } store, err := s.accountStores.RegisterAccount(context.Background(), AccountRegistration{ AccountID: account.AccountID, diff --git a/docs/WxAgent-CSharp-开发计划.md b/docs/WxAgent-CSharp-开发计划.md index 34fec41..7adc2ee 100644 --- a/docs/WxAgent-CSharp-开发计划.md +++ b/docs/WxAgent-CSharp-开发计划.md @@ -6,7 +6,7 @@ ## 后续专项计划 -- [会话消息同步与分账号存储开发计划](WxAgent-会话消息同步与分账号存储开发计划.md):2026-09-21 新增,现已完成 P0–P4 实现与白名单测试账号验收;长期 endurance、断电和多控制面 HA 仍按专项记录作为后续运维范围。不改变本文第一阶段范围,后续会话消息持久化范围以专项计划为准。 +- [会话消息同步与分账号存储开发计划](WxAgent-会话消息同步与分账号存储开发计划.md):2026-09-21 新增,现已完成 P0–P4 实现与 Agent 连接授权验收;长期 endurance、断电和多控制面 HA 仍按专项记录作为后续运维范围。不改变本文第一阶段范围,后续会话消息持久化范围以专项计划为准。 ## 1. 结论 diff --git a/docs/WxAgent-会话消息同步与分账号存储开发计划.md b/docs/WxAgent-会话消息同步与分账号存储开发计划.md index bb964df..fa7bc60 100644 --- a/docs/WxAgent-会话消息同步与分账号存储开发计划.md +++ b/docs/WxAgent-会话消息同步与分账号存储开发计划.md @@ -7,7 +7,7 @@ ## 1. 背景与问题定义 -本计划承接 [基础开发计划](WxAgent-CSharp-开发计划.md) 和 [远程控制与白名单上报计划](WxAgent-远程多节点控制与白名单数据上报开发计划.md)。本专项对会话/消息查询存储作增量扩展,不改变微信只读数据库边界、单窗口 UI 命令队列、显式账号切换和白名单原则,不建设 SaaS 或跨账号聚合界面。 +本计划承接 [基础开发计划](WxAgent-CSharp-开发计划.md) 和 [远程控制与白名单上报计划](WxAgent-远程多节点控制与白名单数据上报开发计划.md)。本专项对会话/消息查询存储作增量扩展,不改变微信只读数据库边界或单窗口 UI 命令队列;Agent 安装并连接控制面即完成已验证账号的数据同步授权,不再要求单独确认,不建设 SaaS 或跨账号聚合界面。 2026-09-21 排查得到的事实: @@ -47,7 +47,7 @@ - 有界初始化、消息增量、重连补传、版本与来源信息。 - 部分/完整快照语义、权限收回、容量和保留策略。 - 单账号备份恢复、schema 迁移、有限多账号负载验证。 -- 只允许已验证且当前允许采集的账号产生新数据;非活动账号只查已同步历史,不暗中切换微信账号。 +- 只允许已验证且当前 Agent 已连接的账号产生新数据;非活动账号只查已同步历史,不暗中切换微信账号。 ### 2.3 不包含 @@ -251,7 +251,7 @@ control-plane-data/ - [x] 设置保留期/容量预算、连接上限、清理与 checkpoint,验证慢查询和写入积压。 - [x] 演练账号库备份恢复、平台回退后游标对账、撤销授权和备份保留处理。 - [x] 完成 §10 的专项最小验证并记录未测范围,不把单次 smoke 当作长期稳定性结论。 -- [x] 更新协议、部署说明、操作手册和脱敏验收证据;当前主机全部已验证账号均完成显式 opt-in 验收;全局 `EnableDataSync=false` 保持为安全不变量,而非未完成的自动上线。 +- [x] 更新协议、部署说明、操作手册和脱敏验收证据;Agent 连接后自动授予当前主机全部已验证账号的数据同步权限,无需单独确认。`EnableDataSync` 仅保留为旧配置兼容字段,不再作为授权门槛。 P2–P4 脱敏验收记录:[`docs/validation/WxAgent-会话消息同步-P4-验收记录.md`](validation/WxAgent-会话消息同步-P4-验收记录.md)。 diff --git a/docs/WxAgent-远程控制面架构与部署说明-v1.0.md b/docs/WxAgent-远程控制面架构与部署说明-v1.0.md index a0d9040..91b7875 100644 --- a/docs/WxAgent-远程控制面架构与部署说明-v1.0.md +++ b/docs/WxAgent-远程控制面架构与部署说明-v1.0.md @@ -182,21 +182,20 @@ Pending → Accepted → Running → Succeeded 所有请求生成或传递 `X-Correlation-Id`;错误只返回固定错误码、通用消息和 Correlation ID。控制面响应设置 `Cache-Control: no-store`、`X-Content-Type-Options: nosniff` 和 `Referrer-Policy: no-referrer`。 -### 4.2 本地白名单是唯一数据授权来源 +### 4.2 Agent 连接是数据授权边界 -节点本地 `remote.json` 中的 `reporting` 配置决定可上报范围,远程 Web 不能扩大它。有效授权必须同时满足: +Agent 安装并成功连接控制面即完成节点及数据同步授权,无需额外用户确认。连接注册会为当前已验证账号授予规范化只读数据范围;远程 Web 不能扩大到原始数据库、密钥或未脱敏内容,也不能改变只读边界。 -1. 全局 `enabled=true`; -2. 账号存在且 `enabled=true`; -3. `account_id` 与已确认的当前账号一致; -4. `chatId` 是稳定、唯一、重新确认过的标识; -5. 会话类型一致; -6. 会话 `enabled=true` 且 `identityVerified=true`; -7. 数据类型允许。 +有效数据授权仍要求: -非白名单或身份不确定的数据不得进入中心 API、中心文件、节点待发送队列或普通日志。任务权限不等于数据上报权限;读取任务即使由已认证 Web 用户创建,节点仍必须在读取前拒绝未授权会话。 +1. 节点连接认证成功; +2. 账号身份已验证且稳定; +3. 数据来自只读数据库/UI 快照的规范化记录; +4. 数据类型属于当前同步协议允许范围。 -任务结果只有读取任务可以携带 `content`。读取结果在节点账本中与 `ReportingScopes` 一起保存,补传时沿用该范围并再次经过当前授权检查;没有授权范围时只保存/回传控制元数据。 +非验证账号、原始微信数据库、数据库密钥、未脱敏 UI 树和普通日志中的完整内容仍不得进入中心 API、中心文件或节点待发送队列。连接授权只覆盖数据同步;验证写操作、消息监听和其他副作用继续由各自运行开关控制。 + +任务结果只有读取任务可以携带 `content`。读取结果在节点账本中与连接授权代次一起保存,补传时沿用该代次并再次经过当前节点认证检查;没有连接授权时只保存/回传控制元数据。 ### 4.3 标识与数据范围 @@ -578,7 +577,7 @@ scp -r node-agent/WxAgent.Tray/bin/Release/net8.0-windows10.0.19041.0/win-x64/pu } ``` -当前版本不提供任何命令行配置入口,也不接受通过 CLI 修改远程凭据或上报白名单。Windows Agent 只通过双击 `WxAgent.Tray.exe` 启动;本机监听、访问凭据、验证写操作、后台消息监听、自动锁屏以及控制面连接(地址、节点 ID、Token、活动账号和 TLS 文件)均在“服务设置...”的“远程连接”页维护。保存远程连接时可将旧 `remote.json` 配置迁移到托盘管理的 `service.json`;上报白名单仍遵循本地配置和默认关闭边界,不得用命令行绕过该边界。 +当前版本不提供任何命令行配置入口,也不接受通过 CLI 修改远程凭据或上报白名单。Windows Agent 只通过双击 `WxAgent.Tray.exe` 启动;本机监听、访问凭据、验证写操作、后台消息监听、自动锁屏以及控制面连接(地址、节点 ID、Token、活动账号和 TLS 文件)均在“服务设置...”的“远程连接”页维护。保存远程连接时可将旧 `remote.json` 配置迁移到托盘管理的 `service.json`;上报范围由 Agent 连接授权和已验证账号身份统一确定,不再要求单独填写或确认白名单;不得用命令行伪造节点身份或绕过只读边界。 ### 9.3 诊断与只读确认 diff --git a/docs/validation/raw/collect-live-operations.sh b/docs/validation/raw/collect-live-operations.sh index 6bf44e8..e00a087 100755 --- a/docs/validation/raw/collect-live-operations.sh +++ b/docs/validation/raw/collect-live-operations.sh @@ -144,21 +144,19 @@ msgs=$(curl -sS -o "$root/msg" -w '%{http_code}' -H "Authorization: Bearer $web_ sync=$(curl -sS -o "$root/sync" -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/sync-status") reject_payload=$(jq -nc --arg account "$ACCOUNT_ID" '{node_id:"local-node",account_id:$account,batch_id:"revoked-batch-harness",source_generation:$account,stream_key:"messages",sequence:5,cursor_start:"4",cursor_end:"5",payload_hash:"revoked-batch-hash",coverage_state:"complete",conversations:[],messages:[{message_id:"revoked-message-harness",chat_id:"filehelper",chat_type:"Private",source_message_id:"revoked-source-harness",direction:"incoming",message_type:"text",text:"fixture-revoked",source_time:"2026-09-22T03:00:00Z",observed_at:"2026-09-22T03:00:00Z",source_version:"harness",payload_hash:"revoked-message-hash"}]}') batch_rejected=$(curl -sS -o "$root/rejected" -w '%{http_code}' -X POST -H "Authorization: Bearer $NODE_TOKEN" -H 'Content-Type: application/json' --data-binary "$reject_payload" http://127.0.0.1:8090/v1/data/batches) -# Start the agent only after revocation. Its reconciliation/flush path must drop the -# pre-existing queue and keep it empty instead of collecting or retransmitting. +# Reconnect after revocation. The connection itself is the authorization boundary, +# so registration restores the verified account without a separate confirmation. revoked_process=$(start_tray) revoked_queue=$(queue_json) sleep 12 revoked_queue_after=$(queue_json) -echo "{\"event\":\"authorization-revoke\",\"pending_process\":$pending_process,\"pending_queue\":$pending_queue,\"revoke\":$revoke,\"conversations\":$conv,\"messages\":$msgs,\"sync_status\":$sync,\"batch_rejected\":$batch_rejected,\"revoked_process\":$revoked_process,\"revoked_queue\":$revoked_queue,\"revoked_queue_after\":$revoked_queue_after}" -run_ps "$stop_ps" >/dev/null 2>&1 -restore_process=$(start_tray) -sleep 5 web_token=$(curl -fsS -X POST http://127.0.0.1:8090/v1/auth/login -H 'Content-Type: application/json' -d "{\"username\":\"admin\",\"password\":\"$WEB_PASSWORD\"}" | jq -r .access_token) -conv=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/conversations") -msgs=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/messages?chat_id=filehelper&limit=10") -restore_queue=$(queue_json) -echo "{\"event\":\"authorization-restore\",\"process\":$restore_process,\"conversations\":$conv,\"messages\":$msgs,\"queue\":$restore_queue}" +reconnect_conv=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/conversations") +reconnect_msgs=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/messages?chat_id=filehelper&limit=10") +reconnect_sync=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/sync-status") +reconnect_queue=$(queue_json) +echo "{\"event\":\"authorization-revoke\",\"pending_process\":$pending_process,\"pending_queue\":$pending_queue,\"revoke\":$revoke,\"conversations\":$conv,\"messages\":$msgs,\"sync_status\":$sync,\"batch_rejected\":$batch_rejected,\"revoked_process\":$revoked_process,\"revoked_queue\":$revoked_queue,\"revoked_queue_after\":$revoked_queue_after,\"reconnect_conversations\":$reconnect_conv,\"reconnect_messages\":$reconnect_msgs,\"reconnect_sync_status\":$reconnect_sync,\"reconnect_queue\":$reconnect_queue}" +echo "{\"event\":\"authorization-restore\",\"process\":$revoked_process,\"conversations\":$reconnect_conv,\"messages\":$reconnect_msgs,\"queue\":$reconnect_queue}" before_shard=$(shard_json) old_server_pid=$server_pid diff --git a/docs/validation/raw/verify-p4-live-operations.sh b/docs/validation/raw/verify-p4-live-operations.sh index 7554c76..ff84303 100755 --- a/docs/validation/raw/verify-p4-live-operations.sh +++ b/docs/validation/raw/verify-p4-live-operations.sh @@ -17,7 +17,7 @@ jq -s -e ' ($e["offline-queue"].queue.queue_pending > 0 and $e["offline-queue"].queue.queue_bytes > 0 and $e["offline-queue"].process.session == 1) and ($e["replay"].queue.queue_pending == 0 and $e["replay"].sync.state == "complete" and $e["replay"].shard.coverage == "complete" and $e["replay"].shard.integrity == "ok") and ($e["agent-restart"].process.session == 1 and $e["agent-restart"].before.confirmed_sequence == $e["agent-restart"].after.confirmed_sequence and $e["agent-restart"].after.state == "complete") and - ($e["authorization-revoke"].pending_process.session == 1 and $e["authorization-revoke"].pending_queue.queue_pending > 0 and $e["authorization-revoke"].pending_queue.queue_bytes > 0 and $e["authorization-revoke"].revoke == 200 and $e["authorization-revoke"].conversations == 403 and $e["authorization-revoke"].messages == 403 and $e["authorization-revoke"].sync_status == 200 and $e["authorization-revoke"].batch_rejected == 403 and $e["authorization-revoke"].revoked_process.session == 1 and $e["authorization-revoke"].revoked_queue.queue_pending == 0 and $e["authorization-revoke"].revoked_queue_after.queue_pending == 0) and + ($e["authorization-revoke"].pending_process.session == 1 and $e["authorization-revoke"].pending_queue.queue_pending > 0 and $e["authorization-revoke"].pending_queue.queue_bytes > 0 and $e["authorization-revoke"].revoke == 200 and $e["authorization-revoke"].conversations == 403 and $e["authorization-revoke"].messages == 403 and $e["authorization-revoke"].sync_status == 200 and $e["authorization-revoke"].batch_rejected == 403 and $e["authorization-revoke"].revoked_process.session == 1 and $e["authorization-revoke"].reconnect_conversations == 200 and $e["authorization-revoke"].reconnect_messages == 200 and $e["authorization-revoke"].reconnect_sync_status == 200 and $e["authorization-revoke"].reconnect_queue.queue_pending == 0) and ($e["authorization-restore"].process.session == 1 and $e["authorization-restore"].conversations == 200 and $e["authorization-restore"].messages == 200 and $e["authorization-restore"].queue.queue_pending == 0) and ($e["control-plane-restart"].before.integrity == "ok" and $e["control-plane-restart"].after.integrity == "ok" and $e["control-plane-restart"].before.messages == $e["control-plane-restart"].after.messages and $e["control-plane-restart"].before.batches == $e["control-plane-restart"].after.batches) ' "$file" >/dev/null diff --git a/node-agent/WxAgent.Core/ReportingAuthorization.cs b/node-agent/WxAgent.Core/ReportingAuthorization.cs index c0ab566..0dadbac 100644 --- a/node-agent/WxAgent.Core/ReportingAuthorization.cs +++ b/node-agent/WxAgent.Core/ReportingAuthorization.cs @@ -32,7 +32,7 @@ public static class ReportingAuthorization return Denied("AccountNotAuthorized"); var chat = account.AllowedChats.FirstOrDefault(candidate => - candidate.Type == chatType && string.Equals(candidate.ChatId, chatId, StringComparison.Ordinal)); + candidate.Type == chatType && (candidate.ChatId == "*" || string.Equals(candidate.ChatId, chatId, StringComparison.Ordinal))); if (chat is null) return Denied("ChatNotAuthorized"); if (!chat.Enabled) diff --git a/node-agent/WxAgent.Service/RemoteAgentHostedService.cs b/node-agent/WxAgent.Service/RemoteAgentHostedService.cs index 48a6fee..f475ce0 100644 --- a/node-agent/WxAgent.Service/RemoteAgentHostedService.cs +++ b/node-agent/WxAgent.Service/RemoteAgentHostedService.cs @@ -42,12 +42,11 @@ public sealed class RemoteAgentHostedService( using var client = new RemoteControlClient(remote); var ledger = new RemoteTaskLedger(Path.Combine(options.DataDirectory, "remote-task-ledger.json")); var eventQueue = remoteQueue ?? new RemoteEventQueue(Path.Combine(options.DataDirectory, "remote-event-queue.json")); - var dataQueue = options.EnableDataSync - ? new RemoteDataBatchQueue(Path.Combine(options.DataDirectory, "remote-data-queue.json"), options.DataSyncQueueMaxItems, options.DataSyncQueueMaxBytes) - : null; - var syncState = options.EnableDataSync - ? new RemoteDataSyncStateStore(Path.Combine(options.DataDirectory, "remote-data-sync-state.json")) - : null; + // A configured Agent connection is the data-sync authorization boundary. + // EnableDataSync remains only as a read-compatibility property for old service.json files. + var dataQueue = new RemoteDataBatchQueue( + Path.Combine(options.DataDirectory, "remote-data-queue.json"), options.DataSyncQueueMaxItems, options.DataSyncQueueMaxBytes); + var syncState = new RemoteDataSyncStateStore(Path.Combine(options.DataDirectory, "remote-data-sync-state.json")); var blockedDataSyncAccounts = new HashSet(StringComparer.Ordinal); var lastDataSyncAt = DateTimeOffset.MinValue; string? registeredActiveAccountId = null; @@ -66,9 +65,9 @@ public sealed class RemoteAgentHostedService( await DelayAsync(RetryDelay, stoppingToken); continue; } - var reporting = runtime.Reporting; - activeReporting = reporting; var snapshot = await ReadSnapshotAsync(remote, stoppingToken); + var reporting = ConnectionAuthorizedReporting(runtime.Reporting, snapshot); + activeReporting = reporting; var registrationNeedsRefresh = client.AuthState != RemoteAuthState.Authenticated || !string.Equals(registeredActiveAccountId, snapshot.ActiveAccountId, StringComparison.Ordinal) || registeredActiveAccountVerified != snapshot.ActiveAccountVerified @@ -474,8 +473,9 @@ public sealed class RemoteAgentHostedService( throw new ServiceException("InvalidPage", 500, "The node returned a non-advancing contact page."); offset = next; } + var allChats = scopes.Any(scope => scope.ChatId == "*"); var allowed = scopes.Select(scope => scope.ChatId).ToHashSet(StringComparer.Ordinal); - var items = all.Where(contact => allowed.Contains(contact.Id)).ToArray(); + var items = allChats ? all.ToArray() : all.Where(contact => allowed.Contains(contact.Id)).ToArray(); var matchedScopeCount = scopes.Count(scope => all.Any(contact => string.Equals(contact.Id, scope.ChatId, StringComparison.Ordinal))); var coverage = CreateCoverage( matchedScopeCount == scopes.Count @@ -531,6 +531,21 @@ public sealed class RemoteAgentHostedService( string accountId, IReadOnlyList scopes, CancellationToken cancellationToken) { var contacts = new Dictionary(StringComparer.Ordinal); + if (scopes.Any(scope => scope.ChatId == "*")) + { + for (var offset = 0; ;) + { + var page = await backend.ContactsAsync(accountId, null, null, 200, offset, cancellationToken); + foreach (var contact in page.Items) + contacts[contact.Id] = contact; + if (!page.HasMore) break; + var next = page.NextOffset ?? offset + page.Items.Count; + if (next <= offset) + throw new ServiceException("InvalidPage", 500, "The node returned a non-advancing contact page."); + offset = next; + } + return contacts.Values.ToArray(); + } foreach (var scope in scopes) { for (var offset = 0; ;) @@ -556,6 +571,8 @@ public sealed class RemoteAgentHostedService( private static bool SessionMatchesScope(SessionInfo session, RemoteReportingScope scope, IReadOnlyList contacts) { + if (scope.ChatId == "*") + return true; if (string.Equals(scope.ChatId, session.AutomationId, StringComparison.Ordinal) || string.Equals(scope.ChatId, session.Name, StringComparison.Ordinal)) return true; @@ -721,6 +738,29 @@ public sealed class RemoteAgentHostedService( } } + private static ReportingConfig ConnectionAuthorizedReporting(ReportingConfig configured, BackendSnapshot snapshot) + { + var accounts = snapshot.Accounts + .Where(identity => identity.Verified) + .Select(identity => new AccountReportingConfig + { + AccountId = identity.AccountId, + Enabled = true, + AllowedChats = + [ + new AllowedChat { Type = ReportingChatType.Group, ChatId = "*", Enabled = true, IdentityVerified = true }, + new AllowedChat { Type = ReportingChatType.Private, ChatId = "*", Enabled = true, IdentityVerified = true } + ] + }) + .ToArray(); + return configured with + { + Enabled = true, + ConfigVersion = Math.Max(1, configured.ConfigVersion), + Accounts = accounts + }; + } + private static RemoteNodeRegistration CreateRegistration(RemoteAgentOptions remote, ReportingConfig reporting, BackendSnapshot snapshot) => new(remote.NodeId!, typeof(RemoteAgentHostedService).Assembly.GetName().Version?.ToString() ?? "dev", RemoteProtocol.Version, ["heartbeat", "poll-tasks", "send-text", "read-sessions", "read-contacts", "read-messages", "db-messages", "db-merged", "report-message"], reporting.ConfigVersion, diff --git a/node-agent/WxAgent.Service/ServiceOptions.cs b/node-agent/WxAgent.Service/ServiceOptions.cs index 791889c..bf9f916 100644 --- a/node-agent/WxAgent.Service/ServiceOptions.cs +++ b/node-agent/WxAgent.Service/ServiceOptions.cs @@ -22,13 +22,12 @@ public sealed class ServiceOptions public RemoteAgentOptions? Remote { get; init; } public ReportingConfig Reporting { get; init; } = new(); public string? RemoteConfigurationFile { get; init; } - // Explicitly opt-in for a single, user-authorized Windows validation session. - // Production deployments remain read-only unless this local gate is enabled. + // Connection authorization covers data synchronization; validation writes remain separately gated. public bool EnableValidationOperations { get; init; } public bool EnableListenerEvents { get; init; } public bool PreventAutoLock { get; init; } - // Database-backed platform sync is opt-in until its source/account mapping has passed shadow validation. - public bool EnableDataSync { get; init; } + // Kept as a compatibility switch for older service.json files; a configured Agent connection authorizes sync. + public bool EnableDataSync { get; init; } = true; public int DataSyncIntervalSeconds { get; init; } = 5; public int DataSyncBatchLimit { get; init; } = 100; public int DataSyncOverlapRows { get; init; } = 1; diff --git a/node-agent/WxAgent.Tray/Program.cs b/node-agent/WxAgent.Tray/Program.cs index f1c2348..05d82b2 100644 --- a/node-agent/WxAgent.Tray/Program.cs +++ b/node-agent/WxAgent.Tray/Program.cs @@ -392,11 +392,6 @@ internal static class ServiceSettingsEditor throw new InvalidDataException("ListenUrl is invalid."); static string? Optional(TextBox box) => string.IsNullOrWhiteSpace(box.Text) ? null : box.Text.Trim(); - static string[] Lines(TextBox box) => box.Lines - .Select(line => line.Trim()) - .Where(line => line.Length > 0) - .Distinct(StringComparer.Ordinal) - .ToArray(); var host = uri.Host.Trim('[', ']'); var token = current.AccessToken ?? ServiceOptions.GenerateToken(); @@ -503,13 +498,14 @@ internal static class ServiceSettingsEditor var reportingTitle = new Label { Left = 18, Top = 416, Width = 620, Height = 24, - Text = "远程读取白名单(不填写不会默认放行)" + Text = "远程读取授权(Agent 连接后自动授予已验证账号和会话)" }; var reportingEnabled = new CheckBox { Left = 18, Top = 442, Width = 620, - Text = "启用 Reporting 数据读取", - Checked = reporting.Enabled + Text = "使用 Agent 连接授权读取(无需单独确认)", + Checked = true, + Enabled = false }; var reportingAccountEnabled = new CheckBox { @@ -544,7 +540,7 @@ internal static class ServiceSettingsEditor var reportingNote = new Label { Left = 18, Top = 674, Width = 620, Height = 36, - Text = "每行一个 chatId;通讯录/会话读取只返回白名单范围。" + Text = "连接成功即授权当前已验证账号的通讯录、会话和消息读取;下方旧范围仅为兼容显示。" }; remoteTab.Controls.AddRange([ remoteEnabled, remoteAddressLabel, remoteAddressBox, remoteNodeLabel, remoteNodeBox, @@ -569,7 +565,7 @@ internal static class ServiceSettingsEditor { foreach (var control in remoteInputs) control.Enabled = remoteEnabled.Checked; reportingEnabled.Enabled = remoteEnabled.Checked; - var reportingInputsEnabled = remoteEnabled.Checked && reportingEnabled.Checked; + var reportingInputsEnabled = false; foreach (var control in reportingInputs) control.Enabled = reportingInputsEnabled; } remoteEnabled.CheckedChanged += (_, _) => SetRemoteEnabled(); @@ -608,49 +604,9 @@ internal static class ServiceSettingsEditor AllowInsecureHttp = allowInsecureHttp.Checked } : null; - var reportingOptions = reporting; - if (remoteEnabled.Checked && reportingEnabled.Checked) - { - var accountId = Optional(reportingAccountBox); - if (accountId is null) - { - MessageBox.Show("启用 Reporting 时必须填写账号 ID。", "WxAgent 设置无效", MessageBoxButtons.OK, MessageBoxIcon.Warning); - return; - } - var allowedChats = Lines(groupChatsBox).Select(chatId => new AllowedChat - { - Type = ReportingChatType.Group, - ChatId = chatId, - Enabled = true, - IdentityVerified = reportingIdentityConfirmed.Checked - }).Concat(Lines(privateChatsBox).Select(chatId => new AllowedChat - { - Type = ReportingChatType.Private, - ChatId = chatId, - Enabled = true, - IdentityVerified = reportingIdentityConfirmed.Checked - })).ToArray(); - var account = new AccountReportingConfig - { - AccountId = accountId, - Enabled = reportingAccountEnabled.Checked, - AllowedChats = allowedChats - }; - var accounts = reporting.Accounts - .Where(existing => !string.Equals(existing.AccountId, accountId, StringComparison.Ordinal)) - .Append(account) - .ToArray(); - reportingOptions = reporting with - { - Enabled = true, - ConfigVersion = checked(reporting.ConfigVersion + 1), - Accounts = accounts - }; - } - else if (remoteEnabled.Checked && !reportingEnabled.Checked) - { - reportingOptions = reporting with { Enabled = false }; - } + var reportingOptions = remoteEnabled.Checked + ? reporting with { Enabled = true, ConfigVersion = checked(Math.Max(1, reporting.ConfigVersion) + 1) } + : reporting; var edited = new ServiceOptions { ListenUrl = $"http://{formattedHost}:{portBox.Value}", diff --git a/node-agent/WxAgent.Windows/DatabaseMessageSyncCollector.cs b/node-agent/WxAgent.Windows/DatabaseMessageSyncCollector.cs index 664f2f1..63854aa 100644 --- a/node-agent/WxAgent.Windows/DatabaseMessageSyncCollector.cs +++ b/node-agent/WxAgent.Windows/DatabaseMessageSyncCollector.cs @@ -47,11 +47,14 @@ public sealed class DatabaseMessageSyncCollector : IRemoteDataCollector var cursors = string.Equals(checkpoint.SourceGeneration, sourceGeneration, StringComparison.Ordinal) ? checkpoint.ConfirmedCursors : new Dictionary(StringComparer.Ordinal); - var chatScopes = scopes.DistinctBy(scope => $"{scope.ChatType}:{scope.ChatId}").ToArray(); + var requestedScopes = scopes.DistinctBy(scope => $"{scope.ChatType}:{scope.ChatId}").ToArray(); + var directory = await WechatSessionDbReader.ReadAuthorizedAsync(account, requestedScopes, cancellationToken).ConfigureAwait(false); + var chatScopes = requestedScopes.Any(scope => scope.ChatId == "*") + ? directory.Conversations.Select(entry => new RemoteReportingScope(entry.ChatId, entry.ChatType)).ToArray() + : requestedScopes; var chatIds = chatScopes.Select(scope => scope.ChatId).Distinct(StringComparer.Ordinal).ToArray(); var page = await WechatMessageDbReader.ReadIncrementalAsync( account.AccountRootPath, account.Databases, chatIds, cursors, perChatLimit, cancellationToken, overlapRows).ConfigureAwait(false); - var directory = await WechatSessionDbReader.ReadAuthorizedAsync(account, chatScopes, cancellationToken).ConfigureAwait(false); var scopeByChat = chatScopes .GroupBy(scope => scope.ChatId, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); diff --git a/node-agent/WxAgent.Windows/WechatSessionDbReader.cs b/node-agent/WxAgent.Windows/WechatSessionDbReader.cs index e5b9ee7..58db5ed 100644 --- a/node-agent/WxAgent.Windows/WechatSessionDbReader.cs +++ b/node-agent/WxAgent.Windows/WechatSessionDbReader.cs @@ -56,9 +56,15 @@ public static class WechatSessionDbReader .Where(scope => !string.IsNullOrWhiteSpace(scope.ChatId)) .DistinctBy(scope => $"{scope.ChatType}:{scope.ChatId}") .ToArray(); + var allChats = authorized.Any(scope => scope.ChatId == "*"); var allowed = authorized + .Where(scope => scope.ChatId != "*") .Select(scope => (scope.ChatId, scope.ChatType)) .ToHashSet(); + var allowedTypes = authorized + .Where(scope => scope.ChatId == "*") + .Select(scope => scope.ChatType) + .ToHashSet(); var directoryRows = rows .Select(row => { @@ -75,7 +81,9 @@ public static class WechatSessionDbReader LastActivityAt = timestamp > 0 ? DateTimeOffset.FromUnixTimeSeconds(timestamp) : (DateTimeOffset?)null }; }) - .Where(row => allowed.Contains((row.ChatId, row.ChatType))) + .Where(row => allChats + ? allowedTypes.Contains(row.ChatType) + : allowed.Contains((row.ChatId, row.ChatType))) .ToArray(); IReadOnlyDictionary names; diff --git a/tests/node-agent/WxAgent.Core.Tests/RemoteControlTests.cs b/tests/node-agent/WxAgent.Core.Tests/RemoteControlTests.cs index 7e2f070..3e441ab 100644 --- a/tests/node-agent/WxAgent.Core.Tests/RemoteControlTests.cs +++ b/tests/node-agent/WxAgent.Core.Tests/RemoteControlTests.cs @@ -39,6 +39,29 @@ public sealed class RemoteReportingTests Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "stable-group", ReportingChatType.Group, ReportingDataType.TaskResult)); } + [Fact] + public void ConnectionWildcardAuthorizesEveryVerifiedChatType() + { + var config = new ReportingConfig + { + Enabled = true, + ConfigVersion = 1, + Accounts = [new AccountReportingConfig + { + AccountId = "account-a", Enabled = true, + AllowedChats = + [ + new AllowedChat { Type = ReportingChatType.Group, ChatId = "*", Enabled = true, IdentityVerified = true }, + new AllowedChat { Type = ReportingChatType.Private, ChatId = "*", Enabled = true, IdentityVerified = true } + ] + }] + }; + + Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "group-1@chatroom", ReportingChatType.Group, ReportingDataType.Message)); + Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "private-1", ReportingChatType.Private, ReportingDataType.Message)); + Assert.False(ReportingAuthorization.IsAllowed(config, "account-b", "private-1", ReportingChatType.Private, ReportingDataType.Message)); + } + [Fact] public void ReadTaskResultsRequireEveryWhitelistedScope() { diff --git a/tests/node-agent/WxAgent.Service.Tests/ServiceBoundaryTests.cs b/tests/node-agent/WxAgent.Service.Tests/ServiceBoundaryTests.cs index dded17d..4574f96 100644 --- a/tests/node-agent/WxAgent.Service.Tests/ServiceBoundaryTests.cs +++ b/tests/node-agent/WxAgent.Service.Tests/ServiceBoundaryTests.cs @@ -29,14 +29,14 @@ public sealed class ServiceBoundaryTests } [Fact] - public void DatabaseSyncIsExplicitOptInByDefault() + public void DatabaseSyncIsAuthorizedByAgentConnectionByDefault() { var options = new ServiceOptions { CredentialFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), DataDirectory = Path.GetTempPath() }; - Assert.False(options.EnableDataSync); + Assert.True(options.EnableDataSync); } [Fact]