diff --git a/Makefile b/Makefile index 9581fa6332..5b2fb7b672 100644 --- a/Makefile +++ b/Makefile @@ -130,10 +130,8 @@ build: generate build-launcher: @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @if [ ! -f web/backend/dist/index.html ]; then \ - echo "Building frontend..."; \ - cd web/frontend && pnpm install && pnpm build:backend; \ - fi + @echo "Building embedded web frontend ..." + @cd web/frontend && pnpm install && pnpm build:backend @$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend @ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher" diff --git a/docs/configuration.md b/docs/configuration.md index 9360d38970..5bf4740cb7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,16 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa > **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request. +### Web launcher dashboard + +When you run **picoclaw-launcher** and use the browser UI, you sign in first. On first run the app creates `launcher-config.json` next to your main `config.json`. It stores an access token and a key used for browser sessions—treat the file as sensitive. + +- **Where it lives**: Same folder as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is named `launcher-config.json`. +- **Override the token (temporary)**: Set `PICOCLAW_LAUNCHER_TOKEN` to use a different token for this run. It is **not** written back to disk. +- **Everyday use**: Enter the token on the login page. You can also open a login URL that includes the token; after sign-in the token is removed from the address bar (similar to Jupyter). + +> **Advanced:** For your own scripts calling the launcher APIs, send `Authorization: Bearer ` or reuse an authenticated browser session. + ### Skill Sources By default, skills are loaded from: diff --git a/docs/docker.md b/docs/docker.md index a00dfbe9f8..84c3de057e 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. > [!WARNING] -> The web console does not yet support authentication. Avoid exposing it to the public internet. +> The web console uses a dashboard token. Treat `dashboard_token` and `auth_signing_key` as secrets and **do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide. ### Agent Mode (One-shot) diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 335566d361..c532104a41 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -51,6 +51,16 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work > **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。 +### Web 启动器控制台 + +用 **picoclaw-launcher** 在浏览器里打开控制台时,需要先登录。首次运行会在你的主配置文件旁边自动生成 `launcher-config.json`,里面保存访问口令和用于浏览器会话的密钥,请当作敏感信息保管。 + +- **文件在哪**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用配置的文件名是 `launcher-config.json`。 +- **改口令(临时)**:设置环境变量 `PICOCLAW_LAUNCHER_TOKEN` 可指定本次运行使用的口令,**不会**写回文件。 +- **平时怎么用**:在浏览器里按登录页提示输入口令即可;也支持在登录页链接里带上口令(登录成功后地址栏里的口令会被去掉,类似 Jupyter)。 + +> **进阶**:自己写脚本调控制台接口时,可在请求头加上 `Authorization: Bearer <口令>`,或使用已在浏览器里登录过的会话。 + ### 技能来源 (Skill Sources) 默认情况下,技能会按以下顺序加载: diff --git a/docs/zh/docker.md b/docs/zh/docker.md index 10bc465448..c72ec57c3b 100644 --- a/docs/zh/docker.md +++ b/docs/zh/docker.md @@ -42,10 +42,10 @@ docker compose -f docker/docker-compose.yml --profile gateway down docker compose -f docker/docker-compose.yml --profile launcher up -d ``` -在浏览器中打开 http://localhost:18800。Launcher 会自动管理 Gateway 进程。 +在浏览器中打开 。Launcher 会自动管理 Gateway 进程。 > [!WARNING] -> Web 控制台尚不支持身份验证。请勿将其暴露到公网。 +> Web 控制台通过 dashboard 令牌鉴权。请将 `dashboard_token`、`auth_signing_key` 视为密钥,**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 ### Agent 模式 (一次性运行) diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 002988f2f8..0213312c61 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -// Test JSON unmarshal of private fields +// Test JSON unmarshal of private fields (unexported fields are never filled, with or without json tag). func TestJSONUnmarshalPrivateFields(t *testing.T) { type testStruct struct { PublicField string `json:"public"` diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go new file mode 100644 index 0000000000..602f36fbcb --- /dev/null +++ b/web/backend/api/auth.go @@ -0,0 +1,81 @@ +package api + +import ( + "crypto/subtle" + "encoding/json" + "net/http" + "strings" + + "github.com/sipeed/picoclaw/web/backend/middleware" +) + +// LauncherAuthRouteOpts configures dashboard token login handlers. +type LauncherAuthRouteOpts struct { + DashboardToken string + SessionCookie string + SecureCookie func(*http.Request) bool +} + +type launcherAuthLoginBody struct { + Token string `json:"token"` +} + +// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status. +func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) { + secure := opts.SecureCookie + if secure == nil { + secure = middleware.DefaultLauncherDashboardSecureCookie + } + h := &launcherAuthHandlers{ + token: opts.DashboardToken, + sessionCookie: opts.SessionCookie, + secureCookie: secure, + } + mux.HandleFunc("POST /api/auth/login", h.handleLogin) + mux.HandleFunc("GET /api/auth/logout", h.handleLogout) + mux.HandleFunc("GET /api/auth/status", h.handleStatus) +} + +type launcherAuthHandlers struct { + token string + sessionCookie string + secureCookie func(*http.Request) bool +} + +func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) { + var body launcherAuthLoginBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + in := strings.TrimSpace(body.Token) + if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid token"}`)) + return + } + + middleware.SetLauncherDashboardSessionCookie(w, r, h.sessionCookie, h.secureCookie) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Request) { + middleware.ClearLauncherDashboardSessionCookie(w, r, h.secureCookie) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) { + ok := false + if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { + ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 + } + w.Header().Set("Content-Type", "application/json") + if ok { + _, _ = w.Write([]byte(`{"authenticated":true}`)) + return + } + _, _ = w.Write([]byte(`{"authenticated":false}`)) +} diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 592571a286..b722ea077d 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -93,16 +93,118 @@ func requestWSScheme(r *http.Request) string { return "ws" } -func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string { - host := h.effectiveGatewayBindHost(cfg) - if host == "" || host == "0.0.0.0" { - host = requestHostName(r) +// requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE). +func requestHTTPScheme(r *http.Request) string { + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) + if proto == "https" || proto == "wss" { + return "https" + } + if proto == "http" || proto == "ws" { + return "http" + } + } + if r.TLS != nil { + return "https" + } + return "http" +} + +// forwardedHostFirst returns the client-visible host from reverse-proxy / tunnel headers +// (e.g. VS Code port forwarding, nginx). Empty if unset. +func forwardedHostFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) + if raw == "" { + raw = forwardedRFC7239Host(r) + } + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return raw +} + +// forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239). +func forwardedRFC7239Host(r *http.Request) string { + v := strings.TrimSpace(r.Header.Get("Forwarded")) + if v == "" { + return "" + } + first := strings.TrimSpace(strings.Split(v, ",")[0]) + for _, part := range strings.Split(first, ";") { + part = strings.TrimSpace(part) + low := strings.ToLower(part) + if !strings.HasPrefix(low, "host=") { + continue + } + val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:]) + if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' { + val = val[1 : len(val)-1] + } + return val + } + return "" +} + +// forwardedPortFirst returns the first X-Forwarded-Port value, or empty. +func forwardedPortFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Port")) + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return raw +} + +// clientVisiblePort picks the TCP port the browser uses to reach this app (after proxies). +func clientVisiblePort(r *http.Request, serverListenPort int) string { + if p := forwardedPortFirst(r); p != "" { + return p + } + if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { + return port + } + if requestHTTPScheme(r) == "https" { + return "443" + } + return strconv.Itoa(serverListenPort) +} + +// joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser. +func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort int) string { + if h, p, err := net.SplitHostPort(host); err == nil { + return net.JoinHostPort(h, p) } - // Use web server port instead of gateway port to avoid exposing extra ports - // The WebSocket connection will be proxied by the backend to the gateway + return net.JoinHostPort(host, clientVisiblePort(r, serverListenPort)) +} + +// picoWebUIAddr is host:port for URLs returned to the browser (/pico/ws, /pico/events, /pico/send). +// It must match the HTTP Host the client used (or X-Forwarded-*), not cfg.Gateway.Host — otherwise +// e.g. page on localhost with ws_url 127.0.0.1 omits cookies and the dashboard auth handshake fails. +func (h *Handler) picoWebUIAddr(r *http.Request) string { wsPort := h.serverPort if wsPort == 0 { wsPort = 18800 // default web server port } - return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws" + if fwdHost := forwardedHostFirst(r); fwdHost != "" { + return joinClientVisibleHostPort(r, fwdHost, wsPort) + } + host := requestHostName(r) + return net.JoinHostPort(host, strconv.Itoa(wsPort)) +} + +func (h *Handler) buildWsURL(r *http.Request) string { + return requestWSScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/ws" +} + +func (h *Handler) buildPicoEventsURL(r *http.Request) string { + return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/events" +} + +func (h *Handler) buildPicoSendURL(r *http.Request) string { + return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/send" } diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index ae3434862f..7150b6feed 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -51,9 +51,16 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) req.Host = "192.168.1.9:18800" - if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" { + if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws") } + + if got := h.buildPicoEventsURL(req); got != "http://192.168.1.9:18800/pico/events" { + t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/events") + } + if got := h.buildPicoSendURL(req); got != "http://192.168.1.9:18800/pico/send" { + t.Fatalf("buildPicoSendURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/send") + } } func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { @@ -147,7 +154,7 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { req.Host = "chat.example.com" req.Header.Set("X-Forwarded-Proto", "https") - if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" { + if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws") } } @@ -164,11 +171,45 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { req.Host = "secure.example.com" req.TLS = &tls.ConnectionState{} - if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" { + if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws") } } +func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 18800, + Public: true, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil) + req.Host = "127.0.0.1:18800" + req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Port", "443") + + if got := h.buildPicoEventsURL(req); got != "https://vscode-tunnel.example.com:443/pico/events" { + t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/events") + } + if got := h.buildPicoSendURL(req); got != "https://vscode-tunnel.example.com:443/pico/send" { + t.Fatalf("buildPicoSendURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/send") + } + if got := h.buildWsURL(req); got != "wss://vscode-tunnel.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://vscode-tunnel.example.com:443/pico/ws") + } +} + func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -182,7 +223,20 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { req.TLS = &tls.ConnectionState{} req.Header.Set("X-Forwarded-Proto", "http") - if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" { + if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") } } + +func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + + req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil) + req.Host = "localhost:18800" + + if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws") + } +} diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go index e149d56716..b077dfca18 100644 --- a/web/backend/api/launcher_config.go +++ b/web/backend/api/launcher_config.go @@ -61,10 +61,18 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ return } + prev, err := h.loadLauncherConfig() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError) + return + } + cfg := launcherconfig.Config{ - Port: payload.Port, - Public: payload.Public, - AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), + Port: payload.Port, + Public: payload.Public, + AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), + DashboardToken: prev.DashboardToken, + AuthSigningKey: prev.AuthSigningKey, } if err := launcherconfig.Validate(cfg); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 4faafc2ae9..693aed8543 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -53,7 +53,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { return } - wsURL := h.buildWsURL(r, cfg) + wsURL := h.buildWsURL(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -81,7 +81,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { return } - wsURL := h.buildWsURL(r, cfg) + wsURL := h.buildWsURL(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -146,7 +146,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { return } - wsURL := h.buildWsURL(r, cfg) + wsURL := h.buildWsURL(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go index e3a9ec64f5..ab564db2c6 100644 --- a/web/backend/app_runtime.go +++ b/web/backend/app_runtime.go @@ -55,8 +55,12 @@ func shutdownApp() { } func openBrowser() error { - if serverAddr == "" { + target := browserLaunchURL + if target == "" { + target = serverAddr + } + if target == "" { return fmt.Errorf("server address not set") } - return utils.OpenBrowser(serverAddr) + return utils.OpenBrowser(target) } diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index 4dca45b0e6..2272e83184 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -1,6 +1,9 @@ package launcherconfig import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "net" @@ -14,6 +17,9 @@ const ( FileName = "launcher-config.json" // DefaultPort is the default port for the web launcher. DefaultPort = 18800 + + // EnvDashboardToken overrides the persisted dashboard token when non-empty. + EnvDashboardToken = "PICOCLAW_LAUNCHER_TOKEN" ) // Config stores launch parameters for the web backend service. @@ -21,6 +27,11 @@ type Config struct { Port int `json:"port"` Public bool `json:"public"` AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` + // DashboardToken is the shared secret for browser access to the launcher UI. + // When empty on first run, a random value is generated and saved. + DashboardToken string `json:"dashboard_token,omitempty"` + // AuthSigningKey is a hex-encoded 32-byte secret used to derive the session cookie value. + AuthSigningKey string `json:"auth_signing_key,omitempty"` } // Default returns default launcher settings. @@ -41,6 +52,79 @@ func Validate(cfg Config) error { return nil } +// EnsureDashboardSecrets ensures auth_signing_key and (unless env overrides) a persisted +// dashboard_token exist. It returns the effective token (env wins over file), signing key +// bytes, whether the config file was written, whether a new random dashboard_token was +// persisted this run, and any error. +func EnsureDashboardSecrets( + path string, + cfg *Config, +) (effectiveToken string, signingKey []byte, saved bool, newPersistedDashboardToken bool, err error) { + if cfg == nil { + return "", nil, false, false, fmt.Errorf("nil config") + } + + changed := false + var newDashTok bool + + if strings.TrimSpace(cfg.AuthSigningKey) == "" { + if regErr := regenerateAuthSigningKey(cfg); regErr != nil { + return "", nil, false, false, regErr + } + changed = true + } + signingKey, err = hex.DecodeString(cfg.AuthSigningKey) + if err != nil || len(signingKey) != 32 { + if regErr := regenerateAuthSigningKey(cfg); regErr != nil { + return "", nil, false, false, regErr + } + changed = true + signingKey, err = hex.DecodeString(cfg.AuthSigningKey) + if err != nil || len(signingKey) != 32 { + return "", nil, false, false, fmt.Errorf("invalid auth_signing_key") + } + } + + effectiveToken = strings.TrimSpace(os.Getenv(EnvDashboardToken)) + if effectiveToken == "" { + if strings.TrimSpace(cfg.DashboardToken) == "" { + tok, genErr := randomDashboardToken() + if genErr != nil { + return "", nil, false, false, genErr + } + cfg.DashboardToken = tok + changed = true + newDashTok = true + } + effectiveToken = cfg.DashboardToken + } + + if changed { + if err := Save(path, *cfg); err != nil { + return "", nil, false, false, err + } + return effectiveToken, signingKey, true, newDashTok, nil + } + return effectiveToken, signingKey, false, false, nil +} + +func regenerateAuthSigningKey(cfg *Config) error { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return err + } + cfg.AuthSigningKey = hex.EncodeToString(buf) + return nil +} + +func randomDashboardToken() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + // NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs. func NormalizeCIDRs(cidrs []string) []string { if len(cidrs) == 0 { diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go index c63bee09af..924657e27b 100644 --- a/web/backend/launcherconfig/config_test.go +++ b/web/backend/launcherconfig/config_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/sipeed/picoclaw/web/backend/middleware" ) func TestLoadReturnsFallbackWhenMissing(t *testing.T) { @@ -75,6 +77,59 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { } } +func TestEnsureDashboardSecrets_GeneratesAndPersists(t *testing.T) { + t.Setenv(EnvDashboardToken, "") + dir := t.TempDir() + path := filepath.Join(dir, "launcher-config.json") + cfg := Config{Port: 18801, Public: false} + + tok, key, saved, newTok, err := EnsureDashboardSecrets(path, &cfg) + if err != nil { + t.Fatalf("EnsureDashboardSecrets() error = %v", err) + } + if !saved || !newTok || tok == "" || len(key) != 32 { + t.Fatalf("unexpected first run: saved=%v newTok=%v tok=%q keyLen=%d", saved, newTok, tok, len(key)) + } + if cfg.DashboardToken == "" || cfg.AuthSigningKey == "" { + t.Fatal("cfg should hold persisted secrets") + } + mac := middleware.SessionCookieValue(key, tok) + if mac == "" { + t.Fatal("empty session mac") + } + + // Second run: load from disk, no new persisted token. + cfg2, err := Load(path, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + tok2, key2, saved2, newTok2, err := EnsureDashboardSecrets(path, &cfg2) + if err != nil { + t.Fatalf("EnsureDashboardSecrets() second error = %v", err) + } + if tok2 != tok || string(key2) != string(key) || saved2 || newTok2 { + t.Fatalf("second run drift: tok=%q vs %q saved=%v newTok=%v", tok, tok2, saved2, newTok2) + } +} + +func TestEnsureDashboardSecrets_EnvOverridesFile(t *testing.T) { + t.Setenv(EnvDashboardToken, "env-only-token-override") + dir := t.TempDir() + path := filepath.Join(dir, "launcher-config.json") + cfg := Config{Port: 18802, Public: false, DashboardToken: "file-token"} + + tok, signingKey, saved, newTok, err := EnsureDashboardSecrets(path, &cfg) + _ = signingKey + _ = saved + _ = newTok + if err != nil { + t.Fatalf("EnsureDashboardSecrets() error = %v", err) + } + if tok != "env-only-token-override" { + t.Fatalf("token = %q, want env value", tok) + } +} + func TestNormalizeCIDRs(t *testing.T) { got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"}) want := []string{"192.168.1.0/24", "10.0.0.0/8"} diff --git a/web/backend/main.go b/web/backend/main.go index 6987a4515f..5ac2f58dc8 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -16,6 +16,7 @@ import ( "flag" "fmt" "net/http" + "net/url" "os" "os/signal" "path/filepath" @@ -44,7 +45,10 @@ var ( server *http.Server serverAddr string - apiHandler *api.Handler + // browserLaunchURL is opened by openBrowser() (auto-open + tray "open console"). + // Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use. + browserLaunchURL string + apiHandler *api.Handler noBrowser *bool ) @@ -57,7 +61,7 @@ func main() { console := flag.Bool("console", false, "Console mode, no GUI") flag.Usage = func() { - fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n") + fmt.Fprintf(os.Stderr, "%s Launcher - A web-based configuration editor\n\n", appName) fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Arguments:\n") fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") @@ -98,8 +102,8 @@ func main() { defer logger.DisableFileLogging() } - logger.InfoC("web", fmt.Sprintf("%s Launcher %s starting...", appName, appVersion)) - logger.InfoC("web", fmt.Sprintf("PicoClaw Home: %s", picoHome)) + logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion)) + logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome)) // Set language from command line or auto-detect if *lang != "" { @@ -118,7 +122,7 @@ func main() { } err = utils.EnsureOnboarded(absPath) if err != nil { - logger.Errorf("Warning: Failed to initialize PicoClaw config automatically: %v", err) + logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err) } var explicitPort bool @@ -156,6 +160,15 @@ func main() { logger.Fatalf("Invalid port %q: %v", effectivePort, err) } + dashboardToken, dashboardSigningKey, dashCfgSaved, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets( + launcherPath, + &launcherCfg, + ) + if dashErr != nil { + logger.Fatalf("Dashboard auth setup failed: %v", dashErr) + } + dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken) + // Determine listen address var addr string if effectivePublic { @@ -167,6 +180,11 @@ func main() { // Initialize Server components mux := http.NewServeMux() + api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ + DashboardToken: dashboardToken, + SessionCookie: dashboardSessionCookie, + }) + // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) if _, err = apiHandler.EnsurePicoChannel(""); err != nil { @@ -183,10 +201,15 @@ func main() { logger.Fatalf("Invalid allowed CIDR configuration: %v", err) } + dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{ + ExpectedCookie: dashboardSessionCookie, + Token: dashboardToken, + }, accessControlledMux) + // Apply middleware stack handler := middleware.Recoverer( middleware.Logger( - middleware.JSONContentType(accessControlledMux), + middleware.JSONContentType(dashAuth), ), ) @@ -205,6 +228,15 @@ func main() { fmt.Println() } + if os.Getenv(launcherconfig.EnvDashboardToken) != "" { + logger.InfoC("web", "Dashboard token: environment "+launcherconfig.EnvDashboardToken) + } else if dashCfgSaved { + logger.InfoC("web", fmt.Sprintf("Launcher settings updated: %s", launcherPath)) + } + if newDashTok && enableConsole { + fmt.Fprintf(os.Stderr, "\n Dashboard token (save this): %s\n\n", dashboardToken) + } + // Log startup info to file logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort)) if effectivePublic { @@ -215,6 +247,11 @@ func main() { // Share the local URL with the launcher runtime. serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort) + if dashboardToken != "" { + browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken) + } else { + browserLaunchURL = serverAddr + } // Auto-open browser will be handled by the launcher runtime. diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go new file mode 100644 index 0000000000..587ed5a7ad --- /dev/null +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -0,0 +1,223 @@ +package middleware + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "net/http" + "path" + "strings" + "time" +) + +// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login. +const LauncherDashboardCookieName = "picoclaw_launcher_auth" + +const launcherSessionMACLabel = "picoclaw-launcher-v1" + +// SessionCookieValue is the expected cookie value for the given signing key and dashboard token. +func SessionCookieValue(signingKey []byte, dashboardToken string) string { + mac := hmac.New(sha256.New, signingKey) + _, _ = mac.Write([]byte(launcherSessionMACLabel)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(dashboardToken)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// LauncherDashboardAuthConfig holds runtime material for dashboard access checks. +type LauncherDashboardAuthConfig struct { + ExpectedCookie string + Token string + // SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used. + SecureCookie func(*http.Request) bool +} + +// DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto). +func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login. +func SetLauncherDashboardSessionCookie( + w http.ResponseWriter, + r *http.Request, + sessionValue string, + secure func(*http.Request) bool, +) { + if secure == nil { + secure = DefaultLauncherDashboardSecureCookie + } + http.SetCookie(w, &http.Cookie{ + Name: LauncherDashboardCookieName, + Value: sessionValue, + Path: "/", + MaxAge: 30 * 24 * 3600, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure(r), + }) +} + +// ClearLauncherDashboardSessionCookie clears the dashboard session (e.g. logout). +func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request, secure func(*http.Request) bool) { + if secure == nil { + secure = DefaultLauncherDashboardSecureCookie + } + http.SetCookie(w, &http.Cookie{ + Name: LauncherDashboardCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure(r), + Expires: time.Unix(0, 0), + }) +} + +// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer +// before calling next. Public paths are login page and /api/auth/* handlers. +func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := canonicalAuthPath(r.URL.Path) + if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled { + return + } + if isPublicLauncherDashboardPath(r.Method, p) { + next.ServeHTTP(w, r) + return + } + if validLauncherDashboardAuth(r, cfg) { + next.ServeHTTP(w, r) + return + } + rejectLauncherDashboardAuth(w, r, p) + }) +} + +// canonicalAuthPath matches path cleaning used for routing decisions so +// prefixes like /assets/../ cannot bypass auth (CVE-class traversal). + +// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session +// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects. +// Invalid token is rejected like any other unauthenticated browser request. +func tryLauncherQueryTokenLogin( + w http.ResponseWriter, + r *http.Request, + canonicalPath string, + cfg LauncherDashboardAuthConfig, +) bool { + if r.Method != http.MethodGet { + return false + } + if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") { + return false + } + qToken := strings.TrimSpace(r.URL.Query().Get("token")) + if qToken == "" { + return false + } + if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 { + rejectLauncherDashboardAuth(w, r, canonicalPath) + return true + } + SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) + http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther) + return true +} + +func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string { + if canonicalPath == "/launcher-login" { + return "/" + } + q := r.URL.Query() + q.Del("token") + enc := q.Encode() + if enc != "" { + return canonicalPath + "?" + enc + } + return canonicalPath +} + +func canonicalAuthPath(raw string) string { + if raw == "" { + return "/" + } + c := path.Clean(raw) + switch c { + case ".", "": + return "/" + default: + if c[0] != '/' { + return "/" + c + } + return c + } +} + +func isPublicLauncherDashboardPath(method, p string) bool { + if isPublicLauncherDashboardStatic(method, p) { + return true + } + switch p { + case "/api/auth/login": + return method == http.MethodPost + case "/api/auth/logout": + return method == http.MethodGet + case "/api/auth/status": + return method == http.MethodGet + } + return false +} + +// isPublicLauncherDashboardStatic allows the SPA login route and embedded +// frontend assets without a session (GET/HEAD only). +func isPublicLauncherDashboardStatic(method, p string) bool { + if method != http.MethodGet && method != http.MethodHead { + return false + } + if p == "/launcher-login" { + return true + } + if strings.HasPrefix(p, "/assets/") { + return true + } + switch p { + case "/favicon.ico", "/favicon.svg", "/favicon-96x96.png", + "/apple-touch-icon.png", "/site.webmanifest", "/robots.txt": + return true + default: + return false + } +} + +func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig) bool { + if c, err := r.Cookie(LauncherDashboardCookieName); err == nil { + if subtle.ConstantTimeCompare([]byte(c.Value), []byte(cfg.ExpectedCookie)) == 1 { + return true + } + } + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + if strings.HasPrefix(auth, prefix) { + token := strings.TrimSpace(auth[len(prefix):]) + if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 { + return true + } + } + return false +} + +func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) { + if strings.HasPrefix(canonicalPath, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + http.Redirect(w, r, "/launcher-login", http.StatusFound) +} diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go new file mode 100644 index 0000000000..1f7e443fa4 --- /dev/null +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -0,0 +1,160 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSessionCookieValue_Deterministic(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + a := SessionCookieValue(key, "tok-a") + b := SessionCookieValue(key, "tok-a") + if a != b || a == "" { + t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b) + } + c := SessionCookieValue(key, "tok-b") + if c == a { + t.Fatal("SessionCookieValue should differ for different tokens") + } +} + +func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + h := LauncherDashboardAuth(cfg, next) + + for _, tc := range []struct { + method, path string + want int + }{ + {http.MethodGet, "/launcher-login", http.StatusTeapot}, + {http.MethodGet, "/assets/index.js", http.StatusTeapot}, + {http.MethodPost, "/api/auth/login", http.StatusTeapot}, + {http.MethodGet, "/api/auth/status", http.StatusTeapot}, + {http.MethodGet, "/api/config", http.StatusUnauthorized}, + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, nil) + h.ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Fatalf("%s %s: status = %d, want %d", tc.method, tc.path, rec.Code, tc.want) + } + } +} + +func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) { + const tok = "secret" + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok} + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther) + } + if got := rec.Header().Get("Location"); got != "/" { + t.Fatalf("Location = %q, want %q", got, "/") + } + if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName { + t.Fatalf("expected one session cookie, got %#v", c) + } + + rec1b := httptest.NewRecorder() + req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil) + h.ServeHTTP(rec1b, req1b) + if rec1b.Code != http.StatusSeeOther { + t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code) + } + if got := rec1b.Header().Get("Location"); got != "/config?keep=1" { + t.Fatalf("Location = %q, want /config?keep=1", got) + } + + recBad := httptest.NewRecorder() + reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil) + h.ServeHTTP(recBad, reqBad) + if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" { + t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location")) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil) + h.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized) + } + + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil) + h.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusFound { + t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code) + } + + recLogin := httptest.NewRecorder() + reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil) + h.ServeHTTP(recLogin, reqLogin) + if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" { + t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location")) + } +} + +func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("next handler should not run without auth") + }) + h := LauncherDashboardAuth(cfg, next) + + for _, p := range []string{ + "/assets/../api/config", + "/launcher-login/../api/config", + "/./api/config", + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, p, nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%q: status = %d, want %d", p, rec.Code, http.StatusUnauthorized) + } + } +} + +func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = 0xab + } + token := "dashboard-secret-9" + cookieVal := SessionCookieValue(key, token) + cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cookie auth: status = %d", rec.Code) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("Authorization", "Bearer "+token) + h.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("bearer auth: status = %d", rec2.Code) + } +} diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index 85550ca81e..eb4d41fd7b 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -1,5 +1,7 @@ // API client for channels navigation and channel-specific config flows. +import { launcherFetch } from "@/api/http" + export type ChannelConfig = Record export type AppConfig = Record @@ -22,7 +24,7 @@ interface ConfigActionResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts index 9e02a02b52..2742a0a372 100644 --- a/web/frontend/src/api/gateway.ts +++ b/web/frontend/src/api/gateway.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + // API client for gateway process management. interface GatewayStatusResponse { @@ -27,7 +29,7 @@ interface GatewayActionResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`) } diff --git a/web/frontend/src/api/http.ts b/web/frontend/src/api/http.ts new file mode 100644 index 0000000000..0eb872f3fd --- /dev/null +++ b/web/frontend/src/api/http.ts @@ -0,0 +1,42 @@ +import { isLauncherLoginPathname } from "@/lib/launcher-login-path" + +function isLauncherLoginPath(): boolean { + if (typeof globalThis.location === "undefined") { + return false + } + if (isLauncherLoginPathname(globalThis.location.pathname || "/")) { + return true + } + try { + return isLauncherLoginPathname( + new URL(globalThis.location.href).pathname || "/", + ) + } catch { + return false + } +} + +/** + * Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses. + * Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll). + */ +export async function launcherFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const res = await fetch(input, { + credentials: "same-origin", + ...init, + }) + if (res.status === 401) { + const ct = res.headers.get("content-type") || "" + if ( + ct.includes("application/json") && + typeof globalThis.location !== "undefined" && + !isLauncherLoginPath() + ) { + globalThis.location.assign("/launcher-login") + } + } + return res +} diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts new file mode 100644 index 0000000000..ed70ec31d6 --- /dev/null +++ b/web/frontend/src/api/launcher-auth.ts @@ -0,0 +1,15 @@ +/** + * Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid + * redirect loops on 401 while on the login page. + */ +export async function postLauncherDashboardLogin( + token: string, +): Promise { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ token: token.trim() }), + }) + return res.ok +} diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index aa66a7389b..d75b3ec3c3 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -1,3 +1,4 @@ +import { launcherFetch } from "@/api/http" import { refreshGatewayState } from "@/store/gateway" // API client for model list management. @@ -39,7 +40,7 @@ interface ModelActionResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`) } diff --git a/web/frontend/src/api/oauth.ts b/web/frontend/src/api/oauth.ts index a1ed1afcba..689a2bcd13 100644 --- a/web/frontend/src/api/oauth.ts +++ b/web/frontend/src/api/oauth.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + export type OAuthProvider = "openai" | "anthropic" | "google-antigravity" export type OAuthMethod = "browser" | "device_code" | "token" @@ -51,7 +53,7 @@ interface OAuthProvidersResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { const message = await res.text() throw new Error(message || `API error: ${res.status} ${res.statusText}`) diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts index 9a1a553d56..6b8ceb49a9 100644 --- a/web/frontend/src/api/pico.ts +++ b/web/frontend/src/api/pico.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + // API client for Pico Channel configuration. interface PicoTokenResponse { @@ -16,7 +18,7 @@ interface PicoSetupResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`) } diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index 10b0d28fd3..c914959016 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -1,5 +1,7 @@ // Sessions API — list and retrieve chat session history +import { launcherFetch } from "@/api/http" + export interface SessionSummary { id: string title: string @@ -26,7 +28,7 @@ export async function getSessions( limit: limit.toString(), }) - const res = await fetch(`/api/sessions?${params.toString()}`) + const res = await launcherFetch(`/api/sessions?${params.toString()}`) if (!res.ok) { throw new Error(`Failed to fetch sessions: ${res.status}`) } @@ -34,7 +36,7 @@ export async function getSessions( } export async function getSessionHistory(id: string): Promise { - const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`) + const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`) if (!res.ok) { throw new Error(`Failed to fetch session ${id}: ${res.status}`) } @@ -42,7 +44,7 @@ export async function getSessionHistory(id: string): Promise { } export async function deleteSession(id: string): Promise { - const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, { + const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", }) if (!res.ok) { diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts index 307cbd7888..72ccbcfe59 100644 --- a/web/frontend/src/api/skills.ts +++ b/web/frontend/src/api/skills.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + export interface SkillSupportItem { name: string path: string @@ -22,7 +24,7 @@ interface SkillActionResponse { } async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(path, options) + const res = await launcherFetch(path, options) if (!res.ok) { throw new Error(await extractErrorMessage(res)) } @@ -41,7 +43,7 @@ export async function importSkill(file: File): Promise { const formData = new FormData() formData.set("file", file) - const res = await fetch("/api/skills/import", { + const res = await launcherFetch("/api/skills/import", { method: "POST", body: formData, }) diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 543c8694d3..2e2f36f153 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + export interface AutoStartStatus { enabled: boolean supported: boolean @@ -12,7 +14,7 @@ export interface LauncherConfig { } async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(path, options) + const res = await launcherFetch(path, options) if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts index 9f09efbfdf..824bcc0fa6 100644 --- a/web/frontend/src/api/tools.ts +++ b/web/frontend/src/api/tools.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + export interface ToolSupportItem { name: string description: string @@ -16,7 +18,7 @@ interface ToolActionResponse { } async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(path, options) + const res = await launcherFetch(path, options) if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 46f62f4266..1ddd6380a1 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next" import { toast } from "sonner" import { patchAppConfig } from "@/api/channels" +import { launcherFetch } from "@/api/http" import { getAutoStartStatus, getLauncherConfig, @@ -49,7 +50,7 @@ export function ConfigPage() { const { data, isLoading, error } = useQuery({ queryKey: ["config"], queryFn: async () => { - const res = await fetch("/api/config") + const res = await launcherFetch("/api/config") if (!res.ok) { throw new Error("Failed to load config") } diff --git a/web/frontend/src/components/config/raw-config-page.tsx b/web/frontend/src/components/config/raw-config-page.tsx index e40cc73019..3603f5f2df 100644 --- a/web/frontend/src/components/config/raw-config-page.tsx +++ b/web/frontend/src/components/config/raw-config-page.tsx @@ -5,6 +5,7 @@ import { useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" +import { launcherFetch } from "@/api/http" import { PageHeader } from "@/components/page-header" import { AlertDialog, @@ -27,7 +28,7 @@ export function RawConfigPage() { const { data: config, isLoading } = useQuery({ queryKey: ["config"], queryFn: async () => { - const res = await fetch("/api/config") + const res = await launcherFetch("/api/config") if (!res.ok) { throw new Error("Failed to fetch config") } @@ -37,7 +38,7 @@ export function RawConfigPage() { const mutation = useMutation({ mutationFn: async (newConfig: string) => { - const res = await fetch("/api/config", { + const res = await launcherFetch("/api/config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: newConfig, diff --git a/web/frontend/src/features/chat/websocket.ts b/web/frontend/src/features/chat/websocket.ts index 6b132e9a65..17ba36075d 100644 --- a/web/frontend/src/features/chat/websocket.ts +++ b/web/frontend/src/features/chat/websocket.ts @@ -14,6 +14,18 @@ export function normalizeWsUrlForBrowser(wsUrl: string): string { if (isLocalHost && !isBrowserLocal) { parsedUrl.hostname = window.location.hostname finalWsUrl = parsedUrl.toString() + } else if ( + isLocalHost && + isBrowserLocal && + parsedUrl.hostname !== window.location.hostname && + (parsedUrl.hostname === "127.0.0.1" || + parsedUrl.hostname === "localhost") && + (window.location.hostname === "127.0.0.1" || + window.location.hostname === "localhost") + ) { + // Same machine, but cookies are host-specific; match the page origin. + parsedUrl.hostname = window.location.hostname + finalWsUrl = parsedUrl.toString() } } catch (error) { console.warn("Could not parse ws_url:", error) diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index a7c60f8933..32084bd961 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -14,6 +14,15 @@ "config": "Config", "logs": "Logs" }, + "launcherLogin": { + "title": "Launcher access", + "description": "Enter the same token as dashboard_token in workspace/launcher-config.json, or the value set in the PICOCLAW_LAUNCHER_TOKEN environment variable.", + "tokenLabel": "Token", + "tokenPlaceholder": "Enter access token", + "submit": "Continue to Dashboard", + "errorInvalid": "Invalid token. Please try again.", + "errorNetwork": "Network error. Please try again." + }, "chat": { "welcome": "How can I help you today?", "welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 9dde090f81..b7160e1fc5 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -14,6 +14,15 @@ "config": "配置", "logs": "日志" }, + "launcherLogin": { + "title": "Launcher 访问验证", + "description": "请输入与 工作目录/launcher-config.json 中的 dashboard_token 一致的令牌,或环境变量 PICOCLAW_LAUNCHER_TOKEN 所设置的值。", + "tokenLabel": "令牌", + "tokenPlaceholder": "输入访问令牌", + "submit": "进入 Dashboard", + "errorInvalid": "令牌错误,请重试。", + "errorNetwork": "网络错误,请重试。" + }, "chat": { "welcome": "今天我能为您做些什么?", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", diff --git a/web/frontend/src/lib/launcher-login-path.ts b/web/frontend/src/lib/launcher-login-path.ts new file mode 100644 index 0000000000..52c35d2403 --- /dev/null +++ b/web/frontend/src/lib/launcher-login-path.ts @@ -0,0 +1,9 @@ +/** Normalize URL pathname for comparisons (trailing slashes, empty). */ +export function normalizePathname(p: string): string { + const t = p.replace(/\/+$/, "") + return t === "" ? "/" : t +} + +export function isLauncherLoginPathname(pathname: string): boolean { + return normalizePathname(pathname) === "/launcher-login" +} diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts index 60f19ab53b..536ee560ba 100644 --- a/web/frontend/src/routeTree.gen.ts +++ b/web/frontend/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ModelsRouteImport } from './routes/models' import { Route as LogsRouteImport } from './routes/logs' +import { Route as LauncherLoginRouteImport } from './routes/launcher-login' import { Route as CredentialsRouteImport } from './routes/credentials' import { Route as ConfigRouteImport } from './routes/config' import { Route as AgentRouteImport } from './routes/agent' @@ -31,6 +32,11 @@ const LogsRoute = LogsRouteImport.update({ path: '/logs', getParentRoute: () => rootRouteImport, } as any) +const LauncherLoginRoute = LauncherLoginRouteImport.update({ + id: '/launcher-login', + path: '/launcher-login', + getParentRoute: () => rootRouteImport, +} as any) const CredentialsRoute = CredentialsRouteImport.update({ id: '/credentials', path: '/credentials', @@ -83,6 +89,7 @@ export interface FileRoutesByFullPath { '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute + '/launcher-login': typeof LauncherLoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute @@ -96,6 +103,7 @@ export interface FileRoutesByTo { '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute + '/launcher-login': typeof LauncherLoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute @@ -110,6 +118,7 @@ export interface FileRoutesById { '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute + '/launcher-login': typeof LauncherLoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute @@ -125,6 +134,7 @@ export interface FileRouteTypes { | '/agent' | '/config' | '/credentials' + | '/launcher-login' | '/logs' | '/models' | '/agent/skills' @@ -138,6 +148,7 @@ export interface FileRouteTypes { | '/agent' | '/config' | '/credentials' + | '/launcher-login' | '/logs' | '/models' | '/agent/skills' @@ -151,6 +162,7 @@ export interface FileRouteTypes { | '/agent' | '/config' | '/credentials' + | '/launcher-login' | '/logs' | '/models' | '/agent/skills' @@ -165,6 +177,7 @@ export interface RootRouteChildren { AgentRoute: typeof AgentRouteWithChildren ConfigRoute: typeof ConfigRouteWithChildren CredentialsRoute: typeof CredentialsRoute + LauncherLoginRoute: typeof LauncherLoginRoute LogsRoute: typeof LogsRoute ModelsRoute: typeof ModelsRoute } @@ -185,6 +198,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LogsRouteImport parentRoute: typeof rootRouteImport } + '/launcher-login': { + id: '/launcher-login' + path: '/launcher-login' + fullPath: '/launcher-login' + preLoaderRoute: typeof LauncherLoginRouteImport + parentRoute: typeof rootRouteImport + } '/credentials': { id: '/credentials' path: '/credentials' @@ -292,6 +312,7 @@ const rootRouteChildren: RootRouteChildren = { AgentRoute: AgentRouteWithChildren, ConfigRoute: ConfigRouteWithChildren, CredentialsRoute: CredentialsRoute, + LauncherLoginRoute: LauncherLoginRoute, LogsRoute: LogsRoute, ModelsRoute: ModelsRoute, } diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx index 31fdb78040..d2303a29c4 100644 --- a/web/frontend/src/routes/__root.tsx +++ b/web/frontend/src/routes/__root.tsx @@ -1,19 +1,56 @@ -import { Outlet, createRootRoute } from "@tanstack/react-router" +import { + Outlet, + createRootRoute, + useRouterState, +} from "@tanstack/react-router" import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" import { useEffect } from "react" import { AppLayout } from "@/components/app-layout" import { initializeChatStore } from "@/features/chat/controller" +import { isLauncherLoginPathname } from "@/lib/launcher-login-path" const RootLayout = () => { + // Prefer the real address bar path: stale embedded bundles may not register + // /launcher-login in the route tree, which would otherwise keep AppLayout + + // gateway polling → 401 → launcherFetch redirect loop. + const routerState = useRouterState({ + select: (s) => ({ + pathname: s.location.pathname, + matches: s.matches, + }), + }) + + const windowPath = + typeof globalThis.location !== "undefined" + ? globalThis.location.pathname || "/" + : routerState.pathname + + const isLauncherLogin = + isLauncherLoginPathname(windowPath) || + isLauncherLoginPathname(routerState.pathname) || + routerState.matches.some((m) => m.routeId === "/launcher-login") + useEffect(() => { + if (isLauncherLogin) { + return + } initializeChatStore() - }, []) + }, [isLauncherLogin]) + + if (isLauncherLogin) { + return ( + <> + + {import.meta.env.DEV ? : null} + + ) + } return ( - + {import.meta.env.DEV ? : null} ) } diff --git a/web/frontend/src/routes/launcher-login.tsx b/web/frontend/src/routes/launcher-login.tsx new file mode 100644 index 0000000000..ca85e75afe --- /dev/null +++ b/web/frontend/src/routes/launcher-login.tsx @@ -0,0 +1,131 @@ +import { IconLanguage, IconMoon, IconSun } from "@tabler/icons-react" +import { createFileRoute } from "@tanstack/react-router" +import * as React from "react" +import { useTranslation } from "react-i18next" + +import { postLauncherDashboardLogin } from "@/api/launcher-auth" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useTheme } from "@/hooks/use-theme" + +function LauncherLoginPage() { + const { t, i18n } = useTranslation() + const { theme, toggleTheme } = useTheme() + const [token, setToken] = React.useState("") + const [submitting, setSubmitting] = React.useState(false) + const [error, setError] = React.useState("") + + const loginWithToken = React.useCallback( + async (tokenValue: string) => { + setError("") + setSubmitting(true) + try { + const ok = await postLauncherDashboardLogin(tokenValue) + if (ok) { + globalThis.location.assign("/") + return + } + setError(t("launcherLogin.errorInvalid")) + } catch { + setError(t("launcherLogin.errorNetwork")) + } finally { + setSubmitting(false) + } + }, + [t], + ) + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + await loginWithToken(token) + } + + return ( +
+
+ + + + + + i18n.changeLanguage("en")}> + English + + i18n.changeLanguage("zh")}> + 简体中文 + + + + +
+ +
+ + + {t("launcherLogin.title")} + {t("launcherLogin.description")} + + +
+
+ + setToken(e.target.value)} + placeholder={t("launcherLogin.tokenPlaceholder")} + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+
+
+
+
+ ) +} + +export const Route = createFileRoute("/launcher-login")({ + component: LauncherLoginPage, +})