diff --git a/Makefile b/Makefile
index 2f673d3b9..6f8b74484 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 1813cac92..cfe81d7dd 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"
)
@@ -74,7 +76,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 {
@@ -91,6 +101,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 {
@@ -256,7 +268,18 @@ func (h *Handler) startGatewayLocked(initialStatus string) (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.
@@ -311,8 +334,9 @@ func (h *Handler) startGatewayLocked(initialStatus string) (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")
}
@@ -329,6 +353,25 @@ func (h *Handler) startGatewayLocked(initialStatus string) (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()
+ }
+
if shouldBroadcastStopped {
gateway.events.Broadcast(GatewayEvent{
Status: "stopped",
@@ -662,6 +705,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
}
@@ -772,3 +822,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..7602b5a77
--- /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("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(),
+ })
+}
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/
+ {t("credentials.passphrase.description")}
+
+ {t("credentials.passphrase.successMessage")}
+ {error}
+ {t("credentials.passphrase.title")}
+
+
+ {t("credentials.passphrase.description")} +
++ {message.text} +
+ )} +