From adc073ee8d6661190b07bbacb00b4fab602822ce Mon Sep 17 00:00:00 2001
From: sky5454
Date: Sat, 14 Mar 2026 04:54:28 +0800
Subject: [PATCH] feat(launcher): in-memory passphrase store with web UI unlock
Replace env-var passphrase forwarding with a SecureStore-backed flow:
- web/backend/main.go: seed passphrase from env var into SecureStore at
startup, then clear the env var and redirect credential.PassphraseProvider
to apiHandler.GetPassphrase so all LoadConfig calls share one source
- web/backend/api/passphrase.go: POST /api/credential/passphrase stores
passphrase and auto-starts gateway; GET status endpoint
- web/backend/api/gateway.go: build child env via filterEnv() (strips
PassphraseEnvVar from parent env) then inject only from SecureStore;
passphraseState machine (pending/failed/none) tracks gateway start outcome;
expose passphrase_state in /api/gateway/status response
- web/backend/api/router.go: add passphraseStore, passphraseMu,
passphraseLastState fields; SeedPassphrase / GetPassphrase methods
- frontend: PassphraseCard component on credentials page; chat-empty-state
shows passphrase input when start_reason contains 'passphrase' or
passphraseState is failed; i18n keys for en + zh
- Makefile: add -buildvcs=false to GOFLAGS
---
Makefile | 2 +-
web/backend/api/gateway.go | 72 ++++++++++-
web/backend/api/passphrase.go | 80 ++++++++++++
web/backend/api/router.go | 43 ++++++-
web/backend/dist/.gitkeep | 1 -
web/backend/main.go | 16 +++
web/frontend/src/api/gateway.ts | 1 +
web/frontend/src/api/passphrase.ts | 30 +++++
.../src/components/chat/chat-empty-state.tsx | 93 ++++++++++++++
.../src/components/chat/chat-page.tsx | 4 +-
.../credentials/credentials-page.tsx | 6 +
.../credentials/passphrase-card.tsx | 114 ++++++++++++++++++
web/frontend/src/hooks/use-gateway.ts | 8 +-
web/frontend/src/i18n/locales/en.json | 13 ++
web/frontend/src/i18n/locales/zh.json | 13 ++
web/frontend/src/store/gateway.ts | 6 +
16 files changed, 489 insertions(+), 13 deletions(-)
create mode 100644 web/backend/api/passphrase.go
delete mode 100644 web/backend/dist/.gitkeep
create mode 100644 web/frontend/src/api/passphrase.ts
create mode 100644 web/frontend/src/components/credentials/passphrase-card.tsx
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 ?? "",
}))
}