From 99c095b67c08ac6ecf0d4e03359e76cef8c2b612 Mon Sep 17 00:00:00 2001 From: wanghj Date: Mon, 8 Jun 2026 17:34:52 +0800 Subject: [PATCH 1/2] feat(web): add config settings with save and restart Expose common config.toml fields in the Web UI settings modal, add structured config API endpoints with bootstrap validation before save, and restart the daemon or prompt for manual restart after saving. --- cli/app.go | 3 +- cli/serve/restart.go | 124 ++++++++ internal/agent/manager_config.go | 6 + internal/api/config_handlers.go | 229 ++++++++++++++ internal/api/config_test.go | 287 ++++++++++++++++++ internal/api/handler.go | 9 + internal/api/rest_handlers.go | 12 + internal/api/router.go | 8 + internal/apitypes/types.go | 45 +++ internal/config/raw.go | 64 ++++ internal/config/raw_test.go | 65 ++++ internal/config/settings.go | 84 +++++ internal/config/settings_test.go | 94 ++++++ internal/upgrade/cli_args.go | 16 + internal/upgrade/cli_args_test.go | 20 ++ internal/upgrade/helper.go | 7 +- internal/upgrade/helper_test.go | 2 +- internal/upgrade/restart.go | 16 +- internal/upgrade/restart_daemon.go | 57 ++++ internal/upgrade/restart_helper.go | 217 +++++++++++++ internal/upgrade/restart_helper_test.go | 87 ++++++ internal/upgrade/upgrade_test.go | 2 +- web/app/src/api/config.ts | 25 ++ .../business/ProfileControls/APIKeyField.tsx | 8 +- web/app/src/hooks/workspace/types.ts | 27 ++ .../hooks/workspace/useConfigController.ts | 204 +++++++++++++ .../hooks/workspace/useWorkspaceController.ts | 8 + web/app/src/models/configSettings.ts | 207 +++++++++++++ .../WorkspaceModals/ConfigSettingsModal.tsx | 180 +++++++++++ .../WorkspaceModals/WorkspaceModals.css | 52 ++++ .../components/WorkspaceModals/index.ts | 1 + .../WorkspaceOverlays/WorkspaceOverlays.tsx | 2 + .../WorkspaceSidebar/SidebarUserButton.tsx | 11 + .../WorkspaceSidebar/WorkspaceSidebar.tsx | 2 + .../components/WorkspaceSidebar/types.ts | 1 + web/app/src/shared/constants/api.ts | 4 + web/app/src/shared/i18n/messages.ts | 60 ++++ .../components/SidebarUserButton.test.tsx | 28 ++ web/app/tests/models/configSettings.test.ts | 138 +++++++++ 39 files changed, 2385 insertions(+), 27 deletions(-) create mode 100644 cli/serve/restart.go create mode 100644 internal/api/config_handlers.go create mode 100644 internal/api/config_test.go create mode 100644 internal/config/raw.go create mode 100644 internal/config/raw_test.go create mode 100644 internal/config/settings.go create mode 100644 internal/config/settings_test.go create mode 100644 internal/upgrade/cli_args.go create mode 100644 internal/upgrade/cli_args_test.go create mode 100644 internal/upgrade/restart_daemon.go create mode 100644 internal/upgrade/restart_helper.go create mode 100644 internal/upgrade/restart_helper_test.go create mode 100644 web/app/src/api/config.ts create mode 100644 web/app/src/hooks/workspace/useConfigController.ts create mode 100644 web/app/src/models/configSettings.ts create mode 100644 web/app/src/pages/WorkspacePage/components/WorkspaceModals/ConfigSettingsModal.tsx create mode 100644 web/app/tests/models/configSettings.test.ts diff --git a/cli/app.go b/cli/app.go index 27499dba..898bd50a 100644 --- a/cli/app.go +++ b/cli/app.go @@ -90,6 +90,7 @@ func (a *App) registerDefaultCommands() { completioncmd.NewCmd("csgclaw", completioncmd.FullSpec()), completioncmd.NewCompleteCmd("csgclaw", completioncmd.FullSpec()), servecmd.NewInternalServeCmd(), + servecmd.NewInternalRestartCmd(), ) } @@ -180,7 +181,7 @@ func isSpecialOutputCommand(rest []string) bool { return true } switch rest[0] { - case "serve", "stop", "_serve": + case "serve", "stop", "_serve", "_restart": return true case "agent": return len(rest) > 1 && rest[1] == "logs" diff --git a/cli/serve/restart.go b/cli/serve/restart.go new file mode 100644 index 00000000..ce672748 --- /dev/null +++ b/cli/serve/restart.go @@ -0,0 +1,124 @@ +package serve + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + "csgclaw/cli/command" + "csgclaw/internal/upgrade" +) + +type internalRestartCmd struct{} + +func NewInternalRestartCmd() command.Command { + return internalRestartCmd{} +} + +func (internalRestartCmd) Name() string { + return "_restart" +} + +func (internalRestartCmd) Summary() string { + return "Internal config restart helper." +} + +func (internalRestartCmd) Hidden() bool { + return true +} + +func (c internalRestartCmd) Run(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { + fs := run.NewFlagSet("_restart", run.Program+" _restart [flags]", c.Summary()) + fs.Usage = func() { + restartUsage(run, fs) + } + if err := fs.Parse(args); err != nil { + return err + } + if len(fs.Args()) != 0 { + return fmt.Errorf("_restart does not accept positional arguments") + } + + artifacts := upgrade.RestartArtifactsFromEnv() + fail := func(err error) error { + err = explainRestartError(run.Program, err) + if recordErr := artifacts.RecordFailure(err); recordErr != nil { + return fmt.Errorf("%w\nAlso failed to record restart helper status: %v", err, recordErr) + } + return err + } + + restarted, err := upgrade.RestartDaemonFromExecutable(ctx, upgrade.RestartOptions{ + ConfigPath: globals.Config, + }) + if err != nil { + return fail(err) + } + if !restarted.DaemonWasRunning { + message := fmt.Sprintf("Config saved. Stop the running server and run `%s serve` again.", run.Program) + if recordErr := artifacts.RecordManualRestartRequired(message); recordErr != nil { + return fmt.Errorf("record manual restart status: %w", recordErr) + } + } else { + _ = artifacts.ClearStatus() + } + return renderRestartResult(globals.Output, run.Stdout, restarted, run.Program) +} + +func restartUsage(run *command.Context, fs *flag.FlagSet) { + fmt.Fprintln(run.Stderr, "Restart the local CSGClaw daemon after config changes.") + fmt.Fprintln(run.Stderr) + fmt.Fprintln(run.Stderr, "Usage:") + fmt.Fprintf(run.Stderr, " %s _restart [flags]\n", run.Program) + fmt.Fprintln(run.Stderr) + fmt.Fprintln(run.Stderr, "Flags:") + fs.PrintDefaults() +} + +func explainRestartError(program string, err error) error { + if err == nil { + return nil + } + msg := err.Error() + switch { + case strings.Contains(msg, "read pid file"), strings.Contains(msg, "parse pid file"), strings.Contains(msg, "stop running daemon"), strings.Contains(msg, "restart daemon"): + return fmt.Errorf("%w\nRestart manually with `%s stop` and `%s serve -d`.", err, program, program) + default: + return err + } +} + +type restartResultView struct { + PIDPath string `json:"pid_path,omitempty"` + DaemonRunning bool `json:"daemon_running"` + Restarted bool `json:"restarted"` + ManualRestart bool `json:"manual_restart_required"` + Message string `json:"message,omitempty"` +} + +func renderRestartResult(output string, w io.Writer, restarted upgrade.RestartResult, program string) error { + output, err := command.NormalizeOutput(output) + if err != nil { + return err + } + message := "Config saved." + if restarted.Restarted { + message = "Config saved and service restarted." + } else if !restarted.DaemonWasRunning { + message = fmt.Sprintf("%s\nNo daemon detected; stop the running server and run `%s serve` again.", message, program) + } + view := restartResultView{ + PIDPath: restarted.PIDPath, + DaemonRunning: restarted.DaemonWasRunning, + Restarted: restarted.Restarted, + ManualRestart: restarted.DaemonWasRunning == false, + Message: message, + } + if output == "json" { + return command.WriteJSON(w, view) + } + _, err = fmt.Fprintln(w, message) + return err +} diff --git a/internal/agent/manager_config.go b/internal/agent/manager_config.go index 1e00f4ea..23155eb0 100644 --- a/internal/agent/manager_config.go +++ b/internal/agent/manager_config.go @@ -117,6 +117,12 @@ func picoclawBridgeModelID(modelID string) string { } func resolveManagerBaseURL(server config.ServerConfig) string { + return ResolveManagerBaseURL(server) +} + +// ResolveManagerBaseURL returns the base URL injected into agent runtime config. +// It prefers server.advertise_base_url and otherwise resolves a reachable local IPv4 address. +func ResolveManagerBaseURL(server config.ServerConfig) string { if server.AdvertiseBaseURL != "" { baseURL := strings.TrimRight(server.AdvertiseBaseURL, "/") slog.Debug("local ip detector using advertise_base_url", "base_url", baseURL) diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go new file mode 100644 index 00000000..cc4f3e7f --- /dev/null +++ b/internal/api/config_handlers.go @@ -0,0 +1,229 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" + "csgclaw/internal/config" + "csgclaw/internal/hub" + "csgclaw/internal/upgrade" +) + +func (h *Handler) resolveConfigPath() (string, error) { + path := strings.TrimSpace(h.configPath) + if path == "" { + return config.DefaultPath() + } + return path, nil +} + +func (h *Handler) handleConfigSettings(w http.ResponseWriter, r *http.Request) { + path, err := h.resolveConfigPath() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + switch r.Method { + case http.MethodGet: + cfg, _, err := h.loadBootstrapConfig() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, configSettingsView(path, cfg)) + case http.MethodPut: + var req apitypes.UpdateConfigSettingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + cfg, path, err := h.loadBootstrapConfig() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + previousManager := cfg.Bootstrap.ResolvedDefaultManagerTemplate() + previousWorker := cfg.Bootstrap.ResolvedDefaultWorkerTemplate() + accessToken := strings.TrimSpace(req.AccessToken) + if accessToken == "" { + accessToken = cfg.Server.AccessToken + } + cfg, err = config.ApplyUserSettings(cfg, config.UserSettings{ + ListenAddr: req.ListenAddr, + AdvertiseBaseURL: req.AdvertiseBaseURL, + AccessToken: accessToken, + ShowUpgrade: req.ShowUpgrade, + SandboxProvider: req.SandboxProvider, + DefaultManagerTemplate: req.DefaultManagerTemplate, + DefaultWorkerTemplate: req.DefaultWorkerTemplate, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var bootstrapDefaults *hub.BootstrapDefaults + if h.hub != nil { + defaults, err := hub.ResolveBootstrapDefaults(r.Context(), cfg.Bootstrap, h.hub) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + bootstrapDefaults = &defaults + } + if err := cfg.Save(path); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if h.svc != nil && bootstrapDefaults != nil { + managerChanged := cfg.Bootstrap.ResolvedDefaultManagerTemplate() != previousManager + workerChanged := cfg.Bootstrap.ResolvedDefaultWorkerTemplate() != previousWorker + if managerChanged || workerChanged { + if err := h.svc.SetGatewayRuntime(bootstrapDefaults.ManagerRuntimeKind, bootstrapDefaults.ManagerImage); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + } + writeJSON(w, http.StatusOK, configSettingsView(path, cfg)) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func configSettingsView(path string, cfg config.Config) apitypes.ConfigSettingsResponse { + settings := config.UserSettingsFromConfig(cfg) + token := strings.TrimSpace(settings.AccessToken) + effective := agent.ResolveManagerBaseURL(cfg.Server) + if effective == "" { + effective = config.ResolveAdvertiseBaseURL(cfg.Server) + } + return apitypes.ConfigSettingsResponse{ + Path: path, + ListenAddr: settings.ListenAddr, + AdvertiseBaseURL: settings.AdvertiseBaseURL, + AdvertiseBaseURLEffective: effective, + AccessTokenSet: token != "", + AccessTokenPreview: config.AccessTokenPreview(token), + ShowUpgrade: settings.ShowUpgrade, + SandboxProvider: settings.SandboxProvider, + SupportedSandboxProviders: settings.SupportedSandboxProvider, + DefaultManagerTemplate: settings.DefaultManagerTemplate, + DefaultWorkerTemplate: settings.DefaultWorkerTemplate, + } +} + +func (h *Handler) handleConfigFile(w http.ResponseWriter, r *http.Request) { + path, err := h.resolveConfigPath() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + switch r.Method { + case http.MethodGet: + data, err := config.ReadRawFile(path) + if err != nil { + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "config not found") { + status = http.StatusNotFound + } + http.Error(w, err.Error(), status) + return + } + writeJSON(w, http.StatusOK, apitypes.ConfigFileResponse{ + Path: path, + Content: string(data), + }) + case http.MethodPut: + body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20)) + if err != nil { + http.Error(w, fmt.Sprintf("read request: %v", err), http.StatusBadRequest) + return + } + var req apitypes.UpdateConfigRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + if err := config.WriteRawFile(path, []byte(req.Content)); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, apitypes.ConfigFileResponse{ + Path: path, + Content: req.Content, + }) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleConfigApply(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + configPath, err := h.resolveConfigPath() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + apply := h.configRestartApply + if apply == nil { + apply = upgrade.StartRestartHelper + } + if err := apply(upgrade.RestartHelperOptions{ConfigPath: configPath}); err != nil { + http.Error(w, fmt.Sprintf("start restart helper: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusAccepted, apitypes.ConfigActionResponse{ + Status: "accepted", + Message: "restart helper started", + }) +} + +func (h *Handler) handleConfigRestartStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + configPath, err := h.resolveConfigPath() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + record, err := upgrade.ConsumeRestartStatus(configPath) + if err != nil { + http.Error(w, fmt.Sprintf("read restart helper status: %v", err), http.StatusInternalServerError) + return + } + + resp := apitypes.ConfigRestartStatusResponse{} + switch record.Status { + case upgrade.ApplyStatusManualRestartRequired: + resp.ManualRestartRequired = true + resp.Message = record.Message + case upgrade.ApplyStatusFailed: + resp.LastError = record.Message + default: + if record.Message != "" { + resp.LastError = record.Message + } + } + if resp.ManualRestartRequired || resp.LastError != "" || resp.Message != "" { + writeJSON(w, http.StatusOK, resp) + return + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/config_test.go b/internal/api/config_test.go new file mode 100644 index 00000000..97007d1f --- /dev/null +++ b/internal/api/config_test.go @@ -0,0 +1,287 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "csgclaw/internal/apitypes" + "csgclaw/internal/config" + "csgclaw/internal/hub" + "csgclaw/internal/upgrade" +) + +func TestHandleConfigFileGetPut(t *testing.T) { + dir := t.TempDir() + configPath := dir + "/config.toml" + writeMinimalAPIConfig(t, configPath) + + srv := &Handler{} + srv.SetConfigPath(configPath) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got apitypes.ConfigFileResponse + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode GET response: %v", err) + } + if got.Path != configPath || got.Content == "" { + t.Fatalf("GET response = %+v, want path and content", got) + } + + updated := got.Content + "\n# edited\n" + body, err := json.Marshal(apitypes.UpdateConfigRequest{Content: updated}) + if err != nil { + t.Fatalf("marshal PUT body: %v", err) + } + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, "/api/v1/config", bytes.NewReader(body)) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var saved apitypes.ConfigFileResponse + if err := json.NewDecoder(rec.Body).Decode(&saved); err != nil { + t.Fatalf("decode PUT response: %v", err) + } + if saved.Content != updated { + t.Fatalf("PUT content = %q, want %q", saved.Content, updated) + } +} + +func TestHandleConfigApplyStartsHelper(t *testing.T) { + dir := t.TempDir() + configPath := dir + "/config.toml" + writeMinimalAPIConfig(t, configPath) + + var started upgrade.RestartHelperOptions + srv := &Handler{} + srv.SetConfigPath(configPath) + srv.SetConfigRestartApplyFunc(func(opts upgrade.RestartHelperOptions) error { + started = opts + return nil + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/config/apply", nil) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST apply status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String()) + } + if started.ConfigPath != configPath { + t.Fatalf("restart helper config path = %q, want %q", started.ConfigPath, configPath) + } +} + +func TestHandleConfigRestartStatusConsumesManualRestartRequired(t *testing.T) { + dir := t.TempDir() + configPath := dir + "/config.toml" + writeMinimalAPIConfig(t, configPath) + + artifacts, err := upgrade.ResolveRestartArtifacts(configPath) + if err != nil { + t.Fatalf("ResolveRestartArtifacts() error = %v", err) + } + if err := artifacts.RecordManualRestartRequired("manual restart required"); err != nil { + t.Fatalf("RecordManualRestartRequired() error = %v", err) + } + + srv := &Handler{} + srv.SetConfigPath(configPath) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/config/restart/status", nil) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got apitypes.ConfigRestartStatusResponse + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if !got.ManualRestartRequired { + t.Fatalf("ManualRestartRequired = false, want true") + } +} + +func TestHandleConfigSettingsGetPut(t *testing.T) { + dir := t.TempDir() + configPath := dir + "/config.toml" + writeMinimalAPIConfig(t, configPath) + + srv := &Handler{} + srv.SetConfigPath(configPath) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/config/settings", nil) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET settings status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got apitypes.ConfigSettingsResponse + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode GET settings response: %v", err) + } + if got.Path != configPath || got.ListenAddr == "" { + t.Fatalf("GET settings = %+v, want populated fields", got) + } + if !got.AccessTokenSet || got.AccessToken != "" { + t.Fatalf("AccessTokenSet = %v AccessToken = %q, want masked response", got.AccessTokenSet, got.AccessToken) + } + if len(got.SupportedSandboxProviders) == 0 { + t.Fatalf("SupportedSandboxProviders = %#v, want non-empty", got.SupportedSandboxProviders) + } + if got.AdvertiseBaseURLEffective == "" { + t.Fatalf("AdvertiseBaseURLEffective = empty, want resolved manager base URL") + } + + body, err := json.Marshal(apitypes.UpdateConfigSettingsRequest{ + ListenAddr: "127.0.0.1:19080", + AdvertiseBaseURL: "http://192.168.1.10:19080/", + ShowUpgrade: false, + SandboxProvider: "docker", + DefaultManagerTemplate: "builtin.picoclaw-manager", + DefaultWorkerTemplate: "builtin.picoclaw-worker", + }) + if err != nil { + t.Fatalf("marshal PUT body: %v", err) + } + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, "/api/v1/config/settings", bytes.NewReader(body)) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT settings status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var saved apitypes.ConfigSettingsResponse + if err := json.NewDecoder(rec.Body).Decode(&saved); err != nil { + t.Fatalf("decode PUT settings response: %v", err) + } + if saved.ListenAddr != "127.0.0.1:19080" || saved.ShowUpgrade { + t.Fatalf("PUT settings = %+v, want updated listen_addr and show_upgrade=false", saved) + } + if saved.SandboxProvider != "docker" { + t.Fatalf("SandboxProvider = %q, want docker", saved.SandboxProvider) + } + if saved.AdvertiseBaseURL != "http://192.168.1.10:19080" { + t.Fatalf("AdvertiseBaseURL = %q, want updated value without trailing slash", saved.AdvertiseBaseURL) + } + if saved.AdvertiseBaseURLEffective != "http://192.168.1.10:19080" { + t.Fatalf("AdvertiseBaseURLEffective = %q, want configured manager base URL", saved.AdvertiseBaseURLEffective) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + content := string(data) + if !strings.Contains(content, "127.0.0.1:19080") || !strings.Contains(content, "show_upgrade = false") { + t.Fatalf("config content = %q, want updated server fields preserved with models section", content) + } + if !strings.Contains(content, `advertise_base_url = "http://192.168.1.10:19080"`) { + t.Fatalf("config content = %q, want updated advertise_base_url", content) + } +} + +func TestHandleConfigSettingsRejectsInvalidBootstrapBeforeSave(t *testing.T) { + dir := t.TempDir() + configPath := dir + "/config.toml" + writeMinimalAPIConfig(t, configPath) + + original, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + hubSvc, err := hub.NewService(config.HubConfig{}, hub.DefaultStoreFactory) + if err != nil { + t.Fatalf("hub.NewService() error = %v", err) + } + + srv := &Handler{} + srv.SetConfigPath(configPath) + srv.SetHubService(hubSvc) + + body, err := json.Marshal(apitypes.UpdateConfigSettingsRequest{ + ListenAddr: "127.0.0.1:19080", + AdvertiseBaseURL: "http://192.168.1.10:19080", + ShowUpgrade: false, + SandboxProvider: "docker", + DefaultManagerTemplate: "builtin.openclaw-manager", + DefaultWorkerTemplate: "builtin.picoclaw-worker", + }) + if err != nil { + t.Fatalf("marshal PUT body: %v", err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/config/settings", bytes.NewReader(body)) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT settings status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "unsupported runtime_kind") { + t.Fatalf("body = %q, want bootstrap runtime validation error", rec.Body.String()) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(data) != string(original) { + t.Fatalf("config content changed after rejected PUT:\n%s", string(data)) + } +} + +func TestHandleConfigSettingsValidatesBootstrapWithHubBeforeSave(t *testing.T) { + dir := t.TempDir() + configPath := dir + "/config.toml" + writeMinimalAPIConfig(t, configPath) + + hubSvc, err := hub.NewService(config.HubConfig{}, hub.DefaultStoreFactory) + if err != nil { + t.Fatalf("hub.NewService() error = %v", err) + } + + srv := &Handler{} + srv.SetConfigPath(configPath) + srv.SetHubService(hubSvc) + + body, err := json.Marshal(apitypes.UpdateConfigSettingsRequest{ + ListenAddr: "127.0.0.1:19080", + AdvertiseBaseURL: "http://192.168.1.10:19080", + ShowUpgrade: false, + SandboxProvider: "docker", + DefaultManagerTemplate: "builtin.picoclaw-manager", + DefaultWorkerTemplate: "builtin.picoclaw-worker", + }) + if err != nil { + t.Fatalf("marshal PUT body: %v", err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/config/settings", bytes.NewReader(body)) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT settings status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + content := string(data) + if !strings.Contains(content, "127.0.0.1:19080") || !strings.Contains(content, "show_upgrade = false") { + t.Fatalf("config content = %q, want updated server fields", content) + } +} diff --git a/internal/api/handler.go b/internal/api/handler.go index 4f33618d..86130c50 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -50,6 +50,7 @@ type Handler struct { upgradeManager *upgrade.Manager upgradeConfigPath string upgradeApply func(upgrade.ApplyHelperOptions) error + configRestartApply func(upgrade.RestartHelperOptions) error localRuntimeImages func(context.Context, config.Config) ([]string, error) notificationDeliver notification.Fanouter activityDecider ActivityDecider @@ -391,6 +392,14 @@ func (h *Handler) SetUpgradeConfigPath(configPath string) { h.upgradeConfigPath = strings.TrimSpace(configPath) } +func (h *Handler) SetConfigRestartApplyFunc(apply func(upgrade.RestartHelperOptions) error) { + if apply == nil { + h.configRestartApply = upgrade.StartRestartHelper + return + } + h.configRestartApply = apply +} + func (h *Handler) SetUpgradeApplyFunc(apply func(upgrade.ApplyHelperOptions) error) { if apply == nil { h.upgradeApply = upgrade.StartApplyHelper diff --git a/internal/api/rest_handlers.go b/internal/api/rest_handlers.go index 087787da..045f8ff2 100644 --- a/internal/api/rest_handlers.go +++ b/internal/api/rest_handlers.go @@ -80,6 +80,18 @@ func (h *Handler) getBootstrapConfig(w http.ResponseWriter, r *http.Request) { func (h *Handler) updateBootstrapConfig(w http.ResponseWriter, r *http.Request) { h.handleBootstrapConfig(w, r) } +func (h *Handler) getConfigFile(w http.ResponseWriter, r *http.Request) { h.handleConfigFile(w, r) } +func (h *Handler) updateConfigFile(w http.ResponseWriter, r *http.Request) { h.handleConfigFile(w, r) } +func (h *Handler) getConfigSettings(w http.ResponseWriter, r *http.Request) { h.handleConfigSettings(w, r) } +func (h *Handler) updateConfigSettings(w http.ResponseWriter, r *http.Request) { + h.handleConfigSettings(w, r) +} +func (h *Handler) applyConfigRestart(w http.ResponseWriter, r *http.Request) { + h.handleConfigApply(w, r) +} +func (h *Handler) getConfigRestartStatus(w http.ResponseWriter, r *http.Request) { + h.handleConfigRestartStatus(w, r) +} func (h *Handler) getIMBootstrap(w http.ResponseWriter, r *http.Request) { h.handleIMBootstrap(w, r) } func (h *Handler) getIMEvents(w http.ResponseWriter, r *http.Request) { h.handleIMEvents(w, r) } func (h *Handler) listRooms(w http.ResponseWriter, r *http.Request) { h.handleRooms(w, r) } diff --git a/internal/api/router.go b/internal/api/router.go index 564dee6a..6b0d44ff 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -66,6 +66,14 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { r.Get("/", h.getBootstrapConfig) r.Put("/", h.updateBootstrapConfig) }) + r.Route("/config", func(r chi.Router) { + r.Get("/", h.getConfigFile) + r.Put("/", h.updateConfigFile) + r.Get("/settings", h.getConfigSettings) + r.Put("/settings", h.updateConfigSettings) + r.Post("/apply", h.applyConfigRestart) + r.Get("/restart/status", h.getConfigRestartStatus) + }) r.Get("/bootstrap", h.getIMBootstrap) r.Get("/events", h.getIMEvents) r.Route("/rooms", func(r chi.Router) { diff --git a/internal/apitypes/types.go b/internal/apitypes/types.go index 571f27cf..fbb67850 100644 --- a/internal/apitypes/types.go +++ b/internal/apitypes/types.go @@ -175,6 +175,51 @@ type UpgradeActionResponse struct { Message string `json:"message,omitempty"` } +type ConfigFileResponse struct { + Path string `json:"path"` + Content string `json:"content"` +} + +type UpdateConfigRequest struct { + Content string `json:"content"` +} + +type ConfigActionResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +type ConfigRestartStatusResponse struct { + ManualRestartRequired bool `json:"manual_restart_required,omitempty"` + Message string `json:"message,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +type ConfigSettingsResponse struct { + Path string `json:"path"` + ListenAddr string `json:"listen_addr"` + AdvertiseBaseURL string `json:"advertise_base_url,omitempty"` + AdvertiseBaseURLEffective string `json:"advertise_base_url_effective,omitempty"` + AccessToken string `json:"access_token,omitempty"` + AccessTokenSet bool `json:"access_token_set,omitempty"` + AccessTokenPreview string `json:"access_token_preview,omitempty"` + ShowUpgrade bool `json:"show_upgrade"` + SandboxProvider string `json:"sandbox_provider"` + SupportedSandboxProviders []string `json:"supported_sandbox_providers,omitempty"` + DefaultManagerTemplate string `json:"default_manager_template"` + DefaultWorkerTemplate string `json:"default_worker_template"` +} + +type UpdateConfigSettingsRequest struct { + ListenAddr string `json:"listen_addr"` + AdvertiseBaseURL string `json:"advertise_base_url,omitempty"` + AccessToken string `json:"access_token,omitempty"` + ShowUpgrade bool `json:"show_upgrade"` + SandboxProvider string `json:"sandbox_provider"` + DefaultManagerTemplate string `json:"default_manager_template"` + DefaultWorkerTemplate string `json:"default_worker_template"` +} + // UnmarshalJSON keeps room payload decoding backward-compatible with legacy participants fields. func (r *Room) UnmarshalJSON(data []byte) error { type roomAlias Room diff --git a/internal/config/raw.go b/internal/config/raw.go new file mode 100644 index 00000000..5258a49b --- /dev/null +++ b/internal/config/raw.go @@ -0,0 +1,64 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ReadRawFile returns the on-disk config.toml bytes. +func ReadRawFile(path string) ([]byte, error) { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" { + return nil, fmt.Errorf("config path is required") + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("config not found at %s; run `csgclaw serve` to initialize local state first", path) + } + return nil, fmt.Errorf("read config: %w", err) + } + return data, nil +} + +// ValidateRaw parses config content without writing it to disk. +func ValidateRaw(content []byte) error { + tmp, err := os.CreateTemp("", "csgclaw-config-validate-*.toml") + if err != nil { + return fmt.Errorf("create temp config: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(content); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp config: %w", err) + } + if _, err := Load(tmpPath); err != nil { + return err + } + return nil +} + +// WriteRawFile validates and writes config content to path. +func WriteRawFile(path string, content []byte) error { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" { + return fmt.Errorf("config path is required") + } + if err := ValidateRaw(content); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} diff --git a/internal/config/raw_test.go b/internal/config/raw_test.go new file mode 100644 index 00000000..8646ffc2 --- /dev/null +++ b/internal/config/raw_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateRawAcceptsMinimalConfig(t *testing.T) { + content := `[server] +listen_addr = "127.0.0.1:18080" +access_token = "secret" + +[models] +default = "default.model" + +[models.providers.default] +base_url = "http://127.0.0.1:4000" +api_key = "sk" +models = ["model"] +` + if err := ValidateRaw([]byte(content)); err != nil { + t.Fatalf("ValidateRaw() error = %v", err) + } +} + +func TestValidateRawRejectsInvalidConfig(t *testing.T) { + if err := ValidateRaw([]byte("[llm]\nprovider = \"openai\"\n")); err == nil { + t.Fatal("ValidateRaw() error = nil, want legacy section failure") + } +} + +func TestWriteRawFileRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ConfigFileName) + content := `[server] +listen_addr = "127.0.0.1:18080" +access_token = "secret" + +[models] +default = "default.model" + +[models.providers.default] +base_url = "http://127.0.0.1:4000" +api_key = "sk" +models = ["model"] +` + if err := WriteRawFile(path, []byte(content)); err != nil { + t.Fatalf("WriteRawFile() error = %v", err) + } + got, err := ReadRawFile(path) + if err != nil { + t.Fatalf("ReadRawFile() error = %v", err) + } + if string(got) != content { + t.Fatalf("ReadRawFile() = %q, want %q", string(got), content) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat() error = %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("file mode = %o, want 0600", info.Mode().Perm()) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go new file mode 100644 index 00000000..beb051d8 --- /dev/null +++ b/internal/config/settings.go @@ -0,0 +1,84 @@ +package config + +import ( + "fmt" + "net" + "strings" +) + +// UserSettings are the config.toml fields exposed in the Web UI settings form. +type UserSettings struct { + ListenAddr string + AdvertiseBaseURL string + AccessToken string + ShowUpgrade bool + SandboxProvider string + DefaultManagerTemplate string + DefaultWorkerTemplate string + SupportedSandboxProvider []string +} + +func UserSettingsFromConfig(cfg Config) UserSettings { + resolved := cfg.Sandbox.Resolved() + return UserSettings{ + ListenAddr: cfg.Server.ListenAddr, + AdvertiseBaseURL: cfg.Server.AdvertiseBaseURL, + AccessToken: cfg.Server.AccessToken, + ShowUpgrade: cfg.Server.ShowUpgrade, + SandboxProvider: resolved.Provider, + DefaultManagerTemplate: cfg.Bootstrap.ResolvedDefaultManagerTemplate(), + DefaultWorkerTemplate: cfg.Bootstrap.ResolvedDefaultWorkerTemplate(), + SupportedSandboxProvider: SupportedSandboxProviders(), + } +} + +func SupportedSandboxProviders() []string { + return []string{BoxLiteProvider, DockerProvider, CSGHubProvider} +} + +func AccessTokenPreview(token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "" + } + runes := []rune(token) + if len(runes) < 9 { + return "" + } + return string(runes[:4]) + "..." +} + +func ApplyUserSettings(cfg Config, settings UserSettings) (Config, error) { + cfg.Server.ListenAddr = strings.TrimSpace(settings.ListenAddr) + cfg.Server.AdvertiseBaseURL = strings.TrimRight(strings.TrimSpace(settings.AdvertiseBaseURL), "/") + cfg.Server.AccessToken = strings.TrimSpace(settings.AccessToken) + cfg.Server.ShowUpgrade = settings.ShowUpgrade + cfg.Sandbox.Provider = strings.TrimSpace(settings.SandboxProvider) + cfg.Bootstrap.DefaultManagerTemplate = normalizeBootstrapTemplateRef(settings.DefaultManagerTemplate) + cfg.Bootstrap.DefaultWorkerTemplate = normalizeBootstrapTemplateRef(settings.DefaultWorkerTemplate) + + if err := validateUserSettings(cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +func validateUserSettings(cfg Config) error { + if strings.TrimSpace(cfg.Server.ListenAddr) == "" { + return fmt.Errorf("server.listen_addr is required") + } + if _, _, err := net.SplitHostPort(cfg.Server.ListenAddr); err != nil { + return fmt.Errorf("server.listen_addr must be a host:port address: %w", err) + } + if strings.TrimSpace(cfg.Server.AccessToken) == "" { + return fmt.Errorf("server.access_token is required") + } + if err := cfg.Bootstrap.Validate(); err != nil { + return err + } + cfg.Sandbox = cfg.Sandbox.Resolved() + if err := cfg.Sandbox.Validate(); err != nil { + return err + } + return nil +} diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go new file mode 100644 index 00000000..95018b91 --- /dev/null +++ b/internal/config/settings_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "strings" + "testing" +) + +func TestApplyUserSettingsUpdatesExposedFields(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "secret", + ShowUpgrade: true, + }, + Bootstrap: BootstrapConfig{ + DefaultManagerTemplate: DefaultBootstrapManagerTemplate, + DefaultWorkerTemplate: DefaultBootstrapWorkerTemplate, + }, + Sandbox: SandboxConfig{Provider: BoxLiteProvider}, + Models: LLMConfig{ + Default: "default.model", + Providers: map[string]ProviderConfig{ + "default": { + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk", + Models: []string{"model"}, + }, + }, + }, + } + + updated, err := ApplyUserSettings(cfg, UserSettings{ + ListenAddr: "0.0.0.0:19080", + AdvertiseBaseURL: "http://192.168.1.10:19080/", + AccessToken: "new-secret", + ShowUpgrade: false, + SandboxProvider: DockerProvider, + DefaultManagerTemplate: "builtin.picoclaw-manager", + DefaultWorkerTemplate: "builtin.picoclaw-worker", + }) + if err != nil { + t.Fatalf("ApplyUserSettings() error = %v", err) + } + if updated.Server.ListenAddr != "0.0.0.0:19080" { + t.Fatalf("ListenAddr = %q", updated.Server.ListenAddr) + } + if updated.Server.AdvertiseBaseURL != "http://192.168.1.10:19080" { + t.Fatalf("AdvertiseBaseURL = %q", updated.Server.AdvertiseBaseURL) + } + if updated.Server.ShowUpgrade { + t.Fatalf("ShowUpgrade = true, want false") + } + if updated.Sandbox.Provider != DockerProvider { + t.Fatalf("Sandbox.Provider = %q, want %q", updated.Sandbox.Provider, DockerProvider) + } + if updated.Models.Default != "default.model" { + t.Fatalf("Models.Default = %q, want preserved", updated.Models.Default) + } +} + +func TestAccessTokenPreview(t *testing.T) { + if got, want := AccessTokenPreview("secret"), ""; got != want { + t.Fatalf("AccessTokenPreview(short) = %q, want empty", got) + } + if got, want := AccessTokenPreview("your-shared-token"), "your..."; got != want { + t.Fatalf("AccessTokenPreview() = %q, want %q", got, want) + } +} + +func TestApplyUserSettingsRejectsInvalidListenAddr(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AccessToken: "secret", + }, + Bootstrap: BootstrapConfig{ + DefaultManagerTemplate: DefaultBootstrapManagerTemplate, + DefaultWorkerTemplate: DefaultBootstrapWorkerTemplate, + }, + Sandbox: SandboxConfig{Provider: BoxLiteProvider}, + } + _, err := ApplyUserSettings(cfg, UserSettings{ + ListenAddr: "not-an-address", + AccessToken: "secret", + ShowUpgrade: true, + SandboxProvider: BoxLiteProvider, + DefaultManagerTemplate: DefaultBootstrapManagerTemplate, + DefaultWorkerTemplate: DefaultBootstrapWorkerTemplate, + }) + if err == nil || !strings.Contains(err.Error(), "listen_addr") { + t.Fatalf("ApplyUserSettings() error = %v, want listen_addr validation failure", err) + } +} diff --git a/internal/upgrade/cli_args.go b/internal/upgrade/cli_args.go new file mode 100644 index 00000000..02a7cc02 --- /dev/null +++ b/internal/upgrade/cli_args.go @@ -0,0 +1,16 @@ +package upgrade + +import "strings" + +// commandArgsWithConfig prefixes CLI args with a leading --config flag so the +// root csgclaw parser can populate GlobalOptions.Config before the subcommand runs. +func commandArgsWithConfig(configPath string, args ...string) []string { + path := strings.TrimSpace(configPath) + if path == "" { + return append([]string(nil), args...) + } + out := make([]string, 0, len(args)+2) + out = append(out, "--config", path) + out = append(out, args...) + return out +} diff --git a/internal/upgrade/cli_args_test.go b/internal/upgrade/cli_args_test.go new file mode 100644 index 00000000..89044d87 --- /dev/null +++ b/internal/upgrade/cli_args_test.go @@ -0,0 +1,20 @@ +package upgrade + +import ( + "reflect" + "testing" +) + +func TestCommandArgsWithConfig(t *testing.T) { + got := commandArgsWithConfig("/tmp/config.toml", "_restart") + want := []string{"--config", "/tmp/config.toml", "_restart"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("commandArgsWithConfig() = %#v, want %#v", got, want) + } + + got = commandArgsWithConfig("", "serve", "-d") + want = []string{"serve", "-d"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("commandArgsWithConfig() empty path = %#v, want %#v", got, want) + } +} diff --git a/internal/upgrade/helper.go b/internal/upgrade/helper.go index 99cbcde4..aeac940f 100644 --- a/internal/upgrade/helper.go +++ b/internal/upgrade/helper.go @@ -37,12 +37,7 @@ func StartApplyHelper(opts ApplyHelperOptions) error { return fmt.Errorf("open upgrade helper log %s: %w", artifacts.LogPath, err) } - args := []string{"upgrade"} - if opts.ConfigPath != "" { - args = append(args, "--config", opts.ConfigPath) - } - - cmd := startHelperCommand(exe, args...) + cmd := startHelperCommand(exe, commandArgsWithConfig(opts.ConfigPath, "upgrade")...) cmd.Stdin = devNull cmd.Stdout = logFile cmd.Stderr = logFile diff --git a/internal/upgrade/helper_test.go b/internal/upgrade/helper_test.go index 1fb3e55f..9e70cf46 100644 --- a/internal/upgrade/helper_test.go +++ b/internal/upgrade/helper_test.go @@ -40,7 +40,7 @@ func TestStartApplyHelperIncludesConfigPath(t *testing.T) { if gotName != "/tmp/csgclaw" { t.Fatalf("command name = %q, want %q", gotName, "/tmp/csgclaw") } - wantArgs := []string{"upgrade", "--config", configPath} + wantArgs := []string{"--config", configPath, "upgrade"} if !reflect.DeepEqual(gotArgs, wantArgs) { t.Fatalf("command args = %#v, want %#v", gotArgs, wantArgs) } diff --git a/internal/upgrade/restart.go b/internal/upgrade/restart.go index 1d4975a6..e859a934 100644 --- a/internal/upgrade/restart.go +++ b/internal/upgrade/restart.go @@ -55,21 +55,7 @@ func (c Client) RestartIfRunning(ctx context.Context, installed InstalledBundle, if err != nil { return RestartResult{}, err } - exePath := layout.CSGClawPath - if err := runUpgradeCommand(ctx, exePath, "stop"); err != nil { - return RestartResult{}, fmt.Errorf("stop running daemon: %w", err) - } - - args := []string{"serve", "--daemon"} - if strings.TrimSpace(opts.ConfigPath) != "" { - args = append(args, "--config", strings.TrimSpace(opts.ConfigPath)) - } - if err := runUpgradeCommand(ctx, exePath, args...); err != nil { - return RestartResult{}, fmt.Errorf("restart daemon: %w", err) - } - - result.Restarted = true - return result, nil + return RestartDaemon(ctx, layout.CSGClawPath, opts) } func defaultUpgradePIDPath() (string, error) { diff --git a/internal/upgrade/restart_daemon.go b/internal/upgrade/restart_daemon.go new file mode 100644 index 00000000..b8ff1262 --- /dev/null +++ b/internal/upgrade/restart_daemon.go @@ -0,0 +1,57 @@ +package upgrade + +import ( + "context" + "fmt" + "os" + "strings" +) + +// RestartDaemon stops a running daemon (via server.pid) and starts serve --daemon again. +// exePath must point at the csgclaw binary to use for stop/start. +func RestartDaemon(ctx context.Context, exePath string, opts RestartOptions) (RestartResult, error) { + exePath = strings.TrimSpace(exePath) + if exePath == "" { + return RestartResult{}, fmt.Errorf("executable path is required") + } + + pidPath, err := defaultUpgradePIDPath() + if err != nil { + return RestartResult{}, err + } + + running, stale, err := daemonRunning(pidPath) + if err != nil { + return RestartResult{}, err + } + result := RestartResult{ + PIDPath: pidPath, + DaemonWasRunning: running, + } + if stale { + _ = removePIDFilePath(pidPath) + } + if !running { + return result, nil + } + + if err := runUpgradeCommand(ctx, exePath, "stop"); err != nil { + return RestartResult{}, fmt.Errorf("stop running daemon: %w", err) + } + + if err := runUpgradeCommand(ctx, exePath, commandArgsWithConfig(opts.ConfigPath, "serve", "-d")...); err != nil { + return RestartResult{}, fmt.Errorf("restart daemon: %w", err) + } + + result.Restarted = true + return result, nil +} + +// RestartDaemonFromExecutable stops and restarts the daemon using the current process binary. +func RestartDaemonFromExecutable(ctx context.Context, opts RestartOptions) (RestartResult, error) { + exePath, err := os.Executable() + if err != nil { + return RestartResult{}, fmt.Errorf("resolve executable: %w", err) + } + return RestartDaemon(ctx, exePath, opts) +} diff --git a/internal/upgrade/restart_helper.go b/internal/upgrade/restart_helper.go new file mode 100644 index 00000000..0dbe1bdd --- /dev/null +++ b/internal/upgrade/restart_helper.go @@ -0,0 +1,217 @@ +package upgrade + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + restartStatusEnvVar = "CSGCLAW_RESTART_STATUS_PATH" + restartLogEnvVar = "CSGCLAW_RESTART_LOG_PATH" + restartStatusFileName = "config-restart-status.json" + restartLogFileName = "config-restart-helper.log" +) + +var ( + startRestartExecutable = os.Executable + startRestartCommand = exec.Command +) + +type RestartHelperOptions struct { + ConfigPath string +} + +type RestartArtifacts struct { + StatusPath string + LogPath string +} + +func ResolveRestartArtifacts(configPath string) (RestartArtifacts, error) { + artifacts, err := ResolveApplyArtifacts(configPath) + if err != nil { + return RestartArtifacts{}, err + } + logDir := filepath.Dir(artifacts.LogPath) + return RestartArtifacts{ + StatusPath: filepath.Join(logDir, restartStatusFileName), + LogPath: filepath.Join(logDir, restartLogFileName), + }, nil +} + +func PrepareRestartArtifacts(configPath string) (RestartArtifacts, error) { + artifacts, err := ResolveRestartArtifacts(configPath) + if err != nil { + return RestartArtifacts{}, err + } + if err := os.MkdirAll(filepath.Dir(artifacts.StatusPath), 0o755); err != nil { + return RestartArtifacts{}, fmt.Errorf("create restart helper state dir: %w", err) + } + if err := os.Remove(artifacts.StatusPath); err != nil && !os.IsNotExist(err) { + return RestartArtifacts{}, fmt.Errorf("remove stale restart helper status: %w", err) + } + return artifacts, nil +} + +func RestartArtifactsFromEnv() RestartArtifacts { + return RestartArtifacts{ + StatusPath: os.Getenv(restartStatusEnvVar), + LogPath: os.Getenv(restartLogEnvVar), + } +} + +func (a RestartArtifacts) Enabled() bool { + return a.StatusPath != "" +} + +func (a RestartArtifacts) Env() []string { + if !a.Enabled() { + return nil + } + env := []string{fmt.Sprintf("%s=%s", restartStatusEnvVar, a.StatusPath)} + if a.LogPath != "" { + env = append(env, fmt.Sprintf("%s=%s", restartLogEnvVar, a.LogPath)) + } + return env +} + +func (a RestartArtifacts) ClearStatus() error { + if !a.Enabled() { + return nil + } + if err := os.Remove(a.StatusPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove restart helper status: %w", err) + } + return nil +} + +func (a RestartArtifacts) RecordFailure(err error) error { + if err == nil || !a.Enabled() { + return nil + } + record := applyFailureRecord{ + Status: ApplyStatusFailed, + Message: err.Error(), + LogPath: a.LogPath, + UpdatedAt: time.Now().UTC(), + } + return writeRestartStatus(a.StatusPath, record) +} + +func (a RestartArtifacts) RecordManualRestartRequired(message string) error { + if !a.Enabled() { + return nil + } + record := applyFailureRecord{ + Status: ApplyStatusManualRestartRequired, + Message: message, + LogPath: a.LogPath, + UpdatedAt: time.Now().UTC(), + } + return writeRestartStatus(a.StatusPath, record) +} + +func writeRestartStatus(path string, record applyFailureRecord) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create restart helper state dir: %w", err) + } + data, err := json.MarshalIndent(record, "", " ") + if err != nil { + return fmt.Errorf("encode restart helper status: %w", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write restart helper status: %w", err) + } + return nil +} + +func ConsumeRestartStatus(configPath string) (ApplyStatusRecord, error) { + artifacts, err := ResolveRestartArtifacts(configPath) + if err != nil { + return ApplyStatusRecord{}, err + } + data, err := os.ReadFile(artifacts.StatusPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ApplyStatusRecord{}, nil + } + return ApplyStatusRecord{}, fmt.Errorf("read restart helper status: %w", err) + } + + var record applyFailureRecord + if err := json.Unmarshal(data, &record); err != nil { + return ApplyStatusRecord{}, fmt.Errorf("decode restart helper status: %w", err) + } + if err := os.Remove(artifacts.StatusPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return ApplyStatusRecord{}, fmt.Errorf("remove consumed restart helper status: %w", err) + } + + message := strings.TrimSpace(record.Message) + status := strings.TrimSpace(record.Status) + if status == "" { + status = ApplyStatusFailed + } + if status == ApplyStatusFailed { + if message == "" { + return ApplyStatusRecord{}, nil + } + if logPath := strings.TrimSpace(record.LogPath); logPath != "" { + message = fmt.Sprintf("%s\nLog: %s", message, logPath) + } + } + if message == "" && status != ApplyStatusManualRestartRequired { + return ApplyStatusRecord{}, nil + } + return ApplyStatusRecord{ + Status: status, + Message: message, + }, nil +} + +func StartRestartHelper(opts RestartHelperOptions) error { + exe, err := startRestartExecutable() + if err != nil { + return fmt.Errorf("resolve executable: %w", err) + } + + artifacts, err := PrepareRestartArtifacts(opts.ConfigPath) + if err != nil { + return err + } + + devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("open %s: %w", os.DevNull, err) + } + + logFile, err := os.OpenFile(artifacts.LogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + _ = devNull.Close() + return fmt.Errorf("open restart helper log %s: %w", artifacts.LogPath, err) + } + + cmd := startRestartCommand(exe, commandArgsWithConfig(opts.ConfigPath, "_restart")...) + cmd.Stdin = devNull + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Env = append(os.Environ(), artifacts.Env()...) + + if err := cmd.Start(); err != nil { + _ = logFile.Close() + _ = devNull.Close() + return fmt.Errorf("start restart helper: %w", err) + } + + go func() { + _ = cmd.Wait() + _ = logFile.Close() + _ = devNull.Close() + }() + + return nil +} diff --git a/internal/upgrade/restart_helper_test.go b/internal/upgrade/restart_helper_test.go new file mode 100644 index 00000000..9c20d46b --- /dev/null +++ b/internal/upgrade/restart_helper_test.go @@ -0,0 +1,87 @@ +package upgrade + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" +) + +func TestStartRestartHelperIncludesConfigPath(t *testing.T) { + origExecutable := startRestartExecutable + origCommand := startRestartCommand + t.Cleanup(func() { + startRestartExecutable = origExecutable + startRestartCommand = origCommand + }) + + startRestartExecutable = func() (string, error) { + return "/tmp/csgclaw", nil + } + + var gotName string + var gotArgs []string + var startedCmd *exec.Cmd + startRestartCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + startedCmd = exec.Command("sh", "-c", "exit 0") + return startedCmd + } + + configPath := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(configPath, []byte("[server]\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := StartRestartHelper(RestartHelperOptions{ConfigPath: configPath}); err != nil { + t.Fatalf("StartRestartHelper() error = %v", err) + } + + if gotName != "/tmp/csgclaw" { + t.Fatalf("command name = %q, want %q", gotName, "/tmp/csgclaw") + } + wantArgs := []string{"--config", configPath, "_restart"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("command args = %#v, want %#v", gotArgs, wantArgs) + } + artifacts, err := ResolveRestartArtifacts(configPath) + if err != nil { + t.Fatalf("ResolveRestartArtifacts() error = %v", err) + } + if startedCmd == nil { + t.Fatal("startRestartCommand was not called") + } + for _, want := range artifacts.Env() { + found := false + for _, got := range startedCmd.Env { + if got == want { + found = true + break + } + } + if !found { + t.Fatalf("command env missing %q in %#v", want, startedCmd.Env) + } + } +} + +func TestConsumeRestartStatusManualRestartRequired(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + artifacts, err := ResolveRestartArtifacts(configPath) + if err != nil { + t.Fatalf("ResolveRestartArtifacts() error = %v", err) + } + if err := artifacts.RecordManualRestartRequired("manual restart required"); err != nil { + t.Fatalf("RecordManualRestartRequired() error = %v", err) + } + + record, err := ConsumeRestartStatus(configPath) + if err != nil { + t.Fatalf("ConsumeRestartStatus() error = %v", err) + } + if record.Status != ApplyStatusManualRestartRequired { + t.Fatalf("status = %q, want %q", record.Status, ApplyStatusManualRestartRequired) + } +} diff --git a/internal/upgrade/upgrade_test.go b/internal/upgrade/upgrade_test.go index e99494cb..f14ef356 100644 --- a/internal/upgrade/upgrade_test.go +++ b/internal/upgrade/upgrade_test.go @@ -682,7 +682,7 @@ func TestClientRestartIfRunningStopsAndStartsDaemon(t *testing.T) { } want := [][]string{ {filepath.Join(installRoot, "bin", "csgclaw"), "stop"}, - {filepath.Join(installRoot, "bin", "csgclaw"), "serve", "--daemon", "--config", "/tmp/custom.toml"}, + {filepath.Join(installRoot, "bin", "csgclaw"), "--config", "/tmp/custom.toml", "serve", "-d"}, } if !reflect.DeepEqual(calls, want) { t.Fatalf("exec calls = %#v, want %#v", calls, want) diff --git a/web/app/src/api/config.ts b/web/app/src/api/config.ts new file mode 100644 index 00000000..fe04242c --- /dev/null +++ b/web/app/src/api/config.ts @@ -0,0 +1,25 @@ +import { get, post, put } from "@/api/client"; +import { ApiEndpoints } from "@/shared/constants/api"; +import type { ConfigSettings, ConfigSettingsUpdatePayload } from "@/models/configSettings"; + +export type ConfigRestartStatusResponse = { + manual_restart_required?: boolean; + message?: string; + last_error?: string; +}; + +export function fetchConfigSettings(): Promise { + return get(ApiEndpoints.configSettings); +} + +export function updateConfigSettings(payload: ConfigSettingsUpdatePayload): Promise { + return put(ApiEndpoints.configSettings, payload); +} + +export function applyConfigRestart(): Promise { + return post(ApiEndpoints.configApply); +} + +export function fetchConfigRestartStatus(): Promise { + return get(ApiEndpoints.configRestartStatus); +} diff --git a/web/app/src/components/business/ProfileControls/APIKeyField.tsx b/web/app/src/components/business/ProfileControls/APIKeyField.tsx index ba96e9e9..3059ebd7 100644 --- a/web/app/src/components/business/ProfileControls/APIKeyField.tsx +++ b/web/app/src/components/business/ProfileControls/APIKeyField.tsx @@ -6,6 +6,7 @@ import { requiredFieldLabel } from "./requiredFieldLabel"; import { isBlank } from "./utils"; export type APIKeyFieldProps = { + label?: string; onInput?: FormEventHandler; profile?: APIKeyProfile | null; required?: boolean; @@ -13,7 +14,7 @@ export type APIKeyFieldProps = { value: string; }; -export function APIKeyField({ value, onInput, profile, required = false, t }: APIKeyFieldProps) { +export function APIKeyField({ label, value, onInput, profile, required = false, t }: APIKeyFieldProps) { const generatedID = useId(); const inputID = `${generatedID}-api-key`; const labelID = `${generatedID}-api-key-label`; @@ -22,12 +23,13 @@ export function APIKeyField({ value, onInput, profile, required = false, t }: AP const showStoredMask = stored && isBlank(value); const previewPrefix = preview.endsWith("...") ? preview.slice(0, -3) : ""; const placeholder = stored ? "" : t("profileAPIKeyNewPlaceholder"); + const labelText = label || t("profileAPIKey"); return (