feat(launcher): show passphrase prompt on startup; fix 423/model-card

- gateway: check enc:// credentials before model_name so ErrPassphraseRequired
  surfaces as gateway_start_reason and frontend can detect 'passphrase'
- gateway: add configHasEncryptedCredentials() local helper (avoids import cycle)
- config/models API: return 423 Locked for ErrPassphraseRequired/ErrDecryptionFailed
  instead of 500, so frontend can distinguish auth errors from server errors
- passphrase: do NOT clear passphraseStore on gateway exit failure; gateway may
  fail for config reasons unrelated to the passphrase (e.g. missing default model)
- model-card: fix canSetDefault to allow unconfigured models to be set as default
- app-header: remove unused restartRequired/restart from useGateway destructure
- use-gateway: remove dead restartRequired field from stop() updateGatewayStore call
This commit is contained in:
sky5454 2026-03-16 18:41:44 +08:00
parent 0c94bef30e
commit eabc0d385d
7 changed files with 41 additions and 34 deletions

View file

@ -2,11 +2,13 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
) )
// registerConfigRoutes binds configuration management endpoints to the ServeMux. // 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) { func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath) cfg, err := config.LoadConfig(h.configPath)
if err != nil { 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) http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return return
} }

View file

@ -109,11 +109,18 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
return false, "", fmt.Errorf("failed to load config: %w", err) 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()) modelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
if modelName == "" { if modelName == "" {
return false, "no default model configured", nil return false, "no default model configured", nil
} }
modelCfg := lookupModelConfig(cfg, modelName) modelCfg := lookupModelConfig(cfg, modelName)
if modelCfg == nil { if modelCfg == nil {
return false, fmt.Sprintf("default model %q is invalid", modelName), 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, // If we had an active passphrase attempt and the gateway crashed,
// mark passphrase as failed so the frontend can show an error. // 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 { if exitErr != nil {
h.passphraseMu.Lock() h.passphraseMu.Lock()
if h.passphraseLastState == passphraseStatePending { if h.passphraseLastState == passphraseStatePending {
h.passphraseLastState = passphraseStateFailed h.passphraseLastState = passphraseStateFailed
// Clear the bad passphrase so user must re-enter
h.passphraseStore.Clear()
} }
h.passphraseMu.Unlock() h.passphraseMu.Unlock()
} else { } else {
@ -833,3 +842,16 @@ func filterEnv(environ []string, key string) []string {
} }
return result 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
}

View file

@ -2,6 +2,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -9,6 +10,7 @@ import (
"sync" "sync"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
) )
// registerModelRoutes binds model list management endpoints to the ServeMux. // 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) { func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath) cfg, err := config.LoadConfig(h.configPath)
if err != nil { 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) http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return return
} }

View file

@ -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) log.Printf("Failed to start gateway after passphrase unlock: %v", err)
// startGatewayLocked failed before spawning the process, so the exit // startGatewayLocked failed before spawning the process, so the exit
// goroutine will never run. Transition pending → failed manually. // 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() h.passphraseMu.Lock()
if h.passphraseLastState == passphraseStatePending { if h.passphraseLastState == passphraseStatePending {
h.passphraseLastState = passphraseStateFailed h.passphraseLastState = passphraseStateFailed
h.passphraseStore.Clear()
} }
h.passphraseMu.Unlock() h.passphraseMu.Unlock()
return return

View file

@ -6,7 +6,6 @@ import {
IconMoon, IconMoon,
IconPlayerPlay, IconPlayerPlay,
IconPower, IconPower,
IconRefresh,
IconSun, IconSun,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
@ -47,9 +46,7 @@ export function AppHeader() {
state: gwState, state: gwState,
loading: gwLoading, loading: gwLoading,
canStart, canStart,
restartRequired,
start, start,
restart,
stop, stop,
} = useGateway() } = useGateway()
@ -71,11 +68,6 @@ export function AppHeader() {
} }
} }
const handleGatewayRestart = () => {
if (gwLoading || isRestarting || !restartRequired || !canStart) return
void restart()
}
const confirmStop = () => { const confirmStop = () => {
setShowStopDialog(false) setShowStopDialog(false)
stop() stop()
@ -129,26 +121,6 @@ export function AppHeader() {
</AlertDialog> </AlertDialog>
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2"> <div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
{restartRequired && (
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
className="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:bg-amber-500/25"
onClick={handleGatewayRestart}
disabled={gwLoading || isRestarting || !canStart}
aria-label={t("header.gateway.action.restart")}
>
<IconRefresh className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("header.gateway.restartRequired")}
</TooltipContent>
</Tooltip>
)}
{/* Gateway Start/Stop */} {/* Gateway Start/Stop */}
{isRunning ? ( {isRunning ? (
<Tooltip delayDuration={700}> <Tooltip delayDuration={700}>

View file

@ -28,7 +28,7 @@ export function ModelCard({
}: ModelCardProps) { }: ModelCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isOAuth = model.auth_method === "oauth" const isOAuth = model.auth_method === "oauth"
const canSetDefault = model.configured && !model.is_default const canSetDefault = !model.is_default
return ( return (
<div <div

View file

@ -111,7 +111,6 @@ export function useGateway() {
updateGatewayStore({ updateGatewayStore({
status: "stopped", status: "stopped",
canStart: true, canStart: true,
restartRequired: false,
}) })
} catch (err) { } catch (err) {
console.error("Failed to stop gateway:", err) console.error("Failed to stop gateway:", err)