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.
This commit is contained in:
wenjie
2026-03-24 18:06:29 +08:00
parent ffbcbea4dc
commit dea99da7d9
4 changed files with 56 additions and 26 deletions
+1 -1
View File
@@ -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
}
+3 -3
View File
@@ -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
+49 -22
View File
@@ -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)
+3
View File
@@ -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)