diff --git a/Makefile b/Makefile index a3303191..37915445 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ CLI_BIN ?= $(BIN_DIR)/csgclaw-cli ACR_REGISTRY ?= opencsg-registry.cn-beijing.cr.aliyuncs.com IMAGE ?= $(ACR_REGISTRY)/opencsghq/picoclaw -TAG ?= 2026.5.27 +TAG ?= 2026.6.8 DOCKER_EMBED_IMAGE_TAG ?= dev PICOCLAW_IMAGE_TAG ?= $(DOCKER_EMBED_IMAGE_TAG) LOCAL_IMAGE ?= picoclaw:local 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..8826fabb --- /dev/null +++ b/internal/api/config_handlers.go @@ -0,0 +1,182 @@ +package api + +import ( + "encoding/json" + "fmt" + "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) handleServerConfig(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, serverConfigView(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, serverConfigView(path, cfg)) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func serverConfigView(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) handleServerRestart(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 + } + + restart := h.serverRestartApply + if restart == nil { + restart = upgrade.StartRestartHelper + } + if err := restart(upgrade.RestartHelperOptions{ConfigPath: configPath}); err != nil { + http.Error(w, fmt.Sprintf("start restart helper: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusAccepted, apitypes.ServerRestartResponse{ + Status: "accepted", + Message: "restart helper started", + }) +} + +func (h *Handler) handleServerRestartStatus(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.ServerRestartStatusResponse{} + 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..a1bf44ed --- /dev/null +++ b/internal/api/config_test.go @@ -0,0 +1,243 @@ +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 TestHandleServerRestartStartsHelper(t *testing.T) { + dir := t.TempDir() + configPath := dir + "/config.toml" + writeMinimalAPIConfig(t, configPath) + + var started upgrade.RestartHelperOptions + srv := &Handler{} + srv.SetConfigPath(configPath) + srv.SetServerRestartApplyFunc(func(opts upgrade.RestartHelperOptions) error { + started = opts + return nil + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/server/restart", nil) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST restart 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 TestHandleServerRestartStatusConsumesManualRestartRequired(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/server/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.ServerRestartStatusResponse + 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 TestHandleServerConfigGetPut(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/server/config", nil) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET config 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 config response: %v", err) + } + if got.Path != configPath || got.ListenAddr == "" { + t.Fatalf("GET config = %+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/server/config", bytes.NewReader(body)) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT config 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 config response: %v", err) + } + if saved.ListenAddr != "127.0.0.1:19080" || saved.ShowUpgrade { + t.Fatalf("PUT config = %+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 TestHandleServerConfigRejectsInvalidBootstrapBeforeSave(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/server/config", bytes.NewReader(body)) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT config 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 TestHandleServerConfigValidatesBootstrapWithHubBeforeSave(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/server/config", bytes.NewReader(body)) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT config 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..9a7478e8 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 + serverRestartApply 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) SetServerRestartApplyFunc(apply func(upgrade.RestartHelperOptions) error) { + if apply == nil { + h.serverRestartApply = upgrade.StartRestartHelper + return + } + h.serverRestartApply = 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..dcdd7e61 100644 --- a/internal/api/rest_handlers.go +++ b/internal/api/rest_handlers.go @@ -80,6 +80,16 @@ 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) getServerConfig(w http.ResponseWriter, r *http.Request) { h.handleServerConfig(w, r) } +func (h *Handler) updateServerConfig(w http.ResponseWriter, r *http.Request) { + h.handleServerConfig(w, r) +} +func (h *Handler) postServerRestart(w http.ResponseWriter, r *http.Request) { + h.handleServerRestart(w, r) +} +func (h *Handler) getServerRestartStatus(w http.ResponseWriter, r *http.Request) { + h.handleServerRestartStatus(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..f3d016f7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -66,6 +66,12 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { r.Get("/", h.getBootstrapConfig) r.Put("/", h.updateBootstrapConfig) }) + r.Route("/server", func(r chi.Router) { + r.Get("/config", h.getServerConfig) + r.Put("/config", h.updateServerConfig) + r.Post("/restart", h.postServerRestart) + r.Get("/restart/status", h.getServerRestartStatus) + }) 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..2df73b4d 100644 --- a/internal/apitypes/types.go +++ b/internal/apitypes/types.go @@ -175,6 +175,42 @@ type UpgradeActionResponse struct { Message string `json:"message,omitempty"` } +type ServerRestartResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +type ServerRestartStatusResponse 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/templates/embed/picoclaw-manager/Dockerfile b/internal/templates/embed/picoclaw-manager/Dockerfile index 252ef41e..9b7430c9 100644 --- a/internal/templates/embed/picoclaw-manager/Dockerfile +++ b/internal/templates/embed/picoclaw-manager/Dockerfile @@ -11,7 +11,7 @@ # --build-arg PICOCLAW_IMAGE=opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27 \ # -t picoclaw-manager:local . -ARG PICOCLAW_IMAGE=opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27 +ARG PICOCLAW_IMAGE=opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8 FROM ${PICOCLAW_IMAGE} diff --git a/internal/templates/embed/picoclaw-worker/Dockerfile b/internal/templates/embed/picoclaw-worker/Dockerfile index 1bc83b11..1e4f93f1 100644 --- a/internal/templates/embed/picoclaw-worker/Dockerfile +++ b/internal/templates/embed/picoclaw-worker/Dockerfile @@ -11,7 +11,7 @@ # --build-arg PICOCLAW_IMAGE=opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27 \ # -t picoclaw-worker:local . -ARG PICOCLAW_IMAGE=opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27 +ARG PICOCLAW_IMAGE=opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8 FROM ${PICOCLAW_IMAGE} 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..321a19d4 --- /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 ServerRestartStatusResponse = { + manual_restart_required?: boolean; + message?: string; + last_error?: string; +}; + +export function fetchServerConfig(): Promise { + return get(ApiEndpoints.serverConfig); +} + +export function updateServerConfig(payload: ConfigSettingsUpdatePayload): Promise { + return put(ApiEndpoints.serverConfig, payload); +} + +export function restartServer(): Promise { + return post(ApiEndpoints.serverRestart); +} + +export function fetchServerRestartStatus(): Promise { + return get(ApiEndpoints.serverRestartStatus); +} 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 (