diff --git a/cmd/picoclaw/internal/auth/status_test.go b/cmd/picoclaw/internal/auth/status_test.go index 7748ba502..2f9a70721 100644 --- a/cmd/picoclaw/internal/auth/status_test.go +++ b/cmd/picoclaw/internal/auth/status_test.go @@ -1,12 +1,53 @@ package auth import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + pkgauth "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" ) +func captureAuthStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + fn() + + require.NoError(t, w.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + require.NoError(t, r.Close()) + return buf.String() +} + +func setAuthStatusTestHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestNewStatusSubcommand(t *testing.T) { cmd := newStatusCommand() @@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) { assert.False(t, cmd.HasFlags()) } + +func TestAuthStatusCmdShowsCanonicalGoogleAntigravityAfterLegacyRefresh(t *testing.T) { + tmpDir := setAuthStatusTestHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "legacy-project", + }, + }, + } + data, err := json.Marshal(legacyStore) + require.NoError(t, err) + + authPath := filepath.Join(tmpDir, ".picoclaw", "auth.json") + require.NoError(t, os.MkdirAll(filepath.Dir(authPath), 0o755)) + require.NoError(t, os.WriteFile(authPath, data, 0o600)) + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = pkgauth.SetCredential("google-antigravity", &pkgauth.AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + ProjectID: "fresh-project", + }) + require.NoError(t, err) + + output := captureAuthStdout(t, func() { + require.NoError(t, authStatusCmd()) + }) + + assert.Contains(t, output, "\nAuthenticated Providers:") + assert.Contains(t, output, "\n google-antigravity:\n") + assert.NotContains(t, output, "\n antigravity:\n") + assert.Contains(t, output, " Project: fresh-project") + assert.Contains(t, output, " Expires: 2026-04-16 12:30") + assert.Equal(t, 1, strings.Count(output, ":\n Method: oauth")) +} diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md index 38f379c50..3c31f610f 100644 --- a/docs/design/provider-refactoring.md +++ b/docs/design/provider-refactoring.md @@ -154,7 +154,7 @@ Identify protocol via prefix in `model` field: | `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. | | `anthropic/` | Anthropic | Claude series specific | | `antigravity/` | Antigravity | Google Cloud Code Assist | -| `gemini/` | Gemini | Google Gemini native API (if needed) | +| `gemini/` | Gemini | Google Gemini native API | --- diff --git a/docs/guides/configuration.fr.md b/docs/guides/configuration.fr.md index f147fea95..786a0c28f 100644 --- a/docs/guides/configuration.fr.md +++ b/docs/guides/configuration.fr.md @@ -339,7 +339,7 @@ Répond HEARTBEAT_OK Utilisateur reçoit le résultat | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obtenir](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé) | @@ -369,9 +369,12 @@ L'ancienne configuration `providers` est **dépréciée** et a été supprimée PicoClaw route les providers par famille de protocole : - **Compatible OpenAI** : OpenRouter, Groq, Zhipu, endpoints vLLM et la plupart des autres. +- **Gemini natif** : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. - **Anthropic** : Comportement natif de l'API Claude. - **Codex/OAuth** : Route d'authentification OAuth/token OpenAI. +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`). + ### Tâches Planifiées / Rappels PicoClaw supporte les tâches planifiées via l'outil `cron`. L'agent peut définir, lister et annuler des rappels ou tâches récurrentes. diff --git a/docs/guides/configuration.ja.md b/docs/guides/configuration.ja.md index 1940eacda..0234edbd7 100644 --- a/docs/guides/configuration.ja.md +++ b/docs/guides/configuration.ja.md @@ -340,7 +340,7 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [取得](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [取得](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [取得](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [取得](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [取得](https://console.groq.com) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [取得](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) | @@ -370,9 +370,12 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 PicoClaw はプロトコルファミリーで Provider をルーティングします: - **OpenAI 互換**:OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。 +- **Gemini ネイティブ**:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 - **Anthropic**:Claude ネイティブ API の動作。 - **Codex/OAuth**:OpenAI OAuth/トークン認証ルート。 +これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現します。 + ### スケジュールタスク / リマインダー PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index fc7c45fc7..cc3de266a 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -71,15 +71,16 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ### 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**. +**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`. - **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. +- **Password storage**: On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. On platforms where the SQLite password store is unavailable, the bcrypt hash is stored in `launcher-config.json`. +- **Legacy migration**: Older `launcher_token` values are migrated once into password login and removed from saved launcher config. +- **Local auto-login**: When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically. +- **Unsupported auth paths**: URL token login (`?token=...`), `PICOCLAW_LAUNCHER_TOKEN`, and `Authorization: Bearer` dashboard auth are no longer supported. - **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. +- **Session lifetime**: The HttpOnly session cookie lasts about **31 days** by default, but sessions are invalidated when the launcher process restarts. ### Skill Sources @@ -577,7 +578,7 @@ For complete documentation, see [`../security/security_configuration.md`](../sec | **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -844,6 +845,7 @@ The old `providers` configuration is **deprecated** and has been removed in V2. PicoClaw routes providers by protocol family: - **OpenAI-compatible**: OpenRouter, Groq, Zhipu, vLLM-style endpoints, and most others. +- **Gemini native**: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. - **Anthropic**: Claude-native API behavior. - **Codex/OAuth**: OpenAI OAuth/token authentication route. diff --git a/docs/guides/configuration.pt-br.md b/docs/guides/configuration.pt-br.md index c47278484..e5d904e29 100644 --- a/docs/guides/configuration.pt-br.md +++ b/docs/guides/configuration.pt-br.md @@ -340,7 +340,7 @@ Responde HEARTBEAT_OK Usuário recebe resultado diretamente | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obter](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave) | @@ -370,9 +370,12 @@ A configuração antiga `providers` está **depreciada** e foi removida no V2. C PicoClaw roteia providers por família de protocolo: - **Compatível com OpenAI**: OpenRouter, Groq, Zhipu, endpoints vLLM e a maioria dos outros. +- **Gemini nativo**: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. - **Anthropic**: Comportamento nativo da API Claude. - **Codex/OAuth**: Rota de autenticação OAuth/token OpenAI. +Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_keys`). + ### Tarefas Agendadas / Lembretes PicoClaw suporta tarefas agendadas via ferramenta `cron`. diff --git a/docs/guides/configuration.vi.md b/docs/guides/configuration.vi.md index 9efeaa2b6..d905b6d2b 100644 --- a/docs/guides/configuration.vi.md +++ b/docs/guides/configuration.vi.md @@ -340,7 +340,7 @@ Trả lời HEARTBEAT_OK Người dùng nhận kết quả trực tiếp | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Lấy](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Cục bộ (không cần key) | @@ -370,9 +370,12 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b PicoClaw định tuyến provider theo họ giao thức: - **Tương thích OpenAI**: OpenRouter, Groq, Zhipu, endpoint kiểu vLLM và hầu hết các provider khác. +- **Gemini native**: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. - **Anthropic**: Hành vi API Claude gốc. - **Codex/OAuth**: Tuyến xác thực OAuth/token OpenAI. +Điều này giữ runtime nhẹ trong khi khiến backend OpenAI-compatible mới chủ yếu chỉ là thao tác cấu hình (`api_base` + `api_keys`). + ### Tác Vụ Đã Lên Lịch / Nhắc Nhở PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`. diff --git a/docs/guides/configuration.zh.md b/docs/guides/configuration.zh.md index c5956d6b8..dbc853d98 100644 --- a/docs/guides/configuration.zh.md +++ b/docs/guides/configuration.zh.md @@ -69,15 +69,16 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ### Web 启动器控制台 -用 **picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。 - -**到哪里找口令**:**控制台模式**(`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。 +用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。 - **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。 -- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token` 经 `Referer` 头泄露的风险。 +- **密码存储**:支持的平台会把 bcrypt 后的密码哈希存入 `launcher-auth.db`。如果当前平台不支持 SQLite 密码存储,则把 bcrypt 哈希存入 `launcher-config.json`。 +- **旧配置迁移**:旧版 `launcher_token` 会一次性迁移为密码登录,并从保存后的 launcher 配置中移除。 +- **本地自动登录**:launcher 启动后自动打开本地浏览器时,会使用仅允许 loopback 访问的一次性引导入口自动设置会话 Cookie。 +- **不再支持的鉴权方式**:不再支持 URL token 登录(`?token=...`)、`PICOCLAW_LAUNCHER_TOKEN` 和 `Authorization: Bearer` dashboard 鉴权。 - **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。 - **暴力尝试**:`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。 -- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录。 +- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **31 天**有效,但 launcher 进程重启后已有会话会失效。 ### 技能来源 (Skill Sources) @@ -441,7 +442,7 @@ Agent 读取 HEARTBEAT.md | **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) | -| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取](https://aistudio.google.com/api-keys) | | **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) | | **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | @@ -674,10 +675,11 @@ Agent 读取 HEARTBEAT.md PicoClaw 按协议族路由提供商: - **OpenAI 兼容**:OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。 +- **Gemini 原生**:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 - **Anthropic**:Claude 原生 API 行为。 - **Codex/OAuth**:OpenAI OAuth/Token 认证路由。 -这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_key`。 +这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_keys`。
智谱(旧版 providers 格式) diff --git a/docs/guides/docker.fr.md b/docs/guides/docker.fr.md index f8c821570..ed0d14cf3 100644 --- a/docs/guides/docker.fr.md +++ b/docs/guides/docker.fr.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Ouvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway. > [!WARNING] -> La console web ne prend pas encore en charge l'authentification. Évitez de l'exposer sur Internet public. +> La console web est protégée par un mot de passe de connexion au dashboard. Ne l'exposez pas à des réseaux non fiables ni à Internet public. ### Mode Agent (One-shot) diff --git a/docs/guides/docker.ja.md b/docs/guides/docker.ja.md index f5885e775..8fa5ae60c 100644 --- a/docs/guides/docker.ja.md +++ b/docs/guides/docker.ja.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。 > [!WARNING] -> Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。 +> Web コンソールは dashboard ログインパスワードで保護されます。信頼できないネットワークや公開インターネットには公開しないでください。 ### Agent モード (ワンショット) diff --git a/docs/guides/docker.md b/docs/guides/docker.md index 97299bab0..e017538f7 100644 --- a/docs/guides/docker.md +++ b/docs/guides/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 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. +> The web console is protected by dashboard password login. **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/guides/docker.ms.md b/docs/guides/docker.ms.md index 05725e195..7adab6759 100644 --- a/docs/guides/docker.ms.md +++ b/docs/guides/docker.ms.md @@ -44,7 +44,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Buka http://localhost:18800 dalam pelayar anda. Launcher mengurus proses gateway secara automatik. > [!WARNING] -> Konsol web belum menyokong autentikasi. Elakkan mendedahkannya ke internet awam. +> Konsol web dilindungi oleh kata laluan log masuk dashboard. Jangan dedahkannya kepada rangkaian tidak dipercayai atau internet awam. ### Mod Agent (One-shot) diff --git a/docs/guides/docker.pt-br.md b/docs/guides/docker.pt-br.md index 46d273bee..d7d55e753 100644 --- a/docs/guides/docker.pt-br.md +++ b/docs/guides/docker.pt-br.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Abra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente. > [!WARNING] -> O console web ainda não suporta autenticação. Evite expô-lo na internet pública. +> O console web é protegido por senha de login do dashboard. Não exponha o launcher a redes não confiáveis nem à internet pública. ### Modo Agent (One-shot) diff --git a/docs/guides/docker.vi.md b/docs/guides/docker.vi.md index 716c81544..05f1b3d68 100644 --- a/docs/guides/docker.vi.md +++ b/docs/guides/docker.vi.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Mở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway. > [!WARNING] -> Web console chưa hỗ trợ xác thực. Tránh để lộ ra internet công cộng. +> Web console được bảo vệ bằng mật khẩu đăng nhập dashboard. Không để lộ launcher ra mạng không tin cậy hoặc internet công cộng. ### Chế Độ Agent (One-shot) diff --git a/docs/guides/docker.zh.md b/docs/guides/docker.zh.md index f5d483af4..bed445751 100644 --- a/docs/guides/docker.zh.md +++ b/docs/guides/docker.zh.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d 在浏览器中打开 。Launcher 会自动管理 Gateway 进程。 > [!WARNING] -> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 +> Web 控制台通过 dashboard 登录密码保护。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 ### Agent 模式 (一次性运行) diff --git a/docs/guides/providers.fr.md b/docs/guides/providers.fr.md index 5e2700a01..aff600351 100644 --- a/docs/guides/providers.fr.md +++ b/docs/guides/providers.fr.md @@ -46,7 +46,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -108,7 +108,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti | `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) | | `api_base` | string | Non | Remplace l'URL de base API par défaut | | `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle | -| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) | +| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers compatibles OpenAI, Gemini, Anthropic et Azure) | | `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) | | `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) | | `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | @@ -299,10 +299,11 @@ Pour un guide de migration détaillé, voir [migration/model-list-migration.md]( PicoClaw route les fournisseurs par famille de protocoles : - Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM. +- Protocole Gemini natif : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. - Protocole Anthropic : Comportement natif de l'API Claude. - Chemin Codex/OAuth : Route d'authentification OAuth/token OpenAI. -Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_key`). +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`).
Zhipu diff --git a/docs/guides/providers.ja.md b/docs/guides/providers.ja.md index 77cf18d55..fecc74519 100644 --- a/docs/guides/providers.ja.md +++ b/docs/guides/providers.ja.md @@ -47,7 +47,7 @@ | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [キーを取得](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) | @@ -109,7 +109,7 @@ | `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 | | `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き | | `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL | -| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) | +| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Gemini、Anthropic、Azure provider で対応) | | `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる | | `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) | | `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` | @@ -311,6 +311,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック PicoClaw はプロトコルファミリーごとに Provider をルーティングします: - OpenAI 互換プロトコル:OpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。 +- Gemini ネイティブプロトコル:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 - Anthropic プロトコル:Claude ネイティブ API 動作。 - Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。 diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 2d15114b7..d99d8c016 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -54,7 +54,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) | | **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -124,7 +124,7 @@ This design also enables **multi-agent support** with flexible provider selectio | `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) | | `api_base` | string | No | Override the default API endpoint URL | | `proxy` | string | No | HTTP proxy URL for this model entry | -| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Anthropic, and Azure providers) | +| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) | | `request_timeout` | int | No | Request timeout in seconds (default varies by provider) | | `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) | | `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` | @@ -455,10 +455,11 @@ For detailed migration guide, see [migration/model-list-migration.md](../migrati PicoClaw routes providers by protocol family: - OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. +- Gemini native protocol: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. - Anthropic protocol: Claude-native API behavior. - Codex/OAuth path: OpenAI OAuth/token authentication route. -This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`).
Zhipu diff --git a/docs/guides/providers.pt-br.md b/docs/guides/providers.pt-br.md index fedeec5c5..0d45dc309 100644 --- a/docs/guides/providers.pt-br.md +++ b/docs/guides/providers.pt-br.md @@ -46,7 +46,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -108,7 +108,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de | `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) | | `api_base` | string | Não | Substitui a URL base da API padrão | | `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo | -| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) | +| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Gemini, Anthropic e Azure) | | `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) | | `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) | | `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | @@ -299,6 +299,7 @@ Para guia de migração detalhado, veja [migration/model-list-migration.md](../m O PicoClaw roteia provedores por família de protocolo: - Protocolo compatível com OpenAI: OpenRouter, gateways compatíveis com OpenAI, Groq, Zhipu e endpoints estilo vLLM. +- Protocolo Gemini nativo: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. - Protocolo Anthropic: Comportamento nativo da API Claude. - Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI. diff --git a/docs/guides/providers.vi.md b/docs/guides/providers.vi.md index 1bc76092d..c354461cf 100644 --- a/docs/guides/providers.vi.md +++ b/docs/guides/providers.vi.md @@ -46,7 +46,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -108,7 +108,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr | `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) | | `api_base` | string | Không | Ghi đè URL endpoint API mặc định | | `proxy` | string | Không | URL proxy HTTP cho entry model này | -| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) | +| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Gemini, Anthropic và Azure) | | `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) | | `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) | | `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` | @@ -299,6 +299,7 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b PicoClaw định tuyến provider theo họ giao thức: - Giao thức tương thích OpenAI: OpenRouter, gateway tương thích OpenAI, Groq, Zhipu, và endpoint kiểu vLLM. +- Giao thức Gemini native: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. - Giao thức Anthropic: Hành vi API native của Claude. - Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI. diff --git a/docs/guides/providers.zh.md b/docs/guides/providers.zh.md index 421b23030..1302407a3 100644 --- a/docs/guides/providers.zh.md +++ b/docs/guides/providers.zh.md @@ -52,7 +52,7 @@ | **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | -| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取密钥](https://aistudio.google.com/api-keys) | | **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | | **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | @@ -121,7 +121,7 @@ | `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 | | `api_base` | string | 否 | 覆盖默认的 API 端点 URL | | `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL | -| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) | +| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Gemini、Anthropic 和 Azure provider) | | `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 | | `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) | | `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` | @@ -424,10 +424,11 @@ PicoClaw 按下面的规则解析 `provider` 和最终发给上游的模型 ID PicoClaw 按协议族路由 Provider: - OpenAI 兼容协议:OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。 +- Gemini 原生协议:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 - Anthropic 协议:Claude 原生 API 行为。 - Codex/OAuth 路径:OpenAI OAuth/Token 认证路由。 -这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_key`)。 +这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_keys`)。
智谱 (Zhipu) 配置示例 diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 0bbfde7ff..3c242eecb 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -527,10 +527,11 @@ func (al *AgentLoop) runAgentLoop( opts.Dispatch.ChatID(), opts.Dispatch.ReplyToMessageID(), ), - AgentID: agentID, - SessionKey: sessionKey, - Scope: scope, - Content: result.finalContent, + AgentID: agentID, + SessionKey: sessionKey, + Scope: scope, + Content: result.finalContent, + ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey), }) } diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go index f6b4ab5bc..277ef77cd 100644 --- a/pkg/agent/agent_command.go +++ b/pkg/agent/agent_command.go @@ -214,6 +214,24 @@ func (al *AgentLoop) buildCommandsRuntime( rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) { return al.askSideQuestion(ctx, agent, opts, question) } + + rt.GetContextStats = func() *commands.ContextStats { + if opts == nil || agent.Sessions == nil { + return nil + } + usage := computeContextUsage(agent, opts.SessionKey) + if usage == nil { + return nil + } + history := agent.Sessions.GetHistory(opts.SessionKey) + return &commands.ContextStats{ + UsedTokens: usage.UsedTokens, + TotalTokens: usage.TotalTokens, + CompressAtTokens: usage.CompressAtTokens, + UsedPercent: usage.UsedPercent, + MessageCount: len(history), + } + } } return rt } diff --git a/pkg/agent/agent_outbound.go b/pkg/agent/agent_outbound.go index 906bea5d3..7e36e4ad8 100644 --- a/pkg/agent/agent_outbound.go +++ b/pkg/agent/agent_outbound.go @@ -60,10 +60,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI return } - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + msg := bus.OutboundMessage{ Context: bus.NewOutboundContext(channel, chatID, ""), Content: response, - }) + } + if sessionKey != "" { + msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey) + } + al.bus.PublishOutbound(ctx, msg) logger.InfoCF("agent", "Published outbound response", map[string]any{ "channel": channel, diff --git a/pkg/agent/context.go b/pkg/agent/context.go index ecf5da3dc..1e5a75d92 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -11,6 +11,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -210,6 +211,36 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { return prompt } +// EstimateSystemTokens estimates the token count of the full system message +// that would be sent to the LLM, mirroring the composition logic in BuildMessages. +// It includes: static prompt, dynamic context, active skills, and summary with +// wrapping prefixes and separators. This avoids needing all per-request parameters +// that BuildMessages requires (media, channel, chatID, sender, etc.). +func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []string) int { + staticPrompt := cb.BuildSystemPromptWithCache() + + // Dynamic context is small and varies per request; use a representative estimate. + // Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info. + const dynamicContextChars = 300 + + totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars + + if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { + totalChars += utf8.RuneCountInString(skillsText) + totalChars += 7 // separator \n\n---\n\n + } + + if summary != "" { + // Matches the CONTEXT_SUMMARY: prefix added in BuildMessages + const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " + + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n" + totalChars += utf8.RuneCountInString(summaryPrefix) + utf8.RuneCountInString(summary) + totalChars += 7 // separator + } + + return totalChars * 2 / 5 // same heuristic as tokenizer.EstimateMessageTokens +} + // InvalidateCache clears the cached system prompt. // Normally not needed because the cache auto-invalidates via mtime checks, // but this is useful for tests or explicit reload commands. diff --git a/pkg/agent/context_usage.go b/pkg/agent/context_usage.go new file mode 100644 index 000000000..39d4f3dee --- /dev/null +++ b/pkg/agent/context_usage.go @@ -0,0 +1,78 @@ +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/bus" +) + +// computeContextUsage estimates current context window consumption for the +// given agent and session. Includes history, system prompt (with dynamic context, +// summary, and skills — mirroring BuildMessages composition), and tool definitions. +// The output reserve (MaxTokens) is not counted as "used" but reduces the +// effective budget, matching isOverContextBudget's compression trigger: +// +// compress when: history + system + tools + maxTokens > contextWindow +// equivalent to: history + system + tools > contextWindow - maxTokens +// +// Returns nil when the agent or session is unavailable. +func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUsage { + if agent == nil || agent.Sessions == nil { + return nil + } + contextWindow := agent.ContextWindow + if contextWindow <= 0 { + return nil + } + + // History tokens + history := agent.Sessions.GetHistory(sessionKey) + historyTokens := 0 + for _, m := range history { + historyTokens += EstimateMessageTokens(m) + } + + // System message tokens: uses EstimateSystemTokens which mirrors + // the full system message composition in BuildMessages (static prompt, + // dynamic context, active skills, summary with wrapping prefix). + systemTokens := 0 + if agent.ContextBuilder != nil { + summary := agent.Sessions.GetSummary(sessionKey) + // Pass nil for active skills: skills are only injected when the user + // explicitly activates them via /use, which is rare. Using nil matches + // the common case and avoids over-counting all installed skills. + systemTokens = agent.ContextBuilder.EstimateSystemTokens(summary, nil) + } + + // Tool definition tokens + toolTokens := 0 + if agent.Tools != nil { + toolTokens = EstimateToolDefsTokens(agent.Tools.ToProviderDefs()) + } + + // Used = history + system (includes summary) + tools + usedTokens := historyTokens + systemTokens + toolTokens + + // Effective budget = contextWindow minus output reserve (maxTokens) + effectiveWindow := contextWindow - agent.MaxTokens + if effectiveWindow < 0 { + effectiveWindow = contextWindow + } + + // compressAt = effectiveWindow: aligns with isOverContextBudget's + // proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow). + compressAt := effectiveWindow + + usedPercent := 0 + if compressAt > 0 { + usedPercent = usedTokens * 100 / compressAt + } + if usedPercent > 100 { + usedPercent = 100 + } + + return &bus.ContextUsage{ + UsedTokens: usedTokens, + TotalTokens: contextWindow, + CompressAtTokens: compressAt, + UsedPercent: usedPercent, + } +} diff --git a/pkg/auth/store.go b/pkg/auth/store.go index dfea11df4..0e6567a03 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -25,6 +26,11 @@ type AuthStore struct { Credentials map[string]*AuthCredential `json:"credentials"` } +const ( + providerGoogleAntigravity = "google-antigravity" + providerAntigravityAlias = "antigravity" +) + func (c *AuthCredential) IsExpired() bool { if c.ExpiresAt.IsZero() { return false @@ -43,6 +49,125 @@ func authFilePath() string { return filepath.Join(config.GetHome(), "auth.json") } +func canonicalProvider(provider string) string { + normalized := strings.ToLower(strings.TrimSpace(provider)) + switch normalized { + case providerAntigravityAlias: + return providerGoogleAntigravity + default: + return normalized + } +} + +func cloneCredential(cred *AuthCredential) *AuthCredential { + if cred == nil { + return nil + } + cp := *cred + return &cp +} + +func mergeCredentials(primary, secondary *AuthCredential) *AuthCredential { + if primary == nil { + return cloneCredential(secondary) + } + + merged := *primary + if secondary == nil { + return &merged + } + if merged.AccessToken == "" { + merged.AccessToken = secondary.AccessToken + } + if merged.RefreshToken == "" { + merged.RefreshToken = secondary.RefreshToken + } + if merged.AccountID == "" { + merged.AccountID = secondary.AccountID + } + if merged.ExpiresAt.IsZero() { + merged.ExpiresAt = secondary.ExpiresAt + } + if merged.Provider == "" { + merged.Provider = secondary.Provider + } + if merged.AuthMethod == "" { + merged.AuthMethod = secondary.AuthMethod + } + if merged.Email == "" { + merged.Email = secondary.Email + } + if merged.ProjectID == "" { + merged.ProjectID = secondary.ProjectID + } + + return &merged +} + +func shouldPreferCredential( + candidate *AuthCredential, + candidateCanonical bool, + current *AuthCredential, + currentCanonical bool, +) bool { + if candidate == nil { + return false + } + if current == nil { + return true + } + + switch { + case candidate.ExpiresAt.After(current.ExpiresAt): + return true + case current.ExpiresAt.After(candidate.ExpiresAt): + return false + case candidateCanonical != currentCanonical: + return candidateCanonical + default: + return false + } +} + +func normalizeStore(store *AuthStore) { + if store == nil { + return + } + if store.Credentials == nil { + store.Credentials = make(map[string]*AuthCredential) + return + } + + normalized := make(map[string]*AuthCredential, len(store.Credentials)) + canonicalFlags := make(map[string]bool, len(store.Credentials)) + + for provider, cred := range store.Credentials { + normalizedProvider := strings.ToLower(strings.TrimSpace(provider)) + canonical := canonicalProvider(provider) + normalizedCred := cloneCredential(cred) + if normalizedCred != nil { + normalizedCred.Provider = canonicalProvider(normalizedCred.Provider) + if normalizedCred.Provider == "" { + normalizedCred.Provider = canonical + } + } + + current := normalized[canonical] + currentCanonical := canonicalFlags[canonical] + candidateCanonical := normalizedProvider == canonical + + if shouldPreferCredential(normalizedCred, candidateCanonical, current, currentCanonical) { + normalized[canonical] = mergeCredentials(normalizedCred, current) + canonicalFlags[canonical] = candidateCanonical + continue + } + + normalized[canonical] = mergeCredentials(current, normalizedCred) + } + + store.Credentials = normalized +} + func LoadStore() (*AuthStore, error) { path := authFilePath() data, err := os.ReadFile(path) @@ -57,9 +182,7 @@ func LoadStore() (*AuthStore, error) { if err := json.Unmarshal(data, &store); err != nil { return nil, err } - if store.Credentials == nil { - store.Credentials = make(map[string]*AuthCredential) - } + normalizeStore(&store) return &store, nil } @@ -79,7 +202,7 @@ func GetCredential(provider string) (*AuthCredential, error) { if err != nil { return nil, err } - cred, ok := store.Credentials[provider] + cred, ok := store.Credentials[canonicalProvider(provider)] if !ok { return nil, nil } @@ -91,7 +214,17 @@ func SetCredential(provider string, cred *AuthCredential) error { if err != nil { return err } - store.Credentials[provider] = cred + + canonical := canonicalProvider(provider) + normalized := cloneCredential(cred) + if normalized != nil { + normalized.Provider = canonicalProvider(normalized.Provider) + if normalized.Provider == "" { + normalized.Provider = canonical + } + } + + store.Credentials[canonical] = normalized return SaveStore(store) } @@ -100,7 +233,7 @@ func DeleteCredential(provider string) error { if err != nil { return err } - delete(store.Credentials, provider) + delete(store.Credentials, canonicalProvider(provider)) return SaveStore(store) } diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go index f6793cfce..578ed4ead 100644 --- a/pkg/auth/store_test.go +++ b/pkg/auth/store_test.go @@ -1,12 +1,24 @@ package auth import ( + "encoding/json" "os" "path/filepath" + "runtime" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) +func setTestAuthHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestAuthCredentialIsExpired(t *testing.T) { tests := []struct { name string @@ -51,10 +63,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) { } func TestStoreRoundtrip(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "test-access-token", @@ -88,10 +97,7 @@ func TestStoreRoundtrip(t *testing.T) { } func TestStoreFilePermissions(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + tmpDir := setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "secret-token", @@ -108,16 +114,16 @@ func TestStoreFilePermissions(t *testing.T) { t.Fatalf("Stat() error: %v", err) } perm := info.Mode().Perm() + if runtime.GOOS == "windows" { + return + } if perm != 0o600 { t.Errorf("file permissions = %o, want 0600", perm) } } func TestStoreMultiProvider(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} @@ -147,10 +153,7 @@ func TestStoreMultiProvider(t *testing.T) { } func TestDeleteCredential(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} if err := SetCredential("openai", cred); err != nil { @@ -171,10 +174,7 @@ func TestDeleteCredential(t *testing.T) { } func TestLoadStoreEmpty(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) store, err := LoadStore() if err != nil { @@ -187,3 +187,319 @@ func TestLoadStoreEmpty(t *testing.T) { t.Errorf("expected empty credentials, got %d", len(store.Credentials)) } } + +func TestGetCredentialCanonicalizesLegacyAntigravityProvider(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "project-1", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cred, err := GetCredential("google-antigravity") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if cred == nil { + t.Fatal("GetCredential() returned nil") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } +} + +func TestLoadStoreMergesAntigravityAliasesPreferringNewerExpiry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + refreshedExpiry := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + "google-antigravity": map[string]any{ + "access_token": "fresh-token", + "expires_at": refreshedExpiry.Format(time.RFC3339), + "provider": "google-antigravity", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestLoadStorePrefersCanonicalKeyWhenExpiryMatchesAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + " Google-Antigravity ": map[string]any{ + "access_token": "fresh-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": " Google-Antigravity ", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } +} + +func TestSetCredentialReplacesLegacyAntigravityEntry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC).Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = SetCredential("google-antigravity", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + }) + if err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestDeleteCredentialRemovesLegacyAntigravityAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err = DeleteCredential(" google-antigravity ") + if err != nil { + t.Fatalf("DeleteCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 0 { + t.Fatalf("credential count = %d, want 0", len(loaded.Credentials)) + } +} + +func TestSetCredentialCanonicalizesTrimmedMixedCaseProvider(t *testing.T) { + setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC) + if err := SetCredential(" AnTiGrAvItY ", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: expiresAt, + Provider: " AnTiGrAvItY ", + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } + + got, err := GetCredential(" GoOgLe-AnTiGrAvItY ") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if got == nil { + t.Fatal("GetCredential() returned nil") + } + if got.Provider != "google-antigravity" { + t.Fatalf("GetCredential provider = %q, want %q", got.Provider, "google-antigravity") + } +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index aa06ca173..953e69d9c 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -61,6 +61,15 @@ type OutboundScope struct { Values map[string]string `json:"values,omitempty"` } +// ContextUsage describes how much of the model's context window the current +// session consumes, and how far it is from triggering compression. +type ContextUsage struct { + UsedTokens int `json:"used_tokens"` + TotalTokens int `json:"total_tokens"` // model context window + CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression + UsedPercent int `json:"used_percent"` // 0-100 +} + type OutboundMessage struct { Channel string `json:"channel"` ChatID string `json:"chat_id"` @@ -70,6 +79,7 @@ type OutboundMessage struct { Scope *OutboundScope `json:"scope,omitempty"` Content string `json:"content"` ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ContextUsage *ContextUsage `json:"context_usage,omitempty"` } // MediaPart describes a single media attachment to send. diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index f998712c8..8b41023f0 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -262,10 +262,12 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri } isThought := outboundMessageIsThought(msg) - outMsg := newMessage(TypeMessageCreate, map[string]any{ + payload := map[string]any{ PayloadKeyContent: msg.Content, PayloadKeyThought: isThought, - }) + } + setContextUsagePayload(payload, msg.ContextUsage) + outMsg := newMessage(TypeMessageCreate, payload) return nil, c.broadcastToSession(msg.ChatID, outMsg) } @@ -716,3 +718,16 @@ func validateInlineImageDataURL(mediaURL string) error { return nil } + +// setContextUsagePayload adds context window usage stats to a pico payload. +func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) { + if u == nil { + return + } + payload["context_usage"] = map[string]any{ + "used_tokens": u.UsedTokens, + "total_tokens": u.TotalTokens, + "compress_at_tokens": u.CompressAtTokens, + "used_percent": u.UsedPercent, + } +} diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index 5cf9425cb..a7e401bb8 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition { switchCommand(), checkCommand(), clearCommand(), + contextCommand(), subagentsCommand(), reloadCommand(), } diff --git a/pkg/commands/cmd_context.go b/pkg/commands/cmd_context.go new file mode 100644 index 000000000..55481662c --- /dev/null +++ b/pkg/commands/cmd_context.go @@ -0,0 +1,42 @@ +package commands + +import ( + "context" + "fmt" +) + +func contextCommand() Definition { + return Definition{ + Name: "context", + Description: "Show current session context and token usage", + Usage: "/context", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetContextStats == nil { + return req.Reply(unavailableMsg) + } + stats := rt.GetContextStats() + if stats == nil { + return req.Reply("No active session context.") + } + return req.Reply(formatContextStats(stats)) + }, + } +} + +func formatContextStats(s *ContextStats) string { + remaining := s.CompressAtTokens - s.UsedTokens + if remaining < 0 { + remaining = 0 + } + usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1) + return fmt.Sprintf( + "Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens", + s.MessageCount, + s.UsedTokens, + s.TotalTokens, + usedWindowPercent, + s.CompressAtTokens, + s.UsedPercent, + remaining, + ) +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 69373f561..68c286dde 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -6,6 +6,15 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +// ContextStats describes current session context window usage. +type ContextStats struct { + UsedTokens int + TotalTokens int // model context window + CompressAtTokens int // compression threshold + UsedPercent int // 0-100 + MessageCount int +} + // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) // can coexist with long-lived callbacks (like GetModelInfo). @@ -18,6 +27,7 @@ type Runtime struct { ListSkillNames func() []string GetEnabledChannels func() []string GetActiveTurn func() any // Returning any to avoid circular dependency with agent package + GetContextStats func() *ContextStats SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error ClearHistory func() error diff --git a/pkg/config/config_struct.go b/pkg/config/config_struct.go index 6eaf32bc1..65cfeb107 100644 --- a/pkg/config/config_struct.go +++ b/pkg/config/config_struct.go @@ -22,6 +22,11 @@ import ( type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + // Accept a single JSON string for convenience, e.g.: // "text": "Thinking..." var singleString string diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 85a98402e..624cc7305 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1258,6 +1258,11 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { input string expected []string }{ + { + name: "null", + input: `null`, + expected: nil, + }, { name: "single string", input: `"Thinking..."`, @@ -1286,6 +1291,12 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { if err := json.Unmarshal([]byte(tt.input), &f); err != nil { t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err) } + if tt.expected == nil { + if f != nil { + t.Fatalf("json.Unmarshal(%s) = %#v, want nil slice", tt.input, f) + } + return + } if len(f) != len(tt.expected) { t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected)) } diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go index 9434976f7..9b39c85cf 100644 --- a/pkg/isolation/platform_windows.go +++ b/pkg/isolation/platform_windows.go @@ -76,7 +76,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE - if _, err := windows.SetInformationJobObject( + if _, err = windows.SetInformationJobObject( job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&info)), diff --git a/web/README.md b/web/README.md index 0bda4b421..2a57524e0 100644 --- a/web/README.md +++ b/web/README.md @@ -121,23 +121,18 @@ When a gateway process is started by the launcher, the launcher: ### Launcher Authentication -The dashboard is protected by a launcher access token. +The dashboard is protected by password login. -- If `PICOCLAW_LAUNCHER_TOKEN` is set, that token is used. -- Otherwise a random token is generated for each launcher process. -- The browser auto-open URL includes `?token=...` so local launches can sign in automatically. +- First run uses `/launcher-setup` to create the dashboard password. - Manual login uses `/launcher-login`. -- API clients may also authenticate with `Authorization: Bearer `. - -Where users can retrieve the token depends on launch mode: - -- Console mode: printed to stdout -- GUI mode: available through the tray menu on supported builds -- GUI mode without stdout: - - random per-run tokens are written to the launcher log - - default log path: `~/.picoclaw/logs/launcher.log` - - if `PICOCLAW_HOME` is set, use `$PICOCLAW_HOME/logs/launcher.log` - - env-pinned tokens are not reprinted there; the log only notes that `PICOCLAW_LAUNCHER_TOKEN` is in use +- Successful login sets an HttpOnly session cookie. +- Existing sessions are invalidated when the launcher process restarts; otherwise the browser cookie expires after 31 days. +- When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically. +- On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. +- On platforms where the SQLite password store is unavailable, the launcher stores the bcrypt hash in `launcher-config.json`. +- Legacy `launcher_token` values are migrated once into password login and are removed from saved launcher config. +- `PICOCLAW_LAUNCHER_TOKEN` is deprecated and ignored; after upgrading from env-token auth, open `/launcher-setup` to create a password. +- URL token login and `Authorization: Bearer` dashboard auth are not supported. ### Network Exposure @@ -155,7 +150,7 @@ With `-public` or `public: true`, it listens on all interfaces: When public access is enabled: -- the launcher can still protect the dashboard with the access token +- the launcher still protects the dashboard with password login - optional `allowed_cidrs` can restrict which client IP ranges may connect - the gateway host is overridden so remote clients can still use the launcher-managed proxy paths @@ -336,19 +331,8 @@ web/ ### You have to sign in again after the launcher restarts Existing dashboard sessions do not survive launcher restarts. -That is expected: each launcher process generates a new signed session value, so old cookies become invalid. - -To make re-login easier, set a stable token: - -```bash -export PICOCLAW_LAUNCHER_TOKEN="replace-with-a-long-random-token" -``` - -Notes: - -- a stable token does not preserve the old cookie-based session by itself -- when the launcher opens the browser automatically, it appends `?token=...` and signs in again automatically -- if you reopen the dashboard manually, use the same stable token on `/launcher-login` +That is expected: each launcher process generates a new session value, so old cookies become invalid. +Sign in again with the dashboard password on `/launcher-login`. ### "Start Gateway" stays disabled diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go index 3cfc3e20d..da07b76c0 100644 --- a/web/backend/api/auth.go +++ b/web/backend/api/auth.go @@ -12,9 +12,8 @@ import ( "github.com/sipeed/picoclaw/web/backend/middleware" ) -// PasswordStore is the interface for bcrypt-backed dashboard password persistence. -// Implemented by dashboardauth.Store; a nil value falls back to the legacy -// static-token comparison. +// PasswordStore is the interface for dashboard password persistence. +// Implemented by dashboardauth.Store and launcherconfig.PasswordStore. type PasswordStore interface { IsInitialized(ctx context.Context) (bool, error) SetPassword(ctx context.Context, plain string) error @@ -23,18 +22,13 @@ type PasswordStore interface { // LauncherAuthRouteOpts configures dashboard auth handlers. type LauncherAuthRouteOpts struct { - // DashboardToken is the fallback plaintext token used when PasswordStore is - // nil or not yet initialized (env-var / config-file source, and ?token= auto-login). - DashboardToken string - SessionCookie string - SecureCookie func(*http.Request) bool - // PasswordStore enables bcrypt-backed password persistence. When non-nil and - // initialized, web-form login verifies against the stored hash instead of - // the plaintext DashboardToken. + SessionCookie string + SecureCookie func(*http.Request) bool + // PasswordStore enables password login. It must be non-nil for auth to work. PasswordStore PasswordStore // StoreError holds the error returned when opening the password store. When - // non-nil and PasswordStore is nil, the auth endpoints surface a recovery - // message instead of an opaque 501/503. + // non-nil and PasswordStore is nil, auth endpoints fail closed with a + // recovery message. StoreError error } @@ -59,7 +53,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) secure = middleware.DefaultLauncherDashboardSecureCookie } h := &launcherAuthHandlers{ - token: opts.DashboardToken, sessionCookie: opts.SessionCookie, secureCookie: secure, store: opts.PasswordStore, @@ -73,7 +66,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) } type launcherAuthHandlers struct { - token string sessionCookie string secureCookie func(*http.Request) bool store PasswordStore @@ -81,29 +73,18 @@ type launcherAuthHandlers struct { loginLimit *loginRateLimiter } -func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool { - return h.store == nil && h.storeErr == nil && h.token != "" -} - // isStoreInitialized safely queries the store. -// Returns (true, nil) when legacy token auth is active without a password store. -// Returns (false, nil) when no store/token fallback is configured. // Returns (false, err) on store errors — callers must treat this as a 5xx, not as // "uninitialized", to keep auth fail-closed. -// Exception: handleLogin swallows storeErr and falls back to token auth so -// that a corrupt DB does not lock out all access. func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) { if h.store == nil { if h.storeErr != nil { return false, fmt.Errorf( "password store unavailable (%w); "+ - "to recover, stop the application, delete the database file and restart ", + "to recover, stop the application, reset dashboard password storage, and restart", h.storeErr) } - if h.usesLegacyTokenAuth() { - return true, nil - } - return false, nil + return false, fmt.Errorf("password store not configured") } return h.store.IsInitialized(ctx) } @@ -123,35 +104,25 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques return } in := strings.TrimSpace(body.Password) - var ok bool initialized, initErr := h.isStoreInitialized(r.Context()) if initErr != nil { - if h.storeErr != nil { - // Store failed to open at startup — token login remains available. - initialized = false - } else { - w.WriteHeader(http.StatusInternalServerError) - writeErrorf(w, "%v", initErr) - return - } + w.WriteHeader(http.StatusServiceUnavailable) + writeErrorf(w, "%v", initErr) + return + } + if !initialized { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"password has not been set"}`)) + return } - if initialized && h.store != nil { - // Bcrypt path: verify against the stored hash. - var err error - ok, err = h.store.VerifyPassword(r.Context(), in) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - writeErrorf(w, "password verification failed: %v", err) - return - } - } else { - // Fallback: constant-time compare against the plaintext token. - ok = len(in) == len(h.token) && - subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1 + ok, err := h.store.VerifyPassword(r.Context(), in) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeErrorf(w, "password verification failed: %v", err) + return } - if !ok { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"invalid password"}`)) @@ -221,22 +192,19 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque // handleSetup sets or changes the dashboard password. // // Rules: -// - If the store has no password yet, the endpoint is open (no session required). +// - If the store has no password yet, anyone who can reach the setup endpoint +// may initialize the password. // - If a password is already set, the caller must hold a valid session cookie. func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if h.usesLegacyTokenAuth() { - w.WriteHeader(http.StatusNotImplemented) - _, _ = w.Write( - []byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`), - ) - return - } - if h.store == nil { - w.WriteHeader(http.StatusNotImplemented) - _, _ = w.Write([]byte(`{"error":"password store not configured"}`)) + w.WriteHeader(http.StatusServiceUnavailable) + if h.storeErr != nil { + writeErrorf(w, "password store unavailable: %v", h.storeErr) + } else { + _, _ = w.Write([]byte(`{"error":"password store not configured"}`)) + } return } diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go index 58f819ec6..f7f6037a0 100644 --- a/web/backend/api/auth_test.go +++ b/web/backend/api/auth_test.go @@ -2,7 +2,9 @@ package api import ( "bytes" + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -12,17 +14,43 @@ import ( "github.com/sipeed/picoclaw/web/backend/middleware" ) -func TestLauncherAuthLoginAndStatus(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = 0x55 +type fakePasswordStore struct { + initialized bool + password string + err error +} + +func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) { + if s.err != nil { + return false, s.err } - const tok = "dashboard-test-token-9" - sess := middleware.SessionCookieValue(key, tok) + return s.initialized, nil +} + +func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error { + if s.err != nil { + return s.err + } + s.password = plain + s.initialized = true + return nil +} + +func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) { + if s.err != nil { + return false, s.err + } + return s.initialized && plain == s.password, nil +} + +func TestLauncherAuthLoginAndStatus(t *testing.T) { + const password = "dashboard-test-password" + const sess = "session-cookie-value" + store := &fakePasswordStore{initialized: true, password: password} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, + SessionCookie: sess, + PasswordStore: store, }) t.Run("status_unauthenticated", func(t *testing.T) { @@ -45,7 +73,7 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { t.Run("login_ok", func(t *testing.T) { rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`)) req.Header.Set("Content-Type", "application/json") req.RemoteAddr = "127.0.0.1:12345" mux.ServeHTTP(rec, req) @@ -75,14 +103,13 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { }) } -func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) { - key := make([]byte, 32) - const tok = "legacy-fallback-token" - sess := middleware.SessionCookieValue(key, tok) +func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) { + const sess = "session-cookie-value" + store := &fakePasswordStore{} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, + SessionCookie: sess, + PasswordStore: store, }) rec := httptest.NewRecorder() @@ -98,29 +125,80 @@ func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } - if !body.Initialized { - t.Fatalf("initialized = false, want true in legacy token fallback mode") + if body.Initialized { + t.Fatalf("initialized = true, want false before setup") } if body.Authenticated { t.Fatalf("unexpected authenticated=true: %+v", body) } rec = httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest( + http.MethodPost, + "/api/auth/setup", + strings.NewReader(`{"password":"12345678","confirm":"12345678"}`), + ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String()) + t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String()) } } -func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "legacy-token") +func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) { + const sess = "session-cookie-value" + store := &fakePasswordStore{initialized: true, password: "old-password"} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "legacy-token", - SessionCookie: sess, + SessionCookie: sess, + PasswordStore: store, + }) + + body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String()) + } + + body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`) + req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess}) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String()) + } + if store.password != "new-password" { + t.Fatalf("password = %q, want new-password", store.password) + } +} + +func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) { + store := &fakePasswordStore{} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + PasswordStore: store, }) rec := httptest.NewRecorder() @@ -131,18 +209,46 @@ func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) { ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) - if rec.Code != http.StatusNotImplemented { - t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK { + t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) { + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + StoreError: errors.New("open auth store"), + }) + + for _, tc := range []struct { + name string + method string + path string + body string + }{ + {name: "status", method: http.MethodGet, path: "/api/auth/status"}, + {name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`}, + {name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("code = %d body=%s", rec.Code, 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, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() @@ -169,16 +275,14 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { } func TestLauncherAuthLoginRateLimit(t *testing.T) { - key := make([]byte, 32) - const tok = "rate-limit-tok-xxxxxxxx" - sess := middleware.SessionCookieValue(key, tok) + store := &fakePasswordStore{initialized: true, password: "correct-password"} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, + SessionCookie: "session-cookie-value", + PasswordStore: store, }) - // 11 failing logins by wrong token; each consumes allow() slot after valid JSON. + // 11 failing logins by wrong password; each consumes allow() slot after valid JSON. wrongBody := `{"password":"wrong"}` for i := 0; i < loginAttemptsPerIP; i++ { rec := httptest.NewRecorder() @@ -231,12 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) { } func TestLauncherAuthLogoutEmptyBody(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) @@ -249,12 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) { } func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) diff --git a/web/backend/api/config.go b/web/backend/api/config.go index c7bd21197..afcd3f74e 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -56,13 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - var cfg config.Config - if err = json.Unmarshal(body, &cfg); err != nil { + var raw map[string]any + if err = json.Unmarshal(body, &raw); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } - var raw map[string]any - if err = json.Unmarshal(body, &raw); err != nil { + if err = normalizeChannelArrayFields(raw); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } + normalizedBody, err := json.Marshal(raw) + if err != nil { + http.Error(w, "Failed to normalize config payload", http.StatusBadRequest) + return + } + var cfg config.Config + if err = json.Unmarshal(normalizedBody, &cfg); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } @@ -154,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { // Recursively merge patch into base mergeMap(base, patch) + if err = normalizeChannelArrayFields(base); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } // Convert merged map back to Config struct merged, err := json.Marshal(base) @@ -382,6 +395,184 @@ func asMapField(value map[string]any, key string) (map[string]any, bool) { return m, isMap } +var ( + allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]") + allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;;\r\n\t]+") + conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+") +) + +type stringArrayParserOptions struct { + stripHiddenChars bool +} + +func normalizeChannelArrayFields(raw map[string]any) error { + channelsMap, hasChannels := asMapField(raw, "channel_list") + if !hasChannels { + return nil + } + + defaultCfg := config.DefaultConfig() + for channelName, rawChannel := range channelsMap { + chMap, ok := rawChannel.(map[string]any) + if !ok { + continue + } + + if rawAllowFrom, exists := chMap["allow_from"]; exists { + normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{ + stripHiddenChars: true, + }) + if err != nil { + return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err) + } + chMap["allow_from"] = normalized + } + + if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok { + if rawPrefixes, exists := groupTrigger["prefixes"]; exists { + normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{}) + if err != nil { + return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err) + } + groupTrigger["prefixes"] = normalized + } + } + + settingsMap, hasSettings := asMapField(chMap, "settings") + if !hasSettings { + continue + } + + settingsType := channelSettingsType(defaultCfg, channelName, chMap) + if settingsType == nil { + continue + } + + for i := range settingsType.NumField() { + field := settingsType.Field(i) + if !field.IsExported() || !isStringSliceType(field.Type) { + continue + } + jsonKey := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonKey == "" || jsonKey == "-" { + continue + } + rawValue, exists := settingsMap[jsonKey] + if !exists { + continue + } + + options := stringArrayParserOptions{} + if jsonKey == "allow_from" { + options.stripHiddenChars = true + } + normalized, err := normalizeStringArrayValue(rawValue, options) + if err != nil { + return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err) + } + settingsMap[jsonKey] = normalized + } + } + return nil +} + +func channelSettingsType( + defaultCfg *config.Config, + channelName string, + channelMap map[string]any, +) reflect.Type { + if channelType, _ := channelMap["type"].(string); channelType != "" { + if bc := defaultCfg.Channels.GetByType(channelType); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + } + + if bc := defaultCfg.Channels.Get(channelName); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + + return nil +} + +func derefType(typ reflect.Type) reflect.Type { + for typ != nil && typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + return typ +} + +func isStringSliceType(typ reflect.Type) bool { + typ = derefType(typ) + return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String +} + +func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) { + switch typed := value.(type) { + case nil: + return nil, nil + case string: + return parseStringArrayValue(typed, options), nil + case float64: + return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil + case []string: + return normalizeStringArrayItems(typed, options), nil + case []any: + items := make([]string, 0, len(typed)) + for _, item := range typed { + switch raw := item.(type) { + case string: + items = append(items, raw) + case float64: + items = append(items, fmt.Sprintf("%.0f", raw)) + default: + return nil, fmt.Errorf("unsupported list item type %T", item) + } + } + return normalizeStringArrayItems(items, options), nil + default: + return nil, fmt.Errorf("unsupported list field type %T", value) + } +} + +func parseStringArrayValue(raw string, options stringArrayParserOptions) []string { + if strings.TrimSpace(raw) == "" { + return []string{} + } + splitRe := conservativeSplitRe + if options.stripHiddenChars { + splitRe = allowFromSplitRe + } + return normalizeStringArrayItems(splitRe.Split(raw, -1), options) +} + +func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string { + result := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + normalized := item + if options.stripHiddenChars { + normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "") + } + normalized = strings.TrimSpace(normalized) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + if len(result) == 0 { + return []string{} + } + return result +} + func getSecretString(m map[string]any, key string) (string, bool) { if raw, exists := m[key]; exists { s, isString := raw.(string) diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 0e0fa5229..8377c2eca 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -230,6 +230,285 @@ func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) { } } +func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "pico": { + "type": "pico", + "allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ", + "group_trigger": { + "prefixes": "/,!;\n?,/" + }, + "settings": { + "allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com" + } + }, + "irc": { + "type": "irc", + "settings": { + "channels": "#ops,\n#dev,\n#ops", + "request_caps": "multi-prefix,echo-message\tbatch,multi-prefix" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + picoChannel := cfg.Channels[config.ChannelPico] + if len(picoChannel.AllowFrom) != 3 || + picoChannel.AllowFrom[0] != "ou_a" || + picoChannel.AllowFrom[1] != "ou_b" || + picoChannel.AllowFrom[2] != "ou_c" { + t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom) + } + if len(picoChannel.GroupTrigger.Prefixes) != 3 || + picoChannel.GroupTrigger.Prefixes[0] != "/" || + picoChannel.GroupTrigger.Prefixes[1] != "!;" || + picoChannel.GroupTrigger.Prefixes[2] != "?" { + t.Fatalf( + "pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]", + picoChannel.GroupTrigger.Prefixes, + ) + } + + decoded, err := picoChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() pico error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if len(picoCfg.AllowOrigins) != 2 || + picoCfg.AllowOrigins[0] != "https://a.example.com" || + picoCfg.AllowOrigins[1] != "http://localhost:5173" { + t.Fatalf( + "pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]", + picoCfg.AllowOrigins, + ) + } + + ircChannel := cfg.Channels[config.ChannelIRC] + decoded, err = ircChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() irc error = %v", err) + } + ircCfg := decoded.(*config.IRCSettings) + if len(ircCfg.Channels) != 2 || + ircCfg.Channels[0] != "#ops" || + ircCfg.Channels[1] != "#dev" { + t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels) + } + if len(ircCfg.RequestCaps) != 3 || + ircCfg.RequestCaps[0] != "multi-prefix" || + ircCfg.RequestCaps[1] != "echo-message" || + ircCfg.RequestCaps[2] != "batch" { + t.Fatalf( + "irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]", + ircCfg.RequestCaps, + ) + } +} + +func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": 123456 + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" { + t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom) + } +} + +func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + tests := []struct { + name string + body string + }{ + { + name: "object allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": {"id": "bad"} + } + } + }`, + }, + { + name: "boolean allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": true + } + } + }`, + }, + { + name: "object settings array", + body: `{ + "channel_list": { + "irc": { + "type": "irc", + "settings": { + "channels": {"name": "#ops"} + } + } + } + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusBadRequest, + rec.Body.String(), + ) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" { + t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom) + } + }) + } +} + +func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + feishuChannel := cfg.Channels[config.ChannelFeishu] + feishuChannel.Enabled = true + feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"} + decoded, err := feishuChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + feishuCfg := decoded.(*config.FeishuSettings) + feishuCfg.AppID = "cli_existing_app" + feishuCfg.AppSecret = *config.NewSecureString("existing-secret") + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "feishu": { + "enabled": true, + "allow_from": "", + "settings": { + "app_id": "cli_existing_app" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + feishuChannel = cfg.Channels[config.ChannelFeishu] + if len(feishuChannel.AllowFrom) != 0 { + t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom) + } + + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(configPath) error = %v", err) + } + if strings.Contains(string(configData), `"allow_from": [""]`) { + t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData)) + } +} + func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go index d16cd9267..92911157c 100644 --- a/web/backend/api/launcher_config.go +++ b/web/backend/api/launcher_config.go @@ -4,16 +4,14 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) type launcherConfigPayload struct { - Port int `json:"port"` - Public bool `json:"public"` - AllowedCIDRs []string `json:"allowed_cidrs"` - LauncherToken string `json:"launcher_token"` + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs"` } func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { @@ -50,10 +48,9 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ - Port: cfg.Port, - Public: cfg.Public, - AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), - LauncherToken: cfg.LauncherToken, + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), }) } @@ -64,12 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ return } - cfg := launcherconfig.Config{ - Port: payload.Port, - Public: payload.Public, - AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), - LauncherToken: strings.TrimSpace(payload.LauncherToken), + cfg, err := h.loadLauncherConfig() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError) + return } + cfg.Port = payload.Port + cfg.Public = payload.Public + cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...) + cfg.LegacyLauncherToken = "" if err := launcherconfig.Validate(cfg); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -82,9 +82,8 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ - Port: cfg.Port, - Public: cfg.Public, - AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), - LauncherToken: cfg.LauncherToken, + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), }) } diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go index 4e0acf5d0..68ab1be42 100644 --- a/web/backend/api/launcher_config_test.go +++ b/web/backend/api/launcher_config_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -34,9 +35,6 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { if got.Port != 19999 || !got.Public { t.Fatalf("response = %+v, want port=19999 public=true", got) } - if got.LauncherToken != "" { - t.Fatalf("response launcher_token = %q, want empty", got.LauncherToken) - } if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs) } @@ -44,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { func TestPutLauncherConfigPersists(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") + path := launcherconfig.PathForAppConfig(configPath) + if err := os.WriteFile( + path, + []byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } h := NewHandler(configPath) mux := http.NewServeMux() @@ -54,7 +60,7 @@ func TestPutLauncherConfigPersists(t *testing.T) { http.MethodPut, "/api/system/launcher-config", strings.NewReader( - `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"launcher_token":"saved-token"}`, + `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`, ), ) req.Header.Set("Content-Type", "application/json") @@ -64,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - path := launcherconfig.PathForAppConfig(configPath) cfg, err := launcherconfig.Load(path, launcherconfig.Default()) if err != nil { t.Fatalf("launcherconfig.Load() error = %v", err) @@ -72,8 +77,11 @@ func TestPutLauncherConfigPersists(t *testing.T) { if cfg.Port != 18080 || !cfg.Public { t.Fatalf("saved config = %+v, want port=18080 public=true", cfg) } - if cfg.LauncherToken != "saved-token" { - t.Fatalf("saved launcher_token = %q, want %q", cfg.LauncherToken, "saved-token") + if cfg.DashboardPasswordHash != "saved-hash" { + t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash) + } + if cfg.LegacyLauncherToken != "" { + t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken) } if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs) diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 054b78b73..0143f5737 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -460,6 +460,9 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen for _, msg := range messages { switch msg.Role { + case "tool": + continue + case "user": if sessionMessageVisible(msg) { transcript = append(transcript, sessionChatMessage{ @@ -501,7 +504,18 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen } } - return transcript + return filterSessionChatMessages(transcript) +} + +func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage { + filtered := messages[:0] + for _, msg := range messages { + if msg.Role != "user" && msg.Role != "assistant" { + continue + } + filtered = append(filtered, msg) + } + return filtered } func assistantMessageTransientThought(msg providers.Message) bool { @@ -528,22 +542,16 @@ func visibleAssistantToolSummaryMessages( messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { - name := tc.Name - argsJSON := "" - if tc.Function != nil { - if name == "" { - name = tc.Function.Name - } - argsJSON = tc.Function.Arguments - } - + name, argsJSON := toolCallNameAndArguments(tc) if strings.TrimSpace(name) == "" { continue } - - if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { - if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { - argsJSON = string(encodedArgs) + if name == "web_search" || name == "web_fetch" { + continue + } + if name == "message" { + if _, ok := parseMessageToolContent(argsJSON); ok { + continue } } @@ -568,36 +576,53 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { - name := tc.Name - argsJSON := "" - if tc.Function != nil { - if name == "" { - name = tc.Function.Name - } - argsJSON = tc.Function.Arguments + name, argsJSON := toolCallNameAndArguments(tc) + if name != "message" { + continue } - - switch name { - case "message": - var args struct { - Content string `json:"content"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - continue - } - if strings.TrimSpace(args.Content) == "" { - continue - } - messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: args.Content, - }) + content, ok := parseMessageToolContent(argsJSON) + if !ok { + continue } + messages = append(messages, sessionChatMessage{ + Role: "assistant", + Content: content, + }) } return messages } +func toolCallNameAndArguments(tc providers.ToolCall) (string, string) { + name := tc.Name + argsJSON := "" + if tc.Function != nil { + if name == "" { + name = tc.Function.Name + } + argsJSON = tc.Function.Arguments + } + if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { + if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encodedArgs) + } + } + return name, argsJSON +} + +func parseMessageToolContent(argsJSON string) (string, bool) { + var args struct { + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", false + } + if strings.TrimSpace(args.Content) == "" { + return "", false + } + return args.Content, true +} + // sessionsDir resolves the path to the gateway's session storage directory. // It reads the workspace from config, falling back to ~/.picoclaw/workspace. func (h *Handler) sessionsDir() (string, error) { diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index e40a8c77c..f6c643bde 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -346,7 +346,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) { } } -func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { +func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -402,14 +402,19 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 3 { - t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`message`") { - t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { - t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2]) + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" { + t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1]) + } + for _, msg := range resp.Messages { + if msg.Role == "tool" || strings.Contains(msg.Content, "`message`") { + t.Fatalf("unexpected raw tool or duplicate message-tool summary: %#v", msg) + } } } @@ -468,17 +473,17 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t * if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 4 { - t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages)) + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`message`") { - t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { - t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2]) + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" { + t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1]) } - if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" { - t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3]) + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" { + t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2]) } } @@ -535,8 +540,8 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { if len(items) != 1 { t.Fatalf("len(items) = %d, want 1", len(items)) } - if items[0].MessageCount != 3 { - t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount) + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) } } @@ -567,6 +572,7 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) }, }, }, + {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"}, } { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { t.Fatalf("AddFullMessage() error = %v", err) @@ -606,6 +612,11 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" { t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2]) } + for _, msg := range resp.Messages { + if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") { + t.Fatalf("unexpected raw tool result in history: %#v", msg) + } + } } func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) { diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index b6faa63fe..e3595738f 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -1,8 +1,6 @@ package launcherconfig import ( - "crypto/rand" - "encoding/base64" "encoding/json" "fmt" "net" @@ -16,31 +14,19 @@ const ( FileName = "launcher-config.json" // DefaultPort is the default port for the web launcher. DefaultPort = 18800 - // EnvLauncherToken overrides launcher dashboard token. - EnvLauncherToken = "PICOCLAW_LAUNCHER_TOKEN" // EnvLauncherHost overrides launcher listen host. EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST" - - // 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 -) - -type DashboardTokenSource string - -const ( - DashboardTokenSourceEnv DashboardTokenSource = "env" - DashboardTokenSourceConfig DashboardTokenSource = "config" - DashboardTokenSourceRandom DashboardTokenSource = "random" ) // Config stores launch parameters for the web backend service. type Config struct { - Port int `json:"port"` - Public bool `json:"public"` - AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` - LauncherToken string `json:"launcher_token,omitempty"` + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` + DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"` + // LegacyLauncherToken is read only for one-time migration from the removed + // token login flow. Save always clears it so new configs do not persist it. + LegacyLauncherToken string `json:"launcher_token,omitempty"` } // Default returns default launcher settings. @@ -61,41 +47,6 @@ 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 -// EnvLauncherToken when set, otherwise launcher-config.json launcher_token, -// otherwise a new random token. -func EnsureDashboardSecrets( - cfg Config, -) (effectiveToken string, signingKey []byte, source DashboardTokenSource, err error) { - signingKey = make([]byte, dashboardSigningKeyBytes) - if _, err = rand.Read(signingKey); err != nil { - return "", nil, "", err - } - - effectiveToken = strings.TrimSpace(os.Getenv(EnvLauncherToken)) - if effectiveToken != "" { - return effectiveToken, signingKey, DashboardTokenSourceEnv, nil - } - effectiveToken = strings.TrimSpace(cfg.LauncherToken) - if effectiveToken != "" { - return effectiveToken, signingKey, DashboardTokenSourceConfig, nil - } - tok, genErr := randomDashboardToken() - if genErr != nil { - return "", nil, "", genErr - } - return tok, signingKey, DashboardTokenSourceRandom, 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 { @@ -144,7 +95,8 @@ func Load(path string, fallback Config) (Config, error) { return Config{}, err } cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) - cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken) + cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) + cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken) if err := Validate(cfg); err != nil { return Config{}, err } @@ -154,7 +106,8 @@ func Load(path string, fallback Config) (Config, error) { // Save writes launcher settings to disk. func Save(path string, cfg Config) error { cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) - cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken) + cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) + cfg.LegacyLauncherToken = "" if err := Validate(cfg); err != nil { return err } diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go index 528116417..bb13ea115 100644 --- a/web/backend/launcherconfig/config_test.go +++ b/web/backend/launcherconfig/config_test.go @@ -1,11 +1,10 @@ package launcherconfig import ( + "context" "os" "path/filepath" "testing" - - "github.com/sipeed/picoclaw/web/backend/middleware" ) func TestLoadReturnsFallbackWhenMissing(t *testing.T) { @@ -25,10 +24,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "launcher-config.json") want := Config{ - Port: 18080, - Public: true, - AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, - LauncherToken: "saved-launcher-token", + Port: 18080, + Public: true, + AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, + DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash", + LegacyLauncherToken: "legacy-token-should-not-persist", } if err := Save(path, want); err != nil { @@ -41,8 +41,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { if got.Port != want.Port || got.Public != want.Public { t.Fatalf("Load() = %+v, want %+v", got, want) } - if got.LauncherToken != want.LauncherToken { - t.Fatalf("launcher_token = %q, want %q", got.LauncherToken, want.LauncherToken) + if got.DashboardPasswordHash != want.DashboardPasswordHash { + t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash) + } + if got.LegacyLauncherToken != "" { + t.Fatalf("legacy launcher_token = %q, want empty after Save", got.LegacyLauncherToken) } if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) { t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs)) @@ -62,6 +65,21 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { } } +func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + if err := os.WriteFile(path, []byte(`{"port":18800,"launcher_token":"legacy-token"}`), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + got, err := Load(path, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.LegacyLauncherToken != "legacy-token" { + t.Fatalf("legacy launcher_token = %q, want legacy-token", got.LegacyLauncherToken) + } +} + func TestValidateRejectsInvalidPort(t *testing.T) { if err := Validate(Config{Port: 0, Public: false}); err == nil { t.Fatal("Validate() expected error for port 0") @@ -81,66 +99,6 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { } } -func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") - - tok, key, source, err := EnsureDashboardSecrets(Default()) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if source != DashboardTokenSourceRandom || tok == "" || len(key) != dashboardSigningKeyBytes { - t.Fatalf("unexpected first call: source=%q tok=%q keyLen=%d", source, tok, len(key)) - } - mac := middleware.SessionCookieValue(key, tok) - if mac == "" { - t.Fatal("empty session mac") - } - - tok2, key2, source2, err := EnsureDashboardSecrets(Default()) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() second error = %v", err) - } - if source2 != DashboardTokenSourceRandom { - t.Fatalf("second call source = %q, want %q", source2, DashboardTokenSourceRandom) - } - 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, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"}) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if tok != "env-only-token-override" { - t.Fatalf("token = %q, want env value", tok) - } - if source != DashboardTokenSourceEnv { - t.Fatalf("source = %q, want %q", source, DashboardTokenSourceEnv) - } -} - -func TestEnsureDashboardSecrets_ConfigOverridesGenerated(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") - - tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"}) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if tok != "config-token" { - t.Fatalf("token = %q, want config value", tok) - } - if source != DashboardTokenSourceConfig { - t.Fatalf("source = %q, want %q", source, DashboardTokenSourceConfig) - } -} - 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"} @@ -153,3 +111,42 @@ func TestNormalizeCIDRs(t *testing.T) { } } } + +func TestPasswordStoreSetAndVerify(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + store := NewPasswordStore(path, Default()) + ctx := context.Background() + + initialized, err := store.IsInitialized(ctx) + if err != nil { + t.Fatalf("IsInitialized() error = %v", err) + } + if initialized { + t.Fatal("IsInitialized() = true, want false before SetPassword") + } + + if err = store.SetPassword(ctx, "dashboard-password"); err != nil { + t.Fatalf("SetPassword() error = %v", err) + } + initialized, err = store.IsInitialized(ctx) + if err != nil { + t.Fatalf("IsInitialized() after SetPassword error = %v", err) + } + if !initialized { + t.Fatal("IsInitialized() = false, want true after SetPassword") + } + ok, err := store.VerifyPassword(ctx, "dashboard-password") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if !ok { + t.Fatal("VerifyPassword(correct) = false, want true") + } + ok, err = store.VerifyPassword(ctx, "wrong-password") + if err != nil { + t.Fatalf("VerifyPassword(wrong) error = %v", err) + } + if ok { + t.Fatal("VerifyPassword(wrong) = true, want false") + } +} diff --git a/web/backend/launcherconfig/migration.go b/web/backend/launcherconfig/migration.go new file mode 100644 index 000000000..66caa73ae --- /dev/null +++ b/web/backend/launcherconfig/migration.go @@ -0,0 +1,62 @@ +package launcherconfig + +import ( + "context" + "strings" +) + +var ( + loadConfigForMigration = Load + saveConfigForMigration = Save +) + +type dashboardPasswordStore interface { + IsInitialized(ctx context.Context) (bool, error) + SetPassword(ctx context.Context, plain string) error +} + +// LegacyLauncherTokenMigrationResult reports the outcome of converting a +// removed launcher_token value into the current password-based auth flow. +type LegacyLauncherTokenMigrationResult struct { + Migrated bool + // CleanupErr is non-nil when password migration succeeded (or was already in + // place) but removing launcher_token from launcher-config.json failed. + CleanupErr error +} + +// MigrateLegacyLauncherToken converts the removed launcher_token setting into +// the current password-login store, then removes launcher_token from config. +func MigrateLegacyLauncherToken( + ctx context.Context, + store dashboardPasswordStore, + launcherPath string, + fallback Config, +) (LegacyLauncherTokenMigrationResult, error) { + legacyToken := strings.TrimSpace(fallback.LegacyLauncherToken) + if legacyToken == "" || store == nil { + return LegacyLauncherTokenMigrationResult{}, nil + } + + result := LegacyLauncherTokenMigrationResult{} + initialized, err := store.IsInitialized(ctx) + if err != nil { + return result, err + } + if !initialized { + if err = store.SetPassword(ctx, legacyToken); err != nil { + return result, err + } + result.Migrated = true + } + result.CleanupErr = cleanupLegacyLauncherTokenConfig(launcherPath, fallback) + return result, nil +} + +func cleanupLegacyLauncherTokenConfig(launcherPath string, fallback Config) error { + cfg, err := loadConfigForMigration(launcherPath, fallback) + if err != nil { + return err + } + cfg.LegacyLauncherToken = "" + return saveConfigForMigration(launcherPath, cfg) +} diff --git a/web/backend/launcherconfig/migration_test.go b/web/backend/launcherconfig/migration_test.go new file mode 100644 index 000000000..c5c5fa2c9 --- /dev/null +++ b/web/backend/launcherconfig/migration_test.go @@ -0,0 +1,135 @@ +package launcherconfig + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +type stubMigrationPasswordStore struct { + initialized bool + password string +} + +func (s *stubMigrationPasswordStore) IsInitialized(context.Context) (bool, error) { + return s.initialized, nil +} + +func (s *stubMigrationPasswordStore) SetPassword(_ context.Context, plain string) error { + s.password = plain + s.initialized = true + return nil +} + +func TestMigrateLegacyLauncherToken(t *testing.T) { + dir := t.TempDir() + launcherPath := filepath.Join(dir, FileName) + cfg := Config{ + Port: DefaultPort, + LegacyLauncherToken: "legacy-password", + } + if err := os.WriteFile( + launcherPath, + []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := NewPasswordStore(launcherPath, Default()) + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v", err) + } + if !result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true") + } + if result.CleanupErr != nil { + t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr) + } + + loaded, err := Load(launcherPath, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.LegacyLauncherToken != "" { + t.Fatalf("legacy launcher token = %q, want empty", loaded.LegacyLauncherToken) + } + if loaded.DashboardPasswordHash == "" { + t.Fatal("dashboard password hash should be set after migration") + } + ok, err := store.VerifyPassword(context.Background(), "legacy-password") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if !ok { + t.Fatal("VerifyPassword() = false, want true") + } +} + +func TestMigrateLegacyLauncherTokenCleanupFailureIsNonFatal(t *testing.T) { + dir := t.TempDir() + launcherPath := filepath.Join(dir, FileName) + cfg := Config{ + Port: DefaultPort, + LegacyLauncherToken: "legacy-password", + } + if err := os.WriteFile( + launcherPath, + []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := &stubMigrationPasswordStore{} + origSave := saveConfigForMigration + saveConfigForMigration = func(string, Config) error { + return errors.New("write launcher config") + } + t.Cleanup(func() { + saveConfigForMigration = origSave + }) + + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v, want nil", err) + } + if !result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true") + } + if result.CleanupErr == nil { + t.Fatal("MigrateLegacyLauncherToken().CleanupErr = nil, want non-nil") + } + if store.password != "legacy-password" { + t.Fatalf("password = %q, want legacy-password", store.password) + } + + loaded, err := Load(launcherPath, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.LegacyLauncherToken != "legacy-password" { + t.Fatalf( + "legacy launcher token = %q, want legacy-password after cleanup failure", + loaded.LegacyLauncherToken, + ) + } +} + +func TestMigrateLegacyLauncherTokenNoopWithoutToken(t *testing.T) { + launcherPath := filepath.Join(t.TempDir(), FileName) + store := NewPasswordStore(launcherPath, Default()) + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, Default()) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v", err) + } + if result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = true, want false") + } + if result.CleanupErr != nil { + t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr) + } +} diff --git a/web/backend/launcherconfig/password_store.go b/web/backend/launcherconfig/password_store.go new file mode 100644 index 000000000..3813384bb --- /dev/null +++ b/web/backend/launcherconfig/password_store.go @@ -0,0 +1,92 @@ +package launcherconfig + +import ( + "context" + "errors" + "strings" + "sync" + + "golang.org/x/crypto/bcrypt" +) + +const passwordBcryptCost = 12 + +// PasswordStore keeps the dashboard bcrypt hash in launcher-config.json. +// It is used on platforms where the SQLite-backed dashboard auth store is not +// available. +type PasswordStore struct { + path string + fallback Config + mu sync.Mutex +} + +// NewPasswordStore returns a config-backed password store. +func NewPasswordStore(path string, fallback Config) *PasswordStore { + return &PasswordStore{ + path: path, + fallback: fallback, + } +} + +// IsInitialized reports whether a dashboard password hash exists in config. +func (s *PasswordStore) IsInitialized(ctx context.Context) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + cfg, err := s.load() + if err != nil { + return false, err + } + return strings.TrimSpace(cfg.DashboardPasswordHash) != "", nil +} + +// SetPassword hashes plain with bcrypt and writes it to launcher-config.json. +func (s *PasswordStore) SetPassword(ctx context.Context, plain string) error { + if err := ctx.Err(); err != nil { + return err + } + if len([]rune(plain)) == 0 { + return errors.New("password must not be empty") + } + hash, err := bcrypt.GenerateFromPassword([]byte(plain), passwordBcryptCost) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + cfg, err := Load(s.path, s.fallback) + if err != nil { + return err + } + cfg.DashboardPasswordHash = string(hash) + cfg.LegacyLauncherToken = "" + return Save(s.path, cfg) +} + +// VerifyPassword returns true iff plain matches the stored bcrypt hash. +func (s *PasswordStore) VerifyPassword(ctx context.Context, plain string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + cfg, err := s.load() + if err != nil { + return false, err + } + hash := strings.TrimSpace(cfg.DashboardPasswordHash) + if hash == "" { + return false, nil + } + err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + return err == nil, err +} + +func (s *PasswordStore) load() (Config, error) { + s.mu.Lock() + defer s.mu.Unlock() + return Load(s.path, s.fallback) +} diff --git a/web/backend/main.go b/web/backend/main.go index e42558398..f5362174b 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -12,12 +12,12 @@ package main import ( + "context" "errors" "flag" "fmt" "net" "net/http" - "net/url" "os" "os/signal" "path/filepath" @@ -51,7 +51,6 @@ var ( servers []*http.Server serverAddr string // 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 @@ -62,11 +61,34 @@ func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool { return !enableConsole || debug } -func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string { - if source != launcherconfig.DashboardTokenSourceConfig { - return "" +func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool { + return !noBrowser && isLoopbackLaunchHost(probeHost) +} + +func isLoopbackLaunchHost(host string) bool { + host = strings.TrimSpace(host) + if strings.EqualFold(host, "localhost") { + return true } - return launcherPath + host = strings.Trim(host, "[]") + if i := strings.LastIndex(host, "%"); i >= 0 { + host = host[:i] + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func launcherBrowserLaunchSuffix( + needsSetup bool, + localAutoLogin *middleware.LauncherDashboardLocalAutoLogin, +) string { + if needsSetup { + return middleware.LauncherDashboardSetupPath + } + if localAutoLogin != nil { + return localAutoLogin.URLPath() + } + return "" } func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) { @@ -318,24 +340,6 @@ func firstNonEmpty(values ...string) string { return "" } -// maskSecret masks a secret for display. It always shows up to the first 3 -// runes. The last 4 runes are only appended when at least 5 runes remain -// hidden in the middle (i.e. string length >= 12), so an 8-char minimum -// password never exposes its tail. Strings of 3 chars or fewer are fully -// masked. -func maskSecret(s string) string { - runes := []rune(s) - n := len(runes) - const prefixLen, suffixLen, minHidden = 3, 4, 5 - if n < prefixLen+suffixLen+minHidden { - if n <= prefixLen { - return "**********" - } - return string(runes[:prefixLen]) + "**********" - } - return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:]) -} - func main() { port := flag.String("port", "18800", "Port to listen on") host := flag.String("host", "", "Host to listen on (overrides -public when set)") @@ -503,15 +507,11 @@ func main() { } listeners := openResult.Listeners - dashboardToken, dashboardSigningKey, _, dashErr := launcherconfig.EnsureDashboardSecrets( - launcherCfg, - ) + dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie() if dashErr != nil { logger.Fatalf("Dashboard auth setup failed: %v", dashErr) } - dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken) - fmt.Println("dashboardToken: ", dashboardToken) // Open the bcrypt password store (creates the DB file on first run). authStore, authStoreErr := dashboardauth.New(picoHome) var passwordStore api.PasswordStore @@ -522,23 +522,62 @@ func main() { logger.InfoC( "web", fmt.Sprintf( - "Dashboard password store unavailable on this platform; falling back to token login: %v", + "Dashboard SQLite password store unavailable on this platform; using launcher-config password storage: %v", authStoreErr, ), ) + passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg) authStoreErr = nil } else { logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr)) } + migrationResult, migrationErr := launcherconfig.MigrateLegacyLauncherToken( + context.Background(), + passwordStore, + launcherPath, + launcherCfg, + ) + if migrationErr != nil { + logger.Fatalf("Failed to migrate legacy launcher token to password login: %v", migrationErr) + } + if migrationResult.Migrated { + logger.InfoC("web", "Migrated legacy launcher token to dashboard password login") + } + if migrationResult.CleanupErr != nil { + logger.WarnC( + "web", + fmt.Sprintf( + "Legacy launcher token password migration succeeded, but failed to remove launcher_token from %s: %v", + launcherPath, + migrationResult.CleanupErr, + ), + ) + } + + var localAutoLogin *middleware.LauncherDashboardLocalAutoLogin + needsInitialSetup := false + if passwordStore != nil { + initialized, initErr := passwordStore.IsInitialized(context.Background()) + if initErr != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: could not check dashboard password state: %v", initErr)) + } else if !initialized { + needsInitialSetup = true + } else if shouldEnableLocalAutoLogin(*noBrowser, openResult.ProbeHost) { + localAutoLogin, err = middleware.NewLauncherDashboardLocalAutoLogin(5 * time.Minute) + if err != nil { + logger.Fatalf("Failed to create local auto-login grant: %v", err) + } + } + } + // Initialize Server components mux := http.NewServeMux() api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ - DashboardToken: dashboardToken, - SessionCookie: dashboardSessionCookie, - PasswordStore: passwordStore, - StoreError: authStoreErr, + SessionCookie: dashboardSessionCookie, + PasswordStore: passwordStore, + StoreError: authStoreErr, }) // API Routes (e.g. /api/status) @@ -561,7 +600,7 @@ func main() { dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{ ExpectedCookie: dashboardSessionCookie, - Token: dashboardToken, + LocalAutoLogin: localAutoLogin, }, accessControlledMux) // Apply middleware stack @@ -573,13 +612,21 @@ func main() { ), ) - // Print startup banner and token (console mode only). + // Print startup banner (console mode only). if enableConsole || debug { consoleHosts := launcherConsoleHosts(hostInput, effectivePublic) fmt.Print(utils.Banner) fmt.Println() - fmt.Println(" Open the following URL in your browser:") + if needsInitialSetup { + if *noBrowser { + fmt.Println(" First-time setup: open /launcher-setup to create the dashboard password.") + } else { + fmt.Println(" Launcher will open /launcher-setup automatically.") + } + fmt.Println() + } + fmt.Println(" Dashboard address:") fmt.Println() for _, host := range consoleHosts { fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort)) @@ -599,11 +646,7 @@ func main() { // Share the local URL with the launcher runtime. serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort)) - if dashboardToken != "" { - browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken) - } else { - browserLaunchURL = serverAddr - } + browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin) // Auto-open browser will be handled by the launcher runtime. diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 6df5370b1..aea02927e 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/netbind" - "github.com/sipeed/picoclaw/web/backend/launcherconfig" + "github.com/sipeed/picoclaw/web/backend/middleware" ) func TestShouldEnableLauncherFileLogging(t *testing.T) { @@ -43,60 +43,50 @@ func TestShouldEnableLauncherFileLogging(t *testing.T) { } } -func TestDashboardTokenConfigHelpPath(t *testing.T) { - const launcherPath = "/tmp/launcher-config.json" - +func TestShouldEnableLocalAutoLogin(t *testing.T) { tests := []struct { - name string - source launcherconfig.DashboardTokenSource - want string + name string + noBrowser bool + probeHost string + wantEnable bool }{ - { - name: "env token does not expose config path", - source: launcherconfig.DashboardTokenSourceEnv, - want: "", - }, - { - name: "config token exposes config path", - source: launcherconfig.DashboardTokenSourceConfig, - want: launcherPath, - }, - { - name: "random token does not expose config path", - source: launcherconfig.DashboardTokenSourceRandom, - want: "", - }, + {name: "loopback localhost", probeHost: "localhost", wantEnable: true}, + {name: "loopback ipv4", probeHost: "127.0.0.1", wantEnable: true}, + {name: "loopback ipv6", probeHost: "::1", wantEnable: true}, + {name: "browser disabled", noBrowser: true, probeHost: "localhost", wantEnable: false}, + {name: "non-loopback host", probeHost: "192.168.1.50", wantEnable: false}, + {name: "non-loopback hostname", probeHost: "example.com", wantEnable: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := dashboardTokenConfigHelpPath(tt.source, launcherPath); got != tt.want { - t.Fatalf("dashboardTokenConfigHelpPath(%q, %q) = %q, want %q", tt.source, launcherPath, got, tt.want) + if got := shouldEnableLocalAutoLogin(tt.noBrowser, tt.probeHost); got != tt.wantEnable { + t.Fatalf( + "shouldEnableLocalAutoLogin(%t, %q) = %t, want %t", + tt.noBrowser, + tt.probeHost, + got, + tt.wantEnable, + ) } }) } } -func TestMaskSecret(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"sdhjflsjdflksdf", "sdh**********ksdf"}, - {"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"}, - {"abcdefghijkl", "abc**********ijkl"}, - {"abcdefgh", "abc**********"}, - {"abcdefghijk", "abc**********"}, - {"abcdefg", "abc**********"}, - {"abcd", "abc**********"}, - {"abc", "**********"}, - {"", "**********"}, +func TestLauncherBrowserLaunchSuffix(t *testing.T) { + autoLogin, err := middleware.NewLauncherDashboardLocalAutoLogin(time.Minute) + if err != nil { + t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err) } - for _, tt := range tests { - if got := maskSecret(tt.input); got != tt.want { - t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want) - } + if got := launcherBrowserLaunchSuffix(true, autoLogin); got != middleware.LauncherDashboardSetupPath { + t.Fatalf("setup suffix = %q", got) + } + if got := launcherBrowserLaunchSuffix(false, autoLogin); !strings.HasPrefix(got, "/launcher-auto-login?nonce=") { + t.Fatalf("auto-login suffix = %q", got) + } + if got := launcherBrowserLaunchSuffix(false, nil); got != "" { + t.Fatalf("empty suffix = %q, want empty", got) } } diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go index d72bd0f00..fd59958a9 100644 --- a/web/backend/middleware/launcher_dashboard_auth.go +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -1,41 +1,88 @@ package middleware import ( - "crypto/hmac" - "crypto/sha256" + "crypto/rand" "crypto/subtle" - "encoding/hex" + "encoding/base64" + "errors" "net/http" + "net/url" "path" "strings" + "sync" "time" ) -// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login. +// LauncherDashboardCookieName is the HttpOnly cookie set after a successful password login. const LauncherDashboardCookieName = "picoclaw_launcher_auth" -// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days). -const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600 +// launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days). +const launcherDashboardSessionMaxAgeSec = 31 * 24 * 3600 -const launcherSessionMACLabel = "picoclaw-launcher-v1" +const ( + launcherSessionCookieBytes = 32 + launcherGrantNonceBytes = 32 + // LauncherDashboardLocalAutoLoginPath is the one-shot local browser + // bootstrap endpoint used by the launcher-managed auto-open flow. + LauncherDashboardLocalAutoLoginPath = "/launcher-auto-login" + // LauncherDashboardSetupPath is the setup page used before the dashboard + // password is initialized. + LauncherDashboardSetupPath = "/launcher-setup" +) -// 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)) +// NewLauncherDashboardSessionCookie creates the per-process session cookie value. +func NewLauncherDashboardSessionCookie() (string, error) { + return randomURLToken(launcherSessionCookieBytes) +} + +func randomURLToken(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil } // LauncherDashboardAuthConfig holds runtime material for dashboard access checks. type LauncherDashboardAuthConfig struct { ExpectedCookie string - Token string + // LocalAutoLogin enables one-shot startup auto-login. + LocalAutoLogin *LauncherDashboardLocalAutoLogin // SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used. SecureCookie func(*http.Request) bool } +// LauncherDashboardLocalAutoLogin is an in-memory, one-shot startup grant. +// It is not a reusable credential; it only lets the launcher-opened browser +// receive the current process session cookie. +type LauncherDashboardLocalAutoLogin struct { + grant *launcherDashboardOneTimeGrant +} + +type launcherDashboardOneTimeGrant struct { + mu sync.Mutex + expires time.Time + consumed bool + nonce string + now func() time.Time +} + +// NewLauncherDashboardLocalAutoLogin creates a one-shot local auto-login grant. +func NewLauncherDashboardLocalAutoLogin(ttl time.Duration) (*LauncherDashboardLocalAutoLogin, error) { + grant, err := newLauncherDashboardOneTimeGrant(ttl) + if err != nil { + return nil, err + } + return &LauncherDashboardLocalAutoLogin{ + grant: grant, + }, nil +} + +// URLPath returns the one-shot local auto-login URL path including its nonce. +func (a *LauncherDashboardLocalAutoLogin) URLPath() string { + return launcherGrantQueryPath(LauncherDashboardLocalAutoLoginPath, a.grant) +} + // DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto). func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { if r.TLS != nil { @@ -44,7 +91,7 @@ func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } -// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login. +// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard password login. func SetLauncherDashboardSessionCookie( w http.ResponseWriter, r *http.Request, @@ -82,12 +129,13 @@ func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request, }) } -// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer -// before calling next. Public paths are login page and /api/auth/* handlers. +// LauncherDashboardAuth requires a valid session cookie before calling next. +// Public paths are login/setup pages 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 { + if p == LauncherDashboardLocalAutoLoginPath { + handleLauncherLocalAutoLogin(w, r, cfg) return } if isPublicLauncherDashboardPath(r.Method, p) { @@ -105,45 +153,84 @@ func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) h // 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 +func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) { + if validLauncherDashboardAuth(r, cfg) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return } - if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") { - return false + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte("method not allowed")) + return } - qToken := strings.TrimSpace(r.URL.Query().Get("token")) - if qToken == "" { - return false + if r.Method == http.MethodHead { + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) + return } - if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 { - rejectLauncherDashboardAuth(w, r, canonicalPath) - return true + if cfg.LocalAutoLogin != nil && cfg.LocalAutoLogin.consume(r.URL.Query().Get("nonce")) { + SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) + http.Redirect(w, r, "/", http.StatusSeeOther) + return } - SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) - http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther) - return true + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) } -func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string { - if canonicalPath == "/launcher-login" { - return "/" +func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool { + if a == nil || a.grant == nil { + return false } - q := r.URL.Query() - q.Del("token") - enc := q.Encode() - if enc != "" { - return canonicalPath + "?" + enc + return a.grant.use(nonce, nil) == nil +} + +func newLauncherDashboardOneTimeGrant(ttl time.Duration) (*launcherDashboardOneTimeGrant, error) { + nonce, err := randomURLToken(launcherGrantNonceBytes) + if err != nil { + return nil, err } - return canonicalPath + return &launcherDashboardOneTimeGrant{ + expires: time.Now().Add(ttl), + nonce: nonce, + now: time.Now, + }, nil +} + +func launcherGrantQueryPath(basePath string, grant *launcherDashboardOneTimeGrant) string { + if grant == nil { + return basePath + } + return basePath + "?nonce=" + url.QueryEscape(grant.nonce) +} + +// ErrInvalidLauncherDashboardGrant reports that an auto-login grant is missing, +// expired, already consumed, or otherwise invalid. +var ErrInvalidLauncherDashboardGrant = errors.New("invalid launcher dashboard grant") + +func (g *launcherDashboardOneTimeGrant) use(nonce string, fn func() error) error { + if g == nil { + return ErrInvalidLauncherDashboardGrant + } + if len(nonce) != len(g.nonce) || + subtle.ConstantTimeCompare([]byte(nonce), []byte(g.nonce)) != 1 { + return ErrInvalidLauncherDashboardGrant + } + + g.mu.Lock() + defer g.mu.Unlock() + + now := time.Now + if g.now != nil { + now = g.now + } + if g.consumed || !now().Before(g.expires) { + return ErrInvalidLauncherDashboardGrant + } + if fn != nil { + if err := fn(); err != nil { + return err + } + } + g.consumed = true + return nil } func canonicalAuthPath(raw string) string { @@ -206,14 +293,6 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig 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 } diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go index 7b7418998..871b6f607 100644 --- a/web/backend/middleware/launcher_dashboard_auth_test.go +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -4,26 +4,37 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) -func TestSessionCookieValue_Deterministic(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = byte(i) +func TestNewLauncherDashboardSessionCookie(t *testing.T) { + a, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err) } - 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) + b, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err) } - c := SessionCookieValue(key, "tok-b") - if c == a { - t.Fatal("SessionCookieValue should differ for different tokens") + if a == "" || b == "" { + t.Fatalf("session cookie values should be non-empty: %q %q", a, b) + } + if a == b { + t.Fatal("session cookie values should be random") } } +func mustLocalAutoLogin(t *testing.T, ttl time.Duration) *LauncherDashboardLocalAutoLogin { + t.Helper() + autoLogin, err := NewLauncherDashboardLocalAutoLogin(ttl) + if err != nil { + t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err) + } + return autoLogin +} + func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }) @@ -34,9 +45,11 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { want int }{ {http.MethodGet, "/launcher-login", http.StatusTeapot}, + {http.MethodGet, "/launcher-setup", 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/setup", http.StatusTeapot}, {http.MethodPost, "/api/auth/logout", http.StatusTeapot}, {http.MethodGet, "/api/auth/logout", http.StatusUnauthorized}, {http.MethodGet, "/api/config", http.StatusUnauthorized}, @@ -51,68 +64,143 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { } } -func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) { - const tok = "secret" - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok} +func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusTeapot) + t.Fatal("next handler should not run without session cookie") }) h := LauncherDashboardAuth(cfg, next) rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil) + req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil) h.ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" { + t.Fatalf("GET /?token=secret: code=%d loc=%q", rec.Code, rec.Header().Get("Location")) } - if got := rec.Header().Get("Location"); got != "/" { - t.Fatalf("Location = %q, want %q", got, "/") +} + +func TestLauncherDashboardAuth_LocalAutoLogin(t *testing.T) { + const cookieVal = "session-cookie-value" + autoLogin := mustLocalAutoLogin(t, time.Minute) + cfg := LauncherDashboardAuthConfig{ + ExpectedCookie: cookieVal, + LocalAutoLogin: autoLogin, } - if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName { - t.Fatalf("expected one session cookie, got %#v", c) + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath, nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login without nonce code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) } - 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) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath+"?nonce=wrong", nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login with wrong nonce code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) } - 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")) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodHead, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login HEAD code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) } - 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) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { + t.Fatalf("local auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != LauncherDashboardCookieName || cookies[0].Value != cookieVal { + t.Fatalf("cookies = %#v", cookies) + } + if cookies[0].MaxAge != 31*24*3600 { + t.Fatalf("session cookie MaxAge = %d, want 31 days", cookies[0].MaxAge) } - 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) + 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 after auto-login status = %d", rec.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")) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { + t.Fatalf("auto-login path with existing session code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" { + t.Fatalf("consumed auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestLauncherDashboardAuth_LocalAutoLoginRequiresValidNonceAndUnexpired(t *testing.T) { + const cookieVal = "session-cookie-value" + newHandler := func(autoLogin *LauncherDashboardLocalAutoLogin) http.Handler { + return LauncherDashboardAuth(LauncherDashboardAuthConfig{ + ExpectedCookie: cookieVal, + LocalAutoLogin: autoLogin, + }, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + } + + autoLogin := mustLocalAutoLogin(t, time.Minute) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + req.RemoteAddr = "192.168.1.50:12345" + req.Host = "192.168.1.50:18800" + newHandler(autoLogin).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) != 1 { + t.Fatalf("capability auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies()) + } + + expired := mustLocalAutoLogin(t, -time.Second) + h := newHandler(expired) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, expired.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || len(rec.Result().Cookies()) != 0 { + t.Fatalf("expired auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies()) } } func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("next handler should not run without auth") }) @@ -132,14 +220,9 @@ func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { } } -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} +func TestLauncherDashboardAuth_CookieOnly(t *testing.T) { + cookieVal := "session-cookie-value" + cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal} next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -154,16 +237,16 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { } rec2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodGet, "/", nil) - req2.Header.Set("Authorization", "Bearer "+token) + req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req2.Header.Set("Authorization", "Bearer dashboard-secret-9") h.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusOK { - t.Fatalf("bearer auth: status = %d", rec2.Code) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("bearer auth should not be accepted: status = %d", rec2.Code) } } func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) { - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("next handler should not run without auth") }) diff --git a/web/backend/middleware/referrer_policy.go b/web/backend/middleware/referrer_policy.go index 5ac066614..6cb14669d 100644 --- a/web/backend/middleware/referrer_policy.go +++ b/web/backend/middleware/referrer_policy.go @@ -2,8 +2,8 @@ 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. +// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response +// so sensitive paths and query parameters 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") diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts index d6bd93c4d..c7318d962 100644 --- a/web/frontend/src/api/launcher-auth.ts +++ b/web/frontend/src/api/launcher-auth.ts @@ -2,16 +2,26 @@ * Dashboard launcher auth API. * Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages. */ +export type LoginResult = + | { ok: true } + | { ok: false; status: number; error: string } + export async function postLauncherDashboardLogin( password: string, -): Promise { +): Promise { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ password: password.trim() }), }) - return res.ok + if (res.ok) return { ok: true } + + return { + ok: false, + status: res.status, + error: await readLauncherAuthError(res), + } } export type LauncherAuthStatus = { @@ -57,12 +67,16 @@ export async function postLauncherDashboardSetup( }), }) if (res.ok) return { ok: true } - let msg = "Unknown error" + return { ok: false, error: await readLauncherAuthError(res) } +} + +async function readLauncherAuthError(res: Response): Promise { + let msg = `Request failed with status ${res.status}` try { const j = (await res.json()) as { error?: string } if (j.error) msg = j.error } catch { /* ignore */ } - return { ok: false, error: msg } + return msg } diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 8623c7e78..dfc48b6b8 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -11,7 +11,6 @@ export interface LauncherConfig { port: number public: boolean allowed_cidrs: string[] - launcher_token: string } export interface SystemVersionInfo { diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index e94975075..465d218be 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -295,6 +295,22 @@ export function AppHeader() { {/* Theme Toggle */} + + + + + {/* Logout */} ) diff --git a/web/frontend/src/components/channels/channel-array-list-field.tsx b/web/frontend/src/components/channels/channel-array-list-field.tsx new file mode 100644 index 000000000..ff601c07b --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-list-field.tsx @@ -0,0 +1,180 @@ +import { IconX } from "@tabler/icons-react" +import { + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" + +import { + mergeUniqueStringItems, + parseConservativeStringListInput, +} from "@/components/channels/channel-array-utils" +import { Field } from "@/components/shared-form" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +type StringListParser = (raw: string) => string[] +export type ArrayFieldFlusher = () => string[] | null + +type RegisterArrayFieldFlusher = ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, +) => void + +function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false + } + return left.every((item, index) => item === right[index]) +} + +interface ChannelArrayListFieldProps { + label: string + hint?: string + error?: string + required?: boolean + value: string[] + onChange: (value: string[]) => void + placeholder?: string + parser?: StringListParser + fieldPath?: string + registerFlusher?: RegisterArrayFieldFlusher + resetVersion?: number +} + +export function ChannelArrayListField({ + label, + hint, + error, + required, + value, + onChange, + placeholder, + parser = parseConservativeStringListInput, + fieldPath, + registerFlusher, + resetVersion, +}: ChannelArrayListFieldProps) { + const { t } = useTranslation() + const [draft, setDraft] = useState("") + const draftRef = useRef("") + const valueRef = useRef(value) + const localValueRef = useRef(value) + const parserRef = useRef(parser) + const onChangeRef = useRef(onChange) + + useEffect(() => { + valueRef.current = value + localValueRef.current = value + }, [value]) + + useEffect(() => { + draftRef.current = "" + setDraft("") + }, [resetVersion]) + + useEffect(() => { + parserRef.current = parser + }, [parser]) + + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + const commitDraft = useCallback(() => { + const rawDraft = draftRef.current + if (rawDraft.trim() === "") { + if (!areStringArraysEqual(localValueRef.current, valueRef.current)) { + return localValueRef.current + } + draftRef.current = "" + setDraft("") + return null + } + draftRef.current = "" + setDraft("") + const nextItems = parserRef.current(rawDraft) + if (nextItems.length === 0) { + return null + } + const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems) + localValueRef.current = mergedItems + onChangeRef.current(mergedItems) + return mergedItems + }, []) + + useEffect(() => { + if (!fieldPath || !registerFlusher) { + return + } + registerFlusher(fieldPath, commitDraft) + return () => registerFlusher(fieldPath, null) + }, [commitDraft, fieldPath, registerFlusher]) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") { + return + } + event.preventDefault() + commitDraft() + } + + const handleRemove = (index: number) => { + const nextValue = value.filter((_, itemIndex) => itemIndex !== index) + localValueRef.current = nextValue + onChangeRef.current(nextValue) + } + + return ( + +
+ {value.length > 0 && ( +
+ {value.map((item, index) => ( + + {item} + + + ))} +
+ )} + +
+ { + const nextDraft = event.target.value + draftRef.current = nextDraft + setDraft(nextDraft) + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + /> + +
+
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-array-utils.ts b/web/frontend/src/components/channels/channel-array-utils.ts new file mode 100644 index 000000000..0f6268be8 --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-utils.ts @@ -0,0 +1,72 @@ +const ALLOW_FROM_HIDDEN_CHARS_RE = + /\u200b|\u200c|\u200d|\u200e|\u200f|\u202a|\u202b|\u202c|\u202d|\u202e|\u2060|\u2061|\u2062|\u2063|\u2064|\u2066|\u2067|\u2068|\u2069|\ufeff/g + +function normalizeStringListItems( + items: string[], + options: { stripHiddenChars?: boolean } = {}, +): string[] { + const result: string[] = [] + const seen = new Set() + + for (const item of items) { + const normalized = options.stripHiddenChars + ? item.replace(ALLOW_FROM_HIDDEN_CHARS_RE, "") + : item + const trimmed = normalized.trim() + if (trimmed.length === 0 || seen.has(trimmed)) { + continue + } + seen.add(trimmed) + result.push(trimmed) + } + + return result +} + +function splitStringList( + raw: string, + separators: RegExp, + options: { stripHiddenChars?: boolean } = {}, +): string[] { + if (raw.trim() === "") { + return [] + } + return normalizeStringListItems(raw.split(separators), options) +} + +export function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((item): item is string => typeof item === "string") +} + +export function parseAllowFromInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C、;;\n\r\t]+/, { + stripHiddenChars: true, + }) +} + +export function parseConservativeStringListInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C\n\r\t]+/) +} + +export function normalizeAllowFromValues(value: unknown): string[] { + return normalizeStringListItems(asStringArray(value), { + stripHiddenChars: true, + }) +} + +export function mergeUniqueStringItems( + currentItems: string[], + nextItems: string[], +): string[] { + return normalizeStringListItems([...currentItems, ...nextItems]) +} + +export function serializeStringArrayForSubmit(value: unknown): unknown { + if (!Array.isArray(value)) { + return value + } + return normalizeStringListItems(asStringArray(value)).join("\n") +} diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index a235daf8d..f6609e3ba 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -9,6 +9,11 @@ import { getChannelsCatalog, patchAppConfig, } from "@/api/channels" +import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field" +import { + normalizeAllowFromValues, + serializeStringArrayForSubmit, +} from "@/components/channels/channel-array-utils" import { SECRET_FIELD_MAP, buildEditConfig, @@ -48,6 +53,43 @@ function asBool(value: unknown): boolean { return value === true } +function setRecordValueByPath( + source: Record, + pathSegments: string[], + value: unknown, +): Record { + const [segment, ...rest] = pathSegments + if (!segment) { + return source + } + if (rest.length === 0) { + return { ...source, [segment]: value } + } + return { + ...source, + [segment]: setRecordValueByPath(asRecord(source[segment]), rest, value), + } +} + +function setConfigValueByPath( + source: ChannelConfig, + fieldPath: string, + value: unknown, +): ChannelConfig { + return setRecordValueByPath(source, fieldPath.split("."), value) +} + +function serializeGroupTriggerForSubmit(value: unknown): unknown { + const groupTrigger = asRecord(value) + if (Object.keys(groupTrigger).length === 0) { + return value + } + return { + ...groupTrigger, + prefixes: serializeStringArrayForSubmit(groupTrigger.prefixes), + } +} + const CHANNEL_COMMON_CONFIG_KEYS = new Set([ "allow_from", "group_trigger", @@ -82,12 +124,20 @@ function buildSavePayload( if (key.startsWith("_")) continue if (key === "enabled") continue if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) { - payload[key] = value + if (key === "allow_from") { + payload[key] = serializeStringArrayForSubmit( + normalizeAllowFromValues(value), + ) + } else if (key === "group_trigger") { + payload[key] = serializeGroupTriggerForSubmit(value) + } else { + payload[key] = value + } continue } if (isSecretField(key)) continue - settings[key] = value + settings[key] = serializeStringArrayForSubmit(value) } for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) { @@ -244,6 +294,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const [editConfig, setEditConfig] = useState({}) const [configuredSecrets, setConfiguredSecrets] = useState([]) const [enabled, setEnabled] = useState(false) + const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0) + const arrayFieldFlushersRef = useRef(new Map()) const loadData = useCallback( async (silent = false) => { @@ -302,11 +354,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { previousGatewayStatusRef.current = gatewayState }, [gatewayState, loadData]) - const savePayload = useMemo(() => { - if (!channel) return null - return buildSavePayload(channel, editConfig, enabled) - }, [channel, editConfig, enabled]) - const configured = useMemo(() => { if (!channel) return false return isConfigured(channel, editConfig, configuredSecrets) @@ -362,20 +409,52 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }) }, []) + const registerArrayFieldFlusher = useCallback( + (fieldPath: string, flusher: ArrayFieldFlusher | null) => { + if (flusher) { + arrayFieldFlushersRef.current.set(fieldPath, flusher) + return + } + arrayFieldFlushersRef.current.delete(fieldPath) + }, + [], + ) + + const flushPendingArrayFieldDrafts = useCallback( + (sourceConfig: ChannelConfig): ChannelConfig => { + let nextConfig = sourceConfig + for (const [fieldPath, flusher] of arrayFieldFlushersRef.current) { + const flushedValue = flusher() + if (flushedValue === null) { + continue + } + nextConfig = setConfigValueByPath(nextConfig, fieldPath, flushedValue) + } + return nextConfig + }, + [], + ) + const handleReset = () => { if (!channel) return setEditConfig(buildEditConfig(channel.name, baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) } const handleSave = async () => { - if (!channel || !savePayload) return + if (!channel) return + + const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig) + if (preparedEditConfig !== editConfig) { + setEditConfig(preparedEditConfig) + } const missingRequiredFields = requiredKeys.filter((key) => isMissingRequiredValue( - getFieldValueForValidation(editConfig, configuredSecrets, key), + getFieldValueForValidation(preparedEditConfig, configuredSecrets, key), ), ) if (missingRequiredFields.length > 0) { @@ -393,6 +472,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) try { + const savePayload = buildSavePayload(channel, preparedEditConfig, enabled) await patchAppConfig({ channel_list: { [channel.config_key]: savePayload, @@ -462,6 +542,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "discord": @@ -471,6 +553,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "slack": @@ -480,6 +564,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "feishu": @@ -489,6 +575,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "weixin": @@ -498,6 +586,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} isEdit={isEdit} onBindSuccess={() => void handleWeixinBindSuccess()} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "wecom": @@ -518,6 +608,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={[...hiddenKeys, "bot_id"]} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) @@ -530,6 +622,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={hiddenKeys} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) } diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index f72e1c5c7..d2a98d325 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface DiscordFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asBool(value: unknown): boolean { return value === true } @@ -38,6 +46,8 @@ export function DiscordForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: DiscordFormProps) { const { t } = useTranslation() const groupTriggerConfig = asRecord(config.group_trigger) @@ -78,24 +88,17 @@ export function DiscordForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { @@ -21,9 +35,11 @@ function asBool(value: unknown): boolean { return typeof value === "boolean" ? value : false } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} } export function FeishuForm({ @@ -31,8 +47,11 @@ export function FeishuForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: FeishuFormProps) { const { t } = useTranslation() + const groupTriggerConfig = asRecord(config.group_trigger) return (
@@ -104,24 +123,17 @@ export function FeishuForm({ /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
+ + + +
+ { + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + }} + ariaLabel={t("channels.field.groupTriggerMentionOnly")} + /> +
+ + onChange("random_reaction_emoji", value)} + placeholder={t("channels.field.randomReactionEmojiPlaceholder")} + parser={parseConservativeStringListInput} + fieldPath="random_reaction_emoji" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> +
+
) } diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx index 526a3c808..c8ee3f69f 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder, isSecretField, @@ -16,6 +24,11 @@ interface GenericFormProps { hiddenKeys?: string[] requiredKeys?: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } // Fields to skip in the generic form (handled by enabled toggle or internal). @@ -48,11 +61,6 @@ function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -71,6 +79,8 @@ export function GenericForm({ hiddenKeys = [], requiredKeys = [], fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: GenericFormProps) { const { t } = useTranslation() const hiddenFieldSet = new Set(hiddenKeys) @@ -187,26 +197,18 @@ export function GenericForm({ if (Array.isArray(value)) { return ( - - - onChange( - key, - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - /> - + value={asStringArray(value)} + onChange={(nextValue) => onChange(key, nextValue)} + fieldPath={key} + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> ) } @@ -281,46 +283,31 @@ export function GenericForm({ {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_origins !== undefined && !hiddenFieldSet.has("allow_origins") && ( - - - onChange( - "allow_origins", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowOriginsPlaceholder")} - /> - + value={asStringArray(config.allow_origins)} + onChange={(value) => onChange("allow_origins", value)} + placeholder={t("channels.field.allowOriginsPlaceholder")} + fieldPath="allow_origins" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_token_query !== undefined && @@ -356,26 +343,21 @@ export function GenericForm({ />
- - - onChange("group_trigger", { - ...groupTriggerConfig, - prefixes: e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - }) - } - placeholder={t("channels.field.groupTriggerPrefixes")} - /> - + value={asStringArray(groupTriggerConfig.prefixes)} + onChange={(value) => + onChange("group_trigger", { + ...groupTriggerConfig, + prefixes: value, + }) + } + placeholder={t("channels.field.groupTriggerPrefixes")} + fieldPath="group_trigger.prefixes" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx index 14ffa0913..b8184e8bc 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -1,32 +1,41 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" -import { Input } from "@/components/ui/input" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function SlackForm({ config, onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: SlackFormProps) { const { t } = useTranslation() @@ -72,24 +81,17 @@ export function SlackForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx index 696da245d..f9c7c778a 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface TelegramFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -38,6 +46,8 @@ export function TelegramForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: TelegramFormProps) { const { t } = useTranslation() const typingConfig = asRecord(config.typing) @@ -91,24 +101,17 @@ export function TelegramForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void isEdit: boolean onBindSuccess?: () => void + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function WeixinForm({ config, onChange, isEdit, onBindSuccess, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: WeixinFormProps) { const { t } = useTranslation() @@ -321,24 +331,17 @@ export function WeixinForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> -
-
- PicoClaw - {isThought && ( - - - {t("chat.reasoningLabel")} - - )} - {formattedTimestamp && ( - <> - - {formattedTimestamp} - - )} + {!isThought && ( +
+
+ PicoClaw + {formattedTimestamp && ( + <> + + {formattedTimestamp} + + )} +
-
+ )}
-
- setIsExpanded(!isExpanded)} > - {content} - -
- +
+ + {t("chat.reasoningLabel")} +
+ +
+ )} + {(!isThought || isExpanded) && ( +
+ + {content} + +
+ )} + {!isThought && ( + + )}
) diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index 58612d846..b3354cc33 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -3,9 +3,15 @@ import type { KeyboardEvent } from "react" import { useTranslation } from "react-i18next" import TextareaAutosize from "react-textarea-autosize" +import { ContextUsageRing } from "@/components/chat/context-usage-ring" import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { cn } from "@/lib/utils" -import type { ChatAttachment } from "@/store/chat" +import type { ChatAttachment, ContextUsage } from "@/store/chat" export type ChatInputDisabledReason = | "gatewayUnknown" @@ -26,8 +32,10 @@ interface ChatComposerProps { onAddImages: () => void onRemoveAttachment: (index: number) => void onSend: () => void + onContextDetail?: () => void inputDisabledReason: ChatInputDisabledReason | null canSend: boolean + contextUsage?: ContextUsage } export function ChatComposer({ @@ -37,8 +45,10 @@ export function ChatComposer({ onAddImages, onRemoveAttachment, onSend, + onContextDetail, inputDisabledReason, canSend, + contextUsage, }: ChatComposerProps) { const { t } = useTranslation() const canInput = inputDisabledReason === null @@ -57,8 +67,8 @@ export function ChatComposer({ } return ( -
-
+
+
{attachments.length > 0 && (
{attachments.map((attachment, index) => ( @@ -93,17 +103,12 @@ export function ChatComposer({ disabled={!canInput} title={disabledMessage || undefined} className={cn( - "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", + "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", !canInput && "cursor-not-allowed", )} minRows={1} maxRows={8} /> - {!canInput && disabledMessage && ( -
- {disabledMessage} -
- )}
@@ -121,17 +126,35 @@ export function ChatComposer({
- {canInput ? ( - - ) : null} +
+ {contextUsage && ( + + )} + {canInput ? ( + + + + + + + + {t("chat.sendHint")} + + + ) : null} +
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 4129d812a..cb109daf5 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -115,6 +115,7 @@ export function ChatPage() { connectionState, isTyping, activeSessionId, + contextUsage, sendMessage, switchSession, newChat, @@ -153,7 +154,7 @@ export function ChatPage() { }) const syncScrollState = (element: HTMLDivElement) => { - const { scrollTop, scrollHeight, clientHeight } = element + const { clientHeight, scrollHeight, scrollTop } = element setHasScrolled(scrollTop > 0) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) } @@ -294,7 +295,7 @@ export function ChatPage() {
{messages.length === 0 && !isTyping && ( @@ -341,8 +342,14 @@ export function ChatPage() { onAddImages={handleAddImages} onRemoveAttachment={handleRemoveAttachment} onSend={handleSend} + onContextDetail={() => { + if (sendMessage({ content: "/context", attachments: [] })) { + setInput("") + } + }} inputDisabledReason={inputDisabledReason} canSend={canSubmit} + contextUsage={contextUsage} />
) diff --git a/web/frontend/src/components/chat/context-usage-ring.tsx b/web/frontend/src/components/chat/context-usage-ring.tsx new file mode 100644 index 000000000..4a32e617b --- /dev/null +++ b/web/frontend/src/components/chat/context-usage-ring.tsx @@ -0,0 +1,161 @@ +import { IconArrowRight } from "@tabler/icons-react" +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ContextUsage } from "@/store/chat" + +interface ContextUsageRingProps { + usage: ContextUsage + onDetailClick?: () => void +} + +function formatTokens(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return String(n) +} + +export function ContextUsageRing({ + usage, + onDetailClick, +}: ContextUsageRingProps) { + const { t } = useTranslation() + const [intent, setIntent] = useState(false) // user wants open + const [visible, setVisible] = useState(false) // DOM mounted + const [animated, setAnimated] = useState(false) // CSS target state + const [cooldown, setCooldown] = useState(false) + const containerRef = useRef(null) + const timerRef = useRef>(null) + const hoverIntent = useRef>(null) + const closeTimer = useRef>(null) + + useEffect(() => { + if (intent) { + // Mount first, animate in on next frame + if (closeTimer.current) clearTimeout(closeTimer.current) + setVisible(true) + requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimated(true)) + }) + } else if (visible) { + // Animate out, then unmount + setAnimated(false) + closeTimer.current = setTimeout(() => setVisible(false), 150) + } + }, [intent, visible]) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + if (hoverIntent.current) clearTimeout(hoverIntent.current) + if (closeTimer.current) clearTimeout(closeTimer.current) + } + }, []) + + const percent = Math.min(usage.used_percent, 100) + const radius = 8 + const circumference = 2 * Math.PI * radius + const offset = circumference - (percent / 100) * circumference + const barPercent = Math.min(percent, 100) + + const handleDetail = () => { + if (cooldown || !onDetailClick) return + setCooldown(true) + onDetailClick() + setIntent(false) + timerRef.current = setTimeout(() => setCooldown(false), 1000) + } + + // Desktop: hover to open, mouse leave to close (with small delay) + const handleMouseEnter = () => { + if (hoverIntent.current) clearTimeout(hoverIntent.current) + setIntent(true) + } + + const handleMouseLeave = () => { + hoverIntent.current = setTimeout(() => setIntent(false), 150) + } + + // Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter) + const handleTouchStart = (e: React.TouchEvent) => { + e.preventDefault() + setIntent((v) => !v) + } + + return ( +
+ + + {visible && ( +
+
+ +
+ + {t("chat.contextTitle")} + + + {formatTokens(usage.used_tokens)} /{" "} + {formatTokens(usage.compress_at_tokens)} + +
+
+
+
+ + +
+ )} +
+ ) +} diff --git a/web/frontend/src/components/chat/typing-indicator.tsx b/web/frontend/src/components/chat/typing-indicator.tsx index 98580963d..df138553c 100644 --- a/web/frontend/src/components/chat/typing-indicator.tsx +++ b/web/frontend/src/components/chat/typing-indicator.tsx @@ -21,10 +21,7 @@ export function TypingIndicator() { return (
-
- PicoClaw -
-
+
diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx index 96119a534..8bfdf24c9 100644 --- a/web/frontend/src/components/chat/user-message.tsx +++ b/web/frontend/src/components/chat/user-message.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils" import type { ChatAttachment } from "@/store/chat" interface UserMessageProps { @@ -7,6 +8,7 @@ interface UserMessageProps { export function UserMessage({ content, attachments = [] }: UserMessageProps) { const hasText = content.trim().length > 0 + const isCommand = content.trim().startsWith("/") const imageAttachments = attachments.filter( (attachment) => attachment.type === "image", ) @@ -27,8 +29,24 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) { )} {hasText && ( -
- {content} +
+ {isCommand ? ( +
+ + ❯ + + {content} +
+ ) : ( + content + )}
)}
diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 0ad2031f7..f50503dec 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner" import { patchAppConfig } from "@/api/channels" import { launcherFetch } from "@/api/http" +import { postLauncherDashboardSetup } from "@/api/launcher-auth" import { getAutoStartStatus, getLauncherConfig, @@ -94,7 +95,8 @@ export function ConfigPage() { port: String(launcherConfig.port), publicAccess: launcherConfig.public, allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), - launcherToken: launcherConfig.launcher_token ?? "", + dashboardPassword: "", + dashboardPasswordConfirm: "", } setLauncherForm(parsed) setLauncherBaseline(parsed) @@ -107,8 +109,14 @@ export function ConfigPage() { }, [autoStartStatus]) const configDirty = JSON.stringify(form) !== JSON.stringify(baseline) - const launcherDirty = - JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline) + const launcherSettingsDirty = + launcherForm.port !== launcherBaseline.port || + launcherForm.publicAccess !== launcherBaseline.publicAccess || + launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText + const launcherPasswordDirty = + launcherForm.dashboardPassword.trim() !== "" || + launcherForm.dashboardPasswordConfirm.trim() !== "" + const launcherDirty = launcherSettingsDirty || launcherPasswordDirty const autoStartDirty = autoStartEnabled !== autoStartBaseline const isDirty = configDirty || launcherDirty || autoStartDirty @@ -143,6 +151,19 @@ export function ConfigPage() { const handleSave = async () => { try { setSaving(true) + const password = launcherForm.dashboardPassword.trim() + const confirm = launcherForm.dashboardPasswordConfirm.trim() + if (launcherPasswordDirty) { + if (!password) { + throw new Error(t("pages.config.dashboard_password_required")) + } + if (password !== confirm) { + throw new Error(t("pages.config.dashboard_password_mismatch")) + } + if (Array.from(password).length < 8) { + throw new Error(t("pages.config.dashboard_password_min_length")) + } + } if (configDirty) { const workspace = form.workspace.trim() @@ -255,7 +276,8 @@ export function ConfigPage() { queryClient.invalidateQueries({ queryKey: ["config"] }) } - if (launcherDirty) { + let savedLauncherForm: LauncherForm | null = null + if (launcherSettingsDirty) { const port = parseIntField(launcherForm.port, "Service port", { min: 1, max: 65535, @@ -265,7 +287,6 @@ export function ConfigPage() { port, public: launcherForm.publicAccess, allowed_cidrs: allowedCIDRs, - launcher_token: launcherForm.launcherToken.trim(), }) const parsedLauncher: LauncherForm = { port: String(savedLauncherConfig.port), @@ -273,8 +294,10 @@ export function ConfigPage() { allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( "\n", ), - launcherToken: savedLauncherConfig.launcher_token ?? "", + dashboardPassword: "", + dashboardPasswordConfirm: "", } + savedLauncherForm = parsedLauncher setLauncherForm(parsedLauncher) setLauncherBaseline(parsedLauncher) queryClient.setQueryData( @@ -283,6 +306,23 @@ export function ConfigPage() { ) } + if (launcherPasswordDirty) { + const result = await postLauncherDashboardSetup(password, confirm) + if (!result.ok) { + throw new Error(result.error) + } + + const clearedLauncherForm = savedLauncherForm ?? { + ...launcherForm, + dashboardPassword: "", + dashboardPasswordConfirm: "", + } + setLauncherForm(clearedLauncherForm) + if (savedLauncherForm) { + setLauncherBaseline(savedLauncherForm) + } + } + if (autoStartDirty) { if (!autoStartSupported) { throw new Error(t("pages.config.autostart_unsupported")) @@ -304,6 +344,22 @@ export function ConfigPage() { } } + const actionButtons = ( +
+ + +
+ ) + return (
) : (
- {isDirty && ( -
- {t("pages.config.unsaved_changes")} -
- )} - -
- - -
+ {!isDirty && actionButtons}
)}
+ {isDirty && ( +
+
+
+ {t("pages.config.unsaved_changes")} +
+ {actionButtons} +
+
+ )}
) } diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 21f89d7c1..25c335ab1 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -519,23 +519,48 @@ export function LauncherSection({ return ( onFieldChange("launcherToken", e.target.value)} + autoComplete="new-password" + placeholder={t("pages.config.dashboard_password_placeholder")} + onChange={(e) => + onFieldChange("dashboardPassword", e.target.value) + } /> + {launcherForm.dashboardPassword.trim() !== "" && ( + + + onFieldChange("dashboardPasswordConfirm", e.target.value) + } + /> + + )} + ) { +}: React.ComponentProps & { + arrowClassName?: string +}) { return ( {children} - + ) diff --git a/web/frontend/src/features/chat/controller.ts b/web/frontend/src/features/chat/controller.ts index 183b1ba6f..489194421 100644 --- a/web/frontend/src/features/chat/controller.ts +++ b/web/frontend/src/features/chat/controller.ts @@ -392,6 +392,7 @@ export async function switchChatSession(sessionId: string) { messages: historyMessages, isTyping: false, hasHydratedActiveSession: true, + contextUsage: undefined, }) if (store.get(gatewayAtom).status === "running") { @@ -415,6 +416,7 @@ export async function newChatSession() { messages: [], isTyping: false, hasHydratedActiveSession: true, + contextUsage: undefined, }) if (store.get(gatewayAtom).status === "running") { diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts index 717b42f84..cc2ef45e7 100644 --- a/web/frontend/src/features/chat/protocol.ts +++ b/web/frontend/src/features/chat/protocol.ts @@ -1,7 +1,11 @@ import { toast } from "sonner" import { normalizeUnixTimestamp } from "@/features/chat/state" -import { type AssistantMessageKind, updateChatStore } from "@/store/chat" +import { + type AssistantMessageKind, + type ContextUsage, + updateChatStore, +} from "@/store/chat" export interface PicoMessage { type: string @@ -21,6 +25,24 @@ function hasAssistantKindPayload(payload: Record): boolean { return typeof payload.thought === "boolean" } +function parseContextUsage( + payload: Record, +): ContextUsage | undefined { + const raw = payload.context_usage + if (!raw || typeof raw !== "object") return undefined + const obj = raw as Record + const used = Number(obj.used_tokens) + const total = Number(obj.total_tokens) + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) + return undefined + return { + used_tokens: used, + total_tokens: total, + compress_at_tokens: Number(obj.compress_at_tokens) || 0, + used_percent: Number(obj.used_percent) || 0, + } +} + export function handlePicoMessage( message: PicoMessage, expectedSessionId: string, @@ -36,6 +58,7 @@ export function handlePicoMessage( const content = (payload.content as string) || "" const messageId = (payload.message_id as string) || `pico-${Date.now()}` const kind = parseAssistantMessageKind(payload) + const contextUsage = parseContextUsage(payload) const timestamp = message.timestamp !== undefined && Number.isFinite(Number(message.timestamp)) @@ -54,6 +77,7 @@ export function handlePicoMessage( }, ], isTyping: false, + ...(contextUsage ? { contextUsage } : {}), })) break } diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts index 3ac2e1613..02467bd60 100644 --- a/web/frontend/src/hooks/use-pico-chat.ts +++ b/web/frontend/src/hooks/use-pico-chat.ts @@ -55,7 +55,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string { } export function usePicoChat() { - const { messages, connectionState, isTyping, activeSessionId } = + const { messages, connectionState, isTyping, activeSessionId, contextUsage } = useAtomValue(chatAtom) return { @@ -63,6 +63,7 @@ export function usePicoChat() { connectionState, isTyping, activeSessionId, + contextUsage, sendMessage: sendChatMessage, switchSession: switchChatSession, newChat: newChatSession, diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 11cf409a6..7ded188c1 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -38,7 +38,7 @@ "chat": { "welcome": "How can I help you today?", "welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.", - "placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line", + "placeholder": "Start a new message...", "disabledPlaceholder": { "gatewayUnknown": "Unable to chat: Gateway status is still being checked. Please wait, then refresh the page or restart Launcher if needed.", "gatewayStarting": "Unable to chat: Gateway is starting. Wait for startup to complete, then try again.", @@ -60,6 +60,7 @@ "step4": "Almost there..." }, "reasoningLabel": "Reasoning", + "toolLabel": "Tool", "history": "History", "noHistory": "No chat history yet", "historyLoadFailed": "Failed to load chat history", @@ -72,6 +73,10 @@ "notConnected": "Gateway is not running. Start it to chat.", "noModel": "No default model configured. Go to Models page to set one." }, + "sendMessage": "Send message", + "sendHint": "Press Enter to send\nShift + Enter for a new line", + "contextTitle": "Context", + "contextDetail": "View Details", "attachImage": "Add images", "removeImage": "Remove image", "uploadedImage": "Uploaded image", @@ -357,11 +362,15 @@ "placeholderText": "Placeholder Text", "groupTriggerMentionOnly": "Group Mention Only", "groupTriggerPrefixes": "Group Trigger Prefixes", + "groupTriggerPrefixesPlaceholder": "e.g. /, !, ?", + "randomReactionEmoji": "Random Reaction Emoji", + "randomReactionEmojiPlaceholder": "e.g. THUMBSUP, HEART, SMILE", "isLark": "Lark (International)", "allowFrom": "Allow From", "allowFromPlaceholder": "e.g. 123456, 789012", "allowOrigins": "Allow Origins", "allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173", + "removeListItem": "Remove {{value}}", "secretPlaceholder": "Enter secret", "secretHintSet": "A value is already set. Leave blank to keep it unchanged." }, @@ -389,10 +398,11 @@ "typingEnabled": "Display typing status while the assistant is generating a response.", "placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.", "groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.", - "groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.", + "groupTriggerPrefixes": "Custom group-chat trigger prefixes. Add items one by one, or paste multiple values at once.", + "randomReactionEmoji": "PicoClaw adds emoji reactions to user messages to confirm receipt. Example: \"THUMBSUP\", \"HEART\", \"SMILE\". Leave empty to use the default \"Pin\" emoji.", "isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).", - "allowFrom": "Allowed user or group IDs, separated by commas.", - "allowOrigins": "Allowed origin domains, separated by commas.", + "allowFrom": "Allowed user or group IDs. Add items one by one, or paste multiple values at once.", + "allowOrigins": "Allowed origin domains. Add items one by one, or paste multiple values at once.", "wsUrl": "WebSocket service URL.", "reconnectInterval": "Reconnect interval after disconnection (seconds).", "bridgeUrl": "Bridge service URL.", @@ -656,10 +666,16 @@ "autostart_load_error": "Failed to load launch-at-login status.", "server_port": "Service Port", "server_port_hint": "HTTP port used by PicoClaw Web.", - "launcher_token": "Login Token", - "launcher_token_section_hint": "Changes in this section take effect after the launcher restarts.", - "launcher_token_hint": "Used to sign in on the launcher login page.", - "launcher_token_placeholder": "Enter login token", + "launcher_section_hint": "Changes in this section take effect after the launcher restarts.", + "dashboard_password": "Login Password", + "dashboard_password_hint": "Set a new login password.", + "dashboard_password_placeholder": "At least 8 characters", + "dashboard_password_confirm": "Confirm New Password", + "dashboard_password_confirm_hint": "Enter the new login password again.", + "dashboard_password_confirm_placeholder": "Repeat password", + "dashboard_password_required": "Enter and confirm the new login password.", + "dashboard_password_mismatch": "The login passwords do not match.", + "dashboard_password_min_length": "Login password must be at least 8 characters.", "lan_access": "Enable LAN Access", "lan_access_hint": "Allow access from other devices on your local network.", "allowed_cidrs": "Allowed Network CIDRs", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 824cf2b3a..ca71d7ef8 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -38,7 +38,7 @@ "chat": { "welcome": "今天我能为您做些什么?", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", - "placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行", + "placeholder": "输入新消息...", "disabledPlaceholder": { "gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。", "gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。", @@ -60,6 +60,7 @@ "step4": "马上就好..." }, "reasoningLabel": "思考", + "toolLabel": "工具", "history": "历史记录", "noHistory": "暂无对话历史", "historyLoadFailed": "加载历史记录失败", @@ -72,6 +73,10 @@ "notConnected": "服务未运行,请先启动以进行对话。", "noModel": "未设置默认模型,请前往模型页面进行配置。" }, + "sendMessage": "发送消息", + "sendHint": "按 Enter 发送\nShift + Enter 换行", + "contextTitle": "上下文", + "contextDetail": "查看详情", "attachImage": "添加图片", "removeImage": "移除图片", "uploadedImage": "已上传图片", @@ -357,11 +362,15 @@ "placeholderText": "占位文案", "groupTriggerMentionOnly": "群聊仅提及时响应", "groupTriggerPrefixes": "群聊触发前缀", + "groupTriggerPrefixesPlaceholder": "例如 /, !, ?", + "randomReactionEmoji": "随机表情回应", + "randomReactionEmojiPlaceholder": "例如 THUMBSUP, HEART, SMILE", "isLark": "Lark(国际版)", "allowFrom": "允许来源", "allowFromPlaceholder": "例如 123456, 789012", "allowOrigins": "允许来源域名", "allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173", + "removeListItem": "删除 {{value}}", "secretPlaceholder": "输入密钥", "secretHintSet": "配置已保存,留空表示不修改" }, @@ -389,10 +398,11 @@ "typingEnabled": "在生成回复时显示“正在输入”状态", "placeholderEnabled": "在最终回复发送前,先发送临时占位消息", "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应", - "groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔", + "groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。", + "randomReactionEmoji": "PicoClaw 会对用户消息添加表情回复以确认已收到。例如:\"THUMBSUP\", \"HEART\", \"SMILE\"。留空则使用默认的 \"Pin\" 表情。", "isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)", - "allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔", - "allowOrigins": "允许访问的来源域名,多个值用逗号分隔", + "allowFrom": "允许访问的用户或群组 ID。可逐项添加,也支持一次粘贴多个值。", + "allowOrigins": "允许访问的来源域名。可逐项添加,也支持一次粘贴多个值。", "wsUrl": "WebSocket 服务地址", "reconnectInterval": "断线后的重连间隔(秒)", "bridgeUrl": "桥接服务地址", @@ -656,10 +666,16 @@ "autostart_load_error": "加载开机自启状态失败", "server_port": "服务端口", "server_port_hint": "PicoClaw Web 的 HTTP 监听端口", - "launcher_token": "登录令牌", - "launcher_token_section_hint": "此分组中的改动需要在重启 launcher 后生效", - "launcher_token_hint": "用于在 launcher 登录页进行登录", - "launcher_token_placeholder": "输入登录令牌", + "launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效", + "dashboard_password": "登录密码", + "dashboard_password_hint": "设置新的登录密码", + "dashboard_password_placeholder": "至少 8 个字符", + "dashboard_password_confirm": "确认新密码", + "dashboard_password_confirm_hint": "再次输入新的登录密码", + "dashboard_password_confirm_placeholder": "再次输入密码", + "dashboard_password_required": "请输入并确认新的登录密码", + "dashboard_password_mismatch": "两次输入的登录密码不一致", + "dashboard_password_min_length": "登录密码至少需要 8 个字符", "lan_access": "启用局域网访问", "lan_access_hint": "允许局域网中的其他设备访问当前服务", "allowed_cidrs": "允许访问网段", diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx index 60d45ef84..250c68532 100644 --- a/web/frontend/src/routes/__root.tsx +++ b/web/frontend/src/routes/__root.tsx @@ -33,7 +33,6 @@ const RootLayout = () => { const [authError, setAuthError] = useState(null) // Session guard: proactively check auth status on every page load. - // This catches the case where ?token= auto-login bypassed the login/setup UI. useEffect(() => { if (isAuthPage) return void getLauncherAuthStatus() @@ -55,7 +54,7 @@ const RootLayout = () => { setAuthError( err instanceof Error ? err.message - : "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.", + : "Auth service unavailable. Reset dashboard password storage and restart the application.", ) } }) diff --git a/web/frontend/src/routes/launcher-login.tsx b/web/frontend/src/routes/launcher-login.tsx index caa548c79..1e8d7cc28 100644 --- a/web/frontend/src/routes/launcher-login.tsx +++ b/web/frontend/src/routes/launcher-login.tsx @@ -28,7 +28,7 @@ import { useTheme } from "@/hooks/use-theme" function LauncherLoginPage() { const { t, i18n } = useTranslation() const { theme, toggleTheme } = useTheme() - const [token, setToken] = React.useState("") + const [password, setPassword] = React.useState("") const [submitting, setSubmitting] = React.useState(false) const [error, setError] = React.useState("") @@ -45,17 +45,25 @@ function LauncherLoginPage() { }) }, []) - const loginWithToken = React.useCallback( - async (tokenValue: string) => { + const loginWithPassword = React.useCallback( + async (passwordValue: string) => { setError("") setSubmitting(true) try { - const ok = await postLauncherDashboardLogin(tokenValue) - if (ok) { + const result = await postLauncherDashboardLogin(passwordValue) + if (result.ok) { globalThis.location.assign("/") return } - setError(t("launcherLogin.errorInvalid")) + if (result.status === 409) { + globalThis.location.assign("/launcher-setup") + return + } + if (result.status === 401) { + setError(t("launcherLogin.errorInvalid")) + return + } + setError(result.error) } catch { setError(t("launcherLogin.errorNetwork")) } finally { @@ -67,7 +75,7 @@ function LauncherLoginPage() { const onSubmit = async (e: React.FormEvent) => { e.preventDefault() - await loginWithToken(token) + await loginWithPassword(password) } return ( @@ -112,17 +120,17 @@ function LauncherLoginPage() {
-
diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts index 2c6f70610..0a5edb646 100644 --- a/web/frontend/src/store/chat.ts +++ b/web/frontend/src/store/chat.ts @@ -22,6 +22,13 @@ export interface ChatMessage { attachments?: ChatAttachment[] } +export interface ContextUsage { + used_tokens: number + total_tokens: number + compress_at_tokens: number + used_percent: number +} + export type ConnectionState = | "disconnected" | "connecting" @@ -34,6 +41,7 @@ export interface ChatStoreState { isTyping: boolean activeSessionId: string hasHydratedActiveSession: boolean + contextUsage?: ContextUsage } type ChatStorePatch = Partial @@ -48,6 +56,8 @@ const DEFAULT_CHAT_STATE: ChatStoreState = { export const chatAtom = atom(DEFAULT_CHAT_STATE) +export const showThoughtsAtom = atom(true) + const store = getDefaultStore() export function getChatState() {