diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 091e3fbae..228f98c53 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -2,11 +2,13 @@ package api import ( "encoding/json" + "errors" "fmt" "io" "net/http" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" ) // registerConfigRoutes binds configuration management endpoints to the ServeMux. @@ -22,6 +24,10 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { + if errors.Is(err, credential.ErrPassphraseRequired) || errors.Is(err, credential.ErrDecryptionFailed) { + http.Error(w, err.Error(), http.StatusLocked) + return + } http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 10dd07610..5502c9197 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -109,11 +109,18 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { return false, "", fmt.Errorf("failed to load config: %w", err) } + // If passphrase is required but not yet set, report that first so the + // frontend can prompt the user — even before checking the model name. + if h.passphraseStore != nil && !h.passphraseStore.IsSet() { + if configHasEncryptedCredentials(cfg) { + return false, "", credential.ErrPassphraseRequired + } + } + modelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) if modelName == "" { return false, "no default model configured", nil } - modelCfg := lookupModelConfig(cfg, modelName) if modelCfg == nil { return false, fmt.Sprintf("default model %q is invalid", modelName), nil @@ -355,12 +362,14 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { // If we had an active passphrase attempt and the gateway crashed, // mark passphrase as failed so the frontend can show an error. + // But do NOT clear the passphrase store: the gateway may have failed + // for reasons unrelated to the passphrase (e.g. missing default model, + // config error). Keeping the passphrase lets the user access + // /api/config and /api/models to fix the issue without re-entering it. if exitErr != nil { h.passphraseMu.Lock() if h.passphraseLastState == passphraseStatePending { h.passphraseLastState = passphraseStateFailed - // Clear the bad passphrase so user must re-enter - h.passphraseStore.Clear() } h.passphraseMu.Unlock() } else { @@ -833,3 +842,16 @@ func filterEnv(environ []string, key string) []string { } return result } + +// configHasEncryptedCredentials reports whether any model in cfg has an +// api_key that uses the enc:// scheme, meaning a passphrase is required to +// decrypt it before the gateway can start. +func configHasEncryptedCredentials(cfg *config.Config) bool { + const encScheme = "enc://" + for _, m := range cfg.ModelList { + if strings.HasPrefix(m.APIKey, encScheme) { + return true + } + } + return false +} diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 7f3d29c77..7992ddea2 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -9,6 +10,7 @@ import ( "sync" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" ) // registerModelRoutes binds model list management endpoints to the ServeMux. @@ -48,6 +50,10 @@ type modelResponse struct { func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { + if errors.Is(err, credential.ErrPassphraseRequired) || errors.Is(err, credential.ErrDecryptionFailed) { + http.Error(w, err.Error(), http.StatusLocked) + return + } http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } diff --git a/web/backend/api/passphrase.go b/web/backend/api/passphrase.go index 7602b5a77..c015a0bcd 100644 --- a/web/backend/api/passphrase.go +++ b/web/backend/api/passphrase.go @@ -52,10 +52,12 @@ func (h *Handler) handleSetPassphrase(w http.ResponseWriter, r *http.Request) { log.Printf("Failed to start gateway after passphrase unlock: %v", err) // startGatewayLocked failed before spawning the process, so the exit // goroutine will never run. Transition pending → failed manually. + // Do NOT clear the passphrase: the failure may be a config issue + // (e.g. missing default model), not a wrong passphrase. Keeping it + // allows the user to access /api/config to fix the problem. h.passphraseMu.Lock() if h.passphraseLastState == passphraseStatePending { h.passphraseLastState = passphraseStateFailed - h.passphraseStore.Clear() } h.passphraseMu.Unlock() return diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index fe0c84e69..3e6b37504 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -6,7 +6,6 @@ import { IconMoon, IconPlayerPlay, IconPower, - IconRefresh, IconSun, } from "@tabler/icons-react" import { Link } from "@tanstack/react-router" @@ -47,9 +46,7 @@ export function AppHeader() { state: gwState, loading: gwLoading, canStart, - restartRequired, start, - restart, stop, } = useGateway() @@ -71,11 +68,6 @@ export function AppHeader() { } } - const handleGatewayRestart = () => { - if (gwLoading || isRestarting || !restartRequired || !canStart) return - void restart() - } - const confirmStop = () => { setShowStopDialog(false) stop() @@ -129,26 +121,6 @@ export function AppHeader() {