package controlplane import ( "crypto/tls" "database/sql" "encoding/json" "errors" "net" "os" "path/filepath" "strconv" "strings" "testing" "time" "github.com/gorilla/websocket" ) func TestPairDispatchAndTaskEvent(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } port := listener.Addr().(*net.TCPAddr).Port listener.Close() configPath := filepath.Join(t.TempDir(), "controller-config.json") if err := os.WriteFile(configPath, []byte(`{"agent_host":"127.0.0.1","agent_port":`+strconv.Itoa(port)+`}`), 0o600); err != nil { t.Fatal(err) } t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir()) controller, err := Open(configPath) if err != nil { t.Fatal(err) } defer controller.Close() if err := controller.StartAgentServer(); err != nil { t.Fatal(err) } dialer := websocket.Dialer{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} // Test client pins no certificate. client, _, err := dialer.Dial("wss://127.0.0.1:"+strconv.Itoa(port), nil) if err != nil { t.Fatal(err) } defer client.Close() code, err := controller.CreatePairingCode() if err != nil { t.Fatal(err) } if err := client.WriteJSON(map[string]any{"type": "pair_request", "code": code, "agent_name": "test-agent"}); err != nil { t.Fatal(err) } var paired map[string]any if err := client.ReadJSON(&paired); err != nil { t.Fatal(err) } agentID, _ := paired["agent_id"].(string) credential, _ := paired["credential"].(string) if paired["type"] != "pair_accepted" || agentID == "" { t.Fatalf("unexpected pairing response: %#v", paired) } if err := client.WriteJSON(map[string]any{"type": "agent_rename", "name": "renamed-from-agent"}); err != nil { t.Fatal(err) } var renameAck map[string]any if err := client.ReadJSON(&renameAck); err != nil || renameAck["type"] != "agent_rename_ack" { t.Fatalf("expected rename acknowledgement: %#v, %v", renameAck, err) } var pairedName string if err := controller.db.QueryRow("SELECT name FROM managed_agents WHERE agent_id = ?", agentID).Scan(&pairedName); err != nil || pairedName != "renamed-from-agent" { t.Fatalf("expected Agent rename, got %q, %v", pairedName, err) } profile, err := controller.SaveLLMProfile(LLMProfileInput{ Name: "test-openai", Provider: "openai", BaseURL: "https://example.test/v1", Model: "test-model", APIKey: "test-secret", }) if err != nil { t.Fatal(err) } if profiles, err := controller.LLMProfiles(); err != nil || len(profiles) != 1 || !profiles[0].HasAPIKey { t.Fatalf("expected protected LLM profile, got %#v, %v", profiles, err) } if err := controller.DeployLLMProfile(profile.ID, []string{agentID}); err != nil { t.Fatal(err) } var configSnapshot map[string]any if err := client.ReadJSON(&configSnapshot); err != nil { t.Fatal(err) } llm, _ := configSnapshot["llm"].(map[string]any) if configSnapshot["type"] != "config_snapshot" || llm["model"] != "test-model" || llm["api_key"] != "test-secret" { t.Fatalf("unexpected LLM snapshot: %#v", configSnapshot) } if err := client.WriteJSON(map[string]any{"type": "config_ack", "config_version": configSnapshot["config_version"], "status": "applied"}); err != nil { t.Fatal(err) } time.Sleep(100 * time.Millisecond) if statuses, err := controller.AgentLLMStatuses(); err != nil || len(statuses) != 1 || statuses[0].ApplyStatus != "applied" { t.Fatalf("expected applied LLM deployment, got %#v, %v", statuses, err) } if err := controller.SetAgentMCPTools(agentID, []string{"desktop-filesystem"}); err != nil { t.Fatal(err) } var mcpSnapshot map[string]any if err := client.ReadJSON(&mcpSnapshot); err != nil { t.Fatal(err) } if mcpSnapshot["type"] != "mcp_snapshot" { t.Fatalf("expected MCP snapshot, got %#v", mcpSnapshot) } mcpTools, _ := mcpSnapshot["tools"].([]any) mcpTool, _ := mcpTools[0].(map[string]any) mcpVersion, _ := mcpTool["deployment_version"].(float64) if mcpVersion != 1 { t.Fatalf("expected first MCP deployment version, got %#v", mcpSnapshot) } var persistedVersion int if err := controller.db.QueryRow("SELECT deployment_version FROM agent_mcp_assignments WHERE agent_id=? AND tool_id='desktop-filesystem'", agentID).Scan(&persistedVersion); err != nil || persistedVersion != 1 { t.Fatalf("expected persisted MCP deployment assignment, got version %d / %v", persistedVersion, err) } if err := client.WriteJSON(map[string]any{"type": "mcp_ack", "tool_id": "desktop-filesystem", "deployment_version": mcpVersion, "status": "applied"}); err != nil { t.Fatal(err) } time.Sleep(100 * time.Millisecond) skill, err := controller.SaveSkill(SkillInput{Name: "图生图工具", Version: "0.3.2", Content: "# 图生图工具\n\n测试内容。"}) if err != nil { t.Fatal(err) } if err := controller.SetAgentSkills(agentID, []string{skill.ID}); err != nil { t.Fatal(err) } var skillSnapshot map[string]any if err := client.ReadJSON(&skillSnapshot); err != nil || skillSnapshot["type"] != "skill_snapshot" { t.Fatalf("expected Skill snapshot, got %#v, %v", skillSnapshot, err) } if err := client.WriteJSON(map[string]any{"type": "skill_ack", "skill_id": skill.ID, "status": "applied"}); err != nil { t.Fatal(err) } time.Sleep(100 * time.Millisecond) deployments, err := controller.AgentDeploymentStatuses() if err != nil || len(deployments) != 1 || deployments[0].MCPStatus != "applied" || deployments[0].LLMStatus != "applied" { t.Fatalf("expected applied Agent deployment summary, got %#v, %v", deployments, err) } // A stale acknowledgement must not overwrite the recorded applied deployment. if err := client.WriteJSON(map[string]any{"type": "mcp_ack", "tool_id": "desktop-filesystem", "deployment_version": mcpVersion - 1, "status": "failed", "error": "stale"}); err != nil { t.Fatal(err) } time.Sleep(30 * time.Millisecond) if deployments, err = controller.AgentDeploymentStatuses(); err != nil || deployments[0].MCPStatus != "applied" { t.Fatalf("stale MCP acknowledgement changed state: %#v, %v", deployments, err) } // Re-authentication must replay the durable MCP assignment. The Agent can then // acknowledge that same version again without creating a duplicate deployment. _ = client.Close() time.Sleep(30 * time.Millisecond) client, _, err = dialer.Dial("wss://127.0.0.1:"+strconv.Itoa(port), nil) if err != nil { t.Fatal(err) } defer client.Close() if err := client.WriteJSON(map[string]any{"type": "authenticate", "agent_id": agentID, "credential": credential, "agent_name": "renamed-from-agent"}); err != nil { t.Fatal(err) } var authenticated map[string]any if err := client.ReadJSON(&authenticated); err != nil || authenticated["type"] != "auth_accepted" { t.Fatalf("expected re-authentication acknowledgement: %#v, %v", authenticated, err) } var replayedMCPSnapshot map[string]any _ = client.SetReadDeadline(time.Now().Add(time.Second)) for replayedMCPSnapshot == nil { var message map[string]any if err := client.ReadJSON(&message); err != nil { t.Fatalf("expected MCP replay after reconnect: %v", err) } if message["type"] == "mcp_snapshot" { replayedMCPSnapshot = message } } _ = client.SetReadDeadline(time.Time{}) replayedTools, _ := replayedMCPSnapshot["tools"].([]any) replayedTool, _ := replayedTools[0].(map[string]any) if replayedTool["deployment_version"] != mcpVersion { t.Fatalf("reconnect replayed wrong MCP version: %#v", replayedMCPSnapshot) } statusDeadline := time.Now().Add(time.Second) for { deployments, err = controller.AgentDeploymentStatuses() if err == nil && len(deployments) == 1 && deployments[0].MCPStatus == "sent" { break } if time.Now().After(statusDeadline) { break } time.Sleep(10 * time.Millisecond) } if err != nil || len(deployments) != 1 || deployments[0].MCPStatus != "sent" { t.Fatalf("expected MCP to await replay acknowledgement: %#v, %v", deployments, err) } if err := client.WriteJSON(map[string]any{"type": "mcp_ack", "tool_id": "desktop-filesystem", "deployment_version": mcpVersion, "status": "applied"}); err != nil { t.Fatal(err) } time.Sleep(30 * time.Millisecond) if deployments, err = controller.AgentDeploymentStatuses(); err != nil || deployments[0].MCPStatus != "applied" { t.Fatalf("expected replay acknowledgement to restore applied state: %#v, %v", deployments, err) } taskID, err := controller.CreateTask(agentID, "验证 Wails 控制平面", 30) if err != nil { t.Fatal(err) } var dispatch map[string]any _ = client.SetReadDeadline(time.Now().Add(time.Second)) for { if err := client.ReadJSON(&dispatch); err != nil { t.Fatal(err) } if dispatch["type"] == "task_dispatch" && dispatch["task_id"] == taskID { break } } _ = client.SetReadDeadline(time.Time{}) if dispatch["type"] != "task_dispatch" || dispatch["task_id"] != taskID || dispatch["deadline_at"] == nil { t.Fatalf("unexpected task dispatch: %#v", dispatch) } if err := client.WriteJSON(map[string]any{"type": "task_event", "task_id": taskID, "status": "completed", "execution": "desktop-filesystem", "output": "内部推理不能显示已在桌面创建文件夹:test"}); err != nil { t.Fatal(err) } time.Sleep(30 * time.Millisecond) tasks, err := controller.RecentTasks() if err != nil { t.Fatal(err) } if len(tasks) != 1 || tasks[0].Status != "completed" || tasks[0].Output != "已在桌面创建文件夹:test" { encoded, _ := json.Marshal(tasks) t.Fatalf("unexpected task result: %s", encoded) } if tasks[0].Execution != "desktop-filesystem" { t.Fatalf("expected MCP execution source, got %#v", tasks[0]) } if tasks[0].AgentName != "renamed-from-agent" { t.Fatalf("expected persisted task Agent name, got %#v", tasks[0]) } detail, err := controller.TaskDetails(taskID) if err != nil { t.Fatal(err) } if detail.Task.ID != taskID || detail.Task.Execution != "desktop-filesystem" || len(detail.Events) < 2 { t.Fatalf("expected task detail with execution timeline, got %#v", detail) } batch, err := controller.CreateTasks([]TaskRequest{ {AgentID: agentID, Instruction: "并发面板任务一", TimeoutSeconds: 30}, {AgentID: agentID, Instruction: "并发面板任务二", TimeoutSeconds: 30}, {AgentID: "offline-agent", Instruction: "不会阻塞其他任务", TimeoutSeconds: 30}, }) if err != nil || len(batch) != 3 || batch[0].TaskID == "" || batch[1].TaskID == "" || batch[2].Error == "" { t.Fatalf("expected independent batch outcomes, got %#v / %v", batch, err) } dispatchedIDs := map[string]bool{} for range 2 { if err := client.ReadJSON(&dispatch); err != nil || dispatch["type"] != "task_dispatch" { t.Fatalf("expected batch task dispatch: %#v / %v", dispatch, err) } id, _ := dispatch["task_id"].(string) dispatchedIDs[id] = true } if !dispatchedIDs[batch[0].TaskID] || !dispatchedIDs[batch[1].TaskID] { t.Fatalf("batch dispatch IDs missing: %#v", dispatchedIDs) } if _, err := controller.CreateTasks(nil); err == nil { t.Fatal("empty task batch must be rejected") } auditEvents, err := controller.RecentAuditEvents() if err != nil || len(auditEvents) < 4 { t.Fatalf("expected MCP deployment and execution audit records, got %#v, %v", auditEvents, err) } for _, event := range auditEvents { if event.Detail == "内部推理不能显示已在桌面创建文件夹:test" { t.Fatalf("audit event leaked internal reasoning: %#v", event) } } if _, err := controller.RetryTask(taskID); err == nil { t.Fatal("completed tasks must not be retryable") } cancellableID, err := controller.CreateTask(agentID, "验证取消", 30) if err != nil { t.Fatal(err) } if err := client.ReadJSON(&dispatch); err != nil || dispatch["task_id"] != cancellableID { t.Fatalf("expected cancellable dispatch: %#v, %v", dispatch, err) } if err := controller.CancelTask(cancellableID); err != nil { t.Fatal(err) } var cancellation map[string]any if err := client.ReadJSON(&cancellation); err != nil || cancellation["type"] != "task_cancel" { t.Fatalf("expected cancellation: %#v, %v", cancellation, err) } if err := client.WriteJSON(map[string]any{"type": "task_event", "task_id": cancellableID, "status": "cancelled", "message": "已取消"}); err != nil { t.Fatal(err) } time.Sleep(30 * time.Millisecond) retryID, err := controller.RetryTask(cancellableID) if err != nil { t.Fatal(err) } if err := client.ReadJSON(&dispatch); err != nil || dispatch["task_id"] != retryID || dispatch["attempt"].(float64) != 2 { t.Fatalf("expected retry dispatch: %#v, %v", dispatch, err) } pastDeadline := time.Now().UTC().Add(-time.Second).Format(time.RFC3339Nano) if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, instruction, status, created_at, deadline_at, timeout_seconds, attempt) VALUES (?, ?, ?, 'running', ?, ?, 5, 1)", "expired-task", agentID, "过期任务", time.Now().UTC().Format(time.RFC3339Nano), pastDeadline); err != nil { t.Fatal(err) } if _, err := controller.RecentTasks(); err != nil { t.Fatal(err) } var expiredStatus string if err := controller.db.QueryRow("SELECT status FROM task_runs WHERE task_id = 'expired-task'").Scan(&expiredStatus); err != nil || expiredStatus != "timed_out" { t.Fatalf("expected timeout, got %q, %v", expiredStatus, err) } staleAt := time.Now().UTC().Add(-agentHeartbeatTimeout - time.Second).Format(time.RFC3339Nano) if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES (?, ?, ?, 'online', ?)", "stale-agent", "stale-agent", "unused", staleAt); err != nil { t.Fatal(err) } if _, err := controller.Agents(); err != nil { t.Fatal(err) } var staleStatus string if err := controller.db.QueryRow("SELECT status FROM managed_agents WHERE agent_id = 'stale-agent'").Scan(&staleStatus); err != nil || staleStatus != "offline" { t.Fatalf("expected stale Agent to be offline, got %q, %v", staleStatus, err) } agents, err := controller.Agents() if err != nil || len(agents) != 2 { t.Fatalf("unexpected Agent list: %#v, %v", agents, err) } for _, agent := range agents { if agent.ID == agentID && agent.Status != "online" { t.Fatalf("active Agent incorrectly marked %q", agent.Status) } } if err := controller.RenameAgent(agentID, "renamed-agent"); err != nil { t.Fatal(err) } if err := controller.RevokeAgent(agentID); err != nil { t.Fatal(err) } if controller.authenticate(agentID, credential) { t.Fatal("revoked Agent credential must no longer authenticate") } var removedName string if err := controller.db.QueryRow("SELECT name FROM managed_agents WHERE agent_id = ?", agentID).Scan(&removedName); !errors.Is(err, sql.ErrNoRows) { t.Fatalf("revoked Agent record must be deleted, got %q / %v", removedName, err) } } func TestUsableLANAddressRejectsTunnelAndSpecialAddresses(t *testing.T) { for _, value := range []string{"198.18.0.1", "127.0.0.1", "169.254.1.1", "8.8.8.8"} { if isUsableLANIPv4(net.ParseIP(value)) { t.Fatalf("%s must not be selected as a LAN address", value) } } for _, value := range []string{"192.168.1.15", "172.22.201.16", "10.1.2.3"} { if !isUsableLANIPv4(net.ParseIP(value)) { t.Fatalf("%s must be accepted as a private LAN address", value) } } } func TestClearTaskHistoryPreservesActiveTasksAndAuditLog(t *testing.T) { t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir()) controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json")) if err != nil { t.Fatal(err) } defer controller.Close() now := time.Now().UTC().Format(time.RFC3339Nano) for _, task := range []struct{ id, status string }{{"finished-task", "completed"}, {"active-task", "running"}} { if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, timeout_seconds) VALUES (?, 'agent-1', '历史 Agent', '测试任务', ?, ?, 60)", task.id, task.status, now); err != nil { t.Fatal(err) } if _, err := controller.db.Exec("INSERT INTO task_events(task_id, event_type, message, created_at) VALUES (?, 'accepted', '已接收', ?)", task.id, now); err != nil { t.Fatal(err) } } deleted, err := controller.ClearTaskHistory() if err != nil || deleted != 1 { t.Fatalf("expected one terminal task to be cleared, got %d / %v", deleted, err) } var activeCount, finishedEvents int if err := controller.db.QueryRow("SELECT COUNT(*) FROM task_runs WHERE task_id = 'active-task'").Scan(&activeCount); err != nil || activeCount != 1 { t.Fatalf("active task must remain, got %d / %v", activeCount, err) } if err := controller.db.QueryRow("SELECT COUNT(*) FROM task_events WHERE task_id = 'finished-task'").Scan(&finishedEvents); err != nil || finishedEvents != 0 { t.Fatalf("finished task events must be cleared, got %d / %v", finishedEvents, err) } auditEvents, err := controller.RecentAuditEvents() if err != nil || len(auditEvents) != 1 || auditEvents[0].Action != "task.history_cleared" { t.Fatalf("expected history cleanup audit record, got %#v / %v", auditEvents, err) } } func TestMentionResolutionDoesNotMisrouteUnknownDuplicateOrOfflineAgents(t *testing.T) { t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir()) controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json")) if err != nil { t.Fatal(err) } defer controller.Close() now := time.Now().UTC().Format(time.RFC3339Nano) for _, agent := range []struct{ id, name, status string }{ {"design", "设计机", "online"}, {"writer", "文案机", "offline"}, {"dup-a", "重名机", "online"}, {"dup-b", "重名机", "online"}, } { if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES (?, ?, 'test', ?, ?)", agent.id, agent.name, agent.status, now); err != nil { t.Fatal(err) } } items, issues, err := controller.resolveMentions("@设计机 做海报方案\n@文案机 写三条标题\n@重名机 不应误发\n@未知机 不应误发") if err != nil || len(issues) != 2 || len(items) != 2 { t.Fatalf("unexpected mention parse result: %#v / %#v / %v", items, issues, err) } if items[0].AgentID != "design" || items[0].Instruction != "做海报方案" { t.Fatalf("design mention was not split to its exact target: %#v", items[0]) } if items[1].AgentID != "writer" || !strings.Contains(items[1].Error, "离线") { t.Fatalf("offline mention must remain at its own target: %#v", items[1]) } if !strings.Contains(issues[0], "重名") || !strings.Contains(issues[1], "未识别") { t.Fatalf("invalid names must be reported without creating targets: %#v", issues) } noTargets, noTargetIssues, err := controller.resolveMentions("没有目标的公共文本") if err != nil || len(noTargets) != 0 || len(noTargetIssues) != 1 || !strings.Contains(noTargetIssues[0], "未指定") { t.Fatalf("message without @ target must be rejected safely: %#v / %#v / %v", noTargets, noTargetIssues, err) } } func TestMentionLineCanTargetManyAgentsWithSameTask(t *testing.T) { t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir()) controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json")) if err != nil { t.Fatal(err) } defer controller.Close() now := time.Now().UTC().Format(time.RFC3339Nano) for _, agent := range []struct{ id, name string }{{"design", "设计机"}, {"writer", "文案机"}} { if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES (?, ?, 'test', 'online', ?)", agent.id, agent.name, now); err != nil { t.Fatal(err) } } items, issues, err := controller.resolveMentions("@设计机 @文案机 完成同一份发布检查") if err != nil || len(issues) != 0 || len(items) != 2 { t.Fatalf("expected one line to create two direct targets: %#v / %#v / %v", items, issues, err) } if items[0].Instruction != "完成同一份发布检查" || items[1].Instruction != "完成同一份发布检查" || items[0].AgentID == items[1].AgentID { t.Fatalf("same-line task was not duplicated independently: %#v", items) } } func TestMentionImageRoutingRequiresNumbersAndSharesOnlyTheNamedImage(t *testing.T) { images := []taskImage{ {Name: "one", MediaType: "image/png", DataURL: "data:image/png;base64,aQ=="}, {Name: "two", MediaType: "image/png", DataURL: "data:image/png;base64,ag=="}, } if _, err := imagesForMention("分析构图", images, true); err == nil { t.Fatal("multiple valid image tasks without a number must be rejected") } selected, err := imagesForMention("比较图片 1 的风格", images, true) if err != nil || len(selected) != 1 || selected[0].Name != "one" { t.Fatalf("same-line targets must receive only image 1: %#v / %v", selected, err) } selected, err = imagesForMention("为图片 2 写标题", images, true) if err != nil || len(selected) != 1 || selected[0].Name != "two" { t.Fatalf("separate line must receive only image 2: %#v / %v", selected, err) } if _, err := imagesForMention("分析图片 3", images, true); err == nil { t.Fatal("missing image number must be rejected") } } func TestTaskTextRejectsCredentialsAndInternalReasoning(t *testing.T) { pairingCode := "v1.0123456789abcdef0123456789abcdef.0123456789abcdef0123456789abcdef0123456789abcdef.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" for _, value := range []string{"api_key: secret-value", "密码:secret-value", "sk-abcdefghijklmnopqrstuvwxyz", pairingCode, "private reasoning做海报"} { if err := validateSafeTaskText(value); err == nil { t.Fatalf("sensitive task text must be rejected: %q", value) } } if got := sanitizeDisplayText(pairingCode); strings.Contains(got, "0123456789abcdef") { t.Fatalf("pairing credential leaked after display sanitization: %q", got) } } func TestTaskProgressIsRecordedWithoutChangingStateAndRenewsDeadline(t *testing.T) { t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir()) controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json")) if err != nil { t.Fatal(err) } defer controller.Close() now := time.Now().UTC() if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES ('agent-1', '进展测试机', 'test', 'online', ?)", now.Format(time.RFC3339Nano)); err != nil { t.Fatal(err) } if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, deadline_at, timeout_seconds) VALUES ('tool-task', 'agent-1', '进展测试机', '长任务', 'running', ?, ?, 300)", now.Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano)); err != nil { t.Fatal(err) } controller.applyTaskEvent("agent-1", map[string]any{"task_id": "tool-task", "status": "progress", "message": "调用 computer_time:{} api_key: do-not-show"}) var status, deadline string if err := controller.db.QueryRow("SELECT status, deadline_at FROM task_runs WHERE task_id = 'tool-task'").Scan(&status, &deadline); err != nil || status != "running" { t.Fatalf("progress must not change the task state: %q / %v", status, err) } parsed, err := time.Parse(time.RFC3339Nano, deadline) if err != nil || parsed.Before(time.Now().UTC().Add(4*time.Minute)) { t.Fatalf("progress did not renew the no-progress deadline: %q / %v", deadline, err) } detail, err := controller.TaskDetails("tool-task") if err != nil { t.Fatal(err) } progressEvents := 0 for _, event := range detail.Events { if event.Type == "progress" { progressEvents++ if strings.Contains(event.Message, "do-not-show") || !strings.Contains(event.Message, "[已脱敏]") { t.Fatalf("progress text leaked a credential: %q", event.Message) } } } if progressEvents != 1 { t.Fatalf("expected one recorded progress step, got %d in %#v", progressEvents, detail.Events) } // Progress for a task that already ended must be ignored entirely. controller.applyTaskEvent("agent-1", map[string]any{"task_id": "tool-task", "status": "completed", "message": "完成"}) controller.applyTaskEvent("agent-1", map[string]any{"task_id": "tool-task", "status": "progress", "message": "迟到的进展"}) if detail, err = controller.TaskDetails("tool-task"); err != nil { t.Fatal(err) } for _, event := range detail.Events { if event.Message == "迟到的进展" { t.Fatal("a finished task must not gain new progress steps") } } } func TestAgentProgressRenewsNoProgressDeadline(t *testing.T) { t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir()) controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json")) if err != nil { t.Fatal(err) } defer controller.Close() now := time.Now().UTC() if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES ('agent-1', '进展测试机', 'test', 'online', ?)", now.Format(time.RFC3339Nano)); err != nil { t.Fatal(err) } if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, deadline_at, timeout_seconds) VALUES ('progress-task', 'agent-1', '进展测试机', '长任务', 'queued', ?, ?, 300)", now.Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano)); err != nil { t.Fatal(err) } controller.applyTaskEvent("agent-1", map[string]any{"task_id": "progress-task", "status": "accepted", "message": "已接收"}) var acceptedDeadline string if err := controller.db.QueryRow("SELECT deadline_at FROM task_runs WHERE task_id = 'progress-task'").Scan(&acceptedDeadline); err != nil { t.Fatal(err) } parsedAccepted, err := time.Parse(time.RFC3339Nano, acceptedDeadline) if err != nil || parsedAccepted.Before(time.Now().UTC().Add(4*time.Minute)) { t.Fatalf("accepted progress did not renew the five-minute deadline: %q / %v", acceptedDeadline, err) } controller.applyTaskEvent("agent-1", map[string]any{"task_id": "progress-task", "status": "running", "message": "执行中"}) var runningDeadline, status string if err := controller.db.QueryRow("SELECT deadline_at, status FROM task_runs WHERE task_id = 'progress-task'").Scan(&runningDeadline, &status); err != nil || status != "running" { t.Fatalf("running progress was not recorded: %q / %q / %v", runningDeadline, status, err) } parsedRunning, err := time.Parse(time.RFC3339Nano, runningDeadline) if err != nil || parsedRunning.Before(time.Now().UTC().Add(4*time.Minute)) { t.Fatalf("running progress did not renew the five-minute deadline: %q / %v", runningDeadline, err) } } func TestAgentDirectoryPaginatesFiltersAndProvidesOperationalDetails(t *testing.T) { t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir()) controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json")) if err != nil { t.Fatal(err) } defer controller.Close() now := time.Now().UTC().Format(time.RFC3339Nano) for _, agent := range []struct{ id, name, status string }{ {"agent-online", "Alpha desktop", "online"}, {"agent-offline", "Beta laptop", "offline"}, {"agent-failed", "Gamma workstation", "offline"}, } { if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES (?, ?, 'test', ?, ?)", agent.id, agent.name, agent.status, now); err != nil { t.Fatal(err) } } if _, err := controller.db.Exec("INSERT INTO agent_mcp_assignments(agent_id, tool_id, deployment_version, apply_status, last_error, updated_at) VALUES ('agent-online', 'desktop-filesystem', 1, 'applied', '', ?), ('agent-failed', 'desktop-filesystem', 2, 'failed', 'token: do-not-show', ?)", now, now); err != nil { t.Fatal(err) } if _, err := controller.db.Exec("INSERT INTO agent_llm_assignments(agent_id, profile_id, config_version, apply_status, last_error, updated_at) VALUES ('agent-online', 'profile-1', 1, 'applied', '', ?)", now); err != nil { t.Fatal(err) } if _, err := controller.db.Exec("INSERT INTO llm_profiles(profile_id, name, provider, base_url, model, secret_id, updated_at) VALUES ('profile-1', 'Production', 'openai', 'https://example.test/v1', 'model', 'secret-1', ?)", now); err != nil { t.Fatal(err) } if _, err := controller.db.Exec("INSERT INTO skill_packages(skill_id, name, version, content, checksum, updated_at) VALUES ('skill-1', 'Safe Skill', '1.0', '# skill', 'sum', ?)", now); err != nil { t.Fatal(err) } if _, err := controller.db.Exec("INSERT INTO agent_skill_assignments(agent_id, skill_id, apply_status, last_error, updated_at) VALUES ('agent-failed', 'skill-1', 'failed', 'password=do-not-show', ?)", now); err != nil { t.Fatal(err) } page, err := controller.AgentDirectory(AgentDirectoryFilter{Page: 1, PageSize: 1, ConnectionStatus: "offline"}) if err != nil || page.TotalItems != 2 || page.TotalPages != 2 || len(page.Items) != 1 { t.Fatalf("expected paged offline Agents, got %#v / %v", page, err) } page, err = controller.AgentDirectory(AgentDirectoryFilter{Page: 1, PageSize: 10, Name: "gamma", MCPStatus: "failed", SkillStatus: "failed"}) if err != nil || page.TotalItems != 1 || len(page.Items) != 1 || page.Items[0].ID != "agent-failed" || page.Items[0].MCPFailure != "token=[已脱敏]" { t.Fatalf("expected server-side name/status filtering and redaction, got %#v / %v", page, err) } if _, err := controller.AgentDirectory(AgentDirectoryFilter{ConnectionStatus: "unexpected"}); err == nil { t.Fatal("invalid directory filter must be rejected") } if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, timeout_seconds, output) VALUES ('detail-task', 'agent-failed', 'Gamma workstation', '检查状态', 'failed', ?, 60, '失败')", now); err != nil { t.Fatal(err) } controller.recordAudit("mcp.deployment_ack", "agent:agent-failed", "agent:agent-failed:desktop-filesystem", "failed", "token: do-not-show") detail, err := controller.AgentOperationalDetails("agent-failed") if err != nil || detail.Agent.Name != "Gamma workstation" || len(detail.RecentTasks) != 1 || len(detail.RecentAudit) != 1 || len(detail.DeploymentHistory) != 1 || len(detail.Failures) != 2 { t.Fatalf("expected bounded Agent operational detail, got %#v / %v", detail, err) } if detail.RecentAudit[0].Detail != "token=[已脱敏]" || detail.Failures[0].Reason == "token: do-not-show" { t.Fatalf("Agent detail leaked sensitive operational text: %#v", detail) } } func TestAuditTrailUsesUnifiedFieldsFiltersAndSafeDetails(t *testing.T) { t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir()) controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json")) if err != nil { t.Fatal(err) } defer controller.Close() controller.recordAudit("pairing.code_created", "controller", "pairing-token", "created", "配对码不应写入详情。") controller.recordAudit("mcp.deployment_ack", "agent:agent-1", "agent:agent-1:desktop-filesystem", "failed", "api_key: secret-value") controller.recordAudit("task.status_changed", "agent:agent-2", "agent:agent-2", "completed", "Agent 已上报任务状态变更。") page, err := controller.AuditTrail(AuditFilter{Page: 1, PageSize: 1, AgentID: "agent-1", ResourceType: "mcp", Result: "failed"}) if err != nil || page.TotalItems != 1 || page.TotalPages != 1 || len(page.Items) != 1 { t.Fatalf("expected one filtered unified audit event, got %#v / %v", page, err) } event := page.Items[0] if event.Operator != "agent:agent-1" || event.TargetAgentID != "agent-1" || event.ResourceType != "mcp" || event.Result != "failed" { t.Fatalf("unexpected unified audit fields: %#v", event) } if event.SecurityDetail != "api_key=[已脱敏]" || strings.Contains(event.SecurityDetail, "secret-value") { t.Fatalf("audit detail leaked a secret: %#v", event) } if _, err := controller.AuditTrail(AuditFilter{ResourceType: "invalid"}); err == nil { t.Fatal("invalid audit resource filter must be rejected") } if _, err := controller.AuditTrail(AuditFilter{FromAt: "not-a-time"}); err == nil { t.Fatal("invalid audit time filter must be rejected") } }