diff --git a/cmd/clawgo/cmd_status.go b/cmd/clawgo/cmd_status.go index d67ca3b..53df9b0 100644 --- a/cmd/clawgo/cmd_status.go +++ b/cmd/clawgo/cmd_status.go @@ -54,14 +54,14 @@ func statusCmd() { } fmt.Printf("Model: %s\n", activeModel) fmt.Printf("Proxy: %s\n", activeProxyName) - fmt.Printf("CLIProxyAPI Base: %s\n", cfg.Providers.Proxy.APIBase) + fmt.Printf("Provider API Base: %s\n", activeProvider.APIBase) fmt.Printf("Supports /v1/responses/compact: %v\n", providers.ProviderSupportsResponsesCompact(cfg, activeProxyName)) - hasKey := cfg.Providers.Proxy.APIKey != "" + hasKey := strings.TrimSpace(activeProvider.APIKey) != "" status := "not set" if hasKey { status = "✓" } - fmt.Printf("CLIProxyAPI Key: %s\n", status) + fmt.Printf("Provider API Key: %s\n", status) fmt.Printf("Logging: %v\n", cfg.Logging.Enabled) if cfg.Logging.Enabled { fmt.Printf("Log File: %s\n", cfg.LogFilePath()) diff --git a/cmd/clawgo/cmd_status_test.go b/cmd/clawgo/cmd_status_test.go new file mode 100644 index 0000000..33669f4 --- /dev/null +++ b/cmd/clawgo/cmd_status_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "clawgo/pkg/config" +) + +func TestStatusCmdUsesActiveProviderDetails(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + workspace := filepath.Join(tmp, "workspace") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + + cfg := config.DefaultConfig() + cfg.Logging.Enabled = false + cfg.Agents.Defaults.Workspace = workspace + cfg.Agents.Defaults.Proxy = "backup" + cfg.Providers.Proxy.APIBase = "https://primary.example/v1" + cfg.Providers.Proxy.APIKey = "" + cfg.Providers.Proxies["backup"] = config.ProviderConfig{ + APIBase: "https://backup.example/v1", + APIKey: "backup-key", + Models: []string{"backup-model"}, + Auth: "bearer", + TimeoutSec: 30, + } + if err := config.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + prev := globalConfigPathOverride + globalConfigPathOverride = cfgPath + defer func() { globalConfigPathOverride = prev }() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = oldStdout }() + + statusCmd() + + _ = w.Close() + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read stdout: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Proxy: backup") { + t.Fatalf("expected backup proxy in output, got: %s", out) + } + if !strings.Contains(out, "Provider API Base: https://backup.example/v1") { + t.Fatalf("expected active provider api base in output, got: %s", out) + } + if !strings.Contains(out, "Provider API Key: ✓") { + t.Fatalf("expected active provider api key status in output, got: %s", out) + } +} diff --git a/pkg/api/server.go b/pkg/api/server.go index 4e2df2a..be50356 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -311,15 +311,7 @@ func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) { var oldMap map[string]interface{} _ = json.Unmarshal(oldCfgRaw, &oldMap) - riskyPaths := []string{ - "channels.telegram.token", - "channels.telegram.allow_from", - "channels.telegram.allow_chats", - "providers.proxy.base_url", - "providers.proxy.api_key", - "gateway.token", - "gateway.port", - } + riskyPaths := collectRiskyConfigPaths(oldMap, body) changedRisky := make([]string, 0) for _, p := range riskyPaths { if fmt.Sprintf("%v", getPathValue(oldMap, p)) != fmt.Sprintf("%v", getPathValue(body, p)) { @@ -418,6 +410,50 @@ func getPathValue(m map[string]interface{}, path string) interface{} { return cur } +func collectRiskyConfigPaths(oldMap, newMap map[string]interface{}) []string { + paths := []string{ + "channels.telegram.token", + "channels.telegram.allow_from", + "channels.telegram.allow_chats", + "providers.proxy.api_base", + "providers.proxy.api_key", + "gateway.token", + "gateway.port", + } + seen := map[string]bool{} + for _, path := range paths { + seen[path] = true + } + for _, name := range collectProviderProxyNames(oldMap, newMap) { + for _, field := range []string{"api_base", "api_key"} { + path := "providers.proxies." + name + "." + field + if !seen[path] { + paths = append(paths, path) + seen[path] = true + } + } + } + return paths +} + +func collectProviderProxyNames(maps ...map[string]interface{}) []string { + seen := map[string]bool{} + names := make([]string, 0) + for _, root := range maps { + providers, _ := root["providers"].(map[string]interface{}) + proxies, _ := providers["proxies"].(map[string]interface{}) + for name := range proxies { + if strings.TrimSpace(name) == "" || seen[name] { + continue + } + seen[name] = true + names = append(names, name) + } + } + sort.Strings(names) + return names +} + func (s *Server) handleWebUIUpload(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go new file mode 100644 index 0000000..bbaa950 --- /dev/null +++ b/pkg/api/server_test.go @@ -0,0 +1,109 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + cfgpkg "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 + cfg.Providers.Proxy.APIBase = "https://old.example/v1" + cfg.Providers.Proxy.APIKey = "test-key" + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + bodyCfg := cfgpkg.DefaultConfig() + bodyCfg.Logging.Enabled = false + bodyCfg.Providers.Proxy.APIBase = "https://new.example/v1" + bodyCfg.Providers.Proxy.APIKey = "test-key" + 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, "/webui/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(), `providers.proxy.api_base`) { + t.Fatalf("expected providers.proxy.api_base in changed_fields, got: %s", rec.Body.String()) + } +} + +func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + cfg.Providers.Proxies["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.Providers.Proxies["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, "/webui/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(), `providers.proxies.backup.api_key`) { + t.Fatalf("expected providers.proxies.backup.api_key in changed_fields, got: %s", rec.Body.String()) + } +} diff --git a/pkg/tools/subagent_runtime_control_test.go b/pkg/tools/subagent_runtime_control_test.go index b5e3a69..29ae8d2 100644 --- a/pkg/tools/subagent_runtime_control_test.go +++ b/pkg/tools/subagent_runtime_control_test.go @@ -673,7 +673,15 @@ func waitSubagentDone(t *testing.T, manager *SubagentManager, timeout time.Durat tasks := manager.ListTasks() if len(tasks) > 0 { task := tasks[0] - if task.Status != "running" { + for _, candidate := range tasks[1:] { + if candidate.Created > task.Created || (candidate.Created == task.Created && candidate.ID > task.ID) { + task = candidate + } + } + manager.mu.RLock() + _, stillRunning := manager.cancelFuncs[task.ID] + manager.mu.RUnlock() + if task.Status != "running" && !stillRunning { return task } }