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 (
"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
}

View file

@ -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
}

View file

@ -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
}

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)
// 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

View file

@ -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() {
</AlertDialog>
<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 */}
{isRunning ? (
<Tooltip delayDuration={700}>

View file

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

View file

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