You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
662 lines
31 KiB
662 lines
31 KiB
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": "<think>内部推理不能显示</think>已在桌面创建文件夹: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 == "<think>内部推理不能显示</think>已在桌面创建文件夹: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, "<think>private reasoning</think>做海报"} {
|
|
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")
|
|
}
|
|
}
|
|
|