From 6ea364e67dead37e09ffc8d535ccc262a0ffe202 Mon Sep 17 00:00:00 2001 From: zeed zhao Date: Sun, 29 Mar 2026 13:11:43 +0800 Subject: [PATCH 1/3] feat(web): protect launcher dashboard with token and SPA login (#1953) Add token-based authentication for the Launcher's embedded Web Dashboard. - Ephemeral token generated in-memory each run (or via PICOCLAW_LAUNCHER_TOKEN env var) - HMAC-SHA256 session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS) - Bearer token support for API/script access - Rate limiting on login (10 attempts/IP/min) - Referrer-Policy: no-referrer on all responses - POST-only logout with JSON content-type (CSRF-safe) - System tray "Copy dashboard token" action - Login page shows contextual help (console/tray/log file path) - Path traversal protection via path.Clean - X-Forwarded-Host/Port/Proto support for reverse proxy deployments - Full i18n support (English, Chinese) Co-Authored-By: Claude Opus 4.6 --- docs/configuration.md | 12 + docs/docker.md | 2 +- docs/zh/configuration.md | 12 + docs/zh/docker.md | 4 +- go.mod | 1 + go.sum | 2 + pkg/config/security_integration_test.go | 2 +- web/backend/api/auth.go | 142 +++++++++++ web/backend/api/auth_login_limiter.go | 59 +++++ web/backend/api/auth_test.go | 218 +++++++++++++++++ web/backend/api/gateway_host.go | 118 ++++++++- web/backend/api/gateway_host_test.go | 62 ++++- web/backend/api/pico.go | 6 +- web/backend/app_runtime.go | 8 +- web/backend/i18n.go | 6 + web/backend/launcherconfig/config.go | 35 +++ web/backend/launcherconfig/config_test.go | 47 ++++ web/backend/main.go | 67 +++++- .../middleware/launcher_dashboard_auth.go | 226 ++++++++++++++++++ .../launcher_dashboard_auth_test.go | 162 +++++++++++++ web/backend/middleware/referrer_policy.go | 12 + web/backend/systray.go | 13 + web/backend/tray_offers_copy.go | 5 + web/backend/tray_offers_copy_stub.go | 5 + web/frontend/src/api/channels.ts | 4 +- web/frontend/src/api/gateway.ts | 4 +- web/frontend/src/api/http.ts | 42 ++++ web/frontend/src/api/launcher-auth.ts | 48 ++++ web/frontend/src/api/models.ts | 3 +- web/frontend/src/api/oauth.ts | 4 +- web/frontend/src/api/pico.ts | 4 +- web/frontend/src/api/sessions.ts | 8 +- web/frontend/src/api/skills.ts | 6 +- web/frontend/src/api/system.ts | 4 +- web/frontend/src/api/tools.ts | 4 +- .../src/components/config/config-page.tsx | 3 +- .../src/components/config/raw-config-page.tsx | 5 +- web/frontend/src/features/chat/websocket.ts | 12 + web/frontend/src/i18n/locales/en.json | 14 ++ web/frontend/src/i18n/locales/zh.json | 14 ++ web/frontend/src/lib/launcher-login-path.ts | 9 + web/frontend/src/routeTree.gen.ts | 21 ++ web/frontend/src/routes/__root.tsx | 43 +++- web/frontend/src/routes/launcher-login.tsx | 184 ++++++++++++++ 44 files changed, 1617 insertions(+), 45 deletions(-) create mode 100644 web/backend/api/auth.go create mode 100644 web/backend/api/auth_login_limiter.go create mode 100644 web/backend/api/auth_test.go create mode 100644 web/backend/middleware/launcher_dashboard_auth.go create mode 100644 web/backend/middleware/launcher_dashboard_auth_test.go create mode 100644 web/backend/middleware/referrer_policy.go create mode 100644 web/backend/tray_offers_copy.go create mode 100644 web/backend/tray_offers_copy_stub.go create mode 100644 web/frontend/src/api/http.ts create mode 100644 web/frontend/src/api/launcher-auth.ts create mode 100644 web/frontend/src/lib/launcher-login-path.ts create mode 100644 web/frontend/src/routes/launcher-login.tsx diff --git a/docs/configuration.md b/docs/configuration.md index 9360d3897..3462767e6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,18 @@ 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 + +**picoclaw-launcher** serves a browser UI that requires sign-in first. By default, the **dashboard token** and **session signing key** are **generated in memory on each start** (a new random token after every restart). Set **`PICOCLAW_LAUNCHER_TOKEN`** to pin a fixed token for that process (startup logs do not print the secret when this env var is used). + +**Where to read the token**: In **console mode** (`-console`), it is printed at startup. In **tray / GUI mode**, use the tray action **Copy dashboard token**, and check **`$PICOCLAW_HOME/logs/launcher.log`** (typically `~/.picoclaw/logs/launcher.log` if `PICOCLAW_HOME` is unset) for the random token logged on startup. The login page shows hints that match how the launcher is running (including the absolute log path); **responses do not include the token itself**. + +- **Config file**: Same directory as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is `launcher-config.json`. +- **Sign-in and links**: Enter the token on the login page, or open with `?token=` when the browser is launched automatically. All responses include **`Referrer-Policy: no-referrer`** to reduce leakage of `token` via the `Referer` header. +- **Sign-out**: Use **`POST /api/auth/logout`** with **`Content-Type: application/json`** (body may be `{}`). Do not rely on a GET URL for logout (CSRF-safe pattern). +- **Brute-force**: **`POST /api/auth/login`** is **rate-limited per client IP per minute** (HTTP 429 when exceeded). +- **Session lifetime**: The HttpOnly session cookie lasts about **7 days** by default; sign in again with the token after it expires. + ### Skill Sources By default, skills are loaded from: diff --git a/docs/docker.md b/docs/docker.md index a00dfbe9f..18de1ee83 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 (in-memory per run unless `PICOCLAW_LAUNCHER_TOKEN` is set). **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 335566d36..3b0ac9a50 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -51,6 +51,18 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work > **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。 +### Web 启动器控制台 + +用 **picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。 + +**到哪里找口令**:**控制台模式**(`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。 + +- **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。 +- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token` 经 `Referer` 头泄露的风险。 +- **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。 +- **暴力尝试**:`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。 +- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录。 + ### 技能来源 (Skill Sources) 默认情况下,技能会按以下顺序加载: diff --git a/docs/zh/docker.md b/docs/zh/docker.md index 10bc46544..8aed1e86b 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 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 ### Agent 模式 (一次性运行) diff --git a/go.mod b/go.mod index 54c275102..202839c29 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect diff --git a/go.sum b/go.sum index ae12473f3..c64f3593d 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 24170f84b..6ca8637f4 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 000000000..b9b4d5f66 --- /dev/null +++ b/web/backend/api/auth.go @@ -0,0 +1,142 @@ +package api + +import ( + "crypto/subtle" + "encoding/json" + "io" + "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 + // TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets). + TokenHelp LauncherAuthTokenHelp +} + +// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token. +type LauncherAuthTokenHelp struct { + EnvVarName string `json:"env_var_name"` + LogFileAbs string `json:"log_file,omitempty"` + TrayCopyMenu bool `json:"tray_copy_menu"` + ConsoleStdout bool `json:"console_stdout"` +} + +type launcherAuthLoginBody struct { + Token string `json:"token"` +} + +type launcherAuthStatusResponse struct { + Authenticated bool `json:"authenticated"` + TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"` +} + +// 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, + tokenHelp: opts.TokenHelp, + loginLimit: newLoginRateLimiter(), + } + mux.HandleFunc("POST /api/auth/login", h.handleLogin) + mux.HandleFunc("POST /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 + tokenHelp LauncherAuthTokenHelp + loginLimit *loginRateLimiter +} + +func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var body launcherAuthLoginBody + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON"}`)) + return + } + ip := clientIPForLimiter(r) + if !h.loginLimit.allow(ip) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":"too many login attempts"}`)) + return + } + in := strings.TrimSpace(body.Token) + if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid token"}`)) + return + } + + middleware.SetLauncherDashboardSessionCookie(w, r, h.sessionCookie, h.secureCookie) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte(`{"error":"method not allowed"}`)) + return + } + ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))) + if !strings.HasPrefix(ct, "application/json") { + w.WriteHeader(http.StatusUnsupportedMediaType) + _, _ = w.Write([]byte(`{"error":"Content-Type must be application/json"}`)) + return + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, logoutBodyMaxBytes)) + if err := dec.Decode(&struct{}{}); err != nil && err != io.EOF { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON body"}`)) + return + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON body"}`)) + return + } + + middleware.ClearLauncherDashboardSessionCookie(w, r, h.secureCookie) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + ok := false + if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { + ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 + } + if ok { + _, _ = w.Write([]byte(`{"authenticated":true}`)) + return + } + resp := launcherAuthStatusResponse{ + Authenticated: false, + TokenHelp: &h.tokenHelp, + } + enc, err := json.Marshal(resp) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + return + } + _, _ = w.Write(enc) +} diff --git a/web/backend/api/auth_login_limiter.go b/web/backend/api/auth_login_limiter.go new file mode 100644 index 000000000..d606f03cf --- /dev/null +++ b/web/backend/api/auth_login_limiter.go @@ -0,0 +1,59 @@ +package api + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +const ( + loginAttemptsPerIP = 10 + loginAttemptWindow = time.Minute + logoutBodyMaxBytes = 4096 +) + +// loginRateLimiter limits POST /api/auth/login attempts per IP per minute. +type loginRateLimiter struct { + mu sync.Mutex + now func() time.Time + byIP map[string][]time.Time +} + +func newLoginRateLimiter() *loginRateLimiter { + return &loginRateLimiter{ + now: time.Now, + byIP: make(map[string][]time.Time), + } +} + +// allow reserves a slot for this request; false means rate limit exceeded. +func (l *loginRateLimiter) allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + cutoff := now.Add(-loginAttemptWindow) + times := l.byIP[ip] + var kept []time.Time + for _, ts := range times { + if ts.After(cutoff) { + kept = append(kept, ts) + } + } + if len(kept) >= loginAttemptsPerIP { + l.byIP[ip] = kept + return false + } + kept = append(kept, now) + l.byIP[ip] = kept + return true +} + +func clientIPForLimiter(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return strings.TrimSpace(r.RemoteAddr) + } + return host +} diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go new file mode 100644 index 000000000..d2624a440 --- /dev/null +++ b/web/backend/api/auth_test.go @@ -0,0 +1,218 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/web/backend/middleware" +) + +func TestLauncherAuthLoginAndStatus(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = 0x55 + } + const tok = "dashboard-test-token-9" + sess := middleware.SessionCookieValue(key, tok) + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: tok, + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{ + EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", + LogFileAbs: "/tmp/launcher.log", + TrayCopyMenu: true, + ConsoleStdout: false, + }, + }) + + t.Run("status_unauthenticated", func(t *testing.T) { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d", rec.Code) + } + var body struct { + Authenticated bool `json:"authenticated"` + TokenHelp *LauncherAuthTokenHelp `json:"token_help"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Authenticated || body.TokenHelp == nil { + t.Fatalf("unexpected body: %+v", body) + } + if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" { + t.Fatalf("token_help = %+v", body.TokenHelp) + } + }) + + t.Run("login_ok", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "127.0.0.1:12345" + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String()) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != middleware.LauncherDashboardCookieName { + t.Fatalf("cookies = %#v", cookies) + } + }) + + t.Run("status_authenticated", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil) + req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess}) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d", rec.Code) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"authenticated":true`)) { + t.Fatalf("body = %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "token_help") { + t.Fatalf("authenticated response should omit token_help: %s", rec.Body.String()) + } + }) +} + +func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { + key := make([]byte, 32) + sess := middleware.SessionCookieValue(key, "tok") + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: "tok", + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"}, + }) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/logout", nil)) + if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound { + t.Fatalf("GET logout: code = %d (expected 404 or 405)", rec.Code) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusUnsupportedMediaType { + t.Fatalf("wrong content-type: code = %d body=%s", rec2.Code, rec2.Body.String()) + } + + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}`)) + req3.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusOK { + t.Fatalf("POST json logout: code = %d", rec3.Code) + } +} + +func TestLauncherAuthLoginRateLimit(t *testing.T) { + key := make([]byte, 32) + const tok = "rate-limit-tok-xxxxxxxx" + sess := middleware.SessionCookieValue(key, tok) + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: tok, + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, + }) + + // 11 failing logins by wrong token; each consumes allow() slot after valid JSON. + wrongBody := `{"token":"wrong"}` + for i := 0; i < loginAttemptsPerIP; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.5.5:9999" + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("iter %d: want 401 got %d %s", i, rec.Code, rec.Body.String()) + } + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.5.5:9999" + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("11th attempt: want 429 got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestLoginRateLimiterWindow(t *testing.T) { + l := newLoginRateLimiter() + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + l.now = func() time.Time { return t0 } + for i := 0; i < loginAttemptsPerIP; i++ { + if !l.allow("ip") { + t.Fatalf("want allow at %d", i) + } + } + if l.allow("ip") { + t.Fatal("want deny on 11th") + } + l.now = func() time.Time { return t0.Add(loginAttemptWindow + time.Second) } + if !l.allow("ip") { + t.Fatal("want allow after window") + } +} + +func TestReferrerPolicyMiddleware(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + h := middleware.ReferrerPolicyNoReferrer(next) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" { + t.Fatalf("Referrer-Policy = %q", got) + } +} + +func TestLauncherAuthLogoutEmptyBody(t *testing.T) { + key := make([]byte, 32) + sess := middleware.SessionCookieValue(key, "tok") + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: "tok", + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + req.Header.Set("Content-Type", "application/json") + req.Body = http.NoBody + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } +} + +func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) { + key := make([]byte, 32) + sess := middleware.SessionCookieValue(key, "tok") + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: "tok", + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400 got %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 592571a28..6190f0c7c 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -93,16 +93,120 @@ 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" + } } - // 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 + 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). +// Used by picoWebUIAddr → buildWsURL / buildPicoEventsURL / buildPicoSendURL so WebSocket and +// HTTP URLs match the dashboard page origin (cookies / token flow behind tunnels and reverse 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) + } + 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 ae3434862..7150b6fee 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/pico.go b/web/backend/api/pico.go index d345d980c..a3f1a4ffb 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 e3a9ec64f..ab564db2c 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/i18n.go b/web/backend/i18n.go index 9cda9e5d5..106df8506 100644 --- a/web/backend/i18n.go +++ b/web/backend/i18n.go @@ -24,6 +24,8 @@ const ( AppTooltip TranslationKey = "AppTooltip" MenuOpen TranslationKey = "MenuOpen" MenuOpenTooltip TranslationKey = "MenuOpenTooltip" + MenuCopyToken TranslationKey = "MenuCopyToken" + MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint" MenuAbout TranslationKey = "MenuAbout" MenuAboutTooltip TranslationKey = "MenuAboutTooltip" MenuVersion TranslationKey = "MenuVersion" @@ -47,6 +49,8 @@ var translations = map[Language]map[TranslationKey]string{ AppTooltip: "%s - Web Console", MenuOpen: "Open Console", MenuOpenTooltip: "Open PicoClaw console in browser", + MenuCopyToken: "Copy dashboard token", + MenuCopyTokenHint: "Copy the current web console access token to the clipboard", MenuAbout: "About", MenuAboutTooltip: "About PicoClaw", MenuVersion: "Version: %s", @@ -64,6 +68,8 @@ var translations = map[Language]map[TranslationKey]string{ AppTooltip: "%s - Web Console", MenuOpen: "打开控制台", MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台", + MenuCopyToken: "复制控制台口令", + MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板", MenuAbout: "关于", MenuAboutTooltip: "关于 PicoClaw", MenuVersion: "版本: %s", diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index 4dca45b0e..b8465ef74 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -1,6 +1,8 @@ package launcherconfig import ( + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "net" @@ -14,6 +16,11 @@ const ( FileName = "launcher-config.json" // DefaultPort is the default port for the web launcher. DefaultPort = 18800 + + // dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits). + dashboardSigningKeyBytes = 32 + // dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits). + dashboardTokenEntropyBytes = 32 ) // Config stores launch parameters for the web backend service. @@ -41,6 +48,34 @@ func Validate(cfg Config) error { return nil } +// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this +// process. The signing key is freshly random each call; the token comes from the environment +// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token. +func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) { + signingKey = make([]byte, dashboardSigningKeyBytes) + if _, err = rand.Read(signingKey); err != nil { + return "", nil, false, err + } + + effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN")) + if effectiveToken != "" { + return effectiveToken, signingKey, false, nil + } + tok, genErr := randomDashboardToken() + if genErr != nil { + return "", nil, false, genErr + } + return tok, signingKey, true, nil +} + +func randomDashboardToken() (string, error) { + buf := make([]byte, dashboardTokenEntropyBytes) + 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 c63bee09a..4e8a54e41 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,51 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { } } +func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) { + t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") + + tok, key, newTok, err := EnsureDashboardSecrets() + if err != nil { + t.Fatalf("EnsureDashboardSecrets() error = %v", err) + } + if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes { + t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key)) + } + mac := middleware.SessionCookieValue(key, tok) + if mac == "" { + t.Fatal("empty session mac") + } + + tok2, key2, newTok2, err := EnsureDashboardSecrets() + if err != nil { + t.Fatalf("EnsureDashboardSecrets() second error = %v", err) + } + if !newTok2 { + t.Fatal("second call without env should generate another random token") + } + if tok2 == tok { + t.Fatal("expected a new random dashboard token") + } + if string(key2) == string(key) { + t.Fatal("expected a new signing key") + } +} + +func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) { + t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override") + + tok, _, newTok, err := EnsureDashboardSecrets() + if err != nil { + t.Fatalf("EnsureDashboardSecrets() error = %v", err) + } + if tok != "env-only-token-override" { + t.Fatalf("token = %q, want env value", tok) + } + if newTok { + t.Fatal("newRandomDashboardToken should be false when env is set") + } +} + 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 6987a4515..c58e97361 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,12 @@ 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 + // launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode). + launcherDashboardTokenForClipboard string noBrowser *bool ) @@ -57,7 +63,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 +104,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 +124,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 +162,13 @@ func main() { logger.Fatalf("Invalid port %q: %v", effectivePort, err) } + dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets() + if dashErr != nil { + logger.Fatalf("Dashboard auth setup failed: %v", dashErr) + } + dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken) + launcherDashboardTokenForClipboard = dashboardToken + // Determine listen address var addr string if effectivePublic { @@ -167,6 +180,21 @@ func main() { // Initialize Server components mux := http.NewServeMux() + tokenLogFileAbs := "" + if !enableConsole { + tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile) + } + api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ + DashboardToken: dashboardToken, + SessionCookie: dashboardSessionCookie, + TokenHelp: api.LauncherAuthTokenHelp{ + EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", + LogFileAbs: tokenLogFileAbs, + TrayCopyMenu: trayOffersDashboardTokenCopy(), + ConsoleStdout: enableConsole, + }, + }) + // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) if _, err = apiHandler.EnsurePicoChannel(""); err != nil { @@ -183,14 +211,21 @@ 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.ReferrerPolicyNoReferrer( + middleware.JSONContentType(dashAuth), + ), ), ) - // Print startup banner (only in console mode) + // Print startup banner and token (console mode only). if enableConsole { fmt.Print(utils.Banner) fmt.Println() @@ -203,6 +238,19 @@ func main() { } } fmt.Println() + if newDashTok { + fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken) + } else if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" { + fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken) + } + fmt.Println() + } + + if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" { + logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN") + } + if !enableConsole && newDashTok { + logger.InfoC("web", "Dashboard token (this run): "+dashboardToken) } // Log startup info to file @@ -215,6 +263,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 000000000..7e92fca22 --- /dev/null +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -0,0 +1,226 @@ +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" + +// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days). +const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600 + +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: launcherDashboardSessionMaxAgeSec, + 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.MethodPost + 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 000000000..1b919bf96 --- /dev/null +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -0,0 +1,162 @@ +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.MethodPost, "/api/auth/logout", http.StatusTeapot}, + {http.MethodGet, "/api/auth/logout", http.StatusUnauthorized}, + {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/backend/middleware/referrer_policy.go b/web/backend/middleware/referrer_policy.go new file mode 100644 index 000000000..5ac066614 --- /dev/null +++ b/web/backend/middleware/referrer_policy.go @@ -0,0 +1,12 @@ +package middleware + +import "net/http" + +// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive +// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header. +func ReferrerPolicyNoReferrer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} diff --git a/web/backend/systray.go b/web/backend/systray.go index 9dcc025df..744ea4611 100644 --- a/web/backend/systray.go +++ b/web/backend/systray.go @@ -6,6 +6,7 @@ import ( "fmt" "fyne.io/systray" + "github.com/atotto/clipboard" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/utils" @@ -23,6 +24,7 @@ func onReady() { // Create menu items mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip)) + mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint)) mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip)) // Add version info under About menu @@ -50,6 +52,17 @@ func onReady() { logger.Errorf("Failed to open browser: %v", err) } + case <-mCopyTok.ClickedCh: + if launcherDashboardTokenForClipboard == "" { + logger.WarnC("web", "Dashboard token is empty; cannot copy") + continue + } + if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil { + logger.Errorf("Failed to copy dashboard token: %v", err) + } else { + logger.InfoC("web", "Dashboard token copied to clipboard") + } + case <-mVersion.ClickedCh: // Version info - do nothing, just shows current version diff --git a/web/backend/tray_offers_copy.go b/web/backend/tray_offers_copy.go new file mode 100644 index 000000000..6b7d17412 --- /dev/null +++ b/web/backend/tray_offers_copy.go @@ -0,0 +1,5 @@ +//go:build (!darwin && !freebsd) || cgo + +package main + +func trayOffersDashboardTokenCopy() bool { return true } diff --git a/web/backend/tray_offers_copy_stub.go b/web/backend/tray_offers_copy_stub.go new file mode 100644 index 000000000..9312700f3 --- /dev/null +++ b/web/backend/tray_offers_copy_stub.go @@ -0,0 +1,5 @@ +//go:build (darwin || freebsd) && !cgo + +package main + +func trayOffersDashboardTokenCopy() bool { return false } diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index 85550ca81..eb4d41fd7 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 9e02a02b5..2742a0a37 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 000000000..0eb872f3f --- /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 000000000..247d5ab9e --- /dev/null +++ b/web/frontend/src/api/launcher-auth.ts @@ -0,0 +1,48 @@ +/** + * 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 +} + +export type LauncherAuthTokenHelp = { + env_var_name: string + log_file?: string + tray_copy_menu: boolean + console_stdout: boolean +} + +export type LauncherAuthStatus = { + authenticated: boolean + token_help?: LauncherAuthTokenHelp +} + +export async function getLauncherAuthStatus(): Promise { + const res = await fetch("/api/auth/status", { + method: "GET", + credentials: "same-origin", + }) + if (!res.ok) { + throw new Error(`status ${res.status}`) + } + return (await res.json()) as LauncherAuthStatus +} + +export async function postLauncherDashboardLogout(): Promise { + const res = await fetch("/api/auth/logout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: "{}", + }) + return res.ok +} diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index aa66a7389..d75b3ec3c 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 a1ed1afcb..689a2bcd1 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 9a1a553d5..6b8ceb49a 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 10b0d28fd..c91495901 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 307cbd788..72ccbcfe5 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 543c8694d..2e2f36f15 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 9f09efbfd..824bcc0fa 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 664e75440..cbe4d8e91 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, @@ -50,7 +51,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 56a922fe6..f8f987651 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, @@ -28,7 +29,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") } @@ -38,7 +39,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 6b132e9a6..17ba36075 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 25608fe93..38cdeb324 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -14,6 +14,20 @@ "config": "Config", "logs": "Logs" }, + "launcherLogin": { + "title": "Launcher access", + "description": "Sign in with the dashboard access token for this launcher process (it may change after each restart unless you pin it with an environment variable).", + "tokenLabel": "Token", + "tokenPlaceholder": "Enter access token", + "submit": "Continue to Dashboard", + "errorInvalid": "Invalid token. Please try again.", + "errorNetwork": "Network error. Please try again.", + "helpTitle": "Where to find the token", + "helpConsole": "Console mode: printed in the terminal when the launcher starts.", + "helpTray": "Tray mode: menu «Copy dashboard token».", + "helpLogFile": "Log file (startup line includes the token): {{path}}", + "helpEnv": "Stable token: set {{env}}." + }, "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 346822407..9ec4ec967 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -14,6 +14,20 @@ "config": "配置", "logs": "日志" }, + "launcherLogin": { + "title": "Launcher 访问验证", + "description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量固定)。", + "tokenLabel": "令牌", + "tokenPlaceholder": "输入访问令牌", + "submit": "进入 Dashboard", + "errorInvalid": "令牌错误,请重试。", + "errorNetwork": "网络错误,请重试。", + "helpTitle": "口令在哪里", + "helpConsole": "控制台模式:启动时在终端输出。", + "helpTray": "托盘模式:菜单「复制控制台口令」。", + "helpLogFile": "日志文件(启动时会写入口令):{{path}}", + "helpEnv": "固定口令:设置环境变量 {{env}}。" + }, "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 000000000..52c35d240 --- /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 60f19ab53..536ee560b 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 31fdb7804..d2303a29c 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 000000000..e7f774df7 --- /dev/null +++ b/web/frontend/src/routes/launcher-login.tsx @@ -0,0 +1,184 @@ +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 { + getLauncherAuthStatus, + postLauncherDashboardLogin, + type LauncherAuthTokenHelp, +} 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 [tokenHelp, setTokenHelp] = React.useState( + null, + ) + + React.useEffect(() => { + let cancelled = false + void getLauncherAuthStatus() + .then((s) => { + if (cancelled || s.authenticated || !s.token_help) { + return + } + setTokenHelp(s.token_help) + }) + .catch(() => { + /* ignore; login form still usable */ + }) + return () => { + cancelled = true + } + }, []) + + 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} +
+ {tokenHelp ? ( +
+

+ {t("launcherLogin.helpTitle")} +

+
    + {tokenHelp.console_stdout ? ( +
  • {t("launcherLogin.helpConsole")}
  • + ) : null} + {tokenHelp.tray_copy_menu ? ( +
  • {t("launcherLogin.helpTray")}
  • + ) : null} + {tokenHelp.log_file ? ( +
  • + {t("launcherLogin.helpLogFile", { + path: tokenHelp.log_file, + })} +
  • + ) : null} + {tokenHelp.env_var_name ? ( +
  • + {t("launcherLogin.helpEnv", { + env: tokenHelp.env_var_name, + })} +
  • + ) : null} +
+
+ ) : null} +
+
+
+
+ ) +} + +export const Route = createFileRoute("/launcher-login")({ + component: LauncherLoginPage, +}) From e414b82ac3b160ad5efdca83cae9337767cbe8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=88=E9=9D=92=E5=B7=9D?= <46062972+ShenQingchuan@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:47:28 +0800 Subject: [PATCH 2/3] fix(cron): publish agent response to outbound bus for cron-triggered jobs (#2100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cron): publish agent response to outbound bus for cron-triggered jobs When a cron job triggers agent execution via ProcessDirectWithChannel, the agent response was silently discarded — the code assumed AgentLoop would auto-publish it, but SendResponse is false on this path. Delegate to PublishResponseIfNeeded (exported from AgentLoop) so the response reaches the originating channel (e.g. Telegram) only when the message tool did not already deliver content in the same round. Also adds a "directive" message type to CronPayload, allowing cron jobs to instruct the agent to execute a task rather than echo static text. * fix(cron): add type validation and directive test coverage Address reviewer blocking feedback: 1. Server-side whitelist for `type` parameter — the `enum` in Parameters() is only an LLM schema hint; any string was persisted. Now `addJob` rejects values other than "message" and "directive". 2. Comprehensive test coverage for the directive code path: - directive adds prompt prefix to ProcessDirectWithChannel - deliver=true + directive routes through agent (not direct publish) - directive prompt content, sessionKey, channel, chatID are correct - invalid type is rejected; valid types ("", "message", "directive") pass - deliver=true message type goes directly to bus (regression) - agent error path does not trigger publish (regression) Also merge the two UpdateJob calls in addJob into one to avoid redundant disk I/O (non-blocking suggestion from review). * fix(cron): remove omitempty from CronPayload.Type for consistent JSON Empty string and "message" are semantically equivalent defaults; always serializing the field avoids asymmetric JSON output. * test(cron): remove redundant test, strengthen error path coverage - Remove ExecuteJobDirectivePassesCorrectContent: its assertions on sessionKey/channel/chatID duplicate ExecuteJobPublishesAgentResponse; its prompt check duplicates DirectiveAddsPromptPrefix. - Strengthen DirectiveAddsPromptPrefix with exact prompt match and publish response assertion. - Fix ReturnsErrorWithoutPublish: set non-empty stub response so the test verifies the error branch early-return, not the response=="" guard. * fix(ci): satisfy golines and gosmopolitan in cron code --- pkg/agent/loop.go | 6 +- pkg/cron/service.go | 1 + pkg/tools/cron.go | 54 +++++++-- pkg/tools/cron_test.go | 261 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 309 insertions(+), 13 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index db476c212..ef2951365 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -461,7 +461,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { if target == nil { cancelDrain() if finalResponse != "" { - al.publishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse) + al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse) } return } @@ -521,7 +521,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { } if finalResponse != "" { - al.publishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse) + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse) } }() } @@ -603,7 +603,7 @@ func (al *AgentLoop) Stop() { al.running.Store(false) } -func (al *AgentLoop) publishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { +func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { if response == "" { return } diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 77a413133..c1a224013 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -25,6 +25,7 @@ type CronSchedule struct { type CronPayload struct { Kind string `json:"kind"` + Type string `json:"type"` Message string `json:"message"` Command string `json:"command,omitempty"` Deliver bool `json:"deliver"` diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 154ec75f0..60d9d5e5a 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -16,6 +16,9 @@ import ( // JobExecutor is the interface for executing cron jobs through the agent type JobExecutor interface { ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) + // PublishResponseIfNeeded sends response to the outbound bus only when the + // agent did not already deliver content through the message tool in this round. + PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) } // CronTool provides scheduling capabilities for the agent @@ -111,6 +114,11 @@ func (t *CronTool) Parameters() map[string]any { "type": "string", "description": "Job ID (for remove/enable/disable)", }, + "type": map[string]any{ + "type": "string", + "enum": []string{"message", "directive"}, + "description": "Message generation strategy. 'message' (default): content is sent directly as-is. 'directive': content is treated as instructions for an AI agent to execute before delivery.", + }, "deliver": map[string]any{ "type": "boolean", "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false", @@ -197,6 +205,12 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult deliver = d } + // Validate type parameter (server-side whitelist, not just LLM schema hint) + msgType, _ := args["type"].(string) + if msgType != "" && msgType != "message" && msgType != "directive" { + return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType)) + } + // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When // allow_command is disabled, explicit confirmation is required as an override. // Non-command reminders remain open to all channels. @@ -230,9 +244,17 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult return ErrorResult(fmt.Sprintf("Error adding job: %v", err)) } + // Apply optional payload fields and persist in a single UpdateJob call + needsUpdate := false if command != "" { job.Payload.Command = command - // Need to save the updated payload + needsUpdate = true + } + if msgType != "" { + job.Payload.Type = msgType + needsUpdate = true + } + if needsUpdate { t.cronService.UpdateJob(job) } @@ -347,8 +369,13 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { return "ok" } - // If deliver=true, send message directly without agent processing - if job.Payload.Deliver { + // Determine message generation strategy + // Type="directive": treat message as instructions for AI agent to execute + // Type="" or "message" (default): static message content + isDirective := job.Payload.Type == "directive" + + // If deliver=true and not directive, send message directly without agent processing + if job.Payload.Deliver && !isDirective { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ @@ -359,13 +386,23 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { return "ok" } - // For deliver=false, process through agent (for complex tasks) + // For deliver=false OR directive mode, process through agent sessionKey := fmt.Sprintf("cron-%s", job.ID) - // Call agent with job's message + // Prepare the prompt based on type + prompt := job.Payload.Message + if isDirective { + // For directive type, prefix to clarify this is an instruction + prompt = fmt.Sprintf( + "Please execute the following directive and provide the result:\n\n%s", + job.Payload.Message, + ) + } + + // Call agent with the prepared prompt response, err := t.executor.ProcessDirectWithChannel( ctx, - job.Payload.Message, + prompt, sessionKey, channel, chatID, @@ -374,7 +411,8 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { return fmt.Sprintf("Error: %v", err) } - // Response is automatically sent via MessageBus by AgentLoop - _ = response // Will be sent by AgentLoop + if response != "" { + t.executor.PublishResponseIfNeeded(ctx, channel, chatID, response) + } return "ok" } diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index cd7d39860..186c6a75e 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "fmt" "path/filepath" "strings" "testing" @@ -12,18 +13,59 @@ import ( "github.com/sipeed/picoclaw/pkg/cron" ) -func newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool { +type stubJobExecutor struct { + response string + err error + alreadySent bool // simulate message tool having already sent in this round + lastPrompt string + lastKey string + lastChan string + lastChatID string + publishedResp string + publishedChan string + publishedChatID string +} + +func (s *stubJobExecutor) ProcessDirectWithChannel( + _ context.Context, + content, sessionKey, channel, chatID string, +) (string, error) { + s.lastPrompt = content + s.lastKey = sessionKey + s.lastChan = channel + s.lastChatID = chatID + return s.response, s.err +} + +func (s *stubJobExecutor) PublishResponseIfNeeded( + _ context.Context, + channel, chatID, response string, +) { + if s.alreadySent { + return + } + s.publishedResp = response + s.publishedChan = channel + s.publishedChatID = chatID +} + +func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool { t.Helper() storePath := filepath.Join(t.TempDir(), "cron.json") cronService := cron.NewCronService(storePath, nil) msgBus := bus.NewMessageBus() - tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg) + tool, err := NewCronTool(cronService, executor, msgBus, t.TempDir(), true, 0, cfg) if err != nil { t.Fatalf("NewCronTool() error: %v", err) } return tool } +func newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool { + t.Helper() + return newTestCronToolWithExecutorAndConfig(t, nil, cfg) +} + func newTestCronTool(t *testing.T) *CronTool { t.Helper() return newTestCronToolWithConfig(t, config.DefaultConfig()) @@ -237,3 +279,218 @@ func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) { t.Fatalf("expected exec disabled message, got: %s", msg.Content) } } + +func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { + executor := &stubJobExecutor{response: "generated reply"} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-1"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "send me a poem" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.lastKey != "cron-job-1" { + t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey) + } + if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" { + t.Fatalf("executor target = %s/%s, want telegram/chat-1", executor.lastChan, executor.lastChatID) + } + if executor.lastPrompt != "send me a poem" { + t.Fatalf("prompt = %q, want original message", executor.lastPrompt) + } + if executor.publishedResp != "generated reply" { + t.Fatalf("published response = %q, want generated reply", executor.publishedResp) + } + if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" { + t.Fatalf("published target = %s/%s, want telegram/chat-1", executor.publishedChan, executor.publishedChatID) + } +} + +func TestCronTool_ExecuteJobSkipsEmptyAgentResponse(t *testing.T) { + executor := &stubJobExecutor{} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-empty"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "say nothing" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.publishedResp != "" { + t.Fatalf("unexpected published response: %q", executor.publishedResp) + } +} + +func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) { + executor := &stubJobExecutor{response: "Sent.", alreadySent: true} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-msg-sent"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "send weather" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.publishedResp != "" { + t.Fatalf("expected no published response when message tool already sent, got: %q", executor.publishedResp) + } +} + +func TestCronTool_ExecuteJobDirectiveAddsPromptPrefix(t *testing.T) { + executor := &stubJobExecutor{response: "directive result"} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + originalMsg := "check the weather and summarize" + job := &cron.CronJob{ID: "job-dir-1"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = originalMsg + job.Payload.Type = "directive" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + wantPrompt := "Please execute the following directive and provide the result:\n\n" + originalMsg + if executor.lastPrompt != wantPrompt { + t.Fatalf("prompt = %q, want exact %q", executor.lastPrompt, wantPrompt) + } + if executor.publishedResp != "directive result" { + t.Fatalf("published response = %q, want %q", executor.publishedResp, "directive result") + } +} + +func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) { + executor := &stubJobExecutor{response: "agent processed"} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-dir-deliver"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "generate daily report" + job.Payload.Type = "directive" + job.Payload.Deliver = true + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.lastPrompt == "" { + t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked") + } + if executor.publishedResp != "agent processed" { + t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed") + } + + // Verify no direct publish happened on the bus (agent path, not direct path) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + select { + case msg := <-tool.msgBus.OutboundChan(): + t.Fatalf("unexpected direct bus message: %+v", msg) + case <-ctx.Done(): + // expected: no direct bus message + } +} + +func TestCronTool_ExecuteJobDeliverMessageDirectlyToBus(t *testing.T) { + executor := &stubJobExecutor{response: "should not be called"} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-deliver"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "hello world" + job.Payload.Deliver = true + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.lastPrompt != "" { + t.Fatal("expected agent NOT to be invoked for deliver=true message type") + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + select { + case msg := <-tool.msgBus.OutboundChan(): + if msg.Content != "hello world" { + t.Fatalf("bus content = %q, want %q", msg.Content, "hello world") + } + case <-ctx.Done(): + t.Fatal("timeout waiting for direct bus message") + } +} + +func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) { + executor := &stubJobExecutor{ + response: "this response must not be published", + err: fmt.Errorf("agent failure"), + } + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-err"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "do something" + + got := tool.ExecuteJob(context.Background(), job) + if !strings.Contains(got, "agent failure") { + t.Fatalf("ExecuteJob() = %q, want error message", got) + } + + if executor.publishedResp != "" { + t.Fatalf("unexpected publish on error path: %q", executor.publishedResp) + } +} + +func TestCronTool_AddJobRejectsInvalidType(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "test", + "at_seconds": float64(60), + "type": "invalid_type", + }) + + if !result.IsError { + t.Fatal("expected error for invalid type parameter") + } + if !strings.Contains(result.ForLLM, "invalid type") { + t.Errorf("expected 'invalid type' error, got: %s", result.ForLLM) + } +} + +func TestCronTool_AddJobAcceptsValidTypes(t *testing.T) { + for _, msgType := range []string{"", "message", "directive"} { + t.Run("type="+msgType, func(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "cli", "direct") + args := map[string]any{ + "action": "add", + "message": "test", + "at_seconds": float64(60), + } + if msgType != "" { + args["type"] = msgType + } + + result := tool.Execute(ctx, args) + if result.IsError { + t.Fatalf("expected valid type %q to succeed, got: %s", msgType, result.ForLLM) + } + }) + } +} From e70928cc6f4fcf88f4f7a34ba18a7126824b42c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=85=89=E6=98=A5?= Date: Sun, 29 Mar 2026 14:37:22 +0800 Subject: [PATCH 3/3] feat(mcp): support DisableStandaloneSSE for HTTP transport (#2108) --- pkg/mcp/manager.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 7b63cc979..323df0312 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -276,14 +276,25 @@ func (m *Manager) ConnectServer( if cfg.URL == "" { return fmt.Errorf("URL is required for SSE/HTTP transport") } + + // Configure DisableStandaloneSSE based on transport type. + // - "http": Request-response only mode. Disable the standalone SSE stream + // to avoid compatibility issues with servers that don't support GET /mcp. + // - "sse": Bidirectional mode. Enable the standalone SSE stream to receive + // server-initiated notifications (e.g., ToolListChangedNotification). + // - Empty or auto-detected: Defaults to "sse" behavior (standalone SSE enabled). + disableStandaloneSSE := (cfg.Type == "http") + logger.DebugCF("mcp", "Using SSE/HTTP transport", map[string]any{ - "server": name, - "url": cfg.URL, + "server": name, + "url": cfg.URL, + "disableStandaloneSSE": disableStandaloneSSE, }) sseTransport := &mcp.StreamableClientTransport{ - Endpoint: cfg.URL, + Endpoint: cfg.URL, + DisableStandaloneSSE: disableStandaloneSSE, } // Add custom headers if provided