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:
parent
0c94bef30e
commit
eabc0d385d
7 changed files with 41 additions and 34 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ export function useGateway() {
|
|||
updateGatewayStore({
|
||||
status: "stopped",
|
||||
canStart: true,
|
||||
restartRequired: false,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Failed to stop gateway:", err)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue