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
This commit is contained in:
sky5454 2026-03-14 04:54:28 +08:00
parent c513ad22d7
commit 0c94bef30e
14 changed files with 514 additions and 104 deletions

View file

@ -3,6 +3,7 @@ package api
import ( import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -18,6 +19,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/web/backend/utils" "github.com/sipeed/picoclaw/web/backend/utils"
) )
@ -74,7 +76,15 @@ func (h *Handler) TryAutoStartGateway() {
ready, reason, err := h.gatewayStartReady() ready, reason, err := h.gatewayStartReady()
if err != nil { if err != nil {
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) log.Printf("Skip auto-starting gateway: %v", err)
}
return return
} }
if !ready { if !ready {
@ -91,6 +101,8 @@ func (h *Handler) TryAutoStartGateway() {
} }
// gatewayStartReady validates whether current config can start the gateway. // 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) { func (h *Handler) gatewayStartReady() (bool, string, error) {
cfg, err := config.LoadConfig(h.configPath) cfg, err := config.LoadConfig(h.configPath)
if err != nil { if err != nil {
@ -256,7 +268,18 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
execPath := utils.FindPicoclawBinary() execPath := utils.FindPicoclawBinary()
cmd := exec.Command(execPath, "gateway") 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 // Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same // GetConfigPath() already reads, so the gateway sub-process uses the same
// config file without requiring a --config flag on the gateway subcommand. // config file without requiring a --config flag on the gateway subcommand.
@ -311,8 +334,9 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
// Wait for exit in background and clean up // Wait for exit in background and clean up
go func() { go func() {
if err := cmd.Wait(); err != nil { exitErr := cmd.Wait()
log.Printf("Gateway process exited: %v", err) if exitErr != nil {
log.Printf("Gateway process exited: %v", exitErr)
} else { } else {
log.Printf("Gateway process exited normally") log.Printf("Gateway process exited normally")
} }
@ -329,11 +353,27 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
} }
gateway.mu.Unlock() 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()
}
if shouldBroadcastStopped { if shouldBroadcastStopped {
gateway.events.Broadcast(GatewayEvent{ gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
Status: "stopped",
RestartRequired: false,
})
} }
}() }()
@ -662,6 +702,13 @@ func (h *Handler) gatewayStatusData() map[string]any {
} }
} }
// 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)
return data return data
} }
@ -772,3 +819,17 @@ func scanPipe(r io.Reader, buf *LogBuffer) {
buf.Append(scanner.Text()) 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
}

View file

@ -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("starting")
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(),
})
}

View file

@ -4,9 +4,22 @@ import (
"net/http" "net/http"
"sync" "sync"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/web/backend/launcherconfig" "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. // Handler serves HTTP API requests.
type Handler struct { type Handler struct {
configPath string configPath string
@ -17,6 +30,9 @@ type Handler struct {
oauthMu sync.Mutex oauthMu sync.Mutex
oauthFlows map[string]*oauthFlow oauthFlows map[string]*oauthFlow
oauthState map[string]string oauthState map[string]string
passphraseStore *credential.SecureStore
passphraseMu sync.Mutex
passphraseLastState passphraseState
} }
// NewHandler creates an instance of the API handler. // NewHandler creates an instance of the API handler.
@ -26,6 +42,7 @@ func NewHandler(configPath string) *Handler {
serverPort: launcherconfig.DefaultPort, serverPort: launcherconfig.DefaultPort,
oauthFlows: make(map[string]*oauthFlow), oauthFlows: make(map[string]*oauthFlow),
oauthState: make(map[string]string), 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...) 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. // RegisterRoutes binds all API endpoint handlers to the ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Config CRUD // Config CRUD
@ -54,6 +86,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// OAuth login and credential management // OAuth login and credential management
h.registerOAuthRoutes(mux) h.registerOAuthRoutes(mux)
// Passphrase management (in-memory store for encrypted credentials)
h.registerPassphraseRoutes(mux)
// Model list management // Model list management
h.registerModelRoutes(mux) h.registerModelRoutes(mux)

View file

@ -22,6 +22,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware" "github.com/sipeed/picoclaw/web/backend/middleware"
@ -115,6 +116,21 @@ func main() {
// API Routes (e.g. /api/status) // API Routes (e.g. /api/status)
apiHandler := api.NewHandler(absPath) apiHandler := api.NewHandler(absPath)
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) 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/<pid>/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) apiHandler.RegisterRoutes(mux)
// Frontend Embedded Assets // Frontend Embedded Assets

View file

@ -4,7 +4,7 @@ interface GatewayStatusResponse {
gateway_status: "running" | "starting" | "restarting" | "stopped" | "error" gateway_status: "running" | "starting" | "restarting" | "stopped" | "error"
gateway_start_allowed?: boolean gateway_start_allowed?: boolean
gateway_start_reason?: string gateway_start_reason?: string
gateway_restart_required?: boolean passphrase_state?: "" | "pending" | "failed"
pid?: number pid?: number
boot_default_model?: string boot_default_model?: string
config_default_model?: string config_default_model?: string

View file

@ -0,0 +1,30 @@
// API client for in-memory passphrase management.
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
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<T>
}
export interface PassphraseStatusResponse {
passphrase_set: boolean
}
/** Returns whether a passphrase is currently held in the launcher. */
export async function getPassphraseStatus(): Promise<PassphraseStatusResponse> {
return request<PassphraseStatusResponse>("/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 }),
})
}

View file

@ -1,26 +1,118 @@
import { import {
IconKey,
IconLoader2,
IconLock,
IconPlugConnectedX, IconPlugConnectedX,
IconRobot, IconRobot,
IconRobotOff, IconRobotOff,
IconStar, IconStar,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { setPassphrase } from "@/api/passphrase"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
interface ChatEmptyStateProps { interface ChatEmptyStateProps {
hasConfiguredModels: boolean hasConfiguredModels: boolean
defaultModelName: string defaultModelName: string
isConnected: boolean isConnected: boolean
gatewayStartReason?: string
passphraseState?: "" | "pending" | "failed"
} }
export function ChatEmptyState({ export function ChatEmptyState({
hasConfiguredModels, hasConfiguredModels,
defaultModelName, defaultModelName,
isConnected, isConnected,
gatewayStartReason = "",
passphraseState = "",
}: ChatEmptyStateProps) { }: ChatEmptyStateProps) {
const { t } = useTranslation() 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 (
<div className="flex flex-col items-center justify-center py-20">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-500/10 text-blue-500">
<IconLock className="h-8 w-8" />
</div>
<h3 className="mb-2 text-xl font-medium">
{t("credentials.passphrase.title")}
</h3>
<p className="text-muted-foreground mb-6 text-center text-sm">
{t("credentials.passphrase.description")}
</p>
{saved ? (
<p className="text-green-600 dark:text-green-400 text-sm">
{t("credentials.passphrase.successMessage")}
</p>
) : (
<div className="flex w-full max-w-sm flex-col gap-2">
<div className="flex gap-2">
<Input
type="password"
placeholder={t("credentials.passphrase.placeholder")}
value={passphrase}
onChange={(e) => setPassphraseValue(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleUnlock() }}
disabled={saving}
autoFocus
/>
<Button
size="sm"
disabled={saving || !passphrase.trim()}
onClick={() => void handleUnlock()}
>
{saving
? <IconLoader2 className="size-4 animate-spin" />
: <IconKey className="size-4" />}
{saving
? t("credentials.passphrase.saving")
: t("credentials.passphrase.save")}
</Button>
</div>
{error && (
<p className="text-destructive text-xs">{error}</p>
)}
</div>
)}
</div>
)
}
if (!hasConfiguredModels) { if (!hasConfiguredModels) {
return ( return (
@ -85,3 +177,4 @@ export function ChatEmptyState({
</div> </div>
) )
} }

View file

@ -33,8 +33,9 @@ export function ChatPage() {
newChat, newChat,
} = usePicoChat() } = usePicoChat()
const { state: gwState } = useGateway() const { state: gwState, startReason, passphraseState } = useGateway()
const isGatewayRunning = gwState === "running" const isConnected = gwState === "running"
const isGatewayRunning = isConnected
const isChatConnected = connectionState === "connected" const isChatConnected = connectionState === "connected"
const { const {
@ -142,7 +143,9 @@ export function ChatPage() {
<ChatEmptyState <ChatEmptyState
hasConfiguredModels={hasConfiguredModels} hasConfiguredModels={hasConfiguredModels}
defaultModelName={defaultModelName} defaultModelName={defaultModelName}
isConnected={isGatewayRunning} isConnected={isConnected}
gatewayStartReason={startReason}
passphraseState={passphraseState}
/> />
)} )}

View file

@ -9,6 +9,7 @@ import { AntigravityCredentialCard } from "./antigravity-credential-card"
import { DeviceCodeSheet } from "./device-code-sheet" import { DeviceCodeSheet } from "./device-code-sheet"
import { LogoutConfirmDialog } from "./logout-confirm-dialog" import { LogoutConfirmDialog } from "./logout-confirm-dialog"
import { OpenAICredentialCard } from "./openai-credential-card" import { OpenAICredentialCard } from "./openai-credential-card"
import { PassphraseCard } from "./passphrase-card"
export function CredentialsPage() { export function CredentialsPage() {
const { t } = useTranslation() const { t } = useTranslation()
@ -51,6 +52,11 @@ export function CredentialsPage() {
</p> </p>
</div> </div>
{/* Passphrase card is always visible — independent of OAuth loading state */}
<div className="pt-5">
<PassphraseCard />
</div>
{error && ( {error && (
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm"> <div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
{error} {error}

View file

@ -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<boolean | null>(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 (
<section className="bg-card flex h-full flex-col rounded-xl border p-4">
<div className="min-h-16">
<h3 className="text-base font-semibold inline-flex items-center gap-2">
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
<IconKey className="size-3.5" />
</span>
{t("credentials.passphrase.title")}
</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("credentials.passphrase.description")}
</p>
</div>
<div className="mt-3 flex items-center gap-2 text-xs">
{isSet === null ? (
<IconLoader2 className="size-3.5 animate-spin text-muted-foreground" />
) : isSet ? (
<>
<IconLockOpen className="size-3.5 text-green-500" />
<span className="text-green-600 dark:text-green-400">
{t("credentials.passphrase.statusSet")}
</span>
</>
) : (
<>
<IconLock className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{t("credentials.passphrase.statusNotSet")}
</span>
</>
)}
</div>
<div className="mt-auto flex flex-col gap-4 pt-4">
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
<div className="flex h-full flex-col gap-3">
<div className="flex h-full items-center gap-2">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSave()
}}
type="password"
placeholder={t("credentials.passphrase.placeholder")}
disabled={saving}
/>
<Button
size="sm"
className="w-fit"
disabled={saving || !value.trim()}
onClick={() => void handleSave()}
>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{saving
? t("credentials.passphrase.saving")
: t("credentials.passphrase.save")}
</Button>
</div>
{message && (
<p
className={`text-xs ${message.error ? "text-destructive" : "text-green-600 dark:text-green-400"}`}
>
{message.text}
</p>
)}
</div>
</div>
<div className="min-h-8" />
</div>
</section>
)
}

View file

@ -1,30 +1,33 @@
import { useAtomValue } from "jotai" import { useAtom } from "jotai"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { import {
type GatewayStatusResponse, type GatewayStatusResponse,
getGatewayStatus, getGatewayStatus,
restartGateway,
startGateway, startGateway,
stopGateway, stopGateway,
} from "@/api/gateway" } from "@/api/gateway"
import { import { gatewayAtom, updateGatewayStore } from "@/store"
applyGatewayStatusToStore,
gatewayAtom,
updateGatewayStore,
} from "@/store"
// Global variable to ensure we only have one SSE connection // Global variable to ensure we only have one SSE connection
let sseInitialized = false let sseInitialized = false
export function useGateway() { export function useGateway() {
const gateway = useAtomValue(gatewayAtom) const [{ status: state, canStart, startReason, passphraseState }, setGateway] = useAtom(gatewayAtom)
const { status: state, canStart, restartRequired } = gateway
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => { const applyGatewayStatus = useCallback(
applyGatewayStatusToStore(data) (data: GatewayStatusResponse) => {
}, []) setGateway((prev) => ({
...prev,
status: data.gateway_status ?? "unknown",
canStart: data.gateway_start_allowed ?? true,
startReason: data.gateway_start_reason ?? "",
passphraseState: data.passphrase_state ?? "",
}))
},
[setGateway],
)
// Initialize global SSE connection once // Initialize global SSE connection once
useEffect(() => { useEffect(() => {
@ -37,7 +40,8 @@ export function useGateway() {
updateGatewayStore({ updateGatewayStore({
status: "unknown", status: "unknown",
canStart: true, canStart: true,
restartRequired: false, startReason: "",
passphraseState: "",
}) })
}) })
@ -116,37 +120,5 @@ export function useGateway() {
} }
}, []) }, [])
const restart = useCallback(async () => { return { state, loading, canStart, startReason, passphraseState, start, stop }
if (state !== "running") return
const previousState = state
const previousCanStart = canStart
const previousRestartRequired = restartRequired
setLoading(true)
updateGatewayStore({
status: "restarting",
restartRequired: false,
})
try {
await restartGateway()
} catch (err) {
console.error("Failed to restart gateway:", err)
try {
const status = await getGatewayStatus()
applyGatewayStatus(status)
} catch {
updateGatewayStore({
status: previousState,
canStart: previousCanStart,
restartRequired: previousRestartRequired,
})
}
} finally {
setLoading(false)
}
}, [applyGatewayStatus, canStart, restartRequired, state])
return { state, loading, canStart, restartRequired, start, stop, restart }
} }

View file

@ -81,6 +81,19 @@
"credentials": { "credentials": {
"description": "Manage OAuth and token-based credentials for supported providers.", "description": "Manage OAuth and token-based credentials for supported providers.",
"loading": "Loading credentials...", "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": { "providers": {
"openai": { "openai": {
"description": "Supports browser OAuth, device code, and token login." "description": "Supports browser OAuth, device code, and token login."

View file

@ -81,6 +81,19 @@
"credentials": { "credentials": {
"description": "管理已支持服务商的 OAuth 与 Token 凭据。", "description": "管理已支持服务商的 OAuth 与 Token 凭据。",
"loading": "正在加载凭据...", "loading": "正在加载凭据...",
"passphrase": {
"title": "加密密码",
"description": "用于解密配置中的 enc:// API Key仅存储在内存中不会写入磁盘。",
"statusSet": "密码已加载",
"statusNotSet": "未设置密码",
"placeholder": "输入密码",
"save": "解锁",
"saving": "解锁中...",
"successMessage": "密码已存储,网关正在启动...",
"errorEmpty": "密码不能为空。",
"errorSave": "存储密码失败。",
"errorWrongPassphrase": "密码错误,网关启动失败,请重试。"
},
"providers": { "providers": {
"openai": { "openai": {
"description": "支持浏览器 OAuth、设备码和 Token 登录。" "description": "支持浏览器 OAuth、设备码和 Token 登录。"

View file

@ -13,36 +13,19 @@ export type GatewayState =
export interface GatewayStoreState { export interface GatewayStoreState {
status: GatewayState status: GatewayState
canStart: boolean canStart: boolean
restartRequired: boolean startReason: string
passphraseState: "" | "pending" | "failed"
} }
type GatewayStorePatch = Partial<GatewayStoreState> type GatewayStorePatch = Partial<GatewayStoreState>
const DEFAULT_GATEWAY_STATE: GatewayStoreState = { // Global atom for gateway state
export const gatewayAtom = atom<GatewayStoreState>({
status: "unknown", status: "unknown",
canStart: true, canStart: true,
restartRequired: false, startReason: "",
} passphraseState: "",
})
// Global atom for gateway state
export const gatewayAtom = atom<GatewayStoreState>(DEFAULT_GATEWAY_STATE)
function normalizeGatewayStoreState(
prev: GatewayStoreState,
patch: GatewayStorePatch,
) {
const next = { ...prev, ...patch }
if (
next.status === prev.status &&
next.canStart === prev.canStart &&
next.restartRequired === prev.restartRequired
) {
return prev
}
return next
}
export function updateGatewayStore( export function updateGatewayStore(
patch: patch:
@ -51,26 +34,17 @@ export function updateGatewayStore(
) { ) {
getDefaultStore().set(gatewayAtom, (prev) => { getDefaultStore().set(gatewayAtom, (prev) => {
const nextPatch = typeof patch === "function" ? patch(prev) : patch const nextPatch = typeof patch === "function" ? patch(prev) : patch
return normalizeGatewayStoreState(prev, nextPatch) return { ...prev, ...nextPatch }
}) })
} }
export function applyGatewayStatusToStore( function applyGatewayStatusToStore(data: GatewayStatusResponse) {
data: Partial< getDefaultStore().set(gatewayAtom, (prev) => ({
Pick< ...prev,
GatewayStatusResponse, status: data.gateway_status ?? "unknown",
"gateway_status" | "gateway_start_allowed" | "gateway_restart_required" canStart: data.gateway_start_allowed ?? true,
> startReason: data.gateway_start_reason ?? "",
>, passphraseState: data.passphrase_state ?? "",
) {
updateGatewayStore((prev) => ({
status: data.gateway_status ?? prev.status,
canStart: data.gateway_start_allowed ?? prev.canStart,
restartRequired:
data.gateway_restart_required ??
(data.gateway_status && data.gateway_status !== "running"
? false
: prev.restartRequired),
})) }))
} }
@ -79,6 +53,6 @@ export async function refreshGatewayState() {
const status = await getGatewayStatus() const status = await getGatewayStatus()
applyGatewayStatusToStore(status) applyGatewayStatusToStore(status)
} catch { } catch {
updateGatewayStore(DEFAULT_GATEWAY_STATE) updateGatewayStore({ status: "unknown", canStart: true, startReason: "", passphraseState: "" })
} }
} }