From dea99da7d92ab9be6babc67b3bd83e59c9a62cad Mon Sep 17 00:00:00 2001 From: wenjie Date: Tue, 24 Mar 2026 18:06:29 +0800 Subject: [PATCH] fix(web): auto-configure Pico channel on launcher startup Export EnsurePicoChannel and reuse it during launcher and gateway startup so the Pico channel is initialized earlier with a generated token when needed. Also extend backend tests to cover startup-time Pico setup behavior and keep the setup path idempotent. --- web/backend/api/gateway.go | 2 +- web/backend/api/pico.go | 6 +-- web/backend/api/pico_test.go | 71 +++++++++++++++++++++++++----------- web/backend/main.go | 3 ++ 4 files changed, 56 insertions(+), 26 deletions(-) diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 7f72f12b8..4bde5ce82 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -407,7 +407,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int gateway.logs.Reset() // Ensure Pico Channel is configured before starting gateway - if _, err := h.ensurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(""); err != nil { logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err)) // Non-fatal: gateway can still start without pico channel } diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 8fbb8737f..4faafc2ae 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -90,14 +90,14 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { }) } -// ensurePicoChannel enables the Pico channel with sane defaults if it isn't +// EnsurePicoChannel enables the Pico channel with sane defaults if it isn't // already configured. Returns true when the config was modified. // // callerOrigin is the Origin header from the setup request. If non-empty and // no origins are configured yet, it's written as the allowed origin so the // WebSocket handshake works for whatever host the caller is on (LAN, custom // port, etc.). Pass "" when there's no request context. -func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) { +func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return false, fmt.Errorf("failed to load config: %w", err) @@ -134,7 +134,7 @@ func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) { // // POST /api/pico/setup func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { - changed, err := h.ensurePicoChannel(r.Header.Get("Origin")) + changed, err := h.EnsurePicoChannel(r.Header.Get("Origin")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index b59878bf3..051e356cf 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -18,12 +18,12 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - changed, err := h.ensurePicoChannel("") + changed, err := h.EnsurePicoChannel("") if err != nil { - t.Fatalf("ensurePicoChannel() error = %v", err) + t.Fatalf("EnsurePicoChannel() error = %v", err) } if !changed { - t.Fatal("ensurePicoChannel() should report changed on a fresh config") + t.Fatal("EnsurePicoChannel() should report changed on a fresh config") } cfg, err := config.LoadConfig(configPath) @@ -43,8 +43,8 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.ensurePicoChannel(""); err != nil { - t.Fatalf("ensurePicoChannel() error = %v", err) + if _, err := h.EnsurePicoChannel(""); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) } cfg, err := config.LoadConfig(configPath) @@ -61,8 +61,8 @@ func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.ensurePicoChannel("http://localhost:18800"); err != nil { - t.Fatalf("ensurePicoChannel() error = %v", err) + if _, err := h.EnsurePicoChannel("http://localhost:18800"); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) } cfg, err := config.LoadConfig(configPath) @@ -81,8 +81,8 @@ func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.ensurePicoChannel(""); err != nil { - t.Fatalf("ensurePicoChannel() error = %v", err) + if _, err := h.EnsurePicoChannel(""); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) } cfg, err := config.LoadConfig(configPath) @@ -102,8 +102,8 @@ func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { h := NewHandler(configPath) lanOrigin := "http://192.168.1.9:18800" - if _, err := h.ensurePicoChannel(lanOrigin); err != nil { - t.Fatalf("ensurePicoChannel() error = %v", err) + if _, err := h.EnsurePicoChannel(lanOrigin); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) } cfg, err := config.LoadConfig(configPath) @@ -131,12 +131,12 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { h := NewHandler(configPath) - changed, err := h.ensurePicoChannel("") + changed, err := h.EnsurePicoChannel("") if err != nil { - t.Fatalf("ensurePicoChannel() error = %v", err) + t.Fatalf("EnsurePicoChannel() error = %v", err) } if changed { - t.Error("ensurePicoChannel() should not change a fully configured config") + t.Error("EnsurePicoChannel() should not change a fully configured config") } cfg, err = config.LoadConfig(configPath) @@ -169,12 +169,12 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) { h := NewHandler(configPath) - changed, err := h.ensurePicoChannel("") + changed, err := h.EnsurePicoChannel("") if err != nil { - t.Fatalf("ensurePicoChannel() error = %v", err) + t.Fatalf("EnsurePicoChannel() error = %v", err) } if !changed { - t.Fatal("ensurePicoChannel() should report changed when pico is missing") + t.Fatal("EnsurePicoChannel() should report changed when pico is missing") } cfg, err = config.LoadConfig(configPath) @@ -193,6 +193,33 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) { } } +func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = "" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + if _, err := h.EnsurePicoChannel(""); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if !cfg.Channels.Pico.Enabled { + t.Error("expected Pico to be enabled after launcher startup setup") + } + if cfg.Channels.Pico.Token() == "" { + t.Error("expected a non-empty token after launcher startup setup") + } +} + func TestEnsurePicoChannel_Idempotent(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -200,20 +227,20 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { origin := "http://localhost:18800" // First call sets things up - if _, err := h.ensurePicoChannel(origin); err != nil { - t.Fatalf("first ensurePicoChannel() error = %v", err) + if _, err := h.EnsurePicoChannel(origin); err != nil { + t.Fatalf("first EnsurePicoChannel() error = %v", err) } cfg1, _ := config.LoadConfig(configPath) token1 := cfg1.Channels.Pico.Token() // Second call should be a no-op - changed, err := h.ensurePicoChannel(origin) + changed, err := h.EnsurePicoChannel(origin) if err != nil { - t.Fatalf("second ensurePicoChannel() error = %v", err) + t.Fatalf("second EnsurePicoChannel() error = %v", err) } if changed { - t.Error("second ensurePicoChannel() should not report changed") + t.Error("second EnsurePicoChannel() should not report changed") } cfg2, _ := config.LoadConfig(configPath) diff --git a/web/backend/main.go b/web/backend/main.go index 8183731fe..2f181603e 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -169,6 +169,9 @@ func main() { // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) + if _, err = apiHandler.EnsurePicoChannel(""); err != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) + } apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) apiHandler.RegisterRoutes(mux)