feat: validate single-client broadcast operations
Build web service image / build (push) Successful in 1m9s

This commit is contained in:
2026-09-19 14:33:26 +08:00
parent e321d38fa3
commit c7c0ab273f
62 changed files with 2861 additions and 12651 deletions
+176
View File
@@ -0,0 +1,176 @@
package controlplane
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
)
func TestClientsRemainIsolatedWhenOneDisconnects(t *testing.T) {
server, err := NewServer(ServerConfig{
DataFile: filepath.Join(t.TempDir(), "control-plane.json"),
NodeTokens: map[string]string{"client-a": "secret-a", "client-b": "secret-b"},
WebUsers: map[string]string{"admin": "web-secret"},
HeartbeatTimeout: time.Minute,
LeaseTTL: time.Minute,
})
if err != nil {
t.Fatal(err)
}
defer server.Close()
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
client := httpServer.Client()
login := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/auth/login", "", map[string]string{"username": "admin", "password": "web-secret"})
var session struct {
AccessToken string `json:"access_token"`
}
decodeBody(t, login, &session)
webAuth := "Bearer " + session.AccessToken
register := func(nodeID, token, connectionID string) {
response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer "+token, NodeRegistration{
NodeID: nodeID, ConnectionID: connectionID, AgentVersion: "test", ProtocolVersion: ProtocolVersion,
Capabilities: []string{"heartbeat", "poll-tasks", "send-text"},
Accounts: []AccountSummary{{AccountID: "account-a", Active: true, Verified: true}},
})
if response.Code != http.StatusOK {
t.Fatalf("register %s status = %d: %s", nodeID, response.Code, response.Body.String())
}
}
register("client-a", "secret-a", "connection-a-1")
register("client-b", "secret-b", "connection-b-1")
create := func(nodeID, key string) TaskSubmissionResponse {
response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", webAuth, TaskSubmission{
NodeID: nodeID, AccountID: "account-a", Kind: "send-text", IdempotencyKey: key,
Payload: json.RawMessage(`{"target_id":"target","text":"text","confirmed":true}`),
})
if response.Code != http.StatusAccepted {
t.Fatalf("create %s status = %d: %s", nodeID, response.Code, response.Body.String())
}
var result TaskSubmissionResponse
decodeBody(t, response, &result)
return result
}
taskA := create("client-a", "a-1")
taskB := create("client-b", "b-1")
stale := time.Now().UTC().Add(-time.Hour)
if err := server.store.Mutate(func(state *PersistedState) error {
node := state.Nodes["client-a"]
node.LastHeartbeatAt = &stale
state.Nodes["client-a"] = node
return nil
}); err != nil {
t.Fatal(err)
}
nodesResponse := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes", webAuth, nil)
if nodesResponse.Code != http.StatusOK {
t.Fatalf("node list status = %d: %s", nodesResponse.Code, nodesResponse.Body.String())
}
var nodesBody struct {
Nodes []Node `json:"nodes"`
}
decodeBody(t, nodesResponse, &nodesBody)
statuses := map[string]NodeStatus{}
for _, node := range nodesBody.Nodes {
statuses[node.NodeID] = node.Status
}
if statuses["client-a"] != NodeOffline || statuses["client-b"] != NodeOnline {
t.Fatalf("unexpected client statuses: %+v", statuses)
}
getTask := func(taskID string) Task {
response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/tasks/"+taskID, webAuth, nil)
if response.Code != http.StatusOK {
t.Fatalf("get task %s status = %d: %s", taskID, response.Code, response.Body.String())
}
var task Task
decodeBody(t, response, &task)
return task
}
if task := getTask(taskA.TaskID); task.Status != TaskWaitingForClient {
t.Fatalf("client A task status = %s, want %s", task.Status, TaskWaitingForClient)
}
if task := getTask(taskB.TaskID); task.Status != TaskPending {
t.Fatalf("client B task status = %s, want %s", task.Status, TaskPending)
}
bPoll := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/client-b/tasks?account_id=account-a", "Bearer secret-b", nil)
var bBatch TaskBatch
decodeBody(t, bPoll, &bBatch)
if len(bBatch.Tasks) != 1 || bBatch.Tasks[0].TaskID != taskB.TaskID {
t.Fatalf("client B received the wrong tasks: %+v", bBatch.Tasks)
}
bLease := bBatch.Tasks[0]
bAck := TaskAck{TaskID: bLease.TaskID, AccountID: bLease.AccountID, LeaseGeneration: bLease.LeaseGeneration}
for _, phase := range []string{"ack", "start"} {
response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-b/tasks/"+bLease.TaskID+"/"+phase, "Bearer secret-b", bAck)
if response.Code != http.StatusOK {
t.Fatalf("client B %s status = %d: %s", phase, response.Code, response.Body.String())
}
}
result := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-b/tasks/"+bLease.TaskID+"/result", "Bearer secret-b", TaskResult{
TaskID: bLease.TaskID, AccountID: bLease.AccountID, LeaseGeneration: bLease.LeaseGeneration,
Status: TaskSucceeded, HasSideEffect: true, CorrelationID: "client-b-result",
})
if result.Code != http.StatusOK {
t.Fatalf("client B result status = %d: %s", result.Code, result.Body.String())
}
bEvent := MessageEvent{NodeID: "client-b", AccountID: "account-a", ChatID: "client-b-chat", ChatType: ChatPrivate,
EventSeq: 1, EventType: "message", OccurredAt: time.Now().UTC(), Content: "client-b-content", ConfigVersion: 1, AuthorizationVersion: 1, Authorized: true}
bEventResponse := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-b/events", "Bearer secret-b", bEvent)
if bEventResponse.Code != http.StatusAccepted {
t.Fatalf("client B event status = %d: %s", bEventResponse.Code, bEventResponse.Body.String())
}
bEvents := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/events?node_id=client-b", webAuth, nil)
var bEventList struct {
Events []StoredEvent `json:"events"`
}
decodeBody(t, bEvents, &bEventList)
if len(bEventList.Events) != 1 || bEventList.Events[0].NodeID != "client-b" {
t.Fatalf("client B event isolation failed: %+v", bEventList.Events)
}
aPoll := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/client-a/tasks?account_id=account-a", "Bearer secret-a", nil)
var aBatch TaskBatch
decodeBody(t, aPoll, &aBatch)
if len(aBatch.Tasks) != 0 {
t.Fatalf("offline client A received tasks: %+v", aBatch.Tasks)
}
register("client-a", "secret-a", "connection-a-2")
staleHeartbeat := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-a/heartbeat", "Bearer secret-a", Heartbeat{
NodeID: "client-a", ConnectionID: "connection-a-1", AgentVersion: "test", ProtocolVersion: ProtocolVersion,
NodeStatus: NodeOnline, CorrelationID: "stale-heartbeat",
})
if staleHeartbeat.Code != http.StatusConflict {
t.Fatalf("stale heartbeat status = %d: %s", staleHeartbeat.Code, staleHeartbeat.Body.String())
}
freshHeartbeat := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-a/heartbeat", "Bearer secret-a", Heartbeat{
NodeID: "client-a", ConnectionID: "connection-a-2", AgentVersion: "test", ProtocolVersion: ProtocolVersion,
NodeStatus: NodeOnline, CorrelationID: "fresh-heartbeat",
})
if freshHeartbeat.Code != http.StatusOK {
t.Fatalf("fresh heartbeat status = %d: %s", freshHeartbeat.Code, freshHeartbeat.Body.String())
}
resume := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks/"+taskA.TaskID+"/resume", webAuth, nil)
var resumed Task
decodeBody(t, resume, &resumed)
if resume.Code != http.StatusOK || resumed.Status != TaskPending {
t.Fatalf("resume status = %d task=%+v", resume.Code, resumed)
}
aPoll = doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/client-a/tasks?account_id=account-a", "Bearer secret-a", nil)
aBatch = TaskBatch{}
decodeBody(t, aPoll, &aBatch)
if len(aBatch.Tasks) != 1 || aBatch.Tasks[0].TaskID != taskA.TaskID {
t.Fatalf("resumed client A received the wrong tasks: %+v", aBatch.Tasks)
}
}
+5
View File
@@ -12,6 +12,7 @@ type TaskStatus string
const (
TaskPending TaskStatus = "Pending"
TaskWaitingForClient TaskStatus = "WaitingForClient"
TaskAccepted TaskStatus = "Accepted"
TaskRunning TaskStatus = "Running"
TaskSucceeded TaskStatus = "Succeeded"
@@ -42,6 +43,7 @@ const (
type NodeRegistration struct {
NodeID string `json:"node_id"`
ConnectionID string `json:"connection_id,omitempty"`
AgentVersion string `json:"agent_version"`
ProtocolVersion string `json:"protocol_version"`
Capabilities []string `json:"capabilities"`
@@ -59,6 +61,7 @@ type AccountSummary struct {
type Heartbeat struct {
NodeID string `json:"node_id"`
ConnectionID string `json:"connection_id,omitempty"`
AgentVersion string `json:"agent_version"`
ProtocolVersion string `json:"protocol_version"`
NodeStatus NodeStatus `json:"node_status"`
@@ -169,6 +172,7 @@ type AuditEntry struct {
type Node struct {
NodeID string `json:"node_id"`
ConnectionID string `json:"connection_id,omitempty"`
AgentVersion string `json:"agent_version"`
ProtocolVersion string `json:"protocol_version"`
Capabilities []string `json:"capabilities"`
@@ -327,6 +331,7 @@ type PersistedAIConfig struct {
type NodeResponse struct {
NodeID string `json:"node_id"`
ConnectionID string `json:"connection_id,omitempty"`
Status NodeStatus `json:"status"`
Authenticated bool `json:"authenticated,omitempty"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
+94 -6
View File
@@ -328,13 +328,17 @@ func (s *Server) registerNode(w http.ResponseWriter, r *http.Request, correlatio
if request.NodeID != nodeID {
return requestError{status: http.StatusForbidden, code: "NodeIdentityMismatch", message: "The token is not assigned to this node."}
}
if !validIdentifier(request.AgentVersion, 80) || request.ProtocolVersion != ProtocolVersion || len(request.Capabilities) > 100 || !validCapabilities(request.Capabilities) || !validAccountSummaries(request.Accounts) {
if (request.ConnectionID != "" && !validIdentifier(request.ConnectionID, 200)) || !validIdentifier(request.AgentVersion, 80) || request.ProtocolVersion != ProtocolVersion || len(request.Capabilities) > 100 || !validCapabilities(request.Capabilities) || !validAccountSummaries(request.Accounts) {
return requestError{status: http.StatusBadRequest, code: "InvalidRegistration", message: "Node registration is invalid."}
}
now := time.Now().UTC()
if err := s.store.Mutate(func(state *PersistedState) error {
node := state.Nodes[nodeID]
if node.NodeID != "" && node.ConnectionID != request.ConnectionID {
quarantineNodeTasks(state, nodeID, now)
}
node.NodeID = nodeID
node.ConnectionID = request.ConnectionID
node.AgentVersion = request.AgentVersion
node.ProtocolVersion = request.ProtocolVersion
node.Capabilities = append([]string(nil), request.Capabilities...)
@@ -349,7 +353,7 @@ func (s *Server) registerNode(w http.ResponseWriter, r *http.Request, correlatio
}); err != nil {
return requestError{status: http.StatusInternalServerError, code: "PersistenceFailed", message: "The control plane could not persist the node registration."}
}
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, Status: NodeOnline, Authenticated: true, CorrelationID: correlationID})
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, ConnectionID: request.ConnectionID, Status: NodeOnline, Authenticated: true, CorrelationID: correlationID})
return nil
}
@@ -406,6 +410,8 @@ func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) error {
}
}
if nodeChanged {
quarantineNodeTasks(state, nodeID, now)
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "node.offline", nodeID, "", "success", now)
state.Nodes[nodeID] = node
}
nodes = append(nodes, node)
@@ -440,7 +446,7 @@ func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request, nodeID, corre
if err := decodeJSON(r, &request, 32*1024); err != nil {
return err
}
if request.NodeID != nodeID || request.ProtocolVersion != ProtocolVersion || !validIdentifier(request.AgentVersion, 80) || !validNodeStatus(request.NodeStatus) || request.QueueLength < 0 || request.QueueLength > 100000 {
if request.NodeID != nodeID || (request.ConnectionID != "" && !validIdentifier(request.ConnectionID, 200)) || request.ProtocolVersion != ProtocolVersion || !validIdentifier(request.AgentVersion, 80) || !validNodeStatus(request.NodeStatus) || request.QueueLength < 0 || request.QueueLength > 100000 {
return requestError{status: http.StatusBadRequest, code: "InvalidHeartbeat", message: "Heartbeat is invalid."}
}
now := time.Now().UTC()
@@ -449,6 +455,9 @@ func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request, nodeID, corre
if node.NodeID == "" {
return requestError{status: http.StatusConflict, code: "NodeNotRegistered", message: "Register the node before sending a heartbeat."}
}
if node.ConnectionID != request.ConnectionID {
return requestError{status: http.StatusConflict, code: "StaleConnection", message: "The heartbeat belongs to an older Client connection."}
}
node.AgentVersion = request.AgentVersion
node.ProtocolVersion = request.ProtocolVersion
node.Status = request.NodeStatus
@@ -467,7 +476,7 @@ func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request, nodeID, corre
}); err != nil {
return err
}
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, Status: request.NodeStatus, LastHeartbeatAt: &now, CorrelationID: correlationID})
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, ConnectionID: request.ConnectionID, Status: request.NodeStatus, LastHeartbeatAt: &now, CorrelationID: correlationID})
return nil
}
@@ -951,6 +960,9 @@ func (s *Server) taskRoute(w http.ResponseWriter, r *http.Request, correlationID
if len(parts) == 4 && parts[3] == "cancel" && r.Method == http.MethodPost {
return s.cancelTask(w, parts[2], username, correlationID)
}
if len(parts) == 4 && parts[3] == "resume" && r.Method == http.MethodPost {
return s.resumeTask(w, parts[2], username, correlationID)
}
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
@@ -993,10 +1005,14 @@ func (s *Server) createTaskSubmission(w http.ResponseWriter, request TaskSubmiss
return nil
}
now := time.Now().UTC()
status := TaskPending
if !nodeAvailable(node, now, s.config.HeartbeatTimeout) {
status = TaskWaitingForClient
}
task := Task{
TaskID: randomID(), NodeID: request.NodeID, AccountID: request.AccountID, Kind: request.Kind,
IdempotencyKey: request.IdempotencyKey, Payload: append(jsonRaw(nil), request.Payload...), NotAfter: request.NotAfter,
Status: TaskPending, StateVersion: 1, CreatedAt: now, UpdatedAt: now, LastCorrelationID: correlationID,
Status: status, StateVersion: 1, CreatedAt: now, UpdatedAt: now, LastCorrelationID: correlationID,
}
state.Tasks[task.TaskID] = task
state.Audit = appendAudit(state.Audit, "user:"+username, "task.create", task.TaskID, correlationID, "success", now)
@@ -1068,7 +1084,7 @@ func (s *Server) cancelTask(w http.ResponseWriter, taskID, username, correlation
if task.CancelRequestedAt == nil {
task.CancelRequestedAt = &now
task.StateVersion++
if task.Status == TaskPending && !leaseActive(task, now) {
if (task.Status == TaskPending || task.Status == TaskWaitingForClient) && !leaseActive(task, now) {
task.Status = TaskCancelled
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
@@ -1086,6 +1102,49 @@ func (s *Server) cancelTask(w http.ResponseWriter, taskID, username, correlation
return nil
}
func (s *Server) resumeTask(w http.ResponseWriter, taskID, username, correlationID string) error {
var task Task
err := s.store.Mutate(func(state *PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
task = value
if terminal(task.Status) {
return requestError{status: http.StatusConflict, code: "TerminalState", message: "The task cannot be resumed after it reached a terminal state."}
}
if task.Status != TaskWaitingForClient {
return requestError{status: http.StatusConflict, code: "InvalidTaskState", message: "Only tasks waiting for a Client can be resumed."}
}
if task.CancelRequestedAt != nil {
return requestError{status: http.StatusConflict, code: "CancelRequested", message: "The task was cancelled and cannot be resumed."}
}
node, exists := state.Nodes[task.NodeID]
now := time.Now().UTC()
if !exists || !nodeAvailable(node, now, s.config.HeartbeatTimeout) || !nodeHasAccount(node, task.AccountID) {
return requestError{status: http.StatusConflict, code: "ClientNotReady", message: "The target Client is not connected with the requested account."}
}
if task.NotAfter != nil && !now.Before(*task.NotAfter) {
task.Status = TaskExpired
task.StateVersion++
task.UpdatedAt = now
} else {
task.Status = TaskPending
task.StateVersion++
task.UpdatedAt = now
task.LastCorrelationID = correlationID
}
state.Tasks[taskID] = task
state.Audit = appendAudit(state.Audit, "user:"+username, "task.resume", taskID, correlationID, "success", now)
return nil
})
if err != nil {
return err
}
writeJSON(w, http.StatusOK, task)
return nil
}
func (s *Server) eventRoute(w http.ResponseWriter, r *http.Request, _ string) error {
if r.Method != http.MethodGet {
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."}
@@ -1332,6 +1391,31 @@ func markUnconfirmed(task *Task, reason string, now time.Time) {
task.Result = &TaskResult{TaskID: task.TaskID, AccountID: task.AccountID, LeaseGeneration: task.LeaseGeneration, Status: TaskResultUnconfirmed, ErrorCode: reason, Message: "The control plane could not prove that the previous executor stopped before the lease expired.", CorrelationID: task.LastCorrelationID}
}
func quarantineNodeTasks(state *PersistedState, nodeID string, now time.Time) {
for taskID, value := range state.Tasks {
if value.NodeID != nodeID || terminal(value.Status) {
continue
}
task := value
switch task.Status {
case TaskPending, TaskWaitingForClient:
if task.Status == TaskWaitingForClient && task.LeaseOwner == "" && task.LeaseExpiresAt == nil {
continue
}
task.Status = TaskWaitingForClient
task.StateVersion++
task.UpdatedAt = now
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
case TaskAccepted, TaskRunning:
markUnconfirmed(&task, "ClientDisconnected", now)
default:
continue
}
state.Tasks[taskID] = task
}
}
func validCapabilities(capabilities []string) bool {
for _, capability := range capabilities {
if !validIdentifier(capability, 80) {
@@ -1373,6 +1457,10 @@ func nodeHasAccount(node Node, accountID string) bool {
return false
}
func nodeAvailable(node Node, now time.Time, heartbeatTimeout time.Duration) bool {
return node.Status != NodeOffline && node.LastHeartbeatAt != nil && now.Sub(*node.LastHeartbeatAt) <= heartbeatTimeout
}
func validSendTextPayload(payload jsonRaw) bool {
if len(payload) == 0 || len(payload) > 64*1024 || !json.Valid(payload) {
return false
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -3,10 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1829" />
<title>WxAgent 控制面</title>
<script type="module" crossorigin src="/assets/index-Y-WkDYNQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ub8kdaer.css">
<meta name="theme-color" content="#f6f7fb" />
<meta name="description" content="WxAgent 普通用户工作台" />
<title>WxAgent 工作台</title>
<script type="module" crossorigin src="/assets/index-imGrBp_j.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkrtfWai.css">
</head>
<body>
<div id="root"></div>
+3 -2
View File
@@ -3,8 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1829" />
<title>WxAgent 控制面</title>
<meta name="theme-color" content="#f6f7fb" />
<meta name="description" content="WxAgent 普通用户工作台" />
<title>WxAgent 工作台</title>
</head>
<body>
<div id="root"></div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -2,6 +2,8 @@
状态以 `GET /api/v1/capabilities` 为准;`implemented` 不代表 `validated`,数据库指纹也不代表当前 UI 账号绑定。
> 多 Client 约束:平台可同时连接多个 Desktop Agent/Client;任务、事件、账号绑定和 UI 业务上下文按 `client_id`(内部可兼容 `node_id`)隔离。单个 Client 掉线只影响其自身任务和事件流,不得让其它 Client 进入离线或停止操作。
| 操作 | REST | MCP | 当前状态 | 说明 |
| --- | --- | --- | --- | --- |
| 服务/微信诊断 | `GET /api/v1/status``/api/v1/diagnostics` | `agent_status``agent_diagnose` | Ready/环境依赖 | 脱敏;服务在线不等于微信可用,不执行恢复或 UI 写操作 |
@@ -13,10 +15,38 @@
| 数据库消息/合并记录 | `GET /api/v1/db/messages``/api/v1/db/merged` | `db_messages``db_merged` | Implemented but disabled | 仅接受显式已验证账号 fingerprint;只读 SQLCipher,未提供通用 SQL |
| 任务查询/取消 | `GET /api/v1/operations``/api/v1/operations/{id}``POST /api/v1/operations/{id}/cancel` | `operations_list``operation_get``operation_cancel` | Ready | 按 principal 隔离;`accountId` 仅为调用方视图筛选,不是账号授权;取消不撤销已发生副作用 |
| 事件流 | `GET /api/v1/events` | 暂未注册 | Explicit opt-in | SSE 有界缓存、Last-Event-ID gap、Windows `ListenEventsAsync` 已接入;默认关闭,需受控真机验收后开启 |
| 单目标文本发送 | `POST /api/v1/operations``kind=send-text` | `operation_submit` | Implemented but disabled | 已接入任务/幂等/写后确认契约;`send-text` 保持 disabled,须完成账号绑定、目标唯一性和 Windows 真机验收后才能开放 |
| 单目标文本发送 | `POST /api/v1/operations``kind=send-text` | `operation_submit` | Implemented(仅验证模式开放) | 已完成单 Client 账号绑定、目标唯一性、确认、幂等和 Windows 真机写后确认;默认部署仍只读,Web UI 直接提交继续门控 |
| 多目标文本群发 | `POST /api/v1/operations``kind=broadcast-text` | `operation_submit` | Implemented(仅验证模式开放) | 冻结 1–20 个可见且唯一的会话 automation ID,串行逐项发送、记录结果、按错误停止并支持幂等重放;真实单 Client 最终已完成两个白名单目标均成功的群发验收,并保留失败停止证据 |
| 文件/卡片发送、联系人/群管理、朋友圈 | — | — | Deferred/Disabled | 遵循 `docs/PENDING.md`,需单项授权和真机证据 |
| 任意 SQL/UI 菜单/shell | — | — | Unsupported | 不提供 |
## 本轮普通用户工作台状态(2026-09-19)
| 范围 | 当前状态 | 验证边界 |
| --- | --- | --- |
| 消息首页、会话和消息正文 | 已实现/只读已验证 | 通过真实控制面读取任务和双 Client 联调;仅显示当前 Client 的数据 |
| 通讯录/群列表 | 已实现/只读已验证 | 通过真实控制面读取任务联调;添加好友保持禁用 |
| 任务中心 | 已实现/只读已验证 | 展示 Client 归属、状态和结果;不提供未经验收的写操作重试 |
| 多 Client 选择与隔离 | 已实现/协议及浏览器已验证 | `client_id`/`node_id`、连接代次、任务和事件按 Client 隔离;掉线任务进入等待/待核对,不自动跨 Client 改投 |
| 发送、群发、添加好友 | 发送/群发已具备验证模式入口;添加好友禁用 | 单 Client 白名单对象已完成发送与 `broadcast-text` 多目标逐项/停止/幂等验证;默认部署和 Web UI 直接写入口仍安全门控,添加好友不在本轮 |
详细步骤、自动检查和 Windows 证据见 [WebUI-MCP-2026-09-19 验收记录](validation/WebUI-MCP-2026-09-19.md)。
## 单 Client 生产验证状态(2026-09-19
| 范围 | 状态 | 说明 |
| --- | --- | --- |
| `.mcp.json` Windows 直连、真实 Session 1、账号绑定 | 已验证 | 使用真实微信 UI 和本地 Client 服务;未使用 Session 0 或 fake Client |
| 会话、消息、联系人、群只读 | 已验证 | 真实账号 Rogee;会话/消息/白名单联系人及群查询通过 |
| 单消息发送 | 已验证(受控) | 文件传输助手通过 Windows MCP 发送并在 UIA 中确认;服务 send-text 保留确认、幂等和目标校验 |
| 普通群消息 | 已验证(受控) | 吉祥三宝通过可见会话 automation ID 发送并返回 `Succeeded`;群预览完成写后确认 |
| 多目标文本群发 | 已验证(受控) | 最终真实 `broadcast-text` 冻结“文件传输助手”和“吉祥三宝”两个目标,逐项均返回 `Succeeded`,并确认幂等重放不创建第二次群发;此前失败项按 `stopOnError` 停止的记录也保留。 |
| `@所有人` / `group-at-all` | 禁用 | 当前没有注册独立操作,不能宣称已验证 |
| 添加好友 | 禁用 | 本轮明确排除,未进行真实联系人变更 |
| 多 Client / 跨 Client 故障恢复 | 未纳入 | 本轮只验收单 Client |
详细操作 ID、失败结果、版本差异和剩余风险见 [单 Client 生产验证记录](validation/WebUI-MCP-single-client-2026-09-19.md)。
## 运行边界
- 可通过 `WxAgent.Host serve --config <file>` 启动;安装版使用托盘程序,服务设置和唯一 Token 均在同一个系统设置窗口完成。默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`
+34 -1
View File
@@ -31,7 +31,7 @@
### 1.2 首版范围
- 本机或外部浏览器、HTTP 客户端与 MCP 客户端访问同一 Host;外部访问必须认证,HTTP 与 MCP 共用 Token、身份及权限。默认仍仅监听回环地址,外部 HTTP 监听须显式启用;不要求 HTTPS。
- 一个交互式 Windows 用户会话一个自动化控制目标多数据库账号可显式选择,但不承诺多微信实例并行控制。
- 支持多个 Client/ Desktop Agent 同时连接控制平台;每个 Client 绑定一个交互式 Windows 用户会话一个自动化控制目标多数据库账号可显式选择,但不承诺单个 Client 多微信实例并行控制。
- 完成诊断、会话、基础消息、受控文件、只读联系人/群成员、监听及任务中心。
- 管理和朋友圈等风险功能按能力状态逐项接入,不默认开放未经真机验收的写操作。
- 支持经 Token 认证的外部 HTTP 入口;明文传输不具备保密性,不作为安全公网部署方案。暂不做多租户、Windows Service、自动升级、批量营销或绕过已有确认门禁的破坏性操作。
@@ -87,6 +87,39 @@ node-agent/WxAgent.Core/ 必要的跨平台契约、校验和任务状
- 监听器只在短快照采集时占用 UI 调度,不持锁等待整个监听生命周期;不同目标冲突时排队或明确拒绝,不偷偷切换。
- 关闭服务时停止接单、取消未执行任务、保存已有监听 checkpoint;重启不自动重放写任务。
### 2.3 多 Client 连接与故障隔离
本节中的 **Client** 指连接控制平台的 Desktop Agent/微信运行端,对应现有 `node_id`;浏览器、HTTP 和 MCP 是调用方。平台的 Client 注册表、心跳、任务路由和事件流必须按 Client 隔离,不能把 Client 状态做成全局单例。
#### 设计约束
- 每个 Client 使用稳定 `client_id`(内部可兼容 `node_id`)和独立连接租约/代次;重连不得把旧连接的迟到心跳写回新连接。
- 任务、消息、事件和绑定的作用域至少为 `(client_id, account_id, target_id)`;相同账号或会话名称在不同 Client 上也不得自动合并。
- 控制面可以共享任务存储和认证,但每个 Client 必须有独立执行队列。一个 Client 的 UI 自动化占用只影响该 Client,不得阻塞其它 Client 的队列。
- Client 掉线只更新该 Client 的状态并停止投递到该 Client;其它 Client 继续接收任务、事件和心跳。只有全部 Client 都不可用时,平台才报告“无可用 Client”。
- 目标 Client 掉线时,运行中的只读任务失败并可重新发起;可能已经产生副作用的写任务进入 `Unconfirmed`,禁止自动重试或改投其它 Client。
- 尚未执行的写任务进入 `WaitingForClient`/“等待 Client”,Client 重连后不自动重放;用户必须查看目标和内容后显式恢复或新建任务。其它 Client 的任务不受影响。
- 每个 Client 的 SSE/订阅独立关闭和恢复;一个订阅断开不得关闭控制面的其它订阅。事件游标必须带 Client 作用域。
- UI 的 Client 切换器只切换业务上下文,不清空其它 Client 的任务历史;当前 Client 不可用时显示局部错误和切换入口,而不是全局错误页。
#### 最小契约变更
| 位置 | 要求 |
| --- | --- |
| Client 注册/心跳 | `client_id`、连接代次、最后心跳、微信状态和能力独立记录;迟到连接不能覆盖新连接 |
| 任务 | 保留 `node_id` 兼容字段,同时在对外文案使用 Client;任务创建、查询、取消均校验目标 Client |
| 事件/SSE | 订阅、游标、缓存和断线恢复按 Client 分区;禁止跨 Client 复用游标 |
| 前端状态 | `selectedClientId``globalAvailability` 分离;单 Client 离线只影响选中上下文 |
| 调度 | 共用持久化存储可以保留,但执行队列、门禁、超时和取消状态按 Client 管理 |
#### 验收用例
1. 同时连接 Client A、Client B;A 掉线后,B 仍可查看会话、接收事件并提交任务。
2. A 的任务进入 `WaitingForClient``Unconfirmed`,B 的任务继续执行;A 重连不自动重放写任务。
3. 在 A/B 之间切换时,会话列表、消息、任务状态和错误不会串用;同名账号/会话仍按 Client 分开。
4. A 的事件流断开或游标过期时,B 的事件流不受影响;平台只有在 A、B 都不可用时才显示全局离线。
5. Client 连接顺序变化、旧心跳迟到、重复注册和重连都不会覆盖其它 Client 的在线状态。
## 3. 功能、页面与服务映射
以下 REST 和 MCP 名称为**拟新增契约**,不是现有接口。U0 输出最终逐项操作清单,包括真实方法签名、输入、输出、风险和证据。
@@ -1,11 +1,50 @@
# Web UI 面向普通用户重构落地指导
> 状态:待实施指导文档,不代表功能已开放或通过验收。
> 状态:实现中;文档描述的写操作仍不代表已开放或通过验收。
> 产品定位:普通用户连接 Agent 后,可以查看和回复消息、选择对象群发、添加好友、查看执行结果,无需理解 CLI、MCP、UIA 或数据库。
> 本文依据当前工作区源码整理;已有未提交修改,不视为已发布版本。本次仅交付文档,不修改运行代码、不开放写权限、不执行微信写操作
> 本文依据当前工作区源码整理;当前工作树包含 Web UI、Client 隔离和协议实现修改,不视为已发布版本。消息/通讯录/任务只读闭环按任务队列接入;Web UI 的发送、群发和加好友提交仍保持安全禁用,后端 `send-text`/`broadcast-text` 仅在明确授权的验证模式下开放
>
> 待实施决策:认证成功的 Token 可获取 Agent 当前登录账号的数据,不按 `AccountIds` 做账号级授权隔离;调用方负责账号、页面和业务视图隔离。Agent 仍保留能力权限、账号与窗口绑定一致性、目标核验,并补齐 CSRF/Origin 等服务端安全校验。好友申请冷却明确按“发送账号+目标”计算,为 4 小时。这些是目标行为,不代表当前源码已实现。
## 0. 已确认的产品与交互基线
### 0.1 产品模式
本次改造采用 **Operate(工作台)** 模式,不再把后端接口、节点心跳和诊断指标作为普通用户首页。普通用户默认进入“消息”,目标是完成“确认可用 → 找到会话 → 阅读 → 回复”,而不是理解 Agent、MCP、UIA 或任务队列。
主导航固定为:
```text
消息(默认)
群发
通讯录
任务
设置 → 连接与账号 / 高级诊断
```
群发采用“选择对象 → 编辑内容 → 预览确认 → 执行结果”四步流程;添加好友采用“搜索 → 确认候选 → 填写验证信息 → 提交 → 查看结果”流程。技术字段仅在高级诊断中展示。
### 0.2 Client 定义
本文中的 **Client** 指连接控制平台的 Desktop Agent/微信运行端,对应现有控制面中的 `node_id`;浏览器、HTTP 和 MCP 属于调用方,不与 Client 混用。每个 Client 可以绑定自己的微信账号/窗口,但单个 Client 仍只承诺一个交互式 Windows 会话和一个自动化控制目标。
### 0.3 多 Client 隔离是硬约束
平台必须支持多个 Client 同时在线,且一个 Client 掉线不能影响其它 Client
- Client 使用稳定的 `client_id`(内部可兼容现有 `node_id`)和独立心跳/租约;不得用全局单例表示在线状态。
- 连接、账号/窗口绑定、消息会话、事件流、任务路由和执行队列均以 Client 为作用域;业务上下文至少包含 `(client_id, account_id, target_id)`
- UI 顶部提供 Client 选择器。消息、通讯录和写操作必须先选定 Client;“全部 Client”只用于查看健康状态或任务汇总,不能把不同 Client 的同名会话合并。
- 顶部显示当前 Client 的状态。当前 Client 掉线时只禁用当前上下文并提供切换入口;只有所有 Client 都不可用时才显示全局不可用。
- 运行中的只读任务可失败并允许重新发起;运行中的写任务若无法证明未产生副作用,必须标记“结果待核对”,禁止自动重试或改投其它 Client。
- 已排队但尚未执行的写任务在 Client 掉线后进入“等待 Client/需重新确认”,Client 重连不自动重放;其它 Client 的任务继续排队或执行。
- 一个 Client 的 SSE/事件断开只关闭该 Client 的订阅,不关闭平台或其它 Client 的事件流。
- 任务、事件、错误和审计记录都显示所属 Client,避免用户把乙 Client 的状态误认为甲 Client 的结果。
### 0.4 本次交付边界
本轮按本文件落地 Client 选择与隔离、普通用户工作台壳层以及消息/通讯录/任务的只读任务队列接入。Web UI 的发送、群发和添加好友只保留清晰的禁用状态;后端发送/群发能力仅在明确授权的验证模式下使用,默认部署仍需保持只读。
## 1. 改造结论与范围
当前 UI 是开发者诊断控制台,目标 UI 应是微信业务工作台。改造不是换配色、加卡片,而是把页面组织方式从“后端有什么接口”改成“用户要完成什么事情”。
@@ -22,7 +61,7 @@
### 1.2 本轮不做
- 不引入 CRM、团队客服分配、自动回复机器人、营销自动化、定时发送、多租户云端中转或多 Agent 并行控制。
- 不引入 CRM、团队客服分配、自动回复机器人、营销自动化、定时发送、多租户云端中转;支持多个 Client 同时连接控制平台,但不承诺单个 Client 多微信实例并行控制。
- 不承诺读取全部会话、全部历史或离线消息必达;不实现“下一个未读会话”。
- 不顺带开放朋友圈、删除好友、移出群成员等高风险操作。
- 不引入新前端框架、通用工作流引擎、独立消息队列或 Windows Service。
@@ -48,7 +87,7 @@
| 消息 | 列出可见会话和当前可见消息;当前前端没有完整会话点击/回复流程 | 会话导航、正文权限引导、聊天布局、发送接口和结果验证 |
| 联系人/群 | 已有数据库只读分页查询接口;前端加载首批联系人 | 搜索、加载更多、选中名单和稳定目标解析 |
| 导航/历史 | 已有部分路由;导航待验收、数据库消息能力存在禁用项 | 不得把“接口存在”当成“页面可直接使用” |
| 单条发送 | 服务层尚无文本发送入口,功能矩阵标记 Disabled | 接入现有 Windows 业务方法、权限/唯一目标/写后确认、真机验收 |
| 单条发送 | 服务层已有 `send-text` 验证模式入口;Web UI 直接提交仍门控 | 继续保留账号/权限/唯一目标/写后确认,并以单 Client 真机记录作为受控证据 |
| 群发/加好友 | 尚无 Web 业务闭环 | 有界批量任务、单人好友申请接口及端到端验收 |
| 任务 | 持久化队列、按 ID 查询/取消、幂等与重启状态处理 | 缺少任务列表、业务结果字段、批量逐项结果;不是只改前端就能补齐 |
| 事件 | 服务端已有 SSE,默认关闭,需显式启用并验收;当前前端主要手动或每 15 秒刷新 | 前端事件接入、断线恢复与缺口提示,不能宣称完整实时收件箱 |
@@ -240,8 +279,8 @@ Agent 自己的 `operations.sqlite` 可以保存业务任务元数据;这不
| 建议入口 | 用途 | 必须校验/返回 |
| --- | --- | --- |
| `GET /api/v1/operations` | 当前身份的任务列表 | 按当前登录 principal 过滤;可按 `accountId` 筛选但不做 Token 账号授权拒绝;有界分页、摘要,不泄漏其他 principal 的任务 |
| `POST /api/v1/operations``kind=send-text` | 单目标文本发送 | **基础已接入但 capability disabled**accountId、AutomationId 目标引用、text、idempotencyKey、confirmed;受理返回任务 ID不直接宣称成功;开放前须完成 Windows 真机验收 |
| 同入口,`kind=broadcast-text` | 提交有界群发 | 冻结目标列表、去重、条数/长度限制、显式确认、逐项幂等与批次详情 |
| `POST /api/v1/operations``kind=send-text` | 单目标文本发送 | **验证模式入口已接入,默认部署和 Web UI 仍门控**accountId、AutomationId 目标引用、text、idempotencyKey、confirmed;受理返回任务 ID只有写后确认才标成功;单 Client 真机证据见验收记录 |
| 同入口,`kind=broadcast-text` | 提交有界群发 | **验证模式入口已接入,默认部署和 Web UI 仍门控**冻结目标列表、去重、条数/长度限制、显式确认、串行逐项结果、停止和幂等详情 |
| `POST /api/v1/friend-search` | 好友候选预览 | Token/能力、绑定状态、超时、候选归属当前账号;不发送申请 |
| `POST /api/v1/operations``kind=friend-request` | 单人好友申请 | 当前账号绑定的有效候选引用、验证信息、显式确认、幂等键;按 §4.4 原子接纳并执行,发送账号+目标稳定身份冷却 4 小时,返回任务或冷却 `retryAt` |
+39
View File
@@ -0,0 +1,39 @@
# Web UI / MCP 验收记录(2026-09-19
## 范围
本记录验证普通用户 Web 工作台、多 Client 隔离,以及消息、通讯录、任务的只读任务队列闭环;它是只读基线,不覆盖随后单 Client 验证模式下的写操作。Web UI 的发送、群发、添加好友提交仍保持禁用;真实 Client API 的 `send-text`/`broadcast-text` 受控证据见[单 Client 生产验证记录](WebUI-MCP-single-client-2026-09-19.md)。
## Linux 自动检查
| 检查 | 结果 |
| --- | --- |
| `cd control-plane && go test ./...` | 通过 |
| `cd control-plane/web && npm run build` | 通过;生成 `index-BkrtfWai.css``index-_unPe9bQ.js` |
| `dotnet test tests/node-agent/WxAgent.Core.Tests -c Release --no-restore` | 通过;168 tests0 failed0 skipped |
| `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true --no-restore` | 通过;0 warnings0 errors |
| `git diff --check` | 通过 |
## Web UI 只读联调
- 控制面:`http://127.0.0.1:8090/`;测试身份为本地测试管理员。
- 使用两个隔离的 fake Client`client-a` / `client-b`)持续发送 heartbeat,并只处理真实控制面任务队列中的读取任务;fake Client 不是产品能力,也不计入 Windows 真机通过数。
- 浏览器实测消息页默认打开;Client 切换后会清空旧会话上下文并丢弃过期响应。
- Client A 的会话、消息正文、通讯录/群和任务记录均可通过队列读取;切换 Client 后不串用数据。
- 发送按钮、群发提交、添加好友按钮均保持 `disabled`,并显示未完成能力/真机验收原因。
- 桌面及 390px 窄屏布局通过浏览器检查,无横向溢出;加载、空列表、离线、错误和权限提示由 UI 提供明确状态。
## Windows 真机检查
发布包为 `WxAgent.Host` self-contained `win-x64` single-file,上传至 `C:\Users\Rogee\wx-agent`,在已登录的交互式 Windows 用户会话(Session 1)中执行:
- 微信版本:`4.1.13.65`
- `doctor`:通过;`WindowFound=true``MainView/session_list/chat_message_page/chat_message_list/chat_input_field/tool_bar_accessible` 均为 `true``Errors=[]``InputDesktopAvailable=true`
- `inspect-ui --output artifacts/ui-tree.json`:通过;164 个脱敏节点,`sanitized=true`
- `smoke`:通过;`scope=M1`,目标为白名单“文件传输助手”,`sendSkipped=false``confirmed=true`。该命令是既有 CLI smoke 的受控写验证,不代表 Web UI 已开放写操作。
## 未完成/保留限制
- Web UI 的消息发送、群发、添加好友提交继续禁用;后端验证模式的 `send-text``broadcast-text` 已有真实单 Client 证据,详见单 Client 记录,默认部署仍保持只读。
- fake Client 只验证控制面任务路由和 Client 隔离,不替代真实 Desktop Agent 的只读能力验收。
- 未进行外部网络部署、MCP 客户端兼容性和长时事件流验收;不改变功能矩阵中对应状态。
@@ -0,0 +1,69 @@
# Web UI / MCP 单 Client 生产验证记录(2026-09-19
## 验证范围
通过项目 `.mcp.json` 连接 Windows MCP,在已登录、未锁定的 Session 1 中验证一个真实微信 Client(账号 Rogee)。本轮不验证添加好友和多 Client;只使用白名单对象:文件传输助手、Hao 豪、吉祥三宝、消息测试专用群组。
## 连接与放行
- `.mcp.json``windows-ui` endpoint`http://10.1.1.101:8765/mcp`;连接成功。
- Windows `WxAgent.Tray` 在 Session 1 运行;微信版本由 status 报告为 `4.1.13.65`
- `GET /api/v1/status` 返回 200service、WeChat、session、window 和 active account binding 均可用,未使用 Session 0。
- UI target `11460:81724928` 与账号 Rogee 绑定,`isBound=true`
- 本轮在单 Client 的 `service.json` 显式启用 `enableValidationOperations=true``local-admin` 凭据获得 `write` 权限。代码默认仍为关闭,普通部署不自动放行写操作。
- capability 当前启用 `session-open``sessions-scroll``send-text``broadcast-text`;两种写能力仅因本机明确授权的 `enableValidationOperations=true` 而开放,普通部署仍保持只读。`group-at-all` 保持 disabled,因为当前 Client 没有注册该独立操作。群消息和受控群发均使用已确认的会话 automation ID。
## 只读结果
- UIA 真实会话列表导出 16 个条目,包含“文件传输助手”“Hao 豪”“吉祥三宝”等。
- 当前会话读取成功为“文件传输助手”。消息 API 返回 200,4 条真实消息,类型为 Text/Merge/File/Text;使用 `includeContent=false` 验证脱敏读取。
- 联系人读取分别命中“文件传输助手”“Hao 豪”“吉祥三宝”;群查询命中“消息测试专用群组”(群 ID 不写入本记录)。
- 名称不唯一/不存在时 API 返回 409 NotFound`retry=false`;没有自动猜测、跨目标改投或自动重试。
## 写操作结果
### 文件传输助手
- 首次通过服务操作提交的错误 target 格式(联系人 ID `filehelper`)得到 `Unconfirmed / ExecutionFailed``hasSideEffects=true`;未重放、未自动重试。
- 随后使用 Windows MCP 在真实微信输入框发送唯一测试标记 `[WxAgent验证] UI 单 Client 发送验证 20260919-0239`;UIA 消息列表出现该消息,写后确认通过。
### 吉祥三宝群
- 使用可见会话 automation ID `session_item_吉祥三宝` 提交 confirmed `send-text`,操作 ID`c3be6c1e07c14c2f9fdc685357c44993`
- 结果:`state=Succeeded``stage=complete``hasSideEffects=true`
- 相同 idempotency key 重放返回同一 operation ID,没有创建重复操作;验证了幂等防重。
- 微信真实会话列表的“吉祥三宝”预览出现唯一测试标记 `[WxAgent验证] 单 Client 群消息验证 吉祥三宝 20260919-0258`,作为写后 UI 确认。
- `confirmed=false` 的负向请求返回 HTTP 409 `ConfirmationRequired``retry=false`,没有执行。
### 多目标文本群发(broadcast-text
- 原始 HTTP 响应、请求 fixture、能力/授权状态、幂等重放、确认门禁、只读 sessions/messages 响应和 MCP UIA 摘录已保存于 [`evidence/WebUI-MCP-single-client-2026-09-19/`](evidence/WebUI-MCP-single-client-2026-09-19/),其中 `README.md``manifest.json``sha256sums.txt` 说明采集边界、脱敏规则和校验方式。
- 使用真实 Client API 提交 `kind=broadcast-text`,请求携带两个当前可见且唯一的会话 automation ID`session_item_文件传输助手``session_item_吉祥三宝`,并设置 `confirmed=true``stopOnError=true`。服务在执行前冻结该名单,不使用联系人 ID 猜测或跨目标改投。
- 首次诊断操作 `7b3a9ff33550489eb7fe50478bbd1155` 曾在文件传输助手成功后因吉祥三宝 `ControlNotFound``stopOnError` 停止;该记录保留为失败停止和幂等证据,未自动重试。
- 修正 UI 会话切换为可见中心点击,并将等待聊天页从 2.5 秒扩大到 10 秒后,重新部署最新 self-contained Tray。最终验收操作 ID`463b37b2e0944525af4ded822dddaf8c`Correlation ID`33d6e14673834c64b5d470a07687c760``state=Succeeded``stage=complete``errorCode=null`
- 最终操作冻结同一两个白名单目标:文件传输助手 `Succeeded`、吉祥三宝 `Succeeded``stopped=false``stopReason=null`。这证明了真实多对象名单冻结、串行逐项发送、两个目标写后确认和成功群发。
- 相同 `idempotencyKey=wxagent-single-20260919-broadcast-final-001` 重放返回同一个操作 ID和相同 `Succeeded` 结果(重放请求 Correlation ID`858fd45fc16e4b1383909d4d406ae8bc`),没有创建第二次群发。服务 capability 实际返回 `send-text`/`broadcast-text``implemented=true``validated=true``enabled=true``requiresConfirmation=true`,微信版本 `4.1.13.65``group-at-all` 仍为 disabled。
- 群发结束后分别用只读 `GET /api/v1/messages` 查询两个 automation ID 对应会话,均返回 4 条可见消息,并在不记录完整正文的前提下确认本次唯一标记存在(`markerConfirmed=true`)。
### 未通过/保持禁用
- `消息测试专用群组` 当前搜索结果存在多个同名项,使用联系人群 ID 的操作返回 `Unconfirmed / NotFound`;未猜测目标、未重试,仍保持该路径禁用。
- `group-at-all` 独立能力没有注册操作,仍禁用;本轮不宣称已验证 @所有人
- 添加好友未验证,入口继续禁用。
- 多 Client、跨 Client 故障恢复和远程控制面任务流不属于本轮证据。
## 自动检查
- `cd control-plane && go test ./...`:通过。
- `cd control-plane/web && npm run build`:通过。
- `dotnet test tests/node-agent/WxAgent.Core.Tests -c Release --no-restore`168 tests 通过。
- `dotnet test tests/node-agent/WxAgent.Service.Tests -c Release --no-restore`22 tests 通过,包含 `broadcast-text` 冻结、顺序、停止和幂等覆盖。
- `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true --no-restore`0 warnings、0 errors。
- `git diff --check`、Go `gofmt`:通过。
## 风险与后续
- 版本证据不一致已在最终部署中修正:`GET /api/v1/capabilities` 与 status 均报告微信 `4.1.13.65`send/broadcast 均为 `validated=true`
- 首次诊断中的错误 target 保留为失败停止证据;没有重试该不确定操作,修复后使用新的显式幂等键完成最终成功验收。
- 早期诊断曾出现 60 秒 sessions 队列超时;修复后连续 3 次 sessions 刷新均 HTTP 200、约 2.12.3 秒,且两个目标的只读 marker 回读均成功,不再作为本轮验收阻塞项。
- 当前验证模式只应留在这台明确授权的单 Client;未授权部署必须保持默认只读。
@@ -0,0 +1,40 @@
# 单 Client 真实验证原始证据
本目录保存 2026-09-19 在已登录、未锁定的 Windows Session 1 上采集的原始 HTTP API 响应、请求 fixture、MCP UIA 证据摘录和校验清单。它补充 `../WebUI-MCP-single-client-2026-09-19.md`,不是用文档自述替代接口响应。
## 采集边界
- Windows UI MCP`.mcp.json` 中的 `windows-ui``http://10.1.1.101:8765/mcp`
- UI MCP 用于确认真实微信窗口、`MainView``session_list`、两个白名单 automation ID、`chat_message_list` 和验收标记;对应的可审计摘录见 `mcp-ui-evidence.json`
- Agent HTTP API 在 Windows 测试服务的本地转发端口访问:`http://127.0.0.1:15088`,请求 Host 为 `localhost:5088`。这不是把 `/api/v1/*` 当作 MCP endpoint,而是记录 `.mcp.json` 连接的真实 Client 所运行服务的认证 API 响应。
- 每个 `*.http.txt` 是原始响应头,每个 `*.response.json` 是原始响应体;文件中没有 Bearer token。测试 token 只从本地/远端验证配置读取,未复制到仓库。
- 消息只读证据使用 `includeContent=false`,保留条数、fingerprint 和 summary 中的唯一测试标记,不保存完整聊天正文。
## 证据对应关系
| 文件 | 证明内容 |
| --- | --- |
| `status.*` | 服务在线、微信可用、Session 未锁定、账号绑定、微信版本 `4.1.13.65`、默认只读 |
| `capabilities.*` | `send-text`/`broadcast-text` 为 implemented、validated、enabled;群发要求确认;`group-at-all` disabled |
| `sessions-1..3.*` | 连续 3 次只读会话枚举均 HTTP 200,均含 `session_item_文件传输助手``session_item_吉祥三宝` |
| `broadcast.request.json` | 最终真实群发请求:两个 automation ID、显式确认、幂等键、`stopOnError=true` |
| `broadcast-submit.*` | 原始 POST 受理响应:HTTP 200、operation `463b37b2e0944525af4ded822dddaf8c``Queued`、冻结目标详情 |
| `broadcast-get.*` | 原始最终操作响应:operation `463b37b2e0944525af4ded822dddaf8c``Succeeded/complete`、两个逐项 `Succeeded` |
| `broadcast-replay.*` | 相同幂等请求的原始重放响应,返回相同 operation ID 和结果 |
| `confirmation-gate.*` | `confirmed=false` 的真实 `broadcast-text` 请求返回 HTTP 409 `ConfirmationRequired``retry=false` |
| `send-confirmation-gate.*` | `confirmed=false` 的真实 `send-text` 请求返回 HTTP 409 `ConfirmationRequired``retry=false` |
| `messages-filehelper.*`, `messages-jixiang.*` | 两个目标的原始只读消息响应;summary 含本次唯一标记、content 为 null |
| `readback-summary.json` | 从上述 raw 只读响应计算的脱敏回读结论 |
| `mcp-ui-evidence.json` | UI MCP 实时窗口/UIA 树中真实微信、白名单会话、消息列表和标记的证据摘录 |
## 关键断言
- 最终 operation`463b37b2e0944525af4ded822dddaf8c`Correlation ID`33d6e14673834c64b5d470a07687c760`
- 最终详情的冻结顺序为文件传输助手、吉祥三宝;两项均 `Succeeded``stopped=false`,无错误码。
- 相同幂等键 `wxagent-single-20260919-broadcast-final-001` 的重放仍返回该 operation ID,不创建第二次操作。
- 失败停止流程的早期原始记录仍在主验收文档中,避免只保留成功路径;最终验收使用了新的显式幂等键。
- `group-at-all`、添加好友、多 Client/跨 Client 恢复不属于本轮证据。
## 复核方式
在已授权的验证机上设置临时 shell 变量 `TOKEN` 后,可按 `*.http.txt` 中的 Host 和路径重放 GET;不得把 token 写入文件或日志。POST 请求只能使用目录中的 confirmation-gate fixture(应返回 409)或最终 broadcast fixture(相同幂等键应返回相同操作),不得绕过确认门禁或使用真实非白名单对象。
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:22:44 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: 35ec468485244ba98ba9751327d6b5f7
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"id":"463b37b2e0944525af4ded822dddaf8c","principalId":"local-admin","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","capability":"broadcast-text","state":"Succeeded","stage":"complete","correlationId":"33d6e14673834c64b5d470a07687c760","createdAt":"2026-09-19T04:09:45.5025818+00:00","expiresAt":"2026-09-19T04:14:45.5025818+00:00","errorCode":null,"hasSideEffects":true,"details":"{\"targetIds\":[\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\"],\"items\":[{\"targetId\":\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"state\":\"Succeeded\",\"errorCode\":null},{\"targetId\":\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\",\"state\":\"Succeeded\",\"errorCode\":null}],\"stopped\":false,\"stopReason\":null}"}
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:22:44 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: 94d46404d54a43babfe5bb855f767518
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"id":"463b37b2e0944525af4ded822dddaf8c","principalId":"local-admin","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","capability":"broadcast-text","state":"Succeeded","stage":"complete","correlationId":"33d6e14673834c64b5d470a07687c760","createdAt":"2026-09-19T04:09:45.5025818+00:00","expiresAt":"2026-09-19T04:14:45.5025818+00:00","errorCode":null,"hasSideEffects":true,"details":"{\"targetIds\":[\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\"],\"items\":[{\"targetId\":\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"state\":\"Succeeded\",\"errorCode\":null},{\"targetId\":\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\",\"state\":\"Succeeded\",\"errorCode\":null}],\"stopped\":false,\"stopReason\":null}"}
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:09:45 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: 57fc0ed311354f1586bcfd308dd54c7e
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"id":"463b37b2e0944525af4ded822dddaf8c","principalId":"local-admin","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","capability":"broadcast-text","state":"Queued","stage":"queued","correlationId":"33d6e14673834c64b5d470a07687c760","createdAt":"2026-09-19T04:09:45.5025818+00:00","expiresAt":"2026-09-19T04:14:45.5025818+00:00","errorCode":null,"hasSideEffects":true,"details":"{\"targetIds\":[\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\"],\"stopOnError\":true}"}
@@ -0,0 +1 @@
{"kind":"broadcast-text","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","targets":["session_item_文件传输助手","session_item_吉祥三宝"],"text":"[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412","idempotencyKey":"wxagent-single-20260919-broadcast-final-001","confirmed":true,"stopOnError":true}
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:22:44 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: 65c675b23d324a6ea793c356da41fb51
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:15:34 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: 3183a2753a044909b14f73336dcbe170
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"kind":"broadcast-text","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","targets":["session_item_文件传输助手","session_item_吉祥三宝"],"text":"[WxAgent验证] confirmation gate must not send 20260919-0414","idempotencyKey":"wxagent-single-20260919-confirmation-gate-001","confirmed":false,"stopOnError":true}
@@ -0,0 +1 @@
{"correlationId":"3183a2753a044909b14f73336dcbe170","error":{"code":"ConfirmationRequired","message":"Explicit send confirmation is required.","stage":"request","retry":false}}
@@ -0,0 +1,63 @@
{
"schemaVersion": 1,
"collectedAtUtc": "2026-09-19T04:15:34Z",
"purpose": "Auditable raw evidence for the final single-Client Windows WeChat validation.",
"mcp": {
"config": ".mcp.json",
"server": "windows-ui",
"endpoint": "http://10.1.1.101:8765/mcp",
"uiEvidence": "mcp-ui-evidence.json"
},
"serviceApi": {
"baseUrl": "http://127.0.0.1:15088",
"hostHeader": "localhost:5088",
"authentication": "Bearer token used only by the local validation shell; no token is stored in this directory."
},
"boundAccountId": "a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277",
"wechatVersion": "4.1.13.65",
"finalOperation": {
"id": "463b37b2e0944525af4ded822dddaf8c",
"submitCorrelationId": "57fc0ed311354f1586bcfd308dd54c7e",
"correlationId": "33d6e14673834c64b5d470a07687c760",
"submitResponse": "broadcast-submit.response.json",
"capability": "broadcast-text",
"targets": ["session_item_文件传输助手", "session_item_吉祥三宝"],
"state": "Succeeded",
"stage": "complete",
"itemStates": ["Succeeded", "Succeeded"],
"stopped": false,
"replayResponse": "broadcast-replay.response.json"
},
"confirmationGates": {
"broadcastText": {
"request": "confirmation-gate.request.json",
"httpStatus": 409,
"errorCode": "ConfirmationRequired",
"retry": false,
"correlationId": "3183a2753a044909b14f73336dcbe170"
},
"sendText": {
"request": "send-confirmation-gate.request.json",
"httpStatus": 409,
"errorCode": "ConfirmationRequired",
"retry": false,
"correlationId": "78820fff9db246feb032a873018d43bb"
}
},
"readOnlyEvidence": {
"status": "status.response.json",
"capabilities": "capabilities.response.json",
"sessions": ["sessions-1.response.json", "sessions-2.response.json", "sessions-3.response.json"],
"messages": ["messages-filehelper.response.json", "messages-jixiang.response.json"]
},
"integrity": {
"algorithm": "SHA-256",
"fileList": "sha256sums.txt",
"scope": "All evidence files except manifest.json and sha256sums.txt; README and derived summaries are included."
},
"redactions": [
"Authorization headers/tokens are not stored.",
"Message API evidence uses includeContent=false; full chat bodies are not stored.",
"The bound account ID and test marker are retained because they are required to correlate the operation and read-back evidence."
]
}
@@ -0,0 +1,25 @@
{
"source": {
"mcpConfig": ".mcp.json",
"server": "windows-ui",
"endpoint": "http://10.1.1.101:8765/mcp",
"listTool": "windows-ui_list_windows",
"inspectTool": "windows-ui_inspect_window",
"inspectArgs": {"title_re": "^微信$", "found_index": 0},
"note": "This is a redacted, auditable excerpt of the live MCP UIA response; rectangles and automation IDs are copied from the tool result. Message bodies are not stored here."
},
"visibleTopLevelWindows": ["任务栏", "微信", "操作提示", "设置标题", "JP-Word->033.jpwabc ==> C:\\temp\\jpw7-batch\\, (cpu=0.0)", "微信", "新标签页 - 夸克", "10.1.1.104 - Google Chrome", "QQ", "dummyLayeredWnd", "dummyLayeredWnd", "Program Manager"],
"requiredNodes": [
{"name": "微信", "type": "Window", "automationId": "", "rectangle": "(L371, T56, R1204, B729)"},
{"name": "", "type": "Group", "automationId": "MainView", "rectangle": "(L371, T57, R1204, B729)"},
{"name": "会话", "type": "List", "automationId": "session_list", "rectangle": "(L431, T137, R671, B677)"},
{"name": "文件传输助手\\n已置顶\\nNative Writer\\n12:17\\n", "type": "ListItem", "automationId": "session_item_文件传输助手", "rectangle": "(L431, T137, R671, B202)"},
{"name": "吉祥三宝\\n已置顶\\n[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412\\n12:09\\n", "type": "ListItem", "automationId": "session_item_吉祥三宝", "rectangle": "(L431, T202, R671, B267)"},
{"name": "", "type": "Group", "automationId": "chat_message_page", "rectangle": "(L671, T89, R1200, B725)"},
{"name": "文件传输助手", "type": "Text", "automationId": "content_view.top_content_view.title_h_view.left_v_view.left_content_v_view.left_ui_.big_title_line_h_view.current_chat_name_label", "rectangle": "(L687, T102, R771, B122)"},
{"name": "消息", "type": "List", "automationId": "chat_message_list", "rectangle": "(L671, T137, R1200, B585)"},
{"name": "[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412", "type": "ListItem", "automationId": "chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view", "rectangle": "(L671, T413, R1200, B488)"},
{"name": "文件传输助手", "type": "Edit", "automationId": "chat_input_field", "rectangle": "(L691, T594, R1184, B673)"},
{"name": "", "type": "ToolBar", "automationId": "tool_bar_accessible", "rectangle": "(L679, T673, R1192, B717)"}
]
}
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:16:44 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: 56cc6ebfac8f4c59b26ca9e832149e8f
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"items":[{"fingerprint":"6b2c72487f9a1e179d0b4f2bb8d50038a683b3a5c9d7404aae9cbe1ef2356923","type":"Text","sender":null,"summary":"wxagent-smoke-20260918161447-254c4428e37c46c996c0fa4af0841a55","content":null},{"fingerprint":"8a50e9216c22a893332c8458979bdb9d4a45aa23a8dea1d36199fcf60c2c4aee","type":"Text","sender":null,"summary":"ome[WxAgent验证] UI 单 Client 发送验证 20260919-0239","content":null},{"fingerprint":"0c62388df1125439daf09f1911bb7d3aa27b1b1fb0ecba4d6f49fda74de37567","type":"Text","sender":null,"summary":"[WxAgent验证] 单 Client 多对象广播 20260919-0328","content":null},{"fingerprint":"837b6ef9c70f1ddfa462cc9d973b312e71935539a8e493c519b9099de0fc6c86","type":"Text","sender":null,"summary":"[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412","content":null}],"limit":50,"offset":0,"hasMore":false,"nextOffset":null}
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:16:50 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: ec106963f1be4f45b3e5d9b950e8997d
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"items":[{"fingerprint":"6b2c72487f9a1e179d0b4f2bb8d50038a683b3a5c9d7404aae9cbe1ef2356923","type":"Text","sender":null,"summary":"wxagent-smoke-20260918161447-254c4428e37c46c996c0fa4af0841a55","content":null},{"fingerprint":"8a50e9216c22a893332c8458979bdb9d4a45aa23a8dea1d36199fcf60c2c4aee","type":"Text","sender":null,"summary":"ome[WxAgent验证] UI 单 Client 发送验证 20260919-0239","content":null},{"fingerprint":"0c62388df1125439daf09f1911bb7d3aa27b1b1fb0ecba4d6f49fda74de37567","type":"Text","sender":null,"summary":"[WxAgent验证] 单 Client 多对象广播 20260919-0328","content":null},{"fingerprint":"837b6ef9c70f1ddfa462cc9d973b312e71935539a8e493c519b9099de0fc6c86","type":"Text","sender":null,"summary":"[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412","content":null}],"limit":50,"offset":0,"hasMore":false,"nextOffset":null}
@@ -0,0 +1,22 @@
{
"sourceFiles": [
"messages-filehelper.response.json",
"messages-jixiang.response.json"
],
"query": "GET /api/v1/messages?accountId=<bound-account>&session=<session-name>&limit=50&includeContent=false",
"contentWasStored": false,
"marker": "[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412",
"filehelper": {
"itemCount": 4,
"markerFoundInSummary": true,
"allContentFieldsNull": true,
"lastMessageFingerprint": "837b6ef9c70f1ddfa462cc9d973b312e71935539a8e493c519b9099de0fc6c86"
},
"jixiang": {
"itemCount": 4,
"markerFoundInSummary": true,
"allContentFieldsNull": true,
"lastMessageFingerprint": "837b6ef9c70f1ddfa462cc9d973b312e71935539a8e493c519b9099de0fc6c86"
},
"interpretation": "The raw read-only responses contain the unique test marker in summary and omit full message bodies; the matching final fingerprint is an independent post-send read-back signal for both target sessions."
}
@@ -0,0 +1,10 @@
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:25:03 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: 78820fff9db246feb032a873018d43bb
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"kind":"send-text","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","targetId":"session_item_吉祥三宝","text":"[WxAgent验证] 不应发送","idempotencyKey":"wxagent-single-20260919-confirmation-negative","confirmed":false}
@@ -0,0 +1 @@
{"correlationId":"78820fff9db246feb032a873018d43bb","error":{"code":"ConfirmationRequired","message":"Explicit send confirmation is required.","stage":"request","retry":false}}
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:16:35 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: fd1f07e563774a238c1da3d85ccdf470
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"items":[{"name":"吉祥三宝","automationId":"session_item_吉祥三宝","isCurrent":false},{"name":"文件传输助手","automationId":"session_item_文件传输助手","isCurrent":true},{"name":"运营3部日报","automationId":"session_item_运营3部日报","isCurrent":false},{"name":"李晓敏","automationId":"session_item_李晓敏","isCurrent":false},{"name":"杨礼溪","automationId":"session_item_杨礼溪","isCurrent":false},{"name":"\uD83C\uDF3B2026级初一5班家长群","automationId":"session_item_\uD83C\uDF3B2026级初一5班家长群","isCurrent":false},{"name":"外呼机器人研发对接","automationId":"session_item_外呼机器人研发对接","isCurrent":false},{"name":"商务通系统对接问题反馈","automationId":"session_item_商务通系统对接问题反馈","isCurrent":false},{"name":"潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","automationId":"session_item_潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","isCurrent":false}],"limit":20,"offset":0,"hasMore":false,"nextOffset":null}
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:16:38 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: a5655c213ccd4d82a4b761200618269b
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"items":[{"name":"吉祥三宝","automationId":"session_item_吉祥三宝","isCurrent":false},{"name":"文件传输助手","automationId":"session_item_文件传输助手","isCurrent":true},{"name":"运营3部日报","automationId":"session_item_运营3部日报","isCurrent":false},{"name":"李晓敏","automationId":"session_item_李晓敏","isCurrent":false},{"name":"杨礼溪","automationId":"session_item_杨礼溪","isCurrent":false},{"name":"\uD83C\uDF3B2026级初一5班家长群","automationId":"session_item_\uD83C\uDF3B2026级初一5班家长群","isCurrent":false},{"name":"外呼机器人研发对接","automationId":"session_item_外呼机器人研发对接","isCurrent":false},{"name":"商务通系统对接问题反馈","automationId":"session_item_商务通系统对接问题反馈","isCurrent":false},{"name":"潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","automationId":"session_item_潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","isCurrent":false}],"limit":20,"offset":0,"hasMore":false,"nextOffset":null}
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:16:40 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: 7c10f8eacba04e4ca13cbc896379e177
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"items":[{"name":"吉祥三宝","automationId":"session_item_吉祥三宝","isCurrent":false},{"name":"文件传输助手","automationId":"session_item_文件传输助手","isCurrent":true},{"name":"运营3部日报","automationId":"session_item_运营3部日报","isCurrent":false},{"name":"李晓敏","automationId":"session_item_李晓敏","isCurrent":false},{"name":"杨礼溪","automationId":"session_item_杨礼溪","isCurrent":false},{"name":"\uD83C\uDF3B2026级初一5班家长群","automationId":"session_item_\uD83C\uDF3B2026级初一5班家长群","isCurrent":false},{"name":"外呼机器人研发对接","automationId":"session_item_外呼机器人研发对接","isCurrent":false},{"name":"商务通系统对接问题反馈","automationId":"session_item_商务通系统对接问题反馈","isCurrent":false},{"name":"潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","automationId":"session_item_潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","isCurrent":false}],"limit":20,"offset":0,"hasMore":false,"nextOffset":null}
@@ -0,0 +1,30 @@
3ad3c85c315c6f6d831b3d84f894a3cd34ba237b8ba0d8401d814f8beb6be75a broadcast-get.http.txt
4e01ccec54fa1fd023d150d84ee45c698aa5a08cce4b20048ee6a3d84d41336d broadcast-get.response.json
0de4d771c51fb7f812c3521771cf8126dd015a90f2cfe7efa07b64431993b72b broadcast-replay.http.txt
4e01ccec54fa1fd023d150d84ee45c698aa5a08cce4b20048ee6a3d84d41336d broadcast-replay.response.json
390c3db2dbbbd40886125c41a308c0fd09de34a893abd1594f62e031fb85d834 broadcast.request.json
edaf2a0fd912158a64600afea898ef7e92e1f952b99e8f50dcdf23aff5e3fe73 broadcast-submit.http.txt
8889cbed1f14be4f767cdc3a0ee372f75ba2982e6dba4f53504a98907d9406b2 broadcast-submit.response.json
f0c05ef72bab694caf2d890bfc0fb6fd69fd01717c4b24365d6bb34dc50ef571 capabilities.http.txt
ba09345de35f48ed812cbe54e6298c68f127c0b9cea6ee426e21c3669b16bb04 capabilities.response.json
af5e1cd3d73f4f332a8a32bda24bbc814ab0d90e85ecf6eed6b0b4585be269e9 confirmation-gate.http.txt
4dcde0d485bb221a4846cc53e225f05bb944433c939f5b7595c3a1ff700b9f82 confirmation-gate.request.json
fc0cec0c57504482651dc664903eef4d493a39c12357504282bfa58266388455 confirmation-gate.response.json
529430b446c9b8b1d05c7fcb51514e8ec1b71627af5ffe1c30d29791c83fa008 mcp-ui-evidence.json
64bc614875c15a7ab488fa9740bab0f606c9234596a5e903a88d2e68e22ca8b4 messages-filehelper.http.txt
b825e65ba2f3f5d27817ef64273785cb9be09cf9df8dfde6afe01794659f7994 messages-filehelper.response.json
63fceae775c345adc899b29b0a39d72906a662ba58a20eccea34cd0d6116f842 messages-jixiang.http.txt
b825e65ba2f3f5d27817ef64273785cb9be09cf9df8dfde6afe01794659f7994 messages-jixiang.response.json
54c40a6e30b2dbaa50f8027727b7aeb22cedc4998177c3fe350a44f17e155816 readback-summary.json
623571c8a2bd35f089906e8587c01d5db157e2af5d8b908bcf26d39e0c9b7380 README.md
344b3852dec53fc2b927669b5643863c7a4c184f2ac41d99374e6f16a83ff53e send-confirmation-gate.http.txt
86ed9bc17d74b0ccf463b6519e25df576520b263b4d16eda79864b7500681cc3 send-confirmation-gate.request.json
e2e07ecbbdd276f71e78dfe9160abb04d2bef923b73ae8b0b21eed759806461f send-confirmation-gate.response.json
dc0821a7b6f37f314e7fcd2aa8d68e96c27805cf0fea8e0cb03517f36ecea247 sessions-1.http.txt
976c98db5de557fffd1a7d8ba6c937925d791da4df6b5f6838983c09b4253f9b sessions-1.response.json
843811528eb530326e7d49b4be3fffbb11ad89cd2882232221ff6dca61dcbb4e sessions-2.http.txt
976c98db5de557fffd1a7d8ba6c937925d791da4df6b5f6838983c09b4253f9b sessions-2.response.json
b0499282b98c3bf946c53698a8dcf8de830396a8e9d42b38451168d8c3dcf554 sessions-3.http.txt
976c98db5de557fffd1a7d8ba6c937925d791da4df6b5f6838983c09b4253f9b sessions-3.response.json
fc2fa5ecff7f2c9ad9c71842b725d78d4f999c283e11832989f10bc7fbc910a0 status.http.txt
0280b1df77f973805bb487506002eac4e3ec2d67c8d68d58c1603f9b93e33eca status.response.json
@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 19 Sep 2026 04:22:44 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
X-Correlation-Id: acfbaa6b604646268b3892d050e8a4aa
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
@@ -0,0 +1 @@
{"serviceOnline":true,"wechatAvailable":true,"sessionAvailable":true,"sessionLocked":false,"windowFound":true,"errors":[],"wechatVersions":["4.1.13.65"],"activeAccountBound":true,"defaultReadOnly":true}
+9 -4
View File
@@ -33,6 +33,7 @@ public enum RemoteNodeStatus
public enum RemoteTaskStatus
{
Pending,
WaitingForClient,
Accepted,
Running,
Succeeded,
@@ -246,7 +247,8 @@ public sealed record RemoteNodeRegistration(
[property: JsonPropertyName("protocol_version")] string ProtocolVersion,
[property: JsonPropertyName("capabilities")] IReadOnlyList<string> Capabilities,
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
[property: JsonPropertyName("accounts")] IReadOnlyList<RemoteAccountSummary> Accounts);
[property: JsonPropertyName("accounts")] IReadOnlyList<RemoteAccountSummary> Accounts,
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
public sealed record RemoteAccountSummary(
[property: JsonPropertyName("account_id")] string AccountId,
@@ -259,13 +261,15 @@ public sealed record RemoteNodeRegistrationResponse(
[property: JsonPropertyName("node_id")] string NodeId,
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
[property: JsonPropertyName("authenticated")] bool Authenticated,
[property: JsonPropertyName("correlation_id")] string CorrelationId);
[property: JsonPropertyName("correlation_id")] string CorrelationId,
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
public sealed record RemoteHeartbeatResponse(
[property: JsonPropertyName("node_id")] string NodeId,
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
[property: JsonPropertyName("last_heartbeat_at")] DateTimeOffset LastHeartbeatAt,
[property: JsonPropertyName("correlation_id")] string CorrelationId);
[property: JsonPropertyName("correlation_id")] string CorrelationId,
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
public sealed record RemoteTaskBatch(
[property: JsonPropertyName("tasks")] IReadOnlyList<RemoteTaskEnvelope> Tasks);
@@ -282,7 +286,8 @@ public sealed record RemoteHeartbeat(
[property: JsonPropertyName("queue_length")] int QueueLength,
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
[property: JsonPropertyName("correlation_id")] string CorrelationId,
[property: JsonPropertyName("last_error_code")] string? LastErrorCode = null);
[property: JsonPropertyName("last_error_code")] string? LastErrorCode = null,
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
public sealed record RemoteTaskEnvelope(
[property: JsonPropertyName("task_id")] string TaskId,
@@ -22,6 +22,7 @@ public sealed class RemoteControlClient : IDisposable
private readonly Uri? _baseAddress;
private readonly X509Certificate2? _clientCertificate;
private readonly X509Certificate2? _serverCaCertificate;
private string? _connectionId;
private bool _authenticated;
public RemoteControlClient(RemoteAgentOptions options, HttpClient? httpClient = null)
@@ -57,7 +58,9 @@ public sealed class RemoteControlClient : IDisposable
AuthState = RemoteAuthState.Authenticating;
try
{
var response = await SendAsync<RemoteNodeRegistrationResponse>(HttpMethod.Post, "/v1/nodes/register", registration, false, cancellationToken);
var connectionId = registration.ConnectionId ?? NewConnectionId();
var response = await SendAsync<RemoteNodeRegistrationResponse>(HttpMethod.Post, "/v1/nodes/register", registration with { ConnectionId = connectionId }, false, cancellationToken);
_connectionId = response.ConnectionId ?? connectionId;
_authenticated = response.Authenticated;
AuthState = response.Authenticated ? RemoteAuthState.Authenticated : RemoteAuthState.AuthenticationFailed;
LastSuccessAt = DateTimeOffset.UtcNow;
@@ -75,7 +78,7 @@ public sealed class RemoteControlClient : IDisposable
public Task<RemoteHeartbeatResponse> HeartbeatAsync(RemoteHeartbeat heartbeat, CancellationToken cancellationToken = default) =>
SendAuthenticatedAsync<RemoteHeartbeatResponse>(HttpMethod.Post,
$"/v1/nodes/{Escape(heartbeat.NodeId)}/heartbeat", heartbeat, cancellationToken);
$"/v1/nodes/{Escape(heartbeat.NodeId)}/heartbeat", heartbeat with { ConnectionId = heartbeat.ConnectionId ?? _connectionId }, cancellationToken);
public async Task<IReadOnlyList<RemoteTaskEnvelope>> PollTasksAsync(
string accountId,
@@ -205,7 +208,7 @@ public sealed class RemoteControlClient : IDisposable
if (!response.IsSuccessStatusCode)
{
var error = TryDeserializeError(responseBytes, correlationId);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || error.Code == "StaleConnection")
{
_authenticated = false;
AuthState = RemoteAuthState.AuthenticationFailed;
@@ -292,6 +295,7 @@ public sealed class RemoteControlClient : IDisposable
}
private static string Escape(string value) => Uri.EscapeDataString(value);
private static string NewConnectionId() => Guid.NewGuid().ToString("N");
private static string NewCorrelationId() => Guid.NewGuid().ToString("N");
public void Dispose()
+1 -1
View File
@@ -31,7 +31,7 @@ try
var serviceOptions = JsonSerializer.Deserialize<ServiceOptions>(
await File.ReadAllTextAsync(configPath, shutdown.Token), ServiceHost.ConfigurationJson)
?? throw new ArgumentException("A service configuration is required.");
await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(new AccountBindingStore(serviceOptions)),
await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(new AccountBindingStore(serviceOptions), serviceOptions),
logPath: Path.Combine(Path.GetDirectoryName(configPath)!, "wxagent.log"));
await app.StartAsync(shutdown.Token);
try { await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token); }
+24 -19
View File
@@ -4,33 +4,38 @@ using WxAgent.Windows;
namespace WxAgent.Host;
public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBackend, IAgentEventSource
public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOptions options) : IAgentBackend, IAgentEventSource
{
private const bool ListenerEventsEnabled = true;
private readonly bool validationOperationsEnabled = options.EnableValidationOperations;
private readonly SemaphoreSlim bindingGate = new(1, 1);
public IReadOnlyList<AgentCapability> Capabilities { get; } =
public IReadOnlyList<AgentCapability> Capabilities =>
[
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("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("accounts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("account-binding", true, false, true, true, true, "manage", false, 30, "Auto-binds a unique database/UI identity match; manual binding remains available as fallback.", ["docs/WebUI-MCP-开发计划.md"]),
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("sessions-list", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("sessions-scroll", true, false, validationOperationsEnabled, true, false, "manage", false, 30,
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"),
new("session-open", true, false, validationOperationsEnabled, true, true, "manage", false, 30,
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"),
new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
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.65"),
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.65"),
new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("listener-events", true, ListenerEventsEnabled, ListenerEventsEnabled, true, false, "read", false, 30,
ListenerEventsEnabled ? null : "Listener events are reserved for controlled 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.", []),
ListenerEventsEnabled ? null : "Listener events are reserved for controlled validation.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("send-text", true, true, validationOperationsEnabled, true, true, "write", true, 30,
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("broadcast-text", true, true, validationOperationsEnabled, true, true, "write", true, 300,
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
new("group-at-all", true, false, false, true, true, "write", true, 30,
"Service account binding and group-owner validation required.", []),
"No group-at-all operation is registered; validate group targets through confirmed send-text tasks.", []),
new("next-unread", false, false, false, true, false, "read", false, 30,
"Deferred by docs/PENDING.md.", [])
];
+123 -8
View File
@@ -133,19 +133,134 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security
public Task<Page<MessageInfo>> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) =>
MessagesAsync(null, session, limit, offset, includeContent, ct);
public OperationRecord SubmitOperation(OperationSubmitRequest request, CancellationToken ct)
public async Task<OperationRecord> SubmitOperationAsync(OperationSubmitRequest request, CancellationToken ct)
{
if (!string.Equals(request.Kind, "send-text", StringComparison.Ordinal))
throw new ServiceException("UnsupportedOperation", 400, "Only send-text is available in this phase.");
var kind = RequireText(request.Kind, "kind", 80);
var accountId = RequireText(request.AccountId, "accountId", 200);
var targetId = RequireText(request.TargetId, "targetId", 512);
var text = PrepareText(request.Text);
var idempotencyKey = RequireText(request.IdempotencyKey, "idempotencyKey", 128);
if (!request.Confirmed) throw new ServiceException("ConfirmationRequired", 409, "Explicit send confirmation is required.");
var capability = RequireCapability("send-text");
var canonical = System.Text.Json.JsonSerializer.Serialize(new { request.Kind, accountId, targetId, text });
return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical,
cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation));
if (string.Equals(kind, "send-text", StringComparison.Ordinal))
{
var targetId = RequireText(request.TargetId, "targetId", 512);
var capability = RequireCapability("send-text");
var canonical = System.Text.Json.JsonSerializer.Serialize(new { kind, accountId, targetId, text });
return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical,
cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation));
}
if (!string.Equals(kind, "broadcast-text", StringComparison.Ordinal))
throw new ServiceException("UnsupportedOperation", 400, "Only send-text and broadcast-text are available in this phase.");
if (!string.IsNullOrWhiteSpace(request.TargetId))
throw new ServiceException("InvalidRequest", 400, "broadcast-text uses the targets list, not targetId.");
var capabilityForBroadcast = RequireCapability("broadcast-text");
var targets = await FreezeBroadcastTargetsAsync(accountId, request.Targets, ct).ConfigureAwait(false);
var initialDetails = System.Text.Json.JsonSerializer.Serialize(new
{
targetIds = targets,
stopOnError = request.StopOnError
}, ServiceHost.Json);
var canonicalBroadcast = System.Text.Json.JsonSerializer.Serialize(new
{
kind,
accountId,
targetIds = targets,
text,
stopOnError = request.StopOnError
});
return operations.SubmitResult(Identity, accountId, capabilityForBroadcast, idempotencyKey, canonicalBroadcast,
initialDetails,
cancellation => ExecuteBroadcastAsync(accountId, targets, text, request.StopOnError, cancellation));
}
private async Task<IReadOnlyList<string>> FreezeBroadcastTargetsAsync(string accountId, IReadOnlyList<string>? requested, CancellationToken ct)
{
if (requested is null || requested.Count is < 1 or > 20)
throw new ServiceException("InvalidRequest", 400, "broadcast-text requires 1..20 targets.");
var targets = requested
.Select(target => RequireText(target, "target", 512))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (targets.Length == 0) throw new ServiceException("InvalidRequest", 400, "broadcast-text requires at least one unique target.");
// Freeze the current UI target list before enqueueing. The account binding and identity are
// still revalidated by every side-effecting send, so this preflight cannot bypass account safety.
var visible = await backend.SessionsAsync(ct).ConfigureAwait(false);
var visibleIds = visible.Select(session => session.AutomationId).ToHashSet(StringComparer.Ordinal);
var missing = targets.Where(target => !visibleIds.Contains(target)).ToArray();
if (missing.Length > 0)
throw new ServiceException("TargetNotFound", 409, "Every broadcast target must be a visible, uniquely bound session.");
return targets;
}
private async Task<OperationExecutionResult> ExecuteBroadcastAsync(string accountId, IReadOnlyList<string> targets,
string text, bool stopOnError, CancellationToken ct)
{
var items = new List<BroadcastItemResult>(targets.Count);
var stopped = false;
string? stopReason = null;
string? errorCode = null;
var unconfirmed = false;
foreach (var target in targets)
{
try
{
await backend.SendTextAsync(accountId, target, text, ct).ConfigureAwait(false);
items.Add(new BroadcastItemResult(target, "Succeeded", null));
}
catch (OperationCanceledException)
{
items.Add(new BroadcastItemResult(target, "Unconfirmed", "Cancelled"));
stopped = true;
stopReason = "Cancelled";
errorCode = "Cancelled";
unconfirmed = true;
break;
}
catch (ServiceException exception)
{
var itemState = exception.Code == "ResultUnconfirmed" ? "Unconfirmed" : "Failed";
items.Add(new BroadcastItemResult(target, itemState, exception.Code));
errorCode ??= exception.Code == "ResultUnconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
if (itemState == "Unconfirmed" || stopOnError)
{
stopped = true;
stopReason = exception.Code;
unconfirmed = itemState == "Unconfirmed";
break;
}
}
catch (WxAgentException exception)
{
var code = exception.Code.ToString();
var itemState = exception.Code == WxAgentErrorCode.ResultUnconfirmed ? "Unconfirmed" : "Failed";
items.Add(new BroadcastItemResult(target, itemState, code));
errorCode ??= itemState == "Unconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
if (itemState == "Unconfirmed" || stopOnError)
{
stopped = true;
stopReason = code;
unconfirmed = itemState == "Unconfirmed";
break;
}
}
catch (Exception)
{
items.Add(new BroadcastItemResult(target, "Unconfirmed", "ExecutionFailed"));
stopped = true;
stopReason = "ExecutionFailed";
errorCode = "ExecutionFailed";
unconfirmed = true;
break;
}
}
var details = System.Text.Json.JsonSerializer.Serialize(new BroadcastResult(targets, items, stopped, stopReason), ServiceHost.Json);
return new OperationExecutionResult(details, errorCode, unconfirmed);
}
private static string RequireText(string? value, string name, int maxLength)
+6 -2
View File
@@ -57,9 +57,13 @@ public sealed class AgentTools(AgentService service)
[McpServerTool(Name = "operations_list", ReadOnly = true), Description("List bounded operations owned by the current identity; accountId is a caller-side view filter, not an authorization boundary.")]
public Task<CallToolResult> Operations(string? accountId = null, int limit = 50, int offset = 0) => Result(() => Task.FromResult<object>(service.Operations(accountId, limit, offset)));
[McpServerTool(Name = "operation_submit"), Description("Submit an explicitly confirmed operation. send-text remains disabled until Windows validation is complete.")]
[McpServerTool(Name = "operation_submit"), Description("Submit one explicitly confirmed send-text operation. The targetId must be a frozen visible session automation ID.")]
public Task<CallToolResult> SubmitOperation(string kind, string accountId, string targetId, string? text, string idempotencyKey, bool confirmed = false, CancellationToken cancellationToken = default) =>
Result(() => Task.FromResult<object>(service.SubmitOperation(new OperationSubmitRequest(kind, accountId, targetId, text, idempotencyKey, confirmed), cancellationToken)));
Result(async () => await service.SubmitOperationAsync(new OperationSubmitRequest(kind, accountId, targetId, text, idempotencyKey, confirmed), cancellationToken));
[McpServerTool(Name = "broadcast_text"), Description("Submit an explicitly confirmed broadcast-text operation for a frozen list of 1..20 visible session automation IDs. Sends sequentially, stops on the first known failure by default, and returns per-target results; replaying the same idempotency key never replays terminal writes.")]
public Task<CallToolResult> BroadcastText(string accountId, string[] targets, string text, string idempotencyKey, bool confirmed = false, bool stopOnError = true, CancellationToken cancellationToken = default) =>
Result(async () => await service.SubmitOperationAsync(new OperationSubmitRequest("broadcast-text", accountId, null, text, idempotencyKey, confirmed, targets, stopOnError), 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)));
+23 -5
View File
@@ -6,11 +6,13 @@ using Microsoft.Extensions.Hosting;
namespace WxAgent.Service;
public sealed record OperationExecutionResult(string? Details = null, string? ErrorCode = null, bool Unconfirmed = false);
public sealed class OperationQueue(OperationStore store, ServiceSecurity security) : BackgroundService
{
private const int QueueCapacity = 100;
private sealed record Work(OperationRecord Record, ServiceIdentity Identity, string Permission,
Func<CancellationToken, Task> Action, CancellationTokenSource Cancel);
Func<CancellationToken, Task<OperationExecutionResult>> Action, CancellationTokenSource Cancel);
private readonly Channel<Work> queue = Channel.CreateBounded<Work>(new BoundedChannelOptions(QueueCapacity)
{ SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
private readonly ConcurrentDictionary<string, CancellationTokenSource> cancellations = new();
@@ -18,7 +20,17 @@ public sealed class OperationQueue(OperationStore store, ServiceSecurity securit
private bool stopping;
public OperationRecord Submit(ServiceIdentity identity, string accountId, AgentCapability capability,
string? idempotencyKey, string canonicalParameters, Func<CancellationToken, Task> action)
string? idempotencyKey, string canonicalParameters, Func<CancellationToken, Task> action) =>
SubmitResult(identity, accountId, capability, idempotencyKey, canonicalParameters, null,
async cancellationToken =>
{
await action(cancellationToken).ConfigureAwait(false);
return new OperationExecutionResult();
});
public OperationRecord SubmitResult(ServiceIdentity identity, string accountId, AgentCapability capability,
string? idempotencyKey, string canonicalParameters, string? initialDetails,
Func<CancellationToken, Task<OperationExecutionResult>> action)
{
security.RequireCurrent(identity, capability.Permission, accountId);
if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability unavailable.");
@@ -30,7 +42,7 @@ public sealed class OperationQueue(OperationStore store, ServiceSecurity securit
{
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), QueueCapacity);
idempotencyKey, digest, capability.HasSideEffects, TimeSpan.FromSeconds(capability.TimeoutSeconds), QueueCapacity, initialDetails);
if (!created) return record;
var cancel = new CancellationTokenSource();
cancellations[record.Id] = cancel;
@@ -92,10 +104,16 @@ public sealed class OperationQueue(OperationStore store, ServiceSecurity securit
started = true;
}
// Never release this slot with WaitAsync: the actual action must have stopped first.
await work.Action(budget.Token);
var result = await work.Action(budget.Token);
if (result.ErrorCode is not null)
{
store.Transition(work.Record.Id, result.Unconfirmed ? "Unconfirmed" : "Failed",
"execution", result.ErrorCode, result.Details);
continue;
}
budget.Token.ThrowIfCancellationRequested();
security.RequireCurrent(work.Identity, work.Permission, work.Record.AccountId);
store.Transition(work.Record.Id, "Succeeded", "complete");
store.Transition(work.Record.Id, "Succeeded", "complete", details: result.Details);
}
catch (Exception e)
{
+12 -7
View File
@@ -4,7 +4,7 @@ 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);
string? ErrorCode, bool HasSideEffects, string? Details = null);
public sealed record OperationSummary(string Id, string AccountId, string Capability,
string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt,
@@ -37,10 +37,12 @@ public sealed class OperationStore : IDisposable
stage='restart', error='AgentRestarted' WHERE state='Running';
UPDATE operations SET state='Cancelled', stage='restart', error='AgentRestarted' WHERE state='Queued';
""");
try { Execute("ALTER TABLE operations ADD COLUMN details TEXT"); }
catch (SqliteException exception) when (exception.SqliteErrorCode == 1) { }
}
public (OperationRecord Record, bool Created) Enqueue(string principal, string account, string capability,
string? idempotency, string digest, bool sideEffects, TimeSpan budget, int capacity)
string? idempotency, string digest, bool sideEffects, TimeSpan budget, int capacity, string? details = null)
{
lock (gate)
{
@@ -67,10 +69,10 @@ public sealed class OperationStore : IDisposable
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);
"Queued", "queued", Guid.NewGuid().ToString("N"), now, now.Add(budget), null, sideEffects, details);
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.CommandText = "INSERT INTO operations (id,principal,account,capability,state,stage,correlation,created,expires,error,side_effects,idempotency,digest,details) VALUES ($id,$p,$a,$c,'Queued','queued',$correlation,$created,$expires,NULL,$effects,$key,$digest,$details)";
insert.Parameters.AddWithValue("$id", operation.Id);
insert.Parameters.AddWithValue("$p", principal);
insert.Parameters.AddWithValue("$a", account);
@@ -81,6 +83,7 @@ public sealed class OperationStore : IDisposable
insert.Parameters.AddWithValue("$effects", sideEffects ? 1 : 0);
insert.Parameters.AddWithValue("$key", (object?)idempotency ?? DBNull.Value);
insert.Parameters.AddWithValue("$digest", digest);
insert.Parameters.AddWithValue("$details", (object?)details ?? DBNull.Value);
insert.ExecuteNonQuery();
transaction.Commit();
return (operation, true);
@@ -122,16 +125,17 @@ public sealed class OperationStore : IDisposable
}
}
public void Transition(string id, string state, string stage, string? error = null)
public void Transition(string id, string state, string stage, string? error = null, string? details = 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.CommandText = "UPDATE operations SET state=$state,stage=$stage,error=$error,details=COALESCE($details,details) 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.Parameters.AddWithValue("$details", (object?)details ?? DBNull.Value);
command.ExecuteNonQuery();
}
}
@@ -139,7 +143,8 @@ public sealed class OperationStore : IDisposable
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);
DateTimeOffset.Parse(reader.GetString(8), System.Globalization.CultureInfo.InvariantCulture), reader.IsDBNull(9) ? null : reader.GetString(9), reader.GetInt64(10) != 0,
reader.IsDBNull(13) ? null : reader.GetString(13));
private void Execute(string sql)
{
@@ -5,7 +5,20 @@ public sealed record AccountInfo(string AccountId, string? DisplayName, string?
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 record OperationSubmitRequest(string? Kind, string? AccountId, string? TargetId, string? Text, string? IdempotencyKey, bool Confirmed);
public sealed record OperationSubmitRequest(
string? Kind,
string? AccountId,
string? TargetId,
string? Text,
string? IdempotencyKey,
bool Confirmed,
IReadOnlyList<string>? Targets = null,
bool StopOnError = true);
public sealed record BroadcastItemResult(string TargetId, string State, string? ErrorCode);
public sealed record BroadcastResult(IReadOnlyList<string> TargetIds, IReadOnlyList<BroadcastItemResult> Items,
bool Stopped, string? StopReason);
public sealed class ReadOnlyRequest
{
+1 -1
View File
@@ -137,7 +137,7 @@ public static class ServiceHost
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.MapPost("/api/v1/operations", (OperationSubmitRequest request, AgentService service, CancellationToken ct) => service.SubmitOperation(request, ct));
app.MapPost("/api/v1/operations", async (OperationSubmitRequest request, AgentService service, CancellationToken ct) => await service.SubmitOperationAsync(request, ct));
app.MapGet("/api/v1/operations", (string? accountId, int? limit, int? offset, AgentService service) => service.Operations(accountId, limit ?? 50, offset ?? 0));
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));
@@ -22,6 +22,9 @@ 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.
public bool EnableValidationOperations { get; init; }
// Kept only so older service.json files can be loaded and rewritten by the tray.
[JsonIgnore]
+11 -7
View File
@@ -136,7 +136,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
};
Directory.CreateDirectory(initial.DataDirectory);
WriteJson(configPath, initial);
WriteCredentials(initial.CredentialFile, initialToken);
WriteCredentials(initial.CredentialFile, initialToken, initial.EnableValidationOperations);
options = initial;
return true;
}
@@ -154,7 +154,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
if (changed) WriteJson(configPath, normalized);
Directory.CreateDirectory(normalized.DataDirectory);
// Always rewrite one canonical record so old/malformed/multi-record files self-heal on tray startup.
WriteCredentials(normalized.CredentialFile, token);
WriteCredentials(normalized.CredentialFile, token, normalized.EnableValidationOperations);
options = normalized;
return changed;
}
@@ -177,7 +177,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
DataDirectory = dataDirectory,
Remote = source.Remote,
Reporting = source.Reporting,
RemoteConfigurationFile = source.RemoteConfigurationFile
RemoteConfigurationFile = source.RemoteConfigurationFile,
EnableValidationOperations = source.EnableValidationOperations
};
private ServiceOptions ReadOptions() =>
@@ -190,7 +191,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
var loaded = ReadOptions();
loaded.Validate();
options = loaded;
service = ServiceHost.Build(loaded, new WindowsAgentBackend(new AccountBindingStore(loaded)), logPath: GetLogPath());
service = ServiceHost.Build(loaded, new WindowsAgentBackend(new AccountBindingStore(loaded), loaded), logPath: GetLogPath());
try
{
await service.StartAsync();
@@ -283,7 +284,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
{
EnsureConfiguration();
if (ServiceSettingsEditor.Show(options!) is not { } edited) return;
WriteCredentials(edited.CredentialFile, edited.AccessToken!);
WriteCredentials(edited.CredentialFile, edited.AccessToken!, edited.EnableValidationOperations);
WriteJson(configPath, edited);
_ = ReloadAsync();
}
@@ -329,12 +330,15 @@ internal sealed class TrayApplicationContext : ApplicationContext
base.Dispose(disposing);
}
private static void WriteCredentials(string path, string token)
private static void WriteCredentials(string path, string token, bool includeValidationWrite)
{
var temporary = path + ".tmp";
var permissions = includeValidationWrite
? new[] { "read", "content", "write", "manage" }
: new[] { "read", "content", "manage" };
WriteJson(temporary, new[]
{
new ServiceCredential("local-admin", ServiceOptions.HashToken(token), ["read", "content", "manage"], [])
new ServiceCredential("local-admin", ServiceOptions.HashToken(token), permissions, [])
});
File.Move(temporary, path, true);
}
@@ -500,7 +500,9 @@ public static partial class WechatChatClient
var direct = FindByAutomationId(main, $"session_item_{session}");
if (direct is not null)
{
direct.Click();
// Use the same visible-center click path as the rest of the UI executor;
// FlaUI's Click() can report success without changing WeChat's virtualized chat pane.
ClickCenter(direct);
await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false);
return;
}
@@ -509,7 +511,7 @@ public static partial class WechatChatClient
private static async Task WaitForSessionPageAsync(AutomationElement main, string session, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 25; attempt++)
for (var attempt = 0; attempt < 100; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
if (IsListeningSession(main, session, independent: false)
@@ -0,0 +1,151 @@
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 BroadcastOperationTests
{
private sealed class Backend(bool failSecond = false) : IAgentBackend
{
public List<string> SentTargets { get; } = [];
public IReadOnlyList<AgentCapability> Capabilities =>
[
new("sessions-list", true, true, true, true, false, "read", false, 30, null, []),
new("broadcast-text", true, false, true, true, true, "write", true, 30, null, []),
new("send-text", true, false, true, true, true, "write", false, 30, null, [])
];
public Task<object> StatusAsync(CancellationToken cancellationToken) => Task.FromResult<object>(new { ok = true });
public Task<IReadOnlyList<SessionInfo>> SessionsAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<SessionInfo>>([
new("A", "session-a", false),
new("B", "session-b", false),
new("C", "session-c", false)
]);
public Task SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken)
{
if (failSecond && targetId == "session-b")
throw new ServiceException("TargetUnavailable", 409, "Target is no longer available.");
SentTargets.Add(targetId);
return Task.CompletedTask;
}
}
[Fact]
public async Task BroadcastFreezesTargetsRunsInOrderAndReplaysIdempotently()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('D', 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", "write"], [])
}));
var backend = new Backend();
await using var app = ServiceHost.Build(options, 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 request = new
{
kind = "broadcast-text",
accountId = "account-1",
targets = new[] { "session-a", "session-a", "session-b" },
text = "broadcast-test",
idempotencyKey = "broadcast-1",
confirmed = true,
stopOnError = true
};
var submitted = await client.PostAsJsonAsync("/api/v1/operations", request);
Assert.Equal(HttpStatusCode.OK, submitted.StatusCode);
var queued = await submitted.Content.ReadFromJsonAsync<JsonElement>();
var operationId = queued.GetProperty("id").GetString();
Assert.False(string.IsNullOrWhiteSpace(operationId));
var completed = await WaitForTerminalAsync(client, operationId!);
Assert.Equal("Succeeded", completed.GetProperty("state").GetString());
Assert.Equal(["session-a", "session-b"], backend.SentTargets);
var details = JsonDocument.Parse(completed.GetProperty("details").GetString()!).RootElement;
Assert.Equal(["session-a", "session-b"], details.GetProperty("targetIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
Assert.Equal(["session-a", "session-b"], details.GetProperty("items").EnumerateArray().Select(x => x.GetProperty("targetId").GetString()!).ToArray());
Assert.All(details.GetProperty("items").EnumerateArray(), item => Assert.Equal("Succeeded", item.GetProperty("state").GetString()));
var replay = await client.PostAsJsonAsync("/api/v1/operations", request);
var replayRecord = await replay.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(operationId, replayRecord.GetProperty("id").GetString());
Assert.Equal(2, backend.SentTargets.Count);
}
finally
{
await app.StopAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
Directory.Delete(dir, true);
}
}
[Fact]
public async Task BroadcastStopsAfterKnownTargetFailureAndReportsPerItemResults()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('E', 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", "write"], [])
}));
var backend = new Backend(failSecond: true);
await using var app = ServiceHost.Build(options, 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 submitted = await client.PostAsJsonAsync("/api/v1/operations", new
{
kind = "broadcast-text", accountId = "account-1", targets = new[] { "session-a", "session-b", "session-c" },
text = "broadcast-stop-test", idempotencyKey = "broadcast-stop-1", confirmed = true, stopOnError = true
});
var queued = await submitted.Content.ReadFromJsonAsync<JsonElement>();
var completed = await WaitForTerminalAsync(client, queued.GetProperty("id").GetString()!);
Assert.Equal("Failed", completed.GetProperty("state").GetString());
Assert.Equal(["session-a"], backend.SentTargets);
var details = JsonDocument.Parse(completed.GetProperty("details").GetString()!).RootElement;
Assert.True(details.GetProperty("stopped").GetBoolean());
Assert.Equal(2, details.GetProperty("items").GetArrayLength());
Assert.Equal("Failed", details.GetProperty("items")[1].GetProperty("state").GetString());
Assert.Equal("TargetUnavailable", details.GetProperty("items")[1].GetProperty("errorCode").GetString());
}
finally
{
await app.StopAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
Directory.Delete(dir, true);
}
}
private static async Task<JsonElement> WaitForTerminalAsync(HttpClient client, string operationId)
{
for (var i = 0; i < 100; i++)
{
var response = await client.GetAsync($"/api/v1/operations/{operationId}");
var record = await response.Content.ReadFromJsonAsync<JsonElement>();
var state = record.GetProperty("state").GetString();
if (state is "Succeeded" or "Failed" or "Unconfirmed" or "Cancelled" or "Expired") return record;
await Task.Delay(10);
}
throw new TimeoutException("Broadcast operation did not reach a terminal state.");
}
}