diff --git a/Makefile b/Makefile index 98642703f..fb2e21a85 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit # Go variables GO?=CGO_ENABLED=0 go -GOFLAGS?=-v -tags stdjson +GOFLAGS?=-v -tags stdjson -buildvcs=false # Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600). # diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 41f702e32..01a27d5ec 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -3,6 +3,7 @@ package api import ( "bufio" "encoding/json" + "errors" "fmt" "io" "log" @@ -18,6 +19,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/web/backend/utils" ) @@ -57,7 +59,15 @@ func (h *Handler) TryAutoStartGateway() { ready, reason, err := h.gatewayStartReady() if err != nil { - log.Printf("Skip auto-starting gateway: %v", err) + if errors.Is(err, credential.ErrPassphraseRequired) { + log.Printf("Skip auto-starting gateway: encrypted credentials require a passphrase. "+ + "Enter it on the Credentials page to unlock.", ) + } else if errors.Is(err, credential.ErrDecryptionFailed) { + log.Printf("Skip auto-starting gateway: failed to decrypt credentials. "+ + "Check the passphrase and SSH key on the Credentials page.") + } else { + log.Printf("Skip auto-starting gateway: %v", err) + } return } if !ready { @@ -74,6 +84,8 @@ func (h *Handler) TryAutoStartGateway() { } // gatewayStartReady validates whether current config can start the gateway. +// LoadConfig uses credential.PassphraseProvider (set to SecureStore.Get at +// startup) so enc:// credentials are resolved correctly without os.Environ. func (h *Handler) gatewayStartReady() (bool, string, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { @@ -136,7 +148,18 @@ func (h *Handler) startGatewayLocked() (int, error) { execPath := utils.FindPicoclawBinary() cmd := exec.Command(execPath, "gateway") - cmd.Env = os.Environ() + + // Build a clean environment for the child process. + // Start from the launcher's current environment, but explicitly strip + // PICOCLAW_KEY_PASSPHRASE so it cannot leak from the parent env. + // The passphrase is then injected directly from the in-memory SecureStore + // (child-only; never stored in the launcher's own os.Environ). + childEnv := filterEnv(os.Environ(), credential.PassphraseEnvVar) + if passphrase := h.passphraseStore.Get(); passphrase != "" { + childEnv = append(childEnv, credential.PassphraseEnvVar+"="+passphrase) + } + cmd.Env = childEnv + // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same // config file without requiring a --config flag on the gateway subcommand. @@ -183,8 +206,9 @@ func (h *Handler) startGatewayLocked() (int, error) { // Wait for exit in background and clean up go func() { - if err := cmd.Wait(); err != nil { - log.Printf("Gateway process exited: %v", err) + exitErr := cmd.Wait() + if exitErr != nil { + log.Printf("Gateway process exited: %v", exitErr) } else { log.Printf("Gateway process exited normally") } @@ -195,6 +219,25 @@ func (h *Handler) startGatewayLocked() (int, error) { } gateway.mu.Unlock() + // If we had an active passphrase attempt and the gateway crashed, + // mark passphrase as failed so the frontend can show an error. + 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 { + // Clean normal exit + h.passphraseMu.Lock() + if h.passphraseLastState == passphraseStatePending { + h.passphraseLastState = passphraseStateNone + } + h.passphraseMu.Unlock() + } + // Broadcast stopped event gateway.events.Broadcast(GatewayEvent{Status: "stopped"}) }() @@ -434,6 +477,13 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { } } + // Expose passphrase state so the frontend can distinguish + // "never entered" vs "wrong passphrase" vs "pending start". + h.passphraseMu.Lock() + ps := h.passphraseLastState + h.passphraseMu.Unlock() + data["passphrase_state"] = string(ps) + // Append incremental log data appendGatewayLogs(r, data) @@ -558,3 +608,17 @@ func scanPipe(r io.Reader, buf *LogBuffer) { buf.Append(scanner.Text()) } } + +// filterEnv returns a copy of environ with all entries whose key matches +// the supplied key removed. Used to strip the passphrase from the +// inherited environment before assembling the child-process environ. +func filterEnv(environ []string, key string) []string { + prefix := key + "=" + result := make([]string, 0, len(environ)) + for _, e := range environ { + if !strings.HasPrefix(e, prefix) { + result = append(result, e) + } + } + return result +} diff --git a/web/backend/api/passphrase.go b/web/backend/api/passphrase.go new file mode 100644 index 000000000..ae7eae649 --- /dev/null +++ b/web/backend/api/passphrase.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" +) + +// registerPassphraseRoutes binds the passphrase management endpoints. +func (h *Handler) registerPassphraseRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/credential/passphrase", h.handleSetPassphrase) + mux.HandleFunc("GET /api/credential/passphrase/status", h.handlePassphraseStatus) +} + +// handleSetPassphrase stores the supplied passphrase in the in-memory +// SecureStore, then attempts to auto-start the gateway if it is not running. +// +// POST /api/credential/passphrase +// Body: {"passphrase": "..."} +func (h *Handler) handleSetPassphrase(w http.ResponseWriter, r *http.Request) { + var body struct { + Passphrase string `json:"passphrase"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if body.Passphrase == "" { + http.Error(w, "passphrase must not be empty", http.StatusBadRequest) + return + } + + h.passphraseStore.SetString(body.Passphrase) + + // Mark state as pending before launching gateway + h.passphraseMu.Lock() + h.passphraseLastState = passphraseStatePending + h.passphraseMu.Unlock() + + // Try to start the gateway now that the passphrase is available. + // credential.PassphraseProvider points to passphraseStore.Get, so + // gatewayStartReady() (and all LoadConfig calls) will resolve enc:// + // credentials correctly using the newly stored passphrase. + go func() { + gateway.mu.Lock() + defer gateway.mu.Unlock() + if isGatewayProcessAliveLocked() { + return + } + pid, err := h.startGatewayLocked() + if err != nil { + 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. + h.passphraseMu.Lock() + if h.passphraseLastState == passphraseStatePending { + h.passphraseLastState = passphraseStateFailed + h.passphraseStore.Clear() + } + h.passphraseMu.Unlock() + return + } + log.Printf("Gateway started after passphrase unlock (PID: %d)", pid) + }() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + }) +} + +// handlePassphraseStatus reports whether a passphrase is currently stored. +// +// GET /api/credential/passphrase/status +func (h *Handler) handlePassphraseStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "passphrase_set": h.passphraseStore.IsSet(), + }) +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 5f081dee9..02dc52116 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -4,9 +4,22 @@ import ( "net/http" "sync" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) +// passphraseState tracks what happened with the last passphrase attempt. +// "" → no passphrase submitted yet (or just cleared) +// "pending" → passphrase set, gateway starting +// "failed" → gateway exited; passphrase likely wrong +type passphraseState string + +const ( + passphraseStateNone passphraseState = "" + passphraseStatePending passphraseState = "pending" + passphraseStateFailed passphraseState = "failed" +) + // Handler serves HTTP API requests. type Handler struct { configPath string @@ -17,15 +30,19 @@ type Handler struct { oauthMu sync.Mutex oauthFlows map[string]*oauthFlow oauthState map[string]string + passphraseStore *credential.SecureStore + passphraseMu sync.Mutex + passphraseLastState passphraseState } // NewHandler creates an instance of the API handler. func NewHandler(configPath string) *Handler { return &Handler{ - configPath: configPath, - serverPort: launcherconfig.DefaultPort, - oauthFlows: make(map[string]*oauthFlow), - oauthState: make(map[string]string), + configPath: configPath, + serverPort: launcherconfig.DefaultPort, + oauthFlows: make(map[string]*oauthFlow), + oauthState: make(map[string]string), + passphraseStore: credential.NewSecureStore(), } } @@ -37,6 +54,21 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a h.serverCIDRs = append([]string(nil), allowedCIDRs...) } +// SeedPassphrase pre-loads the passphrase into the in-memory SecureStore. +// Call this at startup when the passphrase was supplied via an environment +// variable; after seeding, the caller should clear the env var so it is no +// longer visible in the process environment. +func (h *Handler) SeedPassphrase(passphrase string) { + h.passphraseStore.SetString(passphrase) +} + +// GetPassphrase returns the currently stored passphrase, or "" if not set. +// This satisfies the credential.PassphraseProvider signature so all LoadConfig +// calls in the launcher automatically use the in-memory store. +func (h *Handler) GetPassphrase() string { + return h.passphraseStore.Get() +} + // RegisterRoutes binds all API endpoint handlers to the ServeMux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Config CRUD @@ -54,6 +86,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // OAuth login and credential management h.registerOAuthRoutes(mux) + // Passphrase management (in-memory store for encrypted credentials) + h.registerPassphraseRoutes(mux) + // Model list management h.registerModelRoutes(mux) diff --git a/web/backend/dist/.gitkeep b/web/backend/dist/.gitkeep deleted file mode 100644 index 4b533f03a..000000000 --- a/web/backend/dist/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Keep the embedded web backend dist directory in version control. diff --git a/web/backend/main.go b/web/backend/main.go index 650540ea8..11fc1aefc 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -22,6 +22,7 @@ import ( "strconv" "time" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/middleware" @@ -115,6 +116,21 @@ func main() { // API Routes (e.g. /api/status) apiHandler := api.NewHandler(absPath) apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) + + // If PICOCLAW_KEY_PASSPHRASE is set in the environment at startup, seed it + // into the in-memory SecureStore and then remove it from the process + // environment so it is no longer visible via /proc//environ or similar. + if envPassphrase := os.Getenv(credential.PassphraseEnvVar); envPassphrase != "" { + apiHandler.SeedPassphrase(envPassphrase) + os.Unsetenv(credential.PassphraseEnvVar) + log.Printf("Seeded passphrase from %s environment variable (env var cleared)", credential.PassphraseEnvVar) + } + + // Point the credential package at the in-memory store so that all + // LoadConfig() calls in the launcher (config API, models API, gateway + // readiness check, etc.) use the same passphrase source. + credential.PassphraseProvider = apiHandler.GetPassphrase + apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts index 020e92e3a..cb9ed8058 100644 --- a/web/frontend/src/api/gateway.ts +++ b/web/frontend/src/api/gateway.ts @@ -4,6 +4,7 @@ interface GatewayStatusResponse { gateway_status: "running" | "starting" | "stopped" | "error" gateway_start_allowed?: boolean gateway_start_reason?: string + passphrase_state?: "" | "pending" | "failed" pid?: number logs?: string[] log_total?: number diff --git a/web/frontend/src/api/passphrase.ts b/web/frontend/src/api/passphrase.ts new file mode 100644 index 000000000..47115ff78 --- /dev/null +++ b/web/frontend/src/api/passphrase.ts @@ -0,0 +1,30 @@ +// API client for in-memory passphrase management. + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + const text = await res.text().catch(() => res.statusText) + throw new Error(text || `API error: ${res.status}`) + } + return res.json() as Promise +} + +export interface PassphraseStatusResponse { + passphrase_set: boolean +} + +/** Returns whether a passphrase is currently held in the launcher. */ +export async function getPassphraseStatus(): Promise { + return request("/api/credential/passphrase/status") +} + +/** Stores the passphrase in the launcher's in-memory SecureStore. */ +export async function setPassphrase(passphrase: string): Promise<{ status: string }> { + return request<{ status: string }>("/api/credential/passphrase", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }), + }) +} diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx index 624ff9c59..3387b7cc9 100644 --- a/web/frontend/src/components/chat/chat-empty-state.tsx +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -1,26 +1,118 @@ import { + IconKey, + IconLoader2, + IconLock, IconPlugConnectedX, IconRobot, IconRobotOff, IconStar, } from "@tabler/icons-react" import { Link } from "@tanstack/react-router" +import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" +import { setPassphrase } from "@/api/passphrase" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" interface ChatEmptyStateProps { hasConfiguredModels: boolean defaultModelName: string isConnected: boolean + gatewayStartReason?: string + passphraseState?: "" | "pending" | "failed" } export function ChatEmptyState({ hasConfiguredModels, defaultModelName, isConnected, + gatewayStartReason = "", + passphraseState = "", }: ChatEmptyStateProps) { const { t } = useTranslation() + const needsPassphrase = gatewayStartReason.toLowerCase().includes("passphrase") + || passphraseState === "failed" + || passphraseState === "pending" + + const [passphrase, setPassphraseValue] = useState("") + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + const [saved, setSaved] = useState(false) + + // When backend signals failure, reset the saved flag and show error. + useEffect(() => { + if (passphraseState === "failed") { + setSaved(false) + setError(t("credentials.passphrase.errorWrongPassphrase")) + } + }, [passphraseState, t]) + + async function handleUnlock() { + if (!passphrase.trim()) return + setSaving(true) + setError("") + try { + await setPassphrase(passphrase.trim()) + setSaved(true) + setPassphraseValue("") + } catch { + setError(t("credentials.passphrase.errorSave")) + } finally { + setSaving(false) + } + } + + // Passphrase unlock takes priority — models/config can't load without it + if (!isConnected && needsPassphrase) { + return ( +
+
+ +
+

+ {t("credentials.passphrase.title")} +

+

+ {t("credentials.passphrase.description")} +

+ {saved ? ( +

+ {t("credentials.passphrase.successMessage")} +

+ ) : ( +
+
+ setPassphraseValue(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void handleUnlock() }} + disabled={saving} + autoFocus + /> + +
+ {error && ( +

{error}

+ )} +
+ )} +
+ ) + } if (!hasConfiguredModels) { return ( @@ -85,3 +177,4 @@ export function ChatEmptyState({ ) } + diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index a3ab843b4..dab4aabaa 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -31,7 +31,7 @@ export function ChatPage() { newChat, } = usePicoChat() - const { state: gwState } = useGateway() + const { state: gwState, startReason, passphraseState } = useGateway() const isConnected = gwState === "running" const { @@ -127,6 +127,8 @@ export function ChatPage() { hasConfiguredModels={hasConfiguredModels} defaultModelName={defaultModelName} isConnected={isConnected} + gatewayStartReason={startReason} + passphraseState={passphraseState} /> )} diff --git a/web/frontend/src/components/credentials/credentials-page.tsx b/web/frontend/src/components/credentials/credentials-page.tsx index 04aceb002..e37d3f9d9 100644 --- a/web/frontend/src/components/credentials/credentials-page.tsx +++ b/web/frontend/src/components/credentials/credentials-page.tsx @@ -9,6 +9,7 @@ import { AntigravityCredentialCard } from "./antigravity-credential-card" import { DeviceCodeSheet } from "./device-code-sheet" import { LogoutConfirmDialog } from "./logout-confirm-dialog" import { OpenAICredentialCard } from "./openai-credential-card" +import { PassphraseCard } from "./passphrase-card" export function CredentialsPage() { const { t } = useTranslation() @@ -51,6 +52,11 @@ export function CredentialsPage() {

+ {/* Passphrase card is always visible — independent of OAuth loading state */} +
+ +
+ {error && (
{error} diff --git a/web/frontend/src/components/credentials/passphrase-card.tsx b/web/frontend/src/components/credentials/passphrase-card.tsx new file mode 100644 index 000000000..2657fd56a --- /dev/null +++ b/web/frontend/src/components/credentials/passphrase-card.tsx @@ -0,0 +1,114 @@ +import { IconKey, IconLock, IconLockOpen, IconLoader2 } from "@tabler/icons-react" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import { getPassphraseStatus, setPassphrase } from "@/api/passphrase" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +export function PassphraseCard() { + const { t } = useTranslation() + const [value, setValue] = useState("") + const [isSet, setIsSet] = useState(null) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null) + + useEffect(() => { + getPassphraseStatus() + .then((res) => setIsSet(res.passphrase_set)) + .catch(() => setIsSet(false)) + }, []) + + async function handleSave() { + if (!value.trim()) { + setMessage({ text: t("credentials.passphrase.errorEmpty"), error: true }) + return + } + setSaving(true) + setMessage(null) + try { + await setPassphrase(value.trim()) + setIsSet(true) + setValue("") + setMessage({ text: t("credentials.passphrase.successMessage"), error: false }) + } catch { + setMessage({ text: t("credentials.passphrase.errorSave"), error: true }) + } finally { + setSaving(false) + } + } + + return ( +
+
+

+ + + + {t("credentials.passphrase.title")} +

+

+ {t("credentials.passphrase.description")} +

+
+ +
+ {isSet === null ? ( + + ) : isSet ? ( + <> + + + {t("credentials.passphrase.statusSet")} + + + ) : ( + <> + + + {t("credentials.passphrase.statusNotSet")} + + + )} +
+ +
+
+
+
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSave() + }} + type="password" + placeholder={t("credentials.passphrase.placeholder")} + disabled={saving} + /> + +
+ {message && ( +

+ {message.text} +

+ )} +
+
+
+
+
+ ) +} diff --git a/web/frontend/src/hooks/use-gateway.ts b/web/frontend/src/hooks/use-gateway.ts index 097dc3598..44d6e4483 100644 --- a/web/frontend/src/hooks/use-gateway.ts +++ b/web/frontend/src/hooks/use-gateway.ts @@ -13,7 +13,7 @@ import { gatewayAtom } from "@/store" let sseInitialized = false export function useGateway() { - const [{ status: state, canStart }, setGateway] = useAtom(gatewayAtom) + const [{ status: state, canStart, startReason, passphraseState }, setGateway] = useAtom(gatewayAtom) const [loading, setLoading] = useState(false) const applyGatewayStatus = useCallback( @@ -22,6 +22,8 @@ export function useGateway() { ...prev, status: data.gateway_status ?? "unknown", canStart: data.gateway_start_allowed ?? true, + startReason: data.gateway_start_reason ?? "", + passphraseState: data.passphrase_state ?? "", })) }, [setGateway], @@ -38,6 +40,8 @@ export function useGateway() { setGateway({ status: "unknown", canStart: true, + startReason: "", + passphraseState: "", }) }) @@ -117,5 +121,5 @@ export function useGateway() { } }, []) - return { state, loading, canStart, start, stop } + return { state, loading, canStart, startReason, passphraseState, start, stop } } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 453c5905f..810d9976e 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -78,6 +78,19 @@ "credentials": { "description": "Manage OAuth and token-based credentials for supported providers.", "loading": "Loading credentials...", + "passphrase": { + "title": "Encryption Passphrase", + "description": "Required to decrypt enc:// API keys in your config. Stored only in memory — never written to disk.", + "statusSet": "Passphrase loaded", + "statusNotSet": "No passphrase set", + "placeholder": "Enter passphrase", + "save": "Unlock", + "saving": "Unlocking...", + "successMessage": "Passphrase stored. Gateway is starting...", + "errorEmpty": "Passphrase must not be empty.", + "errorSave": "Failed to store passphrase.", + "errorWrongPassphrase": "Wrong passphrase — gateway failed to start. Please try again." + }, "providers": { "openai": { "description": "Supports browser OAuth, device code, and token login." diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index b6bdedbfa..75441b217 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -78,6 +78,19 @@ "credentials": { "description": "管理已支持服务商的 OAuth 与 Token 凭据。", "loading": "正在加载凭据...", + "passphrase": { + "title": "加密密码", + "description": "用于解密配置中的 enc:// API Key,仅存储在内存中,不会写入磁盘。", + "statusSet": "密码已加载", + "statusNotSet": "未设置密码", + "placeholder": "输入密码", + "save": "解锁", + "saving": "解锁中...", + "successMessage": "密码已存储,网关正在启动...", + "errorEmpty": "密码不能为空。", + "errorSave": "存储密码失败。", + "errorWrongPassphrase": "密码错误,网关启动失败,请重试。" + }, "providers": { "openai": { "description": "支持浏览器 OAuth、设备码和 Token 登录。" diff --git a/web/frontend/src/store/gateway.ts b/web/frontend/src/store/gateway.ts index 89da9d7fd..11585a7a8 100644 --- a/web/frontend/src/store/gateway.ts +++ b/web/frontend/src/store/gateway.ts @@ -12,12 +12,16 @@ export type GatewayState = export interface GatewayStoreState { status: GatewayState canStart: boolean + startReason: string + passphraseState: "" | "pending" | "failed" } // Global atom for gateway state export const gatewayAtom = atom({ status: "unknown", canStart: true, + startReason: "", + passphraseState: "", }) function applyGatewayStatusToStore(data: GatewayStatusResponse) { @@ -25,6 +29,8 @@ function applyGatewayStatusToStore(data: GatewayStatusResponse) { ...prev, status: data.gateway_status ?? "unknown", canStart: data.gateway_start_allowed ?? true, + startReason: data.gateway_start_reason ?? "", + passphraseState: data.passphrase_state ?? "", })) }