From 617f7cc0f1ac984d50d3a7275f480bc7cc57b2db Mon Sep 17 00:00:00 2001 From: lpf Date: Sun, 15 Mar 2026 14:23:01 +0800 Subject: [PATCH] refactor rpc skills and clean api tests --- pkg/agent/runtime_admin_config_test.go | 347 ++++++++ pkg/agent/runtime_admin_test.go | 373 +-------- pkg/api/rpc_http.go | 29 + pkg/api/rpc_services.go | 291 +++++++ pkg/api/server.go | 3 + pkg/api/server_config_test.go | 378 +++++++++ pkg/api/server_node_artifacts_test.go | 254 ++++++ pkg/api/server_rpc_test.go | 261 ++++++ pkg/api/server_skills.go | 325 ++------ pkg/api/server_test.go | 1013 ------------------------ pkg/api/server_whatsapp_test.go | 232 ++++++ pkg/rpc/skills.go | 64 ++ 12 files changed, 1909 insertions(+), 1661 deletions(-) create mode 100644 pkg/agent/runtime_admin_config_test.go create mode 100644 pkg/api/server_config_test.go create mode 100644 pkg/api/server_node_artifacts_test.go create mode 100644 pkg/api/server_rpc_test.go create mode 100644 pkg/api/server_whatsapp_test.go create mode 100644 pkg/rpc/skills.go diff --git a/pkg/agent/runtime_admin_config_test.go b/pkg/agent/runtime_admin_config_test.go new file mode 100644 index 0000000..256b966 --- /dev/null +++ b/pkg/agent/runtime_admin_config_test.go @@ -0,0 +1,347 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/YspCoder/clawgo/pkg/config" + "github.com/YspCoder/clawgo/pkg/runtimecfg" + "github.com/YspCoder/clawgo/pkg/tools" +) + +func TestHandleSubagentRuntimeUpsertConfigSubagent(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + SystemPromptFile: "agents/main/AGENT.md", + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("save config failed: %v", err) + } + runtimecfg.Set(cfg) + t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) + + manager := tools.NewSubagentManager(nil, workspace, nil) + loop := &AgentLoop{ + configPath: configPath, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + out, err := loop.HandleSubagentRuntime(context.Background(), "upsert_config_subagent", map[string]interface{}{ + "agent_id": "reviewer", + "role": "testing", + "notify_main_policy": "internal_only", + "display_name": "Review Agent", + "system_prompt_file": "agents/reviewer/AGENT.md", + "routing_keywords": []interface{}{"review", "regression"}, + "tool_allowlist": []interface{}{"shell", "sessions"}, + }) + if err != nil { + t.Fatalf("upsert config subagent failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok || payload["ok"] != true { + t.Fatalf("unexpected payload: %#v", out) + } + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + subcfg, ok := reloaded.Agents.Subagents["reviewer"] + if !ok || subcfg.DisplayName != "Review Agent" { + t.Fatalf("expected reviewer subagent in config, got %+v", reloaded.Agents.Subagents) + } + if subcfg.SystemPromptFile != "agents/reviewer/AGENT.md" { + t.Fatalf("expected system_prompt_file to persist, got %+v", subcfg) + } + if subcfg.NotifyMainPolicy != "internal_only" { + t.Fatalf("expected notify_main_policy to persist, got %+v", subcfg) + } + if len(reloaded.Agents.Router.Rules) == 0 { + t.Fatalf("expected router rules to be persisted") + } + data, err := os.ReadFile(configPath) + if err != nil || len(data) == 0 { + t.Fatalf("expected config file to be written") + } +} + +func TestHandleSubagentRuntimeRegistryAndToggleEnabled(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + SystemPromptFile: "agents/main/AGENT.md", + } + cfg.Agents.Subagents["tester"] = config.SubagentConfig{ + Enabled: true, + Type: "worker", + Role: "testing", + DisplayName: "Test Agent", + SystemPromptFile: "agents/tester/AGENT.md", + MemoryNamespace: "tester", + Tools: config.SubagentToolsConfig{ + Allowlist: []string{"shell", "sessions"}, + }, + } + cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "tester", Keywords: []string{"test", "regression"}}} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("save config failed: %v", err) + } + runtimecfg.Set(cfg) + t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) + + manager := tools.NewSubagentManager(nil, workspace, nil) + loop := &AgentLoop{ + configPath: configPath, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + out, err := loop.HandleSubagentRuntime(context.Background(), "registry", nil) + if err != nil { + t.Fatalf("registry failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("unexpected registry payload: %T", out) + } + items, ok := payload["items"].([]map[string]interface{}) + if !ok || len(items) < 2 { + t.Fatalf("expected registry items, got %#v", payload["items"]) + } + var tester map[string]interface{} + for _, item := range items { + if item["agent_id"] == "tester" { + tester = item + break + } + } + if tester == nil { + t.Fatalf("expected tester registry item, got %#v", items) + } + toolVisibility, _ := tester["tool_visibility"].(map[string]interface{}) + if toolVisibility == nil { + t.Fatalf("expected tool_visibility in tester registry item, got %#v", tester) + } + if toolVisibility["mode"] != "allowlist" { + t.Fatalf("expected tester tool mode allowlist, got %#v", toolVisibility) + } + inherited, _ := tester["inherited_tools"].([]string) + if len(inherited) != 1 || inherited[0] != "skill_exec" { + t.Fatalf("expected inherited skill_exec, got %#v", tester["inherited_tools"]) + } + + _, err = loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ + "agent_id": "tester", + "enabled": false, + }) + if err != nil { + t.Fatalf("toggle enabled failed: %v", err) + } + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + if reloaded.Agents.Subagents["tester"].Enabled { + t.Fatalf("expected tester to be disabled") + } +} + +func TestHandleSubagentRuntimeDeleteConfigSubagent(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + SystemPromptFile: "agents/main/AGENT.md", + } + cfg.Agents.Subagents["tester"] = config.SubagentConfig{ + Enabled: true, + Type: "worker", + Role: "testing", + SystemPromptFile: "agents/tester/AGENT.md", + } + cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "tester", Keywords: []string{"test"}}} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("save config failed: %v", err) + } + runtimecfg.Set(cfg) + t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) + + manager := tools.NewSubagentManager(nil, workspace, nil) + loop := &AgentLoop{ + configPath: configPath, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + out, err := loop.HandleSubagentRuntime(context.Background(), "delete_config_subagent", map[string]interface{}{"agent_id": "tester"}) + if err != nil { + t.Fatalf("delete config subagent failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok || payload["ok"] != true { + t.Fatalf("unexpected delete payload: %#v", out) + } + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + if _, ok := reloaded.Agents.Subagents["tester"]; ok { + t.Fatalf("expected tester to be removed") + } + if len(reloaded.Agents.Router.Rules) != 0 { + t.Fatalf("expected tester route rule to be removed") + } +} + +func TestHandleSubagentRuntimeToggleEnabledParsesStringBool(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + SystemPromptFile: "agents/main/AGENT.md", + } + cfg.Agents.Subagents["tester"] = config.SubagentConfig{ + Enabled: true, + Type: "worker", + Role: "testing", + SystemPromptFile: "agents/tester/AGENT.md", + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("save config failed: %v", err) + } + runtimecfg.Set(cfg) + t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) + + manager := tools.NewSubagentManager(nil, workspace, nil) + loop := &AgentLoop{ + configPath: configPath, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + if _, err := loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ + "agent_id": "tester", + "enabled": "false", + }); err != nil { + t.Fatalf("toggle enabled failed: %v", err) + } + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + if reloaded.Agents.Subagents["tester"].Enabled { + t.Fatalf("expected tester to be disabled") + } +} + +func TestHandleSubagentRuntimePromptFileGetSetBootstrap(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewSubagentManager(nil, workspace, nil) + loop := &AgentLoop{ + workspace: workspace, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + + out, err := loop.HandleSubagentRuntime(context.Background(), "prompt_file_get", map[string]interface{}{ + "path": "agents/coder/AGENT.md", + }) + if err != nil { + t.Fatalf("prompt_file_get failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok || payload["found"] != false { + t.Fatalf("expected missing prompt file, got %#v", out) + } + + out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_bootstrap", map[string]interface{}{ + "agent_id": "coder", + "role": "coding", + }) + if err != nil { + t.Fatalf("prompt_file_bootstrap failed: %v", err) + } + payload, ok = out.(map[string]interface{}) + if !ok || payload["created"] != true { + t.Fatalf("expected prompt file bootstrap to create file, got %#v", out) + } + + out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_set", map[string]interface{}{ + "path": "agents/coder/AGENT.md", + "content": "# coder\nupdated", + }) + if err != nil { + t.Fatalf("prompt_file_set failed: %v", err) + } + payload, ok = out.(map[string]interface{}) + if !ok || payload["ok"] != true { + t.Fatalf("expected prompt_file_set ok, got %#v", out) + } + + out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_get", map[string]interface{}{ + "path": "agents/coder/AGENT.md", + }) + if err != nil { + t.Fatalf("prompt_file_get after set failed: %v", err) + } + payload, ok = out.(map[string]interface{}) + if !ok || payload["found"] != true || payload["content"] != "# coder\nupdated" { + t.Fatalf("unexpected prompt file payload: %#v", out) + } +} + +func TestHandleSubagentRuntimeProtectsMainAgent(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Router.MainAgentID = "main" + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + SystemPromptFile: "agents/main/AGENT.md", + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("save config failed: %v", err) + } + runtimecfg.Set(cfg) + t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) + + manager := tools.NewSubagentManager(nil, workspace, nil) + loop := &AgentLoop{ + configPath: configPath, + workspace: workspace, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + if _, err := loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ + "agent_id": "main", + "enabled": false, + }); err == nil { + t.Fatalf("expected disabling main agent to fail") + } + if _, err := loop.HandleSubagentRuntime(context.Background(), "delete_config_subagent", map[string]interface{}{ + "agent_id": "main", + }); err == nil { + t.Fatalf("expected deleting main agent to fail") + } +} diff --git a/pkg/agent/runtime_admin_test.go b/pkg/agent/runtime_admin_test.go index 47c3299..dd159cf 100644 --- a/pkg/agent/runtime_admin_test.go +++ b/pkg/agent/runtime_admin_test.go @@ -2,13 +2,9 @@ package agent import ( "context" - "os" - "path/filepath" "testing" "time" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/runtimecfg" "github.com/YspCoder/clawgo/pkg/tools" ) @@ -51,341 +47,6 @@ func TestHandleSubagentRuntimeDispatchAndWait(t *testing.T) { time.Sleep(20 * time.Millisecond) } -func TestHandleSubagentRuntimeUpsertConfigSubagent(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - out, err := loop.HandleSubagentRuntime(context.Background(), "upsert_config_subagent", map[string]interface{}{ - "agent_id": "reviewer", - "role": "testing", - "notify_main_policy": "internal_only", - "display_name": "Review Agent", - "system_prompt_file": "agents/reviewer/AGENT.md", - "routing_keywords": []interface{}{"review", "regression"}, - "tool_allowlist": []interface{}{"shell", "sessions"}, - }) - if err != nil { - t.Fatalf("upsert config subagent failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok || payload["ok"] != true { - t.Fatalf("unexpected payload: %#v", out) - } - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - subcfg, ok := reloaded.Agents.Subagents["reviewer"] - if !ok || subcfg.DisplayName != "Review Agent" { - t.Fatalf("expected reviewer subagent in config, got %+v", reloaded.Agents.Subagents) - } - if subcfg.SystemPromptFile != "agents/reviewer/AGENT.md" { - t.Fatalf("expected system_prompt_file to persist, got %+v", subcfg) - } - if subcfg.NotifyMainPolicy != "internal_only" { - t.Fatalf("expected notify_main_policy to persist, got %+v", subcfg) - } - if len(reloaded.Agents.Router.Rules) == 0 { - t.Fatalf("expected router rules to be persisted") - } - data, err := os.ReadFile(configPath) - if err != nil || len(data) == 0 { - t.Fatalf("expected config file to be written") - } -} - -func TestHandleSubagentRuntimeRegistryAndToggleEnabled(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - cfg.Agents.Subagents["tester"] = config.SubagentConfig{ - Enabled: true, - Type: "worker", - Role: "testing", - DisplayName: "Test Agent", - SystemPromptFile: "agents/tester/AGENT.md", - MemoryNamespace: "tester", - Tools: config.SubagentToolsConfig{ - Allowlist: []string{"shell", "sessions"}, - }, - } - cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "tester", Keywords: []string{"test", "regression"}}} - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - out, err := loop.HandleSubagentRuntime(context.Background(), "registry", nil) - if err != nil { - t.Fatalf("registry failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok { - t.Fatalf("unexpected registry payload: %T", out) - } - items, ok := payload["items"].([]map[string]interface{}) - if !ok || len(items) < 2 { - t.Fatalf("expected registry items, got %#v", payload["items"]) - } - var tester map[string]interface{} - for _, item := range items { - if item["agent_id"] == "tester" { - tester = item - break - } - } - if tester == nil { - t.Fatalf("expected tester registry item, got %#v", items) - } - toolVisibility, _ := tester["tool_visibility"].(map[string]interface{}) - if toolVisibility == nil { - t.Fatalf("expected tool_visibility in tester registry item, got %#v", tester) - } - if toolVisibility["mode"] != "allowlist" { - t.Fatalf("expected tester tool mode allowlist, got %#v", toolVisibility) - } - inherited, _ := tester["inherited_tools"].([]string) - if len(inherited) != 1 || inherited[0] != "skill_exec" { - t.Fatalf("expected inherited skill_exec, got %#v", tester["inherited_tools"]) - } - - _, err = loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ - "agent_id": "tester", - "enabled": false, - }) - if err != nil { - t.Fatalf("toggle enabled failed: %v", err) - } - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - if reloaded.Agents.Subagents["tester"].Enabled { - t.Fatalf("expected tester to be disabled") - } -} - -func TestHandleSubagentRuntimeDeleteConfigSubagent(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - cfg.Agents.Subagents["tester"] = config.SubagentConfig{ - Enabled: true, - Type: "worker", - Role: "testing", - SystemPromptFile: "agents/tester/AGENT.md", - } - cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "tester", Keywords: []string{"test"}}} - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - out, err := loop.HandleSubagentRuntime(context.Background(), "delete_config_subagent", map[string]interface{}{"agent_id": "tester"}) - if err != nil { - t.Fatalf("delete config subagent failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok || payload["ok"] != true { - t.Fatalf("unexpected delete payload: %#v", out) - } - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - if _, ok := reloaded.Agents.Subagents["tester"]; ok { - t.Fatalf("expected tester to be removed") - } - if len(reloaded.Agents.Router.Rules) != 0 { - t.Fatalf("expected tester route rule to be removed") - } -} - -func TestHandleSubagentRuntimeToggleEnabledParsesStringBool(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - cfg.Agents.Subagents["tester"] = config.SubagentConfig{ - Enabled: true, - Type: "worker", - Role: "testing", - SystemPromptFile: "agents/tester/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - if _, err := loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ - "agent_id": "tester", - "enabled": "false", - }); err != nil { - t.Fatalf("toggle enabled failed: %v", err) - } - reloaded, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("reload config failed: %v", err) - } - if reloaded.Agents.Subagents["tester"].Enabled { - t.Fatalf("expected tester to be disabled") - } -} - -func TestHandleSubagentRuntimePromptFileGetSetBootstrap(t *testing.T) { - workspace := t.TempDir() - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - workspace: workspace, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - - out, err := loop.HandleSubagentRuntime(context.Background(), "prompt_file_get", map[string]interface{}{ - "path": "agents/coder/AGENT.md", - }) - if err != nil { - t.Fatalf("prompt_file_get failed: %v", err) - } - payload, ok := out.(map[string]interface{}) - if !ok || payload["found"] != false { - t.Fatalf("expected missing prompt file, got %#v", out) - } - - out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_bootstrap", map[string]interface{}{ - "agent_id": "coder", - "role": "coding", - }) - if err != nil { - t.Fatalf("prompt_file_bootstrap failed: %v", err) - } - payload, ok = out.(map[string]interface{}) - if !ok || payload["created"] != true { - t.Fatalf("expected prompt file bootstrap to create file, got %#v", out) - } - - out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_set", map[string]interface{}{ - "path": "agents/coder/AGENT.md", - "content": "# coder\nupdated", - }) - if err != nil { - t.Fatalf("prompt_file_set failed: %v", err) - } - payload, ok = out.(map[string]interface{}) - if !ok || payload["ok"] != true { - t.Fatalf("expected prompt_file_set ok, got %#v", out) - } - - out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_get", map[string]interface{}{ - "path": "agents/coder/AGENT.md", - }) - if err != nil { - t.Fatalf("prompt_file_get after set failed: %v", err) - } - payload, ok = out.(map[string]interface{}) - if !ok || payload["found"] != true || payload["content"] != "# coder\nupdated" { - t.Fatalf("unexpected prompt file payload: %#v", out) - } -} - -func TestHandleSubagentRuntimeProtectsMainAgent(t *testing.T) { - workspace := t.TempDir() - configPath := filepath.Join(workspace, "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.MainAgentID = "main" - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - SystemPromptFile: "agents/main/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil) - loop := &AgentLoop{ - configPath: configPath, - workspace: workspace, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - if _, err := loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ - "agent_id": "main", - "enabled": false, - }); err == nil { - t.Fatalf("expected disabling main agent to fail") - } - if _, err := loop.HandleSubagentRuntime(context.Background(), "delete_config_subagent", map[string]interface{}{ - "agent_id": "main", - }); err == nil { - t.Fatalf("expected deleting main agent to fail") - } -} - func TestHandleSubagentRuntimeStream(t *testing.T) { workspace := t.TempDir() manager := tools.NewSubagentManager(nil, workspace, nil) @@ -407,11 +68,9 @@ func TestHandleSubagentRuntimeStream(t *testing.T) { if err != nil { t.Fatalf("spawn failed: %v", err) } - payload, ok := out.(map[string]interface{}) - if !ok { + if _, ok := out.(map[string]interface{}); !ok { t.Fatalf("unexpected spawn payload: %T", out) } - _ = payload var task *tools.SubagentTask for i := 0; i < 50; i++ { tasks := manager.ListTasks() @@ -453,33 +112,3 @@ func TestHandleSubagentRuntimeStream(t *testing.T) { t.Fatalf("expected merged event and message items, got %#v", items) } } - -func TestHandleSubagentRuntimeStreamAll(t *testing.T) { - workspace := t.TempDir() - manager := tools.NewSubagentManager(nil, workspace, nil) - manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { - return "stream-all-result", nil - }) - loop := &AgentLoop{ - workspace: workspace, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - } - - if _, err := loop.HandleSubagentRuntime(context.Background(), "spawn", map[string]interface{}{ - "task": "prepare grouped stream task", - "agent_id": "coder", - "channel": "webui", - "chat_id": "webui", - }); err != nil { - t.Fatalf("spawn failed: %v", err) - } - for i := 0; i < 50; i++ { - tasks := manager.ListTasks() - if len(tasks) > 0 && tasks[0].Status == "completed" { - break - } - time.Sleep(10 * time.Millisecond) - } - -} diff --git a/pkg/api/rpc_http.go b/pkg/api/rpc_http.go index e4649a5..e796ee5 100644 --- a/pkg/api/rpc_http.go +++ b/pkg/api/rpc_http.go @@ -34,6 +34,10 @@ func (s *Server) handleCronRPC(w http.ResponseWriter, r *http.Request) { s.handleRPC(w, r, s.cronRPCRegistry()) } +func (s *Server) handleSkillsRPC(w http.ResponseWriter, r *http.Request) { + s.handleRPC(w, r, s.skillsRPCRegistry()) +} + func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request, registry *rpcpkg.Registry) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -258,6 +262,31 @@ func (s *Server) cronRPCRegistry() *rpcpkg.Registry { return s.cronRPCReg } +func (s *Server) buildSkillsRegistry() *rpcpkg.Registry { + svc := s.skillsRPCService() + reg := rpcpkg.NewRegistry() + rpcpkg.RegisterJSON(reg, "skills.view", func(ctx context.Context, req rpcpkg.SkillsViewRequest) (interface{}, *rpcpkg.Error) { + return svc.View(ctx, req) + }) + rpcpkg.RegisterJSON(reg, "skills.mutate", func(ctx context.Context, req rpcpkg.SkillsMutateRequest) (interface{}, *rpcpkg.Error) { + return svc.Mutate(ctx, req) + }) + return reg +} + +func (s *Server) skillsRPCRegistry() *rpcpkg.Registry { + if s == nil { + return rpcpkg.NewRegistry() + } + s.skillsRPCOnce.Do(func() { + s.skillsRPCReg = s.buildSkillsRegistry() + }) + if s.skillsRPCReg == nil { + return rpcpkg.NewRegistry() + } + return s.skillsRPCReg +} + func writeRPCError(w http.ResponseWriter, status int, requestID string, rpcErr *rpcpkg.Error) { if rpcErr == nil { rpcErr = rpcError("internal", "rpc error", nil, false) diff --git a/pkg/api/rpc_services.go b/pkg/api/rpc_services.go index f5240bb..3b8a7e4 100644 --- a/pkg/api/rpc_services.go +++ b/pkg/api/rpc_services.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -619,6 +620,10 @@ func (s *Server) cronRPCService() rpcpkg.CronService { return &cronRPCAdapter{server: s} } +func (s *Server) skillsRPCService() rpcpkg.SkillsService { + return &skillsRPCAdapter{server: s} +} + type configRPCAdapter struct { server *Server } @@ -627,6 +632,10 @@ type cronRPCAdapter struct { server *Server } +type skillsRPCAdapter struct { + server *Server +} + func (a *configRPCAdapter) View(_ context.Context, req rpcpkg.ConfigViewRequest) (*rpcpkg.ConfigViewResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) @@ -816,6 +825,288 @@ func (a *cronRPCAdapter) Mutate(ctx context.Context, req rpcpkg.MutateCronJobReq return &rpcpkg.MutateCronJobResponse{Result: normalizeCronJob(res)}, nil } +func (a *skillsRPCAdapter) skillsDir() (string, *rpcpkg.Error) { + if a == nil || a.server == nil { + return "", rpcError("unavailable", "server unavailable", nil, false) + } + skillsDir := filepath.Join(a.server.workspacePath, "skills") + if strings.TrimSpace(skillsDir) == "" { + return "", rpcError("unavailable", "workspace not configured", nil, false) + } + if err := os.MkdirAll(skillsDir, 0755); err != nil { + return "", rpcErrorFrom(err) + } + return skillsDir, nil +} + +func (a *skillsRPCAdapter) resolveSkillPath(skillsDir, name string) (string, *rpcpkg.Error) { + name = strings.TrimSpace(name) + if name == "" { + return "", rpcError("invalid_argument", "name required", nil, false) + } + cands := []string{ + filepath.Join(skillsDir, name), + filepath.Join(skillsDir, name+".disabled"), + filepath.Join("/root/clawgo/workspace/skills", name), + filepath.Join("/root/clawgo/workspace/skills", name+".disabled"), + } + for _, p := range cands { + if st, err := os.Stat(p); err == nil && st.IsDir() { + return p, nil + } + } + return "", rpcError("not_found", "skill not found: "+name, nil, false) +} + +func (a *skillsRPCAdapter) View(ctx context.Context, req rpcpkg.SkillsViewRequest) (*rpcpkg.SkillsViewResponse, *rpcpkg.Error) { + skillsDir, rpcErr := a.skillsDir() + if rpcErr != nil { + return nil, rpcErr + } + clawhubPath := strings.TrimSpace(resolveClawHubBinary(ctx)) + clawhubInstalled := clawhubPath != "" + if id := strings.TrimSpace(req.ID); id != "" { + skillPath, rpcErr := a.resolveSkillPath(skillsDir, id) + if rpcErr != nil { + return nil, rpcErr + } + if req.Files { + var files []string + _ = filepath.WalkDir(skillPath, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, _ := filepath.Rel(skillPath, path) + if strings.HasPrefix(rel, "..") { + return nil + } + files = append(files, filepath.ToSlash(rel)) + return nil + }) + return &rpcpkg.SkillsViewResponse{ID: id, FilesList: files}, nil + } + if f := strings.TrimSpace(req.File); f != "" { + clean, content, found, err := readRelativeTextFile(skillPath, f) + if err != nil { + return nil, rpcError("invalid_argument", err.Error(), nil, false) + } + if !found { + return nil, rpcError("not_found", os.ErrNotExist.Error(), nil, false) + } + return &rpcpkg.SkillsViewResponse{ID: id, File: filepath.ToSlash(clean), Content: content}, nil + } + } + type skillItem struct { + ID string + Name string + Description string + Tools []string + SystemPrompt string + Enabled bool + UpdateChecked bool + RemoteFound bool + RemoteVersion string + CheckError string + Source string + } + candDirs := []string{skillsDir, filepath.Join("/root/clawgo/workspace", "skills")} + seenDirs := map[string]struct{}{} + seenSkills := map[string]struct{}{} + items := make([]rpcpkg.SkillsViewItem, 0) + for _, dir := range candDirs { + dir = strings.TrimSpace(dir) + if dir == "" { + continue + } + if _, ok := seenDirs[dir]; ok { + continue + } + seenDirs[dir] = struct{}{} + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, rpcErrorFrom(err) + } + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + enabled := !strings.HasSuffix(name, ".disabled") + baseName := strings.TrimSuffix(name, ".disabled") + if _, ok := seenSkills[baseName]; ok { + continue + } + seenSkills[baseName] = struct{}{} + desc, skillTools, sys := readSkillMeta(filepath.Join(dir, name, "SKILL.md")) + if desc == "" || len(skillTools) == 0 || sys == "" { + d2, t2, s2 := readSkillMeta(filepath.Join(dir, baseName, "SKILL.md")) + if desc == "" { + desc = d2 + } + if len(skillTools) == 0 { + skillTools = t2 + } + if sys == "" { + sys = s2 + } + } + if skillTools == nil { + skillTools = []string{} + } + it := rpcpkg.SkillsViewItem{ + ID: baseName, + Name: baseName, + Description: desc, + Tools: skillTools, + SystemPrompt: sys, + Enabled: enabled, + UpdateChecked: req.CheckUpdates && clawhubInstalled, + Source: dir, + } + if req.CheckUpdates && clawhubInstalled { + found, version, checkErr := queryClawHubSkillVersion(ctx, baseName) + it.RemoteFound = found + it.RemoteVersion = version + if checkErr != nil { + it.CheckError = checkErr.Error() + } + } + items = append(items, it) + } + } + return &rpcpkg.SkillsViewResponse{ + Skills: items, + Source: "clawhub", + ClawhubInstalled: clawhubInstalled, + ClawhubPath: clawhubPath, + }, nil +} + +func createOrUpdateSkillAtPath(enabledPath, name, desc, sys string, toolsList []string, checkExists bool) error { + if checkExists { + if _, err := os.Stat(enabledPath); err == nil { + return fmt.Errorf("skill already exists") + } + } + if err := os.MkdirAll(filepath.Join(enabledPath, "scripts"), 0755); err != nil { + return err + } + skillMD := buildSkillMarkdown(name, desc, toolsList, sys) + return os.WriteFile(filepath.Join(enabledPath, "SKILL.md"), []byte(skillMD), 0644) +} + +func (a *skillsRPCAdapter) Mutate(ctx context.Context, req rpcpkg.SkillsMutateRequest) (*rpcpkg.SkillsMutateResponse, *rpcpkg.Error) { + skillsDir, rpcErr := a.skillsDir() + if rpcErr != nil { + return nil, rpcErr + } + action := strings.ToLower(strings.TrimSpace(req.Action)) + if action == "" { + return nil, rpcError("invalid_argument", "action required", nil, false) + } + name := strings.TrimSpace(firstNonEmptyString(req.Name, req.ID)) + enabledPath := filepath.Join(skillsDir, name) + disabledPath := enabledPath + ".disabled" + switch action { + case "install_clawhub": + output, err := ensureClawHubReady(ctx) + if err != nil { + return nil, rpcErrorFrom(err) + } + return &rpcpkg.SkillsMutateResponse{InstalledOK: true, Output: output, ClawhubPath: resolveClawHubBinary(ctx)}, nil + case "install": + if name == "" { + return nil, rpcError("invalid_argument", "name required", nil, false) + } + clawhubPath := strings.TrimSpace(resolveClawHubBinary(ctx)) + if clawhubPath == "" { + return nil, rpcError("invalid_argument", "clawhub is not installed. please install clawhub first.", nil, false) + } + args := []string{"install", name} + if req.IgnoreSuspicious { + args = append(args, "--force") + } + cmd := exec.CommandContext(ctx, clawhubPath, args...) + cmd.Dir = strings.TrimSpace(a.server.workspacePath) + out, err := cmd.CombinedOutput() + if err != nil { + outText := string(out) + lower := strings.ToLower(outText) + if strings.Contains(lower, "rate limit exceeded") || strings.Contains(lower, "too many requests") { + return nil, rpcError("unavailable", fmt.Sprintf("clawhub rate limit exceeded. please retry later or configure auth token.\n%s", outText), nil, true) + } + return nil, rpcError("internal", fmt.Sprintf("install failed: %v\n%s", err, outText), nil, false) + } + return &rpcpkg.SkillsMutateResponse{Installed: name, Output: string(out)}, nil + case "enable": + if name == "" { + return nil, rpcError("invalid_argument", "name required", nil, false) + } + if _, err := os.Stat(disabledPath); err == nil { + if err := os.Rename(disabledPath, enabledPath); err != nil { + return nil, rpcErrorFrom(err) + } + } + return &rpcpkg.SkillsMutateResponse{Name: name}, nil + case "disable": + if name == "" { + return nil, rpcError("invalid_argument", "name required", nil, false) + } + if _, err := os.Stat(enabledPath); err == nil { + if err := os.Rename(enabledPath, disabledPath); err != nil { + return nil, rpcErrorFrom(err) + } + } + return &rpcpkg.SkillsMutateResponse{Name: name}, nil + case "write_file": + if name == "" { + return nil, rpcError("invalid_argument", "name required", nil, false) + } + skillPath, rpcErr := a.resolveSkillPath(skillsDir, name) + if rpcErr != nil { + return nil, rpcErr + } + clean, err := writeRelativeTextFile(skillPath, req.File, req.Content, true) + if err != nil { + return nil, rpcError("invalid_argument", err.Error(), nil, false) + } + return &rpcpkg.SkillsMutateResponse{Name: name, File: filepath.ToSlash(clean)}, nil + case "create": + if name == "" { + return nil, rpcError("invalid_argument", "name required", nil, false) + } + if err := createOrUpdateSkillAtPath(enabledPath, name, req.Description, req.SystemPrompt, req.Tools, true); err != nil { + return nil, rpcError("invalid_argument", err.Error(), nil, false) + } + return &rpcpkg.SkillsMutateResponse{Name: name}, nil + case "update": + if name == "" { + return nil, rpcError("invalid_argument", "name required", nil, false) + } + if err := createOrUpdateSkillAtPath(enabledPath, name, req.Description, req.SystemPrompt, req.Tools, false); err != nil { + return nil, rpcErrorFrom(err) + } + return &rpcpkg.SkillsMutateResponse{Name: name}, nil + case "delete": + if name == "" { + return nil, rpcError("invalid_argument", "id required", nil, false) + } + deleted := false + if err := os.RemoveAll(enabledPath); err == nil { + deleted = true + } + if err := os.RemoveAll(disabledPath); err == nil { + deleted = true + } + return &rpcpkg.SkillsMutateResponse{Deleted: deleted, ID: name}, nil + default: + return nil, rpcError("invalid_argument", "unsupported action", nil, false) + } +} + func rpcError(code, message string, details interface{}, retryable bool) *rpcpkg.Error { return &rpcpkg.Error{ Code: strings.TrimSpace(code), diff --git a/pkg/api/server.go b/pkg/api/server.go index 3852883..fbe7413 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -69,6 +69,8 @@ type Server struct { configRPCReg *rpcpkg.Registry cronRPCOnce sync.Once cronRPCReg *rpcpkg.Registry + skillsRPCOnce sync.Once + skillsRPCReg *rpcpkg.Registry } var nodesWebsocketUpgrader = websocket.Upgrader{ @@ -247,6 +249,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/api/rpc/workspace", s.handleWorkspaceRPC) mux.HandleFunc("/api/rpc/config", s.handleConfigRPC) mux.HandleFunc("/api/rpc/cron", s.handleCronRPC) + mux.HandleFunc("/api/rpc/skills", s.handleSkillsRPC) mux.HandleFunc("/api/subagents_runtime", s.handleWebUISubagentsRuntime) mux.HandleFunc("/api/tool_allowlist_groups", s.handleWebUIToolAllowlistGroups) mux.HandleFunc("/api/tools", s.handleWebUITools) diff --git a/pkg/api/server_config_test.go b/pkg/api/server_config_test.go new file mode 100644 index 0000000..148f4e2 --- /dev/null +++ b/pkg/api/server_config_test.go @@ -0,0 +1,378 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + cfgpkg "github.com/YspCoder/clawgo/pkg/config" +) + +func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + pc := cfg.Models.Providers["openai"] + pc.APIBase = "https://old.example/v1" + pc.APIKey = "test-key" + cfg.Models.Providers["openai"] = pc + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + bodyCfg := cfgpkg.DefaultConfig() + bodyCfg.Logging.Enabled = false + bodyPC := bodyCfg.Models.Providers["openai"] + bodyPC.APIBase = "https://new.example/v1" + bodyPC.APIKey = "test-key" + bodyCfg.Models.Providers["openai"] = bodyPC + body, err := json.Marshal(bodyCfg) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nil) + srv.SetConfigPath(cfgPath) + srv.SetConfigAfterHook(func() error { return nil }) + + req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"requires_confirm":true`) { + t.Fatalf("expected requires_confirm response, got: %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `models.providers.openai.api_base`) { + t.Fatalf("expected models.providers.openai.api_base in changed_fields, got: %s", rec.Body.String()) + } +} + +func TestHandleWebUIConfigAcceptsStringConfirmRisky(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + pc := cfg.Models.Providers["openai"] + pc.APIBase = "https://old.example/v1" + pc.APIKey = "test-key" + cfg.Models.Providers["openai"] = pc + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + bodyCfg := cfgpkg.DefaultConfig() + bodyCfg.Logging.Enabled = false + bodyPC := bodyCfg.Models.Providers["openai"] + bodyPC.APIBase = "https://new.example/v1" + bodyPC.APIKey = "test-key" + bodyCfg.Models.Providers["openai"] = bodyPC + bodyMap := map[string]interface{}{} + raw, err := json.Marshal(bodyCfg) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + if err := json.Unmarshal(raw, &bodyMap); err != nil { + t.Fatalf("unmarshal body map: %v", err) + } + bodyMap["confirm_risky"] = "true" + body, err := json.Marshal(bodyMap) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nil) + srv.SetConfigPath(cfgPath) + srv.SetConfigAfterHook(func() error { return nil }) + + req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestNormalizeCronJobParsesStringScheduleValues(t *testing.T) { + t.Parallel() + + job := normalizeCronJob(map[string]interface{}{ + "schedule": map[string]interface{}{ + "kind": "every", + "everyMs": "60000", + }, + "payload": map[string]interface{}{ + "message": "hello", + }, + }) + if got, _ := job["expr"].(string); got == "" || !strings.Contains(got, "@every") { + t.Fatalf("expected normalized @every expr, got %#v", job["expr"]) + } +} + +func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + cfg.Models.Providers["backup"] = cfgpkg.ProviderConfig{ + APIBase: "https://backup.example/v1", + APIKey: "old-secret", + Models: []string{"backup-model"}, + Auth: "bearer", + TimeoutSec: 30, + } + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + bodyCfg := cfgpkg.DefaultConfig() + bodyCfg.Logging.Enabled = false + bodyCfg.Models.Providers["backup"] = cfgpkg.ProviderConfig{ + APIBase: "https://backup.example/v1", + APIKey: "new-secret", + Models: []string{"backup-model"}, + Auth: "bearer", + TimeoutSec: 30, + } + body, err := json.Marshal(bodyCfg) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nil) + srv.SetConfigPath(cfgPath) + + req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"requires_confirm":true`) { + t.Fatalf("expected requires_confirm response, got: %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `models.providers.backup.api_key`) { + t.Fatalf("expected models.providers.backup.api_key in changed_fields, got: %s", rec.Body.String()) + } +} + +func TestHandleWebUIConfigRunsReloadHookSynchronously(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + body, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nil) + srv.SetConfigPath(cfgPath) + called := false + srv.SetConfigAfterHook(func() error { + called = true + return nil + }) + + req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !called { + t.Fatalf("expected reload hook to run") + } +} + +func TestHandleWebUIConfigReturnsReloadHookError(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + body, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nil) + srv.SetConfigPath(cfgPath) + srv.SetConfigAfterHook(func() error { + return fmt.Errorf("reload boom") + }) + + req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "reload failed") { + t.Fatalf("expected reload failure in body, got: %s", rec.Body.String()) + } +} + +func TestHandleWebUIConfigNormalizedGet(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + cfg.Agents.Subagents["coder"] = cfgpkg.SubagentConfig{ + Enabled: true, + Role: "coding", + SystemPromptFile: "agents/coder/AGENT.md", + } + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nil) + srv.SetConfigPath(cfgPath) + req := httptest.NewRequest(http.MethodGet, "/api/config?mode=normalized", nil) + rec := httptest.NewRecorder() + + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var payload map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["ok"] != true { + t.Fatalf("expected ok=true, got %#v", payload) + } + configMap, _ := payload["config"].(map[string]interface{}) + coreMap, _ := configMap["core"].(map[string]interface{}) + if strings.TrimSpace(fmt.Sprintf("%v", coreMap["main_agent_id"])) != "main" { + t.Fatalf("unexpected normalized config: %#v", payload) + } +} + +func TestHandleWebUIConfigNormalizedPost(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + body := map[string]interface{}{ + "confirm_risky": true, + "core": map[string]interface{}{ + "default_provider": "openai", + "default_model": "gpt-5.4", + "main_agent_id": "main", + "subagents": map[string]interface{}{ + "reviewer": map[string]interface{}{ + "enabled": true, + "role": "testing", + "prompt": "agents/reviewer/AGENT.md", + "provider": "openai", + "tool_allowlist": []interface{}{"shell"}, + "runtime_class": "provider_bound", + }, + }, + "tools": map[string]interface{}{"shell_enabled": true, "mcp_enabled": false}, + "gateway": map[string]interface{}{"host": "127.0.0.1", "port": float64(18790)}, + }, + "runtime": map[string]interface{}{ + "router": map[string]interface{}{ + "enabled": true, + "strategy": "rules_first", + "allow_direct_agent_chat": false, + "max_hops": float64(6), + "default_timeout_sec": float64(600), + "default_wait_reply": true, + "sticky_thread_owner": true, + "rules": []interface{}{ + map[string]interface{}{"agent_id": "reviewer", "keywords": []interface{}{"review"}}, + }, + }, + "providers": map[string]interface{}{ + "openai": map[string]interface{}{ + "auth": "bearer", + "api_base": "https://api.openai.com/v1", + "timeout_sec": float64(30), + }, + }, + }, + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nil) + srv.SetConfigPath(cfgPath) + srv.SetConfigAfterHook(func() error { return nil }) + + req := httptest.NewRequest(http.MethodPost, "/api/config?mode=normalized", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + loaded, err := cfgpkg.LoadConfig(cfgPath) + if err != nil { + t.Fatalf("reload config: %v", err) + } + if !loaded.Agents.Router.Enabled { + t.Fatalf("expected router to be enabled") + } + if _, ok := loaded.Agents.Subagents["reviewer"]; !ok { + t.Fatalf("expected reviewer subagent, got %+v", loaded.Agents.Subagents) + } +} diff --git a/pkg/api/server_node_artifacts_test.go b/pkg/api/server_node_artifacts_test.go new file mode 100644 index 0000000..099781c --- /dev/null +++ b/pkg/api/server_node_artifacts_test.go @@ -0,0 +1,254 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + cfgpkg "github.com/YspCoder/clawgo/pkg/config" + "github.com/YspCoder/clawgo/pkg/nodes" +) + +func TestHandleWebUINodeArtifactsListAndDelete(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + workspace := t.TempDir() + srv.SetWorkspacePath(workspace) + if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0o755); err != nil { + t.Fatalf("mkdir memory: %v", err) + } + artifactPath := filepath.Join(workspace, "artifact.txt") + if err := os.WriteFile(artifactPath, []byte("artifact-body"), 0o644); err != nil { + t.Fatalf("write artifact: %v", err) + } + auditLine := fmt.Sprintf("{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"run\",\"artifacts\":[{\"name\":\"artifact.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"source_path\":\"%s\",\"size_bytes\":13}]}\n", artifactPath) + if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLine), 0o644); err != nil { + t.Fatalf("write audit: %v", err) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/node_artifacts", nil) + listRec := httptest.NewRecorder() + srv.handleWebUINodeArtifacts(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", listRec.Code) + } + var listBody map[string]interface{} + if err := json.Unmarshal(listRec.Body.Bytes(), &listBody); err != nil { + t.Fatalf("decode list body: %v", err) + } + items, _ := listBody["items"].([]interface{}) + if len(items) != 1 { + t.Fatalf("expected 1 artifact, got %+v", listBody) + } + item, _ := items[0].(map[string]interface{}) + artifactID := strings.TrimSpace(fmt.Sprint(item["id"])) + if artifactID == "" { + t.Fatalf("expected artifact id, got %+v", item) + } + + deleteReq := httptest.NewRequest(http.MethodPost, "/api/node_artifacts/delete", strings.NewReader(fmt.Sprintf(`{"id":"%s"}`, artifactID))) + deleteReq.Header.Set("Content-Type", "application/json") + deleteRec := httptest.NewRecorder() + srv.handleWebUINodeArtifactDelete(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", deleteRec.Code, deleteRec.Body.String()) + } + if _, err := os.Stat(artifactPath); !os.IsNotExist(err) { + t.Fatalf("expected artifact file removed, stat err=%v", err) + } +} + +func TestHandleWebUINodeArtifactsExport(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + workspace := t.TempDir() + srv.SetWorkspacePath(workspace) + if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0o755); err != nil { + t.Fatalf("mkdir memory: %v", err) + } + auditLine := "{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"shot.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"Y2FwdHVyZQ==\",\"size_bytes\":7}]}\n" + if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLine), 0o644); err != nil { + t.Fatalf("write audit: %v", err) + } + srv.mgr.Upsert(nodes.NodeInfo{ID: "edge-a", Name: "Edge A", Online: true}) + + req := httptest.NewRequest(http.MethodGet, "/api/node_artifacts/export?node=edge-a&action=screen_snapshot&kind=text", nil) + rec := httptest.NewRecorder() + srv.handleWebUINodeArtifactsExport(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/zip") { + t.Fatalf("expected zip response, got %q", got) + } + zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len())) + if err != nil { + t.Fatalf("open zip: %v", err) + } + seen := map[string]bool{} + for _, file := range zr.File { + seen[file.Name] = true + } + for _, required := range []string{"manifest.json", "dispatches.json", "alerts.json", "artifacts.json"} { + if !seen[required] { + t.Fatalf("missing zip entry %q in %+v", required, seen) + } + } + foundFile := false + for _, file := range zr.File { + if !strings.HasPrefix(file.Name, "files/") { + continue + } + foundFile = true + rc, err := file.Open() + if err != nil { + t.Fatalf("open artifact file: %v", err) + } + body, _ := io.ReadAll(rc) + _ = rc.Close() + if string(body) != "capture" { + t.Fatalf("unexpected artifact payload %q", string(body)) + } + } + if !foundFile { + t.Fatalf("expected exported artifact file in zip") + } +} + +func TestHandleWebUINodeArtifactsPrune(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + workspace := t.TempDir() + srv.SetWorkspacePath(workspace) + if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0o755); err != nil { + t.Fatalf("mkdir memory: %v", err) + } + auditLines := strings.Join([]string{ + "{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"one.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"b25l\"}]}", + "{\"time\":\"2026-03-09T00:01:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"two.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"dHdv\"}]}", + "{\"time\":\"2026-03-09T00:02:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"three.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"dGhyZWU=\"}]}", + }, "\n") + "\n" + if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLines), 0o644); err != nil { + t.Fatalf("write audit: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/node_artifacts/prune", strings.NewReader(`{"node":"edge-a","action":"screen_snapshot","kind":"text","keep_latest":1}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleWebUINodeArtifactPrune(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + items := srv.webUINodeArtifactsPayloadFiltered("edge-a", "screen_snapshot", "text", 10) + if len(items) != 1 { + t.Fatalf("expected 1 remaining artifact, got %d", len(items)) + } + if got := fmt.Sprint(items[0]["name"]); got != "three.txt" { + t.Fatalf("expected newest artifact to remain, got %q", got) + } +} + +func TestHandleWebUINodeArtifactsAppliesRetentionConfig(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + workspace := t.TempDir() + srv.SetWorkspacePath(workspace) + if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0o755); err != nil { + t.Fatalf("mkdir memory: %v", err) + } + cfg := cfgpkg.DefaultConfig() + cfg.Gateway.Nodes.Artifacts.Enabled = true + cfg.Gateway.Nodes.Artifacts.KeepLatest = 1 + cfg.Gateway.Nodes.Artifacts.PruneOnRead = true + cfgPath := filepath.Join(workspace, "config.json") + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + srv.SetConfigPath(cfgPath) + auditLines := strings.Join([]string{ + "{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"one.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"b25l\"}]}", + "{\"time\":\"2026-03-09T00:01:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"two.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"dHdv\"}]}", + }, "\n") + "\n" + if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLines), 0o644); err != nil { + t.Fatalf("write audit: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/node_artifacts", nil) + rec := httptest.NewRecorder() + srv.handleWebUINodeArtifacts(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + items := srv.webUINodeArtifactsPayload(10) + if len(items) != 1 { + t.Fatalf("expected retention to keep 1 artifact, got %d", len(items)) + } + if got := fmt.Sprint(items[0]["name"]); got != "two.txt" { + t.Fatalf("expected newest artifact to remain, got %q", got) + } + stats := srv.artifactStatsSnapshot() + if fmt.Sprint(stats["pruned"]) == "" || fmt.Sprint(stats["pruned"]) == "0" { + t.Fatalf("expected retention stats to record pruned artifacts, got %+v", stats) + } + if fmt.Sprint(stats["keep_latest"]) != "1" { + t.Fatalf("expected keep_latest in stats, got %+v", stats) + } +} + +func TestHandleWebUINodeArtifactsAppliesRetentionDays(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + workspace := t.TempDir() + srv.SetWorkspacePath(workspace) + if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0o755); err != nil { + t.Fatalf("mkdir memory: %v", err) + } + cfg := cfgpkg.DefaultConfig() + cfg.Gateway.Nodes.Artifacts.Enabled = true + cfg.Gateway.Nodes.Artifacts.KeepLatest = 10 + cfg.Gateway.Nodes.Artifacts.RetainDays = 1 + cfg.Gateway.Nodes.Artifacts.PruneOnRead = true + cfgPath := filepath.Join(workspace, "config.json") + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + srv.SetConfigPath(cfgPath) + oldTime := time.Now().UTC().Add(-48 * time.Hour).Format(time.RFC3339) + newTime := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339) + auditLines := strings.Join([]string{ + fmt.Sprintf("{\"time\":%q,\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"old.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"b2xk\"}]}", oldTime), + fmt.Sprintf("{\"time\":%q,\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"fresh.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"ZnJlc2g=\"}]}", newTime), + }, "\n") + "\n" + if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLines), 0o644); err != nil { + t.Fatalf("write audit: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/node_artifacts", nil) + rec := httptest.NewRecorder() + srv.handleWebUINodeArtifacts(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + items := srv.webUINodeArtifactsPayload(10) + if len(items) != 1 { + t.Fatalf("expected retention days to keep 1 artifact, got %d", len(items)) + } + if got := fmt.Sprint(items[0]["name"]); got != "fresh.txt" { + t.Fatalf("expected fresh artifact to remain, got %q", got) + } +} diff --git a/pkg/api/server_rpc_test.go b/pkg/api/server_rpc_test.go new file mode 100644 index 0000000..4c48df9 --- /dev/null +++ b/pkg/api/server_rpc_test.go @@ -0,0 +1,261 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + cfgpkg "github.com/YspCoder/clawgo/pkg/config" + "github.com/YspCoder/clawgo/pkg/nodes" +) + +func TestHandleSubagentRPCSpawn(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetSubagentHandler(func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + if action != "spawn" { + t.Fatalf("unexpected action: %s", action) + } + if fmt.Sprint(args["agent_id"]) != "coder" || fmt.Sprint(args["task"]) != "ship it" { + t.Fatalf("unexpected args: %+v", args) + } + return map[string]interface{}{"message": "spawned"}, nil + }) + + body := `{"method":"subagent.spawn","request_id":"req-1","params":{"agent_id":"coder","task":"ship it","channel":"webui","chat_id":"group"}}` + req := httptest.NewRequest(http.MethodPost, "/api/rpc/subagent", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleSubagentRPC(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"request_id":"req-1"`) || !strings.Contains(rec.Body.String(), `"message":"spawned"`) { + t.Fatalf("unexpected rpc body: %s", rec.Body.String()) + } +} + +func TestHandleNodeRPCDispatch(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetNodeDispatchHandler(func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error) { + if req.Node != "edge-a" || req.Action != "screen_snapshot" || mode != "relay" { + t.Fatalf("unexpected request: %+v mode=%s", req, mode) + } + return nodes.Response{ + OK: true, + Node: req.Node, + Action: req.Action, + Payload: map[string]interface{}{ + "used_transport": "relay", + }, + }, nil + }) + + body := `{"method":"node.dispatch","request_id":"req-2","params":{"node":"edge-a","action":"screen_snapshot","mode":"relay","args":{"quality":"high"}}}` + req := httptest.NewRequest(http.MethodPost, "/api/rpc/node", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleNodeRPC(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"request_id":"req-2"`) || !strings.Contains(rec.Body.String(), `"used_transport":"relay"`) { + t.Fatalf("unexpected rpc body: %s", rec.Body.String()) + } +} + +func TestHandleProviderRPCListModels(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + pc := cfg.Models.Providers["openai"] + pc.APIBase = "https://example.invalid/v1" + pc.APIKey = "test-key" + pc.Models = []string{"gpt-5.4", "gpt-5.4-mini"} + cfg.Models.Providers["openai"] = pc + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetConfigPath(cfgPath) + + body := `{"method":"provider.list_models","request_id":"req-p1","params":{"provider":"openai"}}` + req := httptest.NewRequest(http.MethodPost, "/api/rpc/provider", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleProviderRPC(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"gpt-5.4"`) || !strings.Contains(rec.Body.String(), `"request_id":"req-p1"`) { + t.Fatalf("unexpected provider rpc body: %s", rec.Body.String()) + } +} + +func TestHandleProviderRPCCountTokensUnavailable(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + pc := cfg.Models.Providers["openai"] + pc.APIBase = "https://example.invalid/v1" + pc.APIKey = "test-key" + pc.Models = []string{"gpt-5.4"} + cfg.Models.Providers["openai"] = pc + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetConfigPath(cfgPath) + + body := `{"method":"provider.count_tokens","request_id":"req-p2","params":{"provider":"openai","messages":[{"role":"user","content":"hello"}]}}` + req := httptest.NewRequest(http.MethodPost, "/api/rpc/provider", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleProviderRPC(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"code":"unavailable"`) { + t.Fatalf("expected unavailable rpc error, got: %s", rec.Body.String()) + } +} + +func TestHandleSkillsRPCView(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + workspace := filepath.Join(tmp, "workspace") + skillsDir := filepath.Join(workspace, "skills", "demo") + if err := os.MkdirAll(skillsDir, 0755); err != nil { + t.Fatalf("mkdir skills dir: %v", err) + } + if err := os.WriteFile(filepath.Join(skillsDir, "SKILL.md"), []byte(buildSkillMarkdown("demo", "Demo skill", []string{"shell"}, "Be useful")), 0644); err != nil { + t.Fatalf("write skill: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetWorkspacePath(workspace) + + body := `{"method":"skills.view","request_id":"req-s1","params":{"id":"demo","files":true}}` + req := httptest.NewRequest(http.MethodPost, "/api/rpc/skills", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleSkillsRPC(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"request_id":"req-s1"`) || !strings.Contains(rec.Body.String(), `SKILL.md`) { + t.Fatalf("unexpected skills rpc body: %s", rec.Body.String()) + } +} + +func TestHandleWebUISkillsUsesRPCFacade(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + workspace := filepath.Join(tmp, "workspace") + skillsDir := filepath.Join(workspace, "skills") + if err := os.MkdirAll(skillsDir, 0755); err != nil { + t.Fatalf("mkdir skills dir: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetWorkspacePath(workspace) + + req := httptest.NewRequest(http.MethodPost, "/api/skills", strings.NewReader(`{"action":"create","name":"demo","description":"Demo skill","system_prompt":"Be useful","tools":["shell"]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWebUISkills(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(skillsDir, "demo", "SKILL.md")); err != nil { + t.Fatalf("expected created skill file: %v", err) + } +} + +func TestHandleWebUIProviderModelsUsesRPCFacade(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + pc := cfg.Models.Providers["openai"] + pc.APIBase = "https://example.invalid/v1" + pc.APIKey = "test-key" + pc.Models = []string{"gpt-5.4-mini"} + cfg.Models.Providers["openai"] = pc + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetConfigPath(cfgPath) + srv.SetConfigAfterHook(func() error { return nil }) + + req := httptest.NewRequest(http.MethodPost, "/api/provider/models", strings.NewReader(`{"provider":"openai","model":"gpt-5.4"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWebUIProviderModels(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"gpt-5.4"`) { + t.Fatalf("unexpected response: %s", rec.Body.String()) + } +} + +func TestHandleWebUIProviderRuntimeUsesRPCFacade(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + pc := cfg.Models.Providers["openai"] + pc.APIBase = "https://example.invalid/v1" + pc.APIKey = "test-key" + pc.Models = []string{"gpt-5.4-mini"} + cfg.Models.Providers["openai"] = pc + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetConfigPath(cfgPath) + + req := httptest.NewRequest(http.MethodGet, "/api/provider/runtime?provider=openai&limit=5", nil) + rec := httptest.NewRecorder() + + srv.handleWebUIProviderRuntime(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"view"`) { + t.Fatalf("unexpected response: %s", rec.Body.String()) + } +} diff --git a/pkg/api/server_skills.go b/pkg/api/server_skills.go index 8d6a623..9dd3e77 100644 --- a/pkg/api/server_skills.go +++ b/pkg/api/server_skills.go @@ -18,14 +18,31 @@ import ( "strings" "time" + rpcpkg "github.com/YspCoder/clawgo/pkg/rpc" "github.com/YspCoder/clawgo/pkg/tools" ) +func mustMap(v interface{}) map[string]interface{} { + if v == nil { + return map[string]interface{}{} + } + data, err := json.Marshal(v) + if err != nil { + return map[string]interface{}{} + } + out := map[string]interface{}{} + if err := json.Unmarshal(data, &out); err != nil { + return map[string]interface{}{} + } + return out +} + func (s *Server) handleWebUISkills(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } + svc := s.skillsRPCService() skillsDir := filepath.Join(s.workspacePath, "skills") if strings.TrimSpace(skillsDir) == "" { http.Error(w, "workspace not configured", http.StatusInternalServerError) @@ -33,151 +50,19 @@ func (s *Server) handleWebUISkills(w http.ResponseWriter, r *http.Request) { } _ = os.MkdirAll(skillsDir, 0755) - resolveSkillPath := func(name string) (string, error) { - name = strings.TrimSpace(name) - if name == "" { - return "", fmt.Errorf("name required") - } - cands := []string{ - filepath.Join(skillsDir, name), - filepath.Join(skillsDir, name+".disabled"), - filepath.Join("/root/clawgo/workspace/skills", name), - filepath.Join("/root/clawgo/workspace/skills", name+".disabled"), - } - for _, p := range cands { - if st, err := os.Stat(p); err == nil && st.IsDir() { - return p, nil - } - } - return "", fmt.Errorf("skill not found: %s", name) - } - switch r.Method { case http.MethodGet: - clawhubPath := strings.TrimSpace(resolveClawHubBinary(r.Context())) - clawhubInstalled := clawhubPath != "" - if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" { - skillPath, err := resolveSkillPath(id) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - if strings.TrimSpace(r.URL.Query().Get("files")) == "1" { - var files []string - _ = filepath.WalkDir(skillPath, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - return nil - } - rel, _ := filepath.Rel(skillPath, path) - if strings.HasPrefix(rel, "..") { - return nil - } - files = append(files, filepath.ToSlash(rel)) - return nil - }) - writeJSON(w, map[string]interface{}{"ok": true, "id": id, "files": files}) - return - } - if f := strings.TrimSpace(r.URL.Query().Get("file")); f != "" { - clean, content, found, err := readRelativeTextFile(skillPath, f) - if err != nil { - http.Error(w, err.Error(), relativeFilePathStatus(err)) - return - } - if !found { - http.Error(w, os.ErrNotExist.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]interface{}{"ok": true, "id": id, "file": filepath.ToSlash(clean), "content": content}) - return - } - } - - type skillItem struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Tools []string `json:"tools"` - SystemPrompt string `json:"system_prompt,omitempty"` - Enabled bool `json:"enabled"` - UpdateChecked bool `json:"update_checked"` - RemoteFound bool `json:"remote_found,omitempty"` - RemoteVersion string `json:"remote_version,omitempty"` - CheckError string `json:"check_error,omitempty"` - Source string `json:"source,omitempty"` - } - candDirs := []string{skillsDir, filepath.Join("/root/clawgo/workspace", "skills")} - seenDirs := map[string]struct{}{} - seenSkills := map[string]struct{}{} - items := make([]skillItem, 0) - checkUpdates := strings.TrimSpace(r.URL.Query().Get("check_updates")) == "1" - - for _, dir := range candDirs { - dir = strings.TrimSpace(dir) - if dir == "" { - continue - } - if _, ok := seenDirs[dir]; ok { - continue - } - seenDirs[dir] = struct{}{} - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - continue - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - for _, e := range entries { - if !e.IsDir() { - continue - } - name := e.Name() - enabled := !strings.HasSuffix(name, ".disabled") - baseName := strings.TrimSuffix(name, ".disabled") - if _, ok := seenSkills[baseName]; ok { - continue - } - seenSkills[baseName] = struct{}{} - desc, skillTools, sys := readSkillMeta(filepath.Join(dir, name, "SKILL.md")) - if desc == "" || len(skillTools) == 0 || sys == "" { - d2, t2, s2 := readSkillMeta(filepath.Join(dir, baseName, "SKILL.md")) - if desc == "" { - desc = d2 - } - if len(skillTools) == 0 { - skillTools = t2 - } - if sys == "" { - sys = s2 - } - } - if skillTools == nil { - skillTools = []string{} - } - it := skillItem{ID: baseName, Name: baseName, Description: desc, Tools: skillTools, SystemPrompt: sys, Enabled: enabled, UpdateChecked: checkUpdates && clawhubInstalled, Source: dir} - if checkUpdates && clawhubInstalled { - found, version, checkErr := queryClawHubSkillVersion(r.Context(), baseName) - it.RemoteFound = found - it.RemoteVersion = version - if checkErr != nil { - it.CheckError = checkErr.Error() - } - } - items = append(items, it) - } - } - writeJSON(w, map[string]interface{}{ - "ok": true, - "skills": items, - "source": "clawhub", - "clawhub_installed": clawhubInstalled, - "clawhub_path": clawhubPath, + resp, rpcErr := svc.View(r.Context(), rpcpkg.SkillsViewRequest{ + ID: strings.TrimSpace(r.URL.Query().Get("id")), + File: strings.TrimSpace(r.URL.Query().Get("file")), + Files: strings.TrimSpace(r.URL.Query().Get("files")) == "1", + CheckUpdates: strings.TrimSpace(r.URL.Query().Get("check_updates")) == "1", }) + if rpcErr != nil { + http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr)) + return + } + writeJSON(w, mergeJSONMap(map[string]interface{}{"ok": true}, mustMap(resp))) case http.MethodPost: ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))) @@ -196,123 +81,34 @@ func (s *Server) handleWebUISkills(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid json", http.StatusBadRequest) return } - action := strings.ToLower(stringFromMap(body, "action")) - if action == "install_clawhub" { - output, err := ensureClawHubReady(r.Context()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]interface{}{ - "ok": true, - "output": output, - "installed": true, - "clawhub_path": resolveClawHubBinary(r.Context()), - }) + ignoreSuspicious, _ := tools.MapBoolArg(body, "ignore_suspicious") + resp, rpcErr := svc.Mutate(r.Context(), rpcpkg.SkillsMutateRequest{ + Action: stringFromMap(body, "action"), + ID: stringFromMap(body, "id"), + Name: stringFromMap(body, "name"), + Description: rawStringFromMap(body, "description"), + SystemPrompt: rawStringFromMap(body, "system_prompt"), + Tools: stringListFromMap(body, "tools"), + IgnoreSuspicious: ignoreSuspicious, + File: stringFromMap(body, "file"), + Content: rawStringFromMap(body, "content"), + }) + if rpcErr != nil { + http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr)) return } - id := stringFromMap(body, "id") - name := strings.TrimSpace(firstNonEmptyString(stringFromMap(body, "name"), id)) - if name == "" { - http.Error(w, "name required", http.StatusBadRequest) - return - } - enabledPath := filepath.Join(skillsDir, name) - disabledPath := enabledPath + ".disabled" - type skillActionHandler func() bool - handlers := map[string]skillActionHandler{ - "install": func() bool { - clawhubPath := strings.TrimSpace(resolveClawHubBinary(r.Context())) - if clawhubPath == "" { - http.Error(w, "clawhub is not installed. please install clawhub first.", http.StatusPreconditionFailed) - return false - } - ignoreSuspicious, _ := tools.MapBoolArg(body, "ignore_suspicious") - args := []string{"install", name} - if ignoreSuspicious { - args = append(args, "--force") - } - cmd := exec.CommandContext(r.Context(), clawhubPath, args...) - cmd.Dir = strings.TrimSpace(s.workspacePath) - out, err := cmd.CombinedOutput() - if err != nil { - outText := string(out) - lower := strings.ToLower(outText) - if strings.Contains(lower, "rate limit exceeded") || strings.Contains(lower, "too many requests") { - http.Error(w, fmt.Sprintf("clawhub rate limit exceeded. please retry later or configure auth token.\n%s", outText), http.StatusTooManyRequests) - return false - } - http.Error(w, fmt.Sprintf("install failed: %v\n%s", err, outText), http.StatusInternalServerError) - return false - } - writeJSON(w, map[string]interface{}{"ok": true, "installed": name, "output": string(out)}) - return true - }, - "enable": func() bool { - if _, err := os.Stat(disabledPath); err == nil { - if err := os.Rename(disabledPath, enabledPath); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return false - } - } - writeJSON(w, map[string]interface{}{"ok": true}) - return true - }, - "disable": func() bool { - if _, err := os.Stat(enabledPath); err == nil { - if err := os.Rename(enabledPath, disabledPath); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return false - } - } - writeJSON(w, map[string]interface{}{"ok": true}) - return true - }, - "write_file": func() bool { - skillPath, err := resolveSkillPath(name) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return false - } - content := rawStringFromMap(body, "content") - filePath := stringFromMap(body, "file") - clean, err := writeRelativeTextFile(skillPath, filePath, content, true) - if err != nil { - http.Error(w, err.Error(), relativeFilePathStatus(err)) - return false - } - writeJSON(w, map[string]interface{}{"ok": true, "name": name, "file": filepath.ToSlash(clean)}) - return true - }, - "create": func() bool { - return createOrUpdateSkill(w, enabledPath, name, body, true) - }, - "update": func() bool { - return createOrUpdateSkill(w, enabledPath, name, body, false) - }, - } - if handler := handlers[action]; handler != nil { - handler() - return - } - http.Error(w, "unsupported action", http.StatusBadRequest) + writeJSON(w, mergeJSONMap(map[string]interface{}{"ok": true}, mustMap(resp))) case http.MethodDelete: - id := strings.TrimSpace(r.URL.Query().Get("id")) - if id == "" { - http.Error(w, "id required", http.StatusBadRequest) + resp, rpcErr := svc.Mutate(r.Context(), rpcpkg.SkillsMutateRequest{ + Action: "delete", + ID: strings.TrimSpace(r.URL.Query().Get("id")), + }) + if rpcErr != nil { + http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr)) return } - pathA := filepath.Join(skillsDir, id) - pathB := pathA + ".disabled" - deleted := false - if err := os.RemoveAll(pathA); err == nil { - deleted = true - } - if err := os.RemoveAll(pathB); err == nil { - deleted = true - } - writeJSON(w, map[string]interface{}{"ok": true, "deleted": deleted, "id": id}) + writeJSON(w, mergeJSONMap(map[string]interface{}{"ok": true}, mustMap(resp))) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -353,29 +149,6 @@ description: %s `, name, desc, name, desc, strings.Join(toolLines, "\n"), systemPrompt) } -func createOrUpdateSkill(w http.ResponseWriter, enabledPath, name string, body map[string]interface{}, checkExists bool) bool { - desc := rawStringFromMap(body, "description") - sys := rawStringFromMap(body, "system_prompt") - toolsList := stringListFromMap(body, "tools") - if checkExists { - if _, err := os.Stat(enabledPath); err == nil { - http.Error(w, "skill already exists", http.StatusBadRequest) - return false - } - } - if err := os.MkdirAll(filepath.Join(enabledPath, "scripts"), 0755); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return false - } - skillMD := buildSkillMarkdown(name, desc, toolsList, sys) - if err := os.WriteFile(filepath.Join(enabledPath, "SKILL.md"), []byte(skillMD), 0644); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return false - } - writeJSON(w, map[string]interface{}{"ok": true}) - return true -} - func readSkillMeta(path string) (desc string, toolsList []string, systemPrompt string) { b, err := os.ReadFile(path) if err != nil { diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 9b6cb35..8a2c795 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -1,609 +1,21 @@ package api import ( - "archive/zip" - "bytes" "context" "encoding/json" "fmt" - "io" - "net" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" - "strconv" "strings" "testing" "time" - cfgpkg "github.com/YspCoder/clawgo/pkg/config" "github.com/YspCoder/clawgo/pkg/nodes" "github.com/gorilla/websocket" ) -func TestHandleWebUIWhatsAppStatus(t *testing.T) { - t.Parallel() - - bridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/status": - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "state": "connected", - "connected": true, - "logged_in": true, - "bridge_addr": "127.0.0.1:3001", - "user_jid": "8613012345678@s.whatsapp.net", - "qr_available": false, - "last_event": "connected", - "updated_at": "2026-03-09T12:00:00+08:00", - }) - default: - http.NotFound(w, r) - } - })) - defer bridge.Close() - - tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "config.json") - cfg := cfgpkg.DefaultConfig() - cfg.Logging.Enabled = false - cfg.Channels.WhatsApp.Enabled = true - cfg.Channels.WhatsApp.BridgeURL = "ws" + strings.TrimPrefix(bridge.URL, "http") + "/ws" - if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - - srv := NewServer("127.0.0.1", 0, "", nil) - srv.SetConfigPath(cfgPath) - - req := httptest.NewRequest(http.MethodGet, "/api/whatsapp/status", nil) - rec := httptest.NewRecorder() - srv.handleWebUIWhatsAppStatus(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), `"bridge_running":true`) { - t.Fatalf("expected bridge_running=true, got: %s", rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), `"user_jid":"8613012345678@s.whatsapp.net"`) { - t.Fatalf("expected user_jid in payload, got: %s", rec.Body.String()) - } -} - -func TestHandleWebUIWhatsAppQR(t *testing.T) { - t.Parallel() - - bridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/status": - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "state": "qr_ready", - "connected": false, - "logged_in": false, - "bridge_addr": "127.0.0.1:3001", - "qr_available": true, - "qr_code": "test-qr-code", - "last_event": "qr_ready", - "updated_at": "2026-03-09T12:00:00+08:00", - }) - default: - http.NotFound(w, r) - } - })) - defer bridge.Close() - - tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "config.json") - cfg := cfgpkg.DefaultConfig() - cfg.Logging.Enabled = false - cfg.Channels.WhatsApp.Enabled = true - cfg.Channels.WhatsApp.BridgeURL = "ws" + strings.TrimPrefix(bridge.URL, "http") + "/ws" - if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - - srv := NewServer("127.0.0.1", 0, "", nil) - srv.SetConfigPath(cfgPath) - - req := httptest.NewRequest(http.MethodGet, "/api/whatsapp/qr.svg", nil) - rec := httptest.NewRecorder() - srv.handleWebUIWhatsAppQR(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") { - t.Fatalf("expected svg content-type, got %q", ct) - } - if !strings.Contains(rec.Body.String(), "