refactor Config to add Version and migratable

This commit is contained in:
Cytown 2026-03-11 16:33:01 +08:00
parent d5cbf198b2
commit 1c123e0162
128 changed files with 7357 additions and 1773 deletions

View file

@ -649,7 +649,6 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) ├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
├── IDENTITY.md # Identité de l'Agent ├── IDENTITY.md # Identité de l'Agent
├── SOUL.md # Âme de l'Agent ├── SOUL.md # Âme de l'Agent
├── TOOLS.md # Description des outils
└── USER.md # Préférences utilisateur └── USER.md # Préférences utilisateur
``` ```
@ -980,6 +979,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) | | **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |

View file

@ -610,7 +610,6 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
├── HEARTBEAT.md # 定期タスクプロンプト30分ごとに確認 ├── HEARTBEAT.md # 定期タスクプロンプト30分ごとに確認
├── IDENTITY.md # エージェントのアイデンティティ ├── IDENTITY.md # エージェントのアイデンティティ
├── SOUL.md # エージェントのソウル ├── SOUL.md # エージェントのソウル
├── TOOLS.md # ツールの説明
└── USER.md # ユーザー設定 └── USER.md # ユーザー設定
``` ```
@ -921,6 +920,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) | | **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ | | **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |

View file

@ -787,7 +787,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) ├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
├── IDENTITY.md # Agent identity ├── IDENTITY.md # Agent identity
├── SOUL.md # Agent soul ├── SOUL.md # Agent soul
├── TOOLS.md # Tool descriptions
└── USER.md # User preferences └── USER.md # User preferences
``` ```
@ -1034,6 +1033,7 @@ This design also enables **multi-agent support** with flexible provider selectio
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -1504,3 +1504,4 @@ This happens when another instance of the bot is running. Make sure only one `pi
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | | **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |

View file

@ -645,7 +645,6 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) ├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
├── IDENTITY.md # Identidade do Agente ├── IDENTITY.md # Identidade do Agente
├── SOUL.md # Alma do Agente ├── SOUL.md # Alma do Agente
├── TOOLS.md # Descrição das ferramentas
└── USER.md # Preferencias do usuario └── USER.md # Preferencias do usuario
``` ```
@ -976,6 +975,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) | | **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth | | **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |

View file

@ -617,7 +617,6 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định:
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) ├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
├── IDENTITY.md # Danh tính Agent ├── IDENTITY.md # Danh tính Agent
├── SOUL.md # Tâm hồn/Tính cách Agent ├── SOUL.md # Tâm hồn/Tính cách Agent
├── TOOLS.md # Mô tả công cụ
└── USER.md # Tùy chọn người dùng └── USER.md # Tùy chọn người dùng
``` ```
@ -945,6 +944,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) | | **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth | | **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |

View file

@ -365,7 +365,6 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) ├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
├── IDENTITY.md # Agent 身份设定 ├── IDENTITY.md # Agent 身份设定
├── SOUL.md # Agent 灵魂/性格 ├── SOUL.md # Agent 灵魂/性格
├── TOOLS.md # 工具描述
└── USER.md # 用户偏好 └── USER.md # 用户偏好
``` ```
@ -517,6 +516,7 @@ Agent 读取 HEARTBEAT.md
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | | **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -879,3 +879,4 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 | | **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 | | **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | | **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 345 KiB

View file

@ -325,7 +325,7 @@ func (s *appState) viewGatewayLog() {
} }
func (s *appState) selectedModelName() string { func (s *appState) selectedModelName() string {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) modelName := strings.TrimSpace(s.config.Agents.Defaults.GetModelName())
if modelName == "" { if modelName == "" {
return "" return ""
} }
@ -413,7 +413,7 @@ func (s *appState) isGatewayRunning() bool {
} }
func (s *appState) validateAgentModel() error { func (s *appState) validateAgentModel() error {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) modelName := strings.TrimSpace(s.config.Agents.Defaults.GetModelName())
if modelName == "" { if modelName == "" {
return nil return nil
} }
@ -422,7 +422,7 @@ func (s *appState) validateAgentModel() error {
} }
func (s *appState) isActiveModelValid() bool { func (s *appState) isActiveModelValid() bool {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) modelName := strings.TrimSpace(s.config.Agents.Defaults.GetModelName())
if modelName == "" { if modelName == "" {
return false return false
} }

View file

@ -15,7 +15,7 @@ import (
func (s *appState) modelMenu() tview.Primitive { func (s *appState) modelMenu() tview.Primitive {
items := make([]MenuItem, 0, 1+len(s.config.ModelList)) items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) currentModel := strings.TrimSpace(s.config.Agents.Defaults.ModelName)
for i := range s.config.ModelList { for i := range s.config.ModelList {
index := i index := i
model := s.config.ModelList[i] model := s.config.ModelList[i]
@ -77,9 +77,9 @@ func (s *appState) modelMenu() tview.Primitive {
) )
return nil return nil
} }
s.config.Agents.Defaults.Model = model.ModelName s.config.Agents.Defaults.ModelName = model.ModelName
s.dirty = true s.dirty = true
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList) refreshModelMenu(menu, s.config.Agents.Defaults.GetModelName(), s.config.ModelList)
refreshMainMenuIfPresent(s) refreshMainMenuIfPresent(s)
} }
return nil return nil
@ -105,8 +105,8 @@ func (s *appState) modelForm(index int) tview.Primitive {
} }
oldName := model.ModelName oldName := model.ModelName
model.ModelName = value model.ModelName = value
if s.config.Agents.Defaults.Model == oldName { if s.config.Agents.Defaults.ModelName == oldName {
s.config.Agents.Defaults.Model = value s.config.Agents.Defaults.ModelName = value
} }
s.dirty = true s.dirty = true
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
@ -258,7 +258,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
func refreshModelMenuFromState(menu *Menu, s *appState) { func refreshModelMenuFromState(menu *Menu, s *appState) {
items := make([]MenuItem, 0, 1+len(s.config.ModelList)) items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) currentModel := strings.TrimSpace(s.config.Agents.Defaults.ModelName)
for i := range s.config.ModelList { for i := range s.config.ModelList {
index := i index := i
model := s.config.ModelList[i] model := s.config.ModelList[i]

View file

@ -56,9 +56,6 @@ func authLoginOpenAI(useDeviceCode bool) error {
appCfg, err := internal.LoadConfig() appCfg, err := internal.LoadConfig()
if err == nil { if err == nil {
// Update Providers (legacy format)
appCfg.Providers.OpenAI.AuthMethod = "oauth"
// Update or add openai in ModelList // Update or add openai in ModelList
foundOpenAI := false foundOpenAI := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
@ -130,9 +127,6 @@ func authLoginGoogleAntigravity() error {
appCfg, err := internal.LoadConfig() appCfg, err := internal.LoadConfig()
if err == nil { if err == nil {
// Update Providers (legacy format, for backward compatibility)
appCfg.Providers.Antigravity.AuthMethod = "oauth"
// Update or add antigravity in ModelList // Update or add antigravity in ModelList
foundAntigravity := false foundAntigravity := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
@ -210,8 +204,6 @@ func authLoginAnthropicSetupToken() error {
appCfg, err := internal.LoadConfig() appCfg, err := internal.LoadConfig()
if err == nil { if err == nil {
appCfg.Providers.Anthropic.AuthMethod = "oauth"
found := false found := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) { if isAnthropicModel(appCfg.ModelList[i].Model) {
@ -287,7 +279,6 @@ func authLoginPasteToken(provider string) error {
if err == nil { if err == nil {
switch provider { switch provider {
case "anthropic": case "anthropic":
appCfg.Providers.Anthropic.AuthMethod = "token"
// Update ModelList // Update ModelList
found := false found := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
@ -306,7 +297,6 @@ func authLoginPasteToken(provider string) error {
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
} }
case "openai": case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList // Update ModelList
found := false found := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
@ -365,15 +355,6 @@ func authLogoutCmd(provider string) error {
} }
} }
} }
// Clear AuthMethod in Providers (legacy)
switch provider {
case "openai":
appCfg.Providers.OpenAI.AuthMethod = ""
case "anthropic":
appCfg.Providers.Anthropic.AuthMethod = ""
case "google-antigravity", "antigravity":
appCfg.Providers.Antigravity.AuthMethod = ""
}
config.SaveConfig(internal.GetConfigPath(), appCfg) config.SaveConfig(internal.GetConfigPath(), appCfg)
} }
@ -392,10 +373,6 @@ func authLogoutCmd(provider string) error {
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
appCfg.ModelList[i].AuthMethod = "" appCfg.ModelList[i].AuthMethod = ""
} }
// Clear all AuthMethods in Providers (legacy)
appCfg.Providers.OpenAI.AuthMethod = ""
appCfg.Providers.Anthropic.AuthMethod = ""
appCfg.Providers.Antigravity.AuthMethod = ""
config.SaveConfig(internal.GetConfigPath(), appCfg) config.SaveConfig(internal.GetConfigPath(), appCfg)
} }

View file

@ -4,19 +4,20 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
const Logo = "🦞" const Logo = pkg.Logo
// GetPicoclawHome returns the picoclaw home directory. // GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw // Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string { func GetPicoclawHome() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv(pkg.PicoClawHome); home != "" {
return home return home
} }
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw") return filepath.Join(home, pkg.DefaultPicoClawHome)
} }
func GetConfigPath() string { func GetConfigPath() string {

View file

@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg"
) )
func TestGetConfigPath(t *testing.T) { func TestGetConfigPath(t *testing.T) {
@ -20,7 +22,7 @@ func TestGetConfigPath(t *testing.T) {
} }
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) { func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") t.Setenv(pkg.PicoClawHome, "/custom/picoclaw")
t.Setenv("HOME", "/tmp/home") t.Setenv("HOME", "/tmp/home")
got := GetConfigPath() got := GetConfigPath()
@ -31,7 +33,7 @@ func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) { func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json") t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") t.Setenv(pkg.PicoClawHome, "/custom/picoclaw")
t.Setenv("HOME", "/tmp/home") t.Setenv("HOME", "/tmp/home")
got := GetConfigPath() got := GetConfigPath()

View file

@ -42,48 +42,6 @@ func statusCmd() {
if _, err := os.Stat(configPath); err == nil { if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName()) fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
hasGemini := cfg.Providers.Gemini.APIKey != ""
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
hasQwen := cfg.Providers.Qwen.APIKey != ""
hasGroq := cfg.Providers.Groq.APIKey != ""
hasVLLM := cfg.Providers.VLLM.APIBase != ""
hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
hasNvidia := cfg.Providers.Nvidia.APIKey != ""
hasOllama := cfg.Providers.Ollama.APIBase != ""
status := func(enabled bool) string {
if enabled {
return "✓"
}
return "not set"
}
fmt.Println("OpenRouter API:", status(hasOpenRouter))
fmt.Println("Anthropic API:", status(hasAnthropic))
fmt.Println("OpenAI API:", status(hasOpenAI))
fmt.Println("Gemini API:", status(hasGemini))
fmt.Println("Zhipu API:", status(hasZhipu))
fmt.Println("Qwen API:", status(hasQwen))
fmt.Println("Groq API:", status(hasGroq))
fmt.Println("Moonshot API:", status(hasMoonshot))
fmt.Println("DeepSeek API:", status(hasDeepSeek))
fmt.Println("VolcEngine API:", status(hasVolcEngine))
fmt.Println("Nvidia API:", status(hasNvidia))
if hasVLLM {
fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
} else {
fmt.Println("vLLM/Local: not set")
}
if hasOllama {
fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
} else {
fmt.Println("Ollama: not set")
}
store, _ := auth.LoadStore() store, _ := auth.LoadStore()
if store != nil && len(store.Credentials) > 0 { if store != nil && len(store.Credentials) > 0 {
fmt.Println("\nOAuth/Token Auth:") fmt.Println("\nOAuth/Token Auth:")

View file

@ -35,6 +35,11 @@
"model": "deepseek/deepseek-chat", "model": "deepseek/deepseek-chat",
"api_key": "sk-your-deepseek-key" "api_key": "sk-your-deepseek-key"
}, },
{
"model_name": "longcat",
"model": "longcat/LongCat-Flash-Thinking",
"api_key": "your-longcat-api-key"
},
{ {
"model_name": "loadbalanced-gpt4", "model_name": "loadbalanced-gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.2",
@ -274,6 +279,10 @@
"avian": { "avian": {
"api_key": "", "api_key": "",
"api_base": "https://api.avian.io/v1" "api_base": "https://api.avian.io/v1"
},
"longcat": {
"api_key": "",
"api_base": "https://api.longcat.chat/openai"
} }
}, },
"tools": { "tools": {
@ -477,6 +486,9 @@
"enabled": false, "enabled": false,
"monitor_usb": true "monitor_usb": true
}, },
"voice": {
"echo_transcription": false
},
"gateway": { "gateway": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 18790 "port": 18790

View file

@ -22,7 +22,8 @@ Add this to `config.json`:
"enabled": true, "enabled": true,
"text": "Thinking..." "text": "Thinking..."
}, },
"reasoning_channel_id": "" "reasoning_channel_id": "",
"message_format": "richtext"
} }
} }
} }
@ -42,10 +43,12 @@ Add this to `config.json`:
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) | | group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
| placeholder | object | No | Placeholder message config | | placeholder | object | No | Placeholder message config |
| reasoning_channel_id | string | No | Target channel for reasoning output | | reasoning_channel_id | string | No | Target channel for reasoning output |
| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only |
## 3. Currently Supported ## 3. Currently Supported
- Text message send/receive - Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)
- Configurable message format (`richtext` / `plain`)
- Incoming image/audio/video/file download (MediaStore first, local path fallback) - Incoming image/audio/video/file download (MediaStore first, local path fallback)
- Incoming audio normalization into existing transcription flow (`[audio: ...]`) - Incoming audio normalization into existing transcription flow (`[audio: ...]`)
- Outgoing image/audio/video/file upload and send - Outgoing image/audio/video/file upload and send

230
docs/config-versioning.md Normal file
View file

@ -0,0 +1,230 @@
# Config Schema Versioning Guide
## Overview
PicoClaw uses a schema versioning system for `config.json` to ensure smooth upgrades as the configuration format evolves.
## Version History
### Version 1
- **Introduction**: Initial version with version field support
- **Changes**: Added `version` field to Config struct
- **Migration**: No structural changes needed for existing configs
## How It Works
### Automatic Migration
When you load a config file:
1. The system first reads the `version` field from the JSON
2. Based on the detected version, it loads the appropriate config struct (`ConfigV0`, `ConfigV1`, etc.)
3. If the loaded version is less than the latest, migrations are applied incrementally
4. The version number is updated automatically
5. The migrated config is automatically saved back to disk
### Version Field
The `version` field in `config.json` indicates the schema version:
- `0` or missing: Legacy config (no version field)
- `1`: Current version with versioning support
```json
{
"version": 1,
"agents": {...},
...
}
```
## Adding a New Migration
When making breaking changes to the config schema:
### Step 1: Define the New Version Struct
Create a new struct for the new version if the structure changes significantly:
```go
// ConfigV2 represents version 2 config structure
type ConfigV2 struct {
Version int `json:"version"`
Agents AgentsConfig `json:"agents"`
// ... other fields with new structure
}
```
### Step 2: Update Current Config Version
```go
const CurrentConfigVersion = 2 // Increment this
```
### Step 3: Add a Loader Function
```go
// loadConfigV2 loads a version 2 config
func loadConfigV2(data []byte) (*Config, error) {
cfg := DefaultConfig()
// Parse to ConfigV2 struct
var v2 ConfigV2
if err := json.Unmarshal(data, &v2); err != nil {
return nil, err
}
// Convert to current Config
cfg.Version = v2.Version
cfg.Agents = v2.Agents
// ... map other fields
return cfg, nil
}
```
### Step 4: Add Migration Logic
```go
// applyMigration applies a single migration step from fromVersion to toVersion
func applyMigration(cfg *Config, fromVersion, toVersion int) (*Config, error) {
switch toVersion {
case 1:
// Migration from version 0 to 1
return &Config{
Version: 1,
Agents: cfg.Agents,
// ... copy all fields
}, nil
case 2:
// Migration from version 1 to 2
// Example: Move or rename fields
migrated := *cfg
migrated.Version = 2
// Apply structural changes
if cfg.SomeOldField != "" {
migrated.SomeNewField = cfg.SomeOldField
}
return &migrated, nil
default:
return nil, fmt.Errorf("unsupported migration target version: %d", toVersion)
}
}
```
### Step 5: Update LoadConfig Switch
```go
func LoadConfig(path string) (*Config, error) {
// ... read file ...
switch versionInfo.Version {
case 0:
cfg, err = loadConfigV0(data)
case 1:
cfg, err = loadConfigV1(data)
case 2:
cfg, err = loadConfigV2(data)
default:
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
}
// ... migrate and validate ...
}
```
### Step 6: Test Your Migration
Create a test in `config_migration_test.go`:
```go
func TestMigrateV1ToV2(t *testing.T) {
// Create a version 1 config
v1Config := Config{
Version: 1,
// ... set up test data
}
// Apply migration
migrated, err := applyMigration(&v1Config, 1, 2)
if err != nil {
t.Fatalf("Migration failed: %v", err)
}
// Verify version is updated
if migrated.Version != 2 {
t.Errorf("Expected version 2, got %d", migrated.Version)
}
// Verify data is preserved/transformed correctly
// ...
}
```
## Migration Best Practices
1. **Version-Specific Structs**: Define a separate struct for each version that has structural changes
2. **Backward Compatibility**: Ensure old configs can still be loaded with their specific structs
3. **No Data Loss**: Migrations should preserve all user settings
4. **Idempotent**: Running the same migration multiple times should be safe
5. **Auto-Save**: Migrated configs are automatically saved to update the user's file
6. **Test Thoroughly**: Test with real user config files
7. **Update Defaults**: Keep `defaults.go` in sync with the latest schema
## Example Migration
### Scenario: Adding a new field with default value
Old config (version 1):
```json
{
"version": 1,
"agents": {
"defaults": {
"max_tokens": 32768
}
}
}
```
Migration to version 2:
```go
case 2:
migrated := *cfg
migrated.Version = 2
// Add new field with default value if not set
if migrated.Agents.Defaults.NewFeatureEnabled == false {
// Use default value
}
return &migrated, nil
```
New config (version 2):
```json
{
"version": 2,
"agents": {
"defaults": {
"max_tokens": 32768,
"new_feature_enabled": false
}
}
}
```
## Troubleshooting
### Config Not Upgrading
- Check that `CurrentConfigVersion` is incremented
- Verify migration logic in `applyMigration()` handles the target version
- Ensure `migrateConfig()` is called in `LoadConfig()`
### Migration Errors
- Check error messages for specific migration failures
- Review migration logic for edge cases
- Ensure all required fields are properly initialized
- Verify the loader function for the source version
### Data Loss After Migration
- Ensure all fields are copied during migration
- Check that the migration doesn't overwrite values with defaults unnecessarily
- Review the conversion logic in the loader functions

3
go.mod
View file

@ -11,6 +11,7 @@ require (
github.com/ergochat/irc-go v0.5.0 github.com/ergochat/irc-go v0.5.0
github.com/gdamore/tcell/v2 v2.13.8 github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3 github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3
@ -20,6 +21,7 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0 github.com/openai/openai-go/v3 v3.22.0
github.com/rivo/tview v0.42.0 github.com/rivo/tview v0.42.0
github.com/rs/zerolog v1.34.0
github.com/slack-go/slack v0.17.3 github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
@ -49,7 +51,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect github.com/segmentio/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect

2
go.sum
View file

@ -79,6 +79,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=

View file

@ -12,6 +12,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
@ -52,14 +53,14 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil
} }
func getGlobalConfigDir() string { func getGlobalConfigDir() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv(pkg.PicoClawHome); home != "" {
return home return home
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "" return ""
} }
return filepath.Join(home, ".picoclaw") return filepath.Join(home, pkg.DefaultPicoClawHome)
} }
func NewContextBuilder(workspace string) *ContextBuilder { func NewContextBuilder(workspace string) *ContextBuilder {

View file

@ -18,7 +18,7 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 1234, MaxTokens: 1234,
MaxToolIterations: 5, MaxToolIterations: 5,
}, },
@ -50,7 +50,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 1234, MaxTokens: 1234,
MaxToolIterations: 5, MaxToolIterations: 5,
}, },
@ -79,7 +79,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 1234, MaxTokens: 1234,
MaxToolIterations: 5, MaxToolIterations: 5,
}, },
@ -133,7 +133,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: tt.aliasName, ModelName: tt.aliasName,
}, },
}, },
ModelList: []config.ModelConfig{ ModelList: []config.ModelConfig{

View file

@ -25,7 +25,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
@ -48,6 +47,7 @@ type AgentLoop struct {
mediaStore media.MediaStore mediaStore media.MediaStore
transcriber voice.Transcriber transcriber voice.Transcriber
cmdRegistry *commands.Registry cmdRegistry *commands.Registry
mcp mcpRuntime
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
@ -239,119 +239,8 @@ func registerSharedTools(
func (al *AgentLoop) Run(ctx context.Context) error { func (al *AgentLoop) Run(ctx context.Context) error {
al.running.Store(true) al.running.Store(true)
if err := al.ensureMCPInitialized(ctx); err != nil {
// Initialize MCP servers for all agents return err
if al.cfg.Tools.IsToolEnabled("mcp") {
mcpManager := mcp.NewManager()
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
defer func() {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}()
defaultAgent := al.registry.GetDefaultAgent()
var workspacePath string
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
} else {
workspacePath = al.cfg.WorkspacePath()
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
"error": err.Error(),
})
} else {
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if al.cfg.Tools.MCP.Discovery.Enabled {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
}
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
})
}
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
"server_count": len(servers),
"unique_tools": uniqueTools,
"total_registrations": totalRegistrations,
"agent_count": agentCount,
})
// Initializes Discovery Tools only if enabled by configuration
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
// Fail fast: If discovery is enabled but no search method is turned on
if !useBM25 && !useRegex {
return fmt.Errorf(
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
)
}
ttl := al.cfg.Tools.MCP.Discovery.TTL
if ttl <= 0 {
ttl = 5 // Default value
}
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5 // Default value
}
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
})
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
}
if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
}
}
}
}
} }
for al.running.Load() { for al.running.Load() {
@ -431,6 +320,17 @@ func (al *AgentLoop) Stop() {
// Close releases resources held by agent session stores. Call after Stop. // Close releases resources held by agent session stores. Call after Stop.
func (al *AgentLoop) Close() { func (al *AgentLoop) Close() {
mcpManager := al.mcp.takeManager()
if mcpManager != nil {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}
al.registry.Close() al.registry.Close()
} }
@ -467,9 +367,10 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and // transcribeAudioInMessage resolves audio media refs, transcribes them, and
// replaces audio annotations in msg.Content with the transcribed text. // replaces audio annotations in msg.Content with the transcribed text.
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage { // Returns the (possibly modified) message and true if audio was transcribed.
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) {
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
return msg return msg, false
} }
// Transcribe each audio media ref in order. // Transcribe each audio media ref in order.
@ -493,9 +394,11 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
} }
if len(transcriptions) == 0 { if len(transcriptions) == 0 {
return msg return msg, false
} }
al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions)
// Replace audio annotations sequentially with transcriptions. // Replace audio annotations sequentially with transcriptions.
idx := 0 idx := 0
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
@ -513,7 +416,48 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
} }
msg.Content = newContent msg.Content = newContent
return msg return msg, true
}
// sendTranscriptionFeedback sends feedback to the user with the result of
// audio transcription if the option is enabled. It uses Manager.SendMessage
// which executes synchronously (rate limiting, splitting, retry) so that
// ordering with the subsequent placeholder is guaranteed.
func (al *AgentLoop) sendTranscriptionFeedback(
ctx context.Context,
channel, chatID, messageID string,
validTexts []string,
) {
if !al.cfg.Voice.EchoTranscription {
return
}
if al.channelManager == nil {
return
}
var nonEmpty []string
for _, t := range validTexts {
if t != "" {
nonEmpty = append(nonEmpty, t)
}
}
var feedbackMsg string
if len(nonEmpty) > 0 {
feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n")
} else {
feedbackMsg = "No voice detected in the audio"
}
err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: feedbackMsg,
ReplyToMessageID: messageID,
})
if err != nil {
logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()})
}
} }
// inferMediaType determines the media type ("image", "audio", "video", "file") // inferMediaType determines the media type ("image", "audio", "video", "file")
@ -575,6 +519,10 @@ func (al *AgentLoop) ProcessDirectWithChannel(
ctx context.Context, ctx context.Context,
content, sessionKey, channel, chatID string, content, sessionKey, channel, chatID string,
) (string, error) { ) (string, error) {
if err := al.ensureMCPInitialized(ctx); err != nil {
return "", err
}
msg := bus.InboundMessage{ msg := bus.InboundMessage{
Channel: channel, Channel: channel,
SenderID: "cron", SenderID: "cron",
@ -627,7 +575,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}, },
) )
msg = al.transcribeAudioInMessage(ctx, msg) var hadAudio bool
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
// For audio messages the placeholder was deferred by the channel.
// Now that transcription (and optional feedback) is done, send it.
if hadAudio && al.channelManager != nil {
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
}
// Route system messages to processSystemMessage // Route system messages to processSystemMessage
if msg.Channel == "system" { if msg.Channel == "system" {

184
pkg/agent/loop_mcp.go Normal file
View file

@ -0,0 +1,184 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package agent
import (
"context"
"fmt"
"sync"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/tools"
)
type mcpRuntime struct {
initOnce sync.Once
mu sync.Mutex
manager *mcp.Manager
initErr error
}
func (r *mcpRuntime) setManager(manager *mcp.Manager) {
r.mu.Lock()
r.manager = manager
r.initErr = nil
r.mu.Unlock()
}
func (r *mcpRuntime) setInitErr(err error) {
r.mu.Lock()
r.initErr = err
r.mu.Unlock()
}
func (r *mcpRuntime) getInitErr() error {
r.mu.Lock()
defer r.mu.Unlock()
return r.initErr
}
func (r *mcpRuntime) takeManager() *mcp.Manager {
r.mu.Lock()
defer r.mu.Unlock()
manager := r.manager
r.manager = nil
return manager
}
func (r *mcpRuntime) hasManager() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.manager != nil
}
// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct
// agent mode share the same initialization path.
func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
if !al.cfg.Tools.IsToolEnabled("mcp") {
return nil
}
al.mcp.initOnce.Do(func() {
mcpManager := mcp.NewManager()
defaultAgent := al.registry.GetDefaultAgent()
workspacePath := al.cfg.WorkspacePath()
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
"error": err.Error(),
})
if closeErr := mcpManager.Close(); closeErr != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": closeErr.Error(),
})
}
return
}
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if al.cfg.Tools.MCP.Discovery.Enabled {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
}
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
})
}
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
"server_count": len(servers),
"unique_tools": uniqueTools,
"total_registrations": totalRegistrations,
"agent_count": agentCount,
})
// Initializes Discovery Tools only if enabled by configuration
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
// Fail fast: If discovery is enabled but no search method is turned on
if !useBM25 && !useRegex {
al.mcp.setInitErr(fmt.Errorf(
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
))
if closeErr := mcpManager.Close(); closeErr != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": closeErr.Error(),
})
}
return
}
ttl := al.cfg.Tools.MCP.Discovery.TTL
if ttl <= 0 {
ttl = 5 // Default value
}
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5 // Default value
}
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
})
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
}
if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
}
}
}
al.mcp.setManager(mcpManager)
})
return al.mcp.getInitErr()
}

View file

@ -42,7 +42,7 @@ func newTestAgentLoop(
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -101,7 +101,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -137,7 +137,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -194,7 +194,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -230,7 +230,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = tmpDir cfg.Agents.Defaults.Workspace = tmpDir
cfg.Agents.Defaults.Model = "test-model" cfg.Agents.Defaults.ModelName = "test-model"
cfg.Agents.Defaults.MaxTokens = 4096 cfg.Agents.Defaults.MaxTokens = 4096
cfg.Agents.Defaults.MaxToolIterations = 10 cfg.Agents.Defaults.MaxToolIterations = 10
@ -274,7 +274,7 @@ func TestAgentLoop_Stop(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -394,7 +394,7 @@ func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -450,7 +450,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -530,7 +530,7 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Provider: "openai", Provider: "openai",
Model: "before-switch", ModelName: "before-switch",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -587,7 +587,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -629,7 +629,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -700,7 +700,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -770,6 +770,56 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
} }
} }
func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{
Enabled: true,
},
},
},
}
msgBus := bus.NewMessageBus()
provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
defer al.Close()
if al.mcp.hasManager() {
t.Fatal("expected MCP manager to be nil before first direct processing")
}
_, err = al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-1",
"cli",
"direct",
)
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
if !al.mcp.hasManager() {
t.Fatal("expected MCP manager to be initialized in direct agent mode")
}
}
func TestTargetReasoningChannelID_AllChannels(t *testing.T) { func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil { if err != nil {
@ -781,7 +831,7 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
@ -851,7 +901,7 @@ func TestHandleReasoning(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", ModelName: "test-model",
MaxTokens: 4096, MaxTokens: 4096,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },

View file

@ -29,7 +29,7 @@ func testCfg(agents []config.AgentConfig) *config.Config {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: "/tmp/picoclaw-test-registry", Workspace: "/tmp/picoclaw-test-registry",
Model: "gpt-4", ModelName: "gpt-4",
MaxTokens: 8192, MaxTokens: 8192,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },

View file

@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
) )
@ -39,11 +40,11 @@ func (c *AuthCredential) NeedsRefresh() bool {
} }
func authFilePath() string { func authFilePath() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv(pkg.PicoClawHome); home != "" {
return filepath.Join(home, "auth.json") return filepath.Join(home, "auth.json")
} }
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw", "auth.json") return filepath.Join(home, pkg.DefaultPicoClawHome, "auth.json")
} }
func LoadStore() (*AuthStore, error) { func LoadStore() (*AuthStore, error) {

View file

@ -33,6 +33,7 @@ type OutboundMessage struct {
Channel string `json:"channel"` Channel string `json:"channel"`
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Content string `json:"content"` Content string `json:"content"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
} }
// MediaPart describes a single media attachment to send. // MediaPart describes a single media attachment to send.

View file

@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
@ -32,6 +33,9 @@ func init() {
uniqueIDPrefix = hex.EncodeToString(b[:]) uniqueIDPrefix = hex.EncodeToString(b[:])
} }
// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]).
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// uniqueID generates a process-unique ID using a random prefix and an atomic counter. // uniqueID generates a process-unique ID using a random prefix and an atomic counter.
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT // This ID is intended for internal correlation (e.g. media scope keys) and is NOT
// cryptographically secure — it must not be used in contexts where unpredictability matters. // cryptographically secure — it must not be used in contexts where unpredictability matters.
@ -284,13 +288,18 @@ func (c *BaseChannel) HandleMessage(
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
} }
} }
// Placeholder — independent pipeline // Placeholder — independent pipeline.
// Skip when the message contains audio: the agent will send the
// placeholder after transcription completes, so the user sees
// "Thinking…" only once the voice has been processed.
if !audioAnnotationRe.MatchString(content) {
if pc, ok := c.owner.(PlaceholderCapable); ok { if pc, ok := c.owner.(PlaceholderCapable); ok {
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
} }
} }
} }
}
if err := c.bus.PublishInbound(ctx, msg); err != nil { if err := c.bus.PublishInbound(ctx, msg); err != nil {
logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{ logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{

View file

@ -10,6 +10,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
@ -39,6 +40,9 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("dingtalk client_id and client_secret are required") return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
} }
// Set the logger for the Stream SDK
dinglog.SetLogger(logger.NewLogger("dingtalk"))
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000), channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),

View file

@ -45,6 +45,14 @@ type DiscordChannel struct {
} }
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
discordgo.Logger = logger.NewLogger("discord").
WithLevels(map[int]logger.LogLevel{
discordgo.LogError: logger.ERROR,
discordgo.LogWarning: logger.WARN,
discordgo.LogInformational: logger.INFO,
discordgo.LogDebug: logger.DEBUG,
}).Log
session, err := discordgo.New("Bot " + cfg.Token) session, err := discordgo.New("Bot " + cfg.Token)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err) return nil, fmt.Errorf("failed to create discord session: %w", err)
@ -134,7 +142,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return nil return nil
} }
return c.sendChunk(ctx, channelID, msg.Content) return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
} }
// SendMedia implements the channels.MediaSender interface. // SendMedia implements the channels.MediaSender interface.
@ -259,14 +267,29 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
return msg.ID, nil return msg.ID, nil
} }
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error {
// Use the passed ctx for timeout control // Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel() defer cancel()
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
_, err := c.session.ChannelMessageSend(channelID, content) var err error
// If we have an ID, we send the message as "Reply"
if replyToID != "" {
_, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: content,
Reference: &discordgo.MessageReference{
MessageID: replyToID,
ChannelID: channelID,
},
})
} else {
// Otherwise, we send a normal message
_, err = c.session.ChannelMessageSend(channelID, content)
}
done <- err done <- err
}() }()

View file

@ -102,6 +102,27 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()}) m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
} }
// SendPlaceholder sends a "Thinking…" placeholder for the given channel/chatID
// and records it for later editing. Returns true if a placeholder was sent.
func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
m.mu.RLock()
ch, ok := m.channels[channel]
m.mu.RUnlock()
if !ok {
return false
}
pc, ok := ch.(PlaceholderCapable)
if !ok {
return false
}
phID, err := pc.SendPlaceholder(ctx, chatID)
if err != nil || phID == "" {
return false
}
m.RecordPlaceholder(channel, chatID, phID)
return true
}
// RecordTypingStop registers a typing stop function for later invocation. // RecordTypingStop registers a typing stop function for later invocation.
// Implements PlaceholderRecorder. // Implements PlaceholderRecorder.
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
@ -813,6 +834,39 @@ func (m *Manager) UnregisterChannel(name string) {
delete(m.channels, name) delete(m.channels, name)
} }
// SendMessage sends an outbound message synchronously through the channel
// worker's rate limiter and retry logic. It blocks until the message is
// delivered (or all retries are exhausted), which preserves ordering when
// a subsequent operation depends on the message having been sent.
func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
m.mu.RLock()
_, exists := m.channels[msg.Channel]
w, wExists := m.workers[msg.Channel]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("channel %s not found", msg.Channel)
}
if !wExists || w == nil {
return fmt.Errorf("channel %s has no active worker", msg.Channel)
}
maxLen := 0
if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength()
}
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
for _, chunk := range SplitMessage(msg.Content, maxLen) {
chunkMsg := msg
chunkMsg.Content = chunk
m.sendWithRetry(ctx, msg.Channel, w, chunkMsg)
}
} else {
m.sendWithRetry(ctx, msg.Channel, w, msg)
}
return nil
}
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
m.mu.RLock() m.mu.RLock()
_, exists := m.channels[channelName] _, exists := m.channels[channelName]

View file

@ -18,15 +18,31 @@ import (
type mockChannel struct { type mockChannel struct {
BaseChannel BaseChannel
sendFn func(ctx context.Context, msg bus.OutboundMessage) error sendFn func(ctx context.Context, msg bus.OutboundMessage) error
sentMessages []bus.OutboundMessage
placeholdersSent int
editedMessages int
lastPlaceholderID string
} }
func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
m.sentMessages = append(m.sentMessages, msg)
return m.sendFn(ctx, msg) return m.sendFn(ctx, msg)
} }
func (m *mockChannel) Start(ctx context.Context) error { return nil } func (m *mockChannel) Start(ctx context.Context) error { return nil }
func (m *mockChannel) Stop(ctx context.Context) error { return nil } func (m *mockChannel) Stop(ctx context.Context) error { return nil }
func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
m.placeholdersSent++
m.lastPlaceholderID = "mock-ph-123"
return m.lastPlaceholderID, nil
}
func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
m.editedMessages++
return nil
}
// newTestManager creates a minimal Manager suitable for unit tests. // newTestManager creates a minimal Manager suitable for unit tests.
func newTestManager() *Manager { func newTestManager() *Manager {
return &Manager{ return &Manager{
@ -860,3 +876,286 @@ func TestBuildMediaScope_WithMessageID(t *testing.T) {
t.Fatalf("expected %s, got %s", expected, scope) t.Fatalf("expected %s, got %s", expected, scope)
} }
} }
func TestManager_PlaceholderConsumedByResponse(t *testing.T) {
mgr := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
placeholders: sync.Map{},
}
mockCh := &mockChannel{
sendFn: func(ctx context.Context, msg bus.OutboundMessage) error {
return nil
},
}
worker := newChannelWorker("mock", mockCh)
mgr.channels["mock"] = mockCh
mgr.workers["mock"] = worker
ctx := context.Background()
key := "mock:chat-1"
// Simulate a placeholder recorded by base.go HandleMessage
mgr.RecordPlaceholder("mock", "chat-1", "ph-123")
if _, ok := mgr.placeholders.Load(key); !ok {
t.Fatal("expected placeholder to be recorded")
}
// Transcription feedback arrives first — it should consume the placeholder
// and be delivered via EditMessage, not Send.
msgTranscript := bus.OutboundMessage{
Channel: "mock",
ChatID: "chat-1",
Content: "Transcript: hello",
}
mgr.sendWithRetry(ctx, "mock", worker, msgTranscript)
if mockCh.editedMessages != 1 {
t.Errorf("expected 1 edited message (placeholder consumed by transcript), got %d", mockCh.editedMessages)
}
if len(mockCh.sentMessages) != 0 {
t.Errorf("expected 0 normal messages (transcript used edit), got %d", len(mockCh.sentMessages))
}
// Placeholder should be gone now
if _, ok := mgr.placeholders.Load(key); ok {
t.Error("expected placeholder to be removed after being consumed")
}
// Final LLM response arrives — no placeholder left, so it goes through Send
msgFinal := bus.OutboundMessage{
Channel: "mock",
ChatID: "chat-1",
Content: "Final Answer",
}
mgr.sendWithRetry(ctx, "mock", worker, msgFinal)
if len(mockCh.sentMessages) != 1 {
t.Errorf("expected 1 normal message sent, got %d", len(mockCh.sentMessages))
}
}
func TestSendMessage_Synchronous(t *testing.T) {
m := newTestManager()
var received []bus.OutboundMessage
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
received = append(received, msg)
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "hello world",
ReplyToMessageID: "msg-456",
}
err := m.SendMessage(context.Background(), msg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
// SendMessage is synchronous — message should already be delivered
if len(received) != 1 {
t.Fatalf("expected 1 message sent, got %d", len(received))
}
if received[0].ReplyToMessageID != "msg-456" {
t.Fatalf("expected ReplyToMessageID msg-456, got %s", received[0].ReplyToMessageID)
}
if received[0].Content != "hello world" {
t.Fatalf("expected content 'hello world', got %s", received[0].Content)
}
}
func TestSendMessage_UnknownChannel(t *testing.T) {
m := newTestManager()
msg := bus.OutboundMessage{
Channel: "nonexistent",
ChatID: "123",
Content: "hello",
}
err := m.SendMessage(context.Background(), msg)
if err == nil {
t.Fatal("expected error for unknown channel")
}
}
func TestSendMessage_NoWorker(t *testing.T) {
m := newTestManager()
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
}
m.channels["test"] = ch
// No worker registered
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "hello",
}
err := m.SendMessage(context.Background(), msg)
if err == nil {
t.Fatal("expected error when no worker exists")
}
}
func TestSendMessage_WithRetry(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
if callCount == 1 {
return fmt.Errorf("transient: %w", ErrTemporary)
}
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "retry me",
}
err := m.SendMessage(context.Background(), msg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 Send calls (1 failure + 1 success), got %d", callCount)
}
}
func TestSendMessage_WithSplitting(t *testing.T) {
m := newTestManager()
var received []string
ch := &mockChannelWithLength{
mockChannel: mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
received = append(received, msg.Content)
return nil
},
},
maxLen: 5,
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "hello world",
}
err := m.SendMessage(context.Background(), msg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(received) < 2 {
t.Fatalf("expected message to be split into at least 2 chunks, got %d", len(received))
}
}
func TestSendMessage_PreservesOrdering(t *testing.T) {
m := newTestManager()
var order []string
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
order = append(order, msg.Content)
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
// Send two messages sequentially — they must arrive in order
_ = m.SendMessage(context.Background(), bus.OutboundMessage{
Channel: "test", ChatID: "1", Content: "first",
})
_ = m.SendMessage(context.Background(), bus.OutboundMessage{
Channel: "test", ChatID: "1", Content: "second",
})
if len(order) != 2 {
t.Fatalf("expected 2 messages, got %d", len(order))
}
if order[0] != "first" || order[1] != "second" {
t.Fatalf("expected [first, second], got %v", order)
}
}
func TestManager_SendPlaceholder(t *testing.T) {
mgr := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
placeholders: sync.Map{},
}
mockCh := &mockChannel{
sendFn: func(ctx context.Context, msg bus.OutboundMessage) error {
return nil
},
}
mgr.channels["mock"] = mockCh
ctx := context.Background()
// SendPlaceholder should send a placeholder and record it
ok := mgr.SendPlaceholder(ctx, "mock", "chat-1")
if !ok {
t.Fatal("expected SendPlaceholder to succeed")
}
if mockCh.placeholdersSent != 1 {
t.Errorf("expected 1 placeholder sent, got %d", mockCh.placeholdersSent)
}
key := "mock:chat-1"
if _, loaded := mgr.placeholders.Load(key); !loaded {
t.Error("expected placeholder to be recorded in manager")
}
// SendPlaceholder on unknown channel should return false
ok = mgr.SendPlaceholder(ctx, "unknown", "chat-1")
if ok {
t.Error("expected SendPlaceholder to fail for unknown channel")
}
}

View file

@ -13,6 +13,9 @@ import (
"sync" "sync"
"time" "time"
"github.com/gomarkdown/markdown"
mdhtml "github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -268,6 +271,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error {
return nil return nil
} }
func markdownToHTML(md string) string {
p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags})
return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer)))
}
func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
return channels.ErrNotRunning return channels.ErrNotRunning
@ -283,16 +292,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil return nil
} }
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content))
MsgType: event.MsgText,
Body: content,
})
if err != nil { if err != nil {
return fmt.Errorf("matrix send: %w", channels.ErrTemporary) return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
} }
return nil return nil
} }
func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent {
mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text}
if c.config.MessageFormat != "plain" {
mc.Format = event.FormatHTML
mc.FormattedBody = markdownToHTML(text)
}
return mc
}
// SendMedia implements channels.MediaSender. // SendMedia implements channels.MediaSender.
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
@ -482,10 +497,7 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI
return fmt.Errorf("matrix message ID is empty") return fmt.Errorf("matrix message ID is empty")
} }
editContent := &event.MessageEventContent{ editContent := c.messageContent(content)
MsgType: event.MsgText,
Body: content,
}
editContent.SetEdit(id.EventID(messageID)) editContent.SetEdit(id.EventID(messageID))
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent) _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)

View file

@ -4,12 +4,15 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"github.com/sipeed/picoclaw/pkg/config"
) )
func TestMatrixLocalpartMentionRegexp(t *testing.T) { func TestMatrixLocalpartMentionRegexp(t *testing.T) {
@ -289,3 +292,50 @@ func TestMatrixOutboundContent(t *testing.T) {
t.Fatalf("unexpected fallback body: %q", noCaption.Body) t.Fatalf("unexpected fallback body: %q", noCaption.Body)
} }
} }
func TestMarkdownToHTML(t *testing.T) {
tests := []struct {
name string
input string
contains string
}{
{"bold", "**hello**", "<strong>hello</strong>"},
{"italic", "_world_", "<em>world</em>"},
{"header", "### Title", "<h3"},
{"code block", "```\nfoo()\n```", "<code>"},
{"inline code", "`x`", "<code>x</code>"},
{"plain text", "just text", "just text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := markdownToHTML(tt.input)
if !strings.Contains(got, tt.contains) {
t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains)
}
})
}
}
func TestMessageContent(t *testing.T) {
richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}}
plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}}
defaultt := &MatrixChannel{config: config.MatrixConfig{}}
for _, c := range []*MatrixChannel{richtext, defaultt} {
mc := c.messageContent("**hi**")
if mc.Format != event.FormatHTML {
t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format)
}
if !strings.Contains(mc.FormattedBody, "<strong>hi</strong>") {
t.Errorf("format %q: FormattedBody %q missing <strong>", c.config.MessageFormat, mc.FormattedBody)
}
if mc.Body != "**hi**" {
t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body)
}
}
mc := plain.messageContent("**hi**")
if mc.Format != "" || mc.FormattedBody != "" {
t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody)
}
}

View file

@ -78,6 +78,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
return fmt.Errorf("QQ app_id and app_secret not configured") return fmt.Errorf("QQ app_id and app_secret not configured")
} }
botgo.SetLogger(logger.NewLogger("botgo"))
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// Reinitialize shutdown signal for clean restart. // Reinitialize shutdown signal for clean restart.

View file

@ -122,7 +122,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
slack.MsgOptionText(msg.Content, false), slack.MsgOptionText(msg.Content, false),
} }
if threadTS != "" { if msg.ReplyToMessageID != "" && threadTS == "" {
// Answer to the message by creating a Thread under it
opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID))
} else if threadTS != "" {
// If we are already in a thread, continue in the thread
opts = append(opts, slack.MsgOptionTS(threadTS)) opts = append(opts, slack.MsgOptionTS(threadTS))
} }

View file

@ -77,6 +77,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" { if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
opts = append(opts, telego.WithAPIServer(baseURL)) opts = append(opts, telego.WithAPIServer(baseURL))
} }
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
bot, err := telego.NewBot(telegramCfg.Token, opts...) bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil { if err != nil {
@ -180,6 +181,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to // so msg.Content is guaranteed to be within that limit. We still need to
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit. // check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
replyToID := msg.ReplyToMessageID
queue := []string{msg.Content} queue := []string{msg.Content}
for len(queue) > 0 { for len(queue) > 0 {
chunk := queue[0] chunk := queue[0]
@ -200,9 +202,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue continue
} }
if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk); err != nil { if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil {
return err return err
} }
// Only the first chunk should be a reply; subsequent chunks are normal messages.
replyToID = ""
} }
return nil return nil
@ -211,12 +215,20 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// sendHTMLChunk sends a single HTML message, falling back to the original // sendHTMLChunk sends a single HTML message, falling back to the original
// markdown as plain text on parse failure so users never see raw HTML tags. // markdown as plain text on parse failure so users never see raw HTML tags.
func (c *TelegramChannel) sendHTMLChunk( func (c *TelegramChannel) sendHTMLChunk(
ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, replyToID string,
) error { ) error {
tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML tgMsg.ParseMode = telego.ModeHTML
tgMsg.MessageThreadID = threadID tgMsg.MessageThreadID = threadID
if replyToID != "" {
if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil {
tgMsg.ReplyParameters = &telego.ReplyParameters{
MessageID: mid,
}
}
}
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
"error": err.Error(), "error": err.Error(),

View file

@ -209,7 +209,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
} }
}) })
t.Run("empty token skips verification", func(t *testing.T) { t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
cfgEmpty := config.WeComAppConfig{ cfgEmpty := config.WeComAppConfig{
CorpID: "test_corp_id", CorpID: "test_corp_id",
CorpSecret: "test_secret", CorpSecret: "test_secret",
@ -218,8 +218,8 @@ func TestWeComAppVerifySignature(t *testing.T) {
} }
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true") t.Error("empty token should reject verification (fail-closed)")
} }
}) })
} }

View file

@ -189,8 +189,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
} }
}) })
t.Run("empty token skips verification", func(t *testing.T) { t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
// Create a channel manually with empty token to test the behavior
cfgEmpty := config.WeComConfig{ cfgEmpty := config.WeComConfig{
Token: "", Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
@ -199,8 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty, config: cfgEmpty,
} }
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true") t.Error("empty token should reject verification (fail-closed)")
} }
}) })
} }

View file

@ -31,7 +31,7 @@ func computeSignature(token, timestamp, nonce, encrypt string) string {
// This is a common function used by both WeCom Bot and WeCom App // This is a common function used by both WeCom Bot and WeCom App
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" { if token == "" {
return true // Skip verification if token is not set return false
} }
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
} }

View file

@ -4,12 +4,15 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"sync/atomic" "sync/atomic"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
) )
// rrCounter is a global counter for round-robin load balancing across models. // rrCounter is a global counter for round-robin load balancing across models.
@ -17,6 +20,8 @@ var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers, // FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123. // so allow_from can contain both "123" and 123.
// It also supports parsing comma-separated strings from environment variables,
// including both English (,) and Chinese () commas.
type FlexibleStringSlice []string type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
@ -48,17 +53,46 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
// It handles comma-separated values with both English (,) and Chinese () commas.
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
if len(text) == 0 {
*f = nil
return nil
}
s := string(text)
// Replace Chinese comma with English comma, then split
s = strings.ReplaceAll(s, "", ",")
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
*f = result
return nil
}
// CurrentVersion is the latest config schema version
const CurrentVersion = 1
// Config is the current config structure with version support
type Config struct { type Config struct {
Version int `json:"version"` // Config schema version for migration
Agents AgentsConfig `json:"agents"` Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"` Bindings []AgentBinding `json:"bindings,omitempty"`
Session SessionConfig `json:"session,omitempty"` Session SessionConfig `json:"session,omitempty"`
Channels ChannelsConfig `json:"channels"` Channels ChannelsConfig `json:"channels"`
Providers ProvidersConfig `json:"providers,omitempty"`
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"` Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"` Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"` Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"` Devices DevicesConfig `json:"devices"`
Voice VoiceConfig `json:"voice"`
// BuildInfo contains build-time version information // BuildInfo contains build-time version information
BuildInfo BuildInfo `json:"build_info,omitempty"` BuildInfo BuildInfo `json:"build_info,omitempty"`
} }
@ -73,19 +107,14 @@ type BuildInfo struct {
// MarshalJSON implements custom JSON marshaling for Config // MarshalJSON implements custom JSON marshaling for Config
// to omit providers section when empty and session when empty // to omit providers section when empty and session when empty
func (c Config) MarshalJSON() ([]byte, error) { func (c *Config) MarshalJSON() ([]byte, error) {
type Alias Config type Alias Config
aux := &struct { aux := &struct {
Providers *ProvidersConfig `json:"providers,omitempty"` Providers *ProvidersConfig `json:"providers,omitempty"`
Session *SessionConfig `json:"session,omitempty"` Session *SessionConfig `json:"session,omitempty"`
*Alias *Alias
}{ }{
Alias: (*Alias)(&c), Alias: (*Alias)(c),
}
// Only include providers if not empty
if !c.Providers.IsEmpty() {
aux.Providers = &c.Providers
} }
// Only include session if not empty // Only include session if not empty
@ -196,7 +225,6 @@ type AgentDefaults struct {
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"` ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
@ -221,10 +249,7 @@ func (d *AgentDefaults) GetMaxMediaSize() int {
// GetModelName returns the effective model name for the agent defaults. // GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility. // It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string { func (d *AgentDefaults) GetModelName() string {
if d.ModelName != "" {
return d.ModelName return d.ModelName
}
return d.Model
} }
type ChannelsConfig struct { type ChannelsConfig struct {
@ -355,6 +380,7 @@ type MatrixConfig struct {
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
@ -472,6 +498,10 @@ type DevicesConfig struct {
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
} }
type VoiceConfig struct {
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
}
type ProvidersConfig struct { type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"` Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"` OpenAI OpenAIProviderConfig `json:"openai"`
@ -495,6 +525,7 @@ type ProvidersConfig struct {
Mistral ProviderConfig `json:"mistral"` Mistral ProviderConfig `json:"mistral"`
Avian ProviderConfig `json:"avian"` Avian ProviderConfig `json:"avian"`
Minimax ProviderConfig `json:"minimax"` Minimax ProviderConfig `json:"minimax"`
LongCat ProviderConfig `json:"longcat"`
} }
// IsEmpty checks if all provider configs are empty (no API keys or API bases set) // IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@ -521,7 +552,8 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
p.Avian.APIKey == "" && p.Avian.APIBase == "" && p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
p.LongCat.APIKey == "" && p.LongCat.APIBase == ""
} }
// MarshalJSON implements custom JSON marshaling for ProvidersConfig // MarshalJSON implements custom JSON marshaling for ProvidersConfig
@ -668,6 +700,7 @@ type CronToolsConfig struct {
type ExecConfig struct { type ExecConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"`
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
@ -766,44 +799,53 @@ type MCPConfig struct {
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return cfg, nil return DefaultConfig(), nil
} }
return nil, err return nil, err
} }
// Pre-scan the JSON to check how many model_list entries the user provided. // First, try to detect config version by reading the version field
// Go's JSON decoder reuses existing slice backing-array elements rather than var versionInfo struct {
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base) Version int `json:"version"`
// would silently inherit values from the DefaultConfig template at the same }
// index position. We only reset cfg.ModelList when the user actually provides if e := json.Unmarshal(data, &versionInfo); e != nil {
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback. return nil, fmt.Errorf("failed to detect config version: %w", e)
var tmp Config }
if err := json.Unmarshal(data, &tmp); err != nil {
// Load config based on detected version
var cfg *Config
switch versionInfo.Version {
case 0:
// Legacy config (no version field)
v, e := loadConfigV0(data)
if e != nil {
return nil, e
}
cfg, e = v.Migrate()
if e != nil {
logger.DebugF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
return nil, e
}
logger.DebugF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
defer func() {
_ = SaveConfig(path, cfg)
}()
case CurrentVersion:
// Current version
cfg, err = loadConfig(data)
if err != nil {
return nil, err return nil, err
} }
if len(tmp.ModelList) > 0 { default:
cfg.ModelList = nil return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
} }
if err := json.Unmarshal(data, cfg); err != nil { // Apply environment variables
return nil, err if e := env.Parse(cfg); e != nil {
} return nil, e
if err := env.Parse(cfg); err != nil {
return nil, err
}
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
// Auto-migrate: if only legacy providers config exists, convert to model_list
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
cfg.ModelList = ConvertProvidersToModelList(cfg)
} }
// Validate model_list for uniqueness and required fields // Validate model_list for uniqueness and required fields
@ -811,23 +853,26 @@ func LoadConfig(path string) (*Config, error) {
return nil, err return nil, err
} }
// Ensure Workspace has a default if not set
if cfg.Agents.Defaults.Workspace == "" {
homePath, _ := os.UserHomeDir()
if picoclawHome := os.Getenv(pkg.PicoClawHome); picoclawHome != "" {
homePath = picoclawHome
} else if homePath != "" {
homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome)
}
cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName)
}
return cfg, nil return cfg, nil
} }
func (c *Config) migrateChannelConfigs() {
// Discord: mention_only -> group_trigger.mention_only
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
c.Channels.Discord.GroupTrigger.MentionOnly = true
}
// OneBot: group_trigger_prefix -> group_trigger.prefixes
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
}
}
func SaveConfig(path string, cfg *Config) error { func SaveConfig(path string, cfg *Config) error {
// Ensure version is always set when saving
if cfg.Version == 0 {
cfg.Version = CurrentVersion
}
data, err := json.MarshalIndent(cfg, "", " ") data, err := json.MarshalIndent(cfg, "", " ")
if err != nil { if err != nil {
return err return err
@ -841,53 +886,6 @@ func (c *Config) WorkspacePath() string {
return expandHome(c.Agents.Defaults.Workspace) return expandHome(c.Agents.Defaults.Workspace)
} }
func (c *Config) GetAPIKey() string {
if c.Providers.OpenRouter.APIKey != "" {
return c.Providers.OpenRouter.APIKey
}
if c.Providers.Anthropic.APIKey != "" {
return c.Providers.Anthropic.APIKey
}
if c.Providers.OpenAI.APIKey != "" {
return c.Providers.OpenAI.APIKey
}
if c.Providers.Gemini.APIKey != "" {
return c.Providers.Gemini.APIKey
}
if c.Providers.Zhipu.APIKey != "" {
return c.Providers.Zhipu.APIKey
}
if c.Providers.Groq.APIKey != "" {
return c.Providers.Groq.APIKey
}
if c.Providers.VLLM.APIKey != "" {
return c.Providers.VLLM.APIKey
}
if c.Providers.ShengSuanYun.APIKey != "" {
return c.Providers.ShengSuanYun.APIKey
}
if c.Providers.Cerebras.APIKey != "" {
return c.Providers.Cerebras.APIKey
}
return ""
}
func (c *Config) GetAPIBase() string {
if c.Providers.OpenRouter.APIKey != "" {
if c.Providers.OpenRouter.APIBase != "" {
return c.Providers.OpenRouter.APIBase
}
return "https://openrouter.ai/api/v1"
}
if c.Providers.Zhipu.APIKey != "" {
return c.Providers.Zhipu.APIBase
}
if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" {
return c.Providers.VLLM.APIBase
}
return ""
}
func expandHome(path string) string { func expandHome(path string) string {
if path == "" { if path == "" {
return path return path
@ -930,11 +928,6 @@ func (c *Config) findMatches(modelName string) []ModelConfig {
return matches return matches
} }
// HasProvidersConfig checks if any provider in the old providers config has configuration.
func (c *Config) HasProvidersConfig() bool {
return !c.Providers.IsEmpty()
}
// ValidateModelList validates all ModelConfig entries in the model_list. // ValidateModelList validates all ModelConfig entries in the model_list.
// It checks that each model config is valid. // It checks that each model config is valid.
// Note: Multiple entries with the same model_name are allowed for load balancing. // Note: Multiple entries with the same model_name are allowed for load balancing.

108
pkg/config/config_old.go Normal file
View file

@ -0,0 +1,108 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
type agentDefaultsV0 struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
Routing *RoutingConfig `json:"routing,omitempty"`
}
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *agentDefaultsV0) GetModelName() string {
if d.ModelName != "" {
return d.ModelName
}
return d.Model
}
type agentsConfigV0 struct {
Defaults agentDefaultsV0 `json:"defaults"`
List []AgentConfig `json:"list,omitempty"`
}
// configV0 represents the config structure before versioning was introduced.
// This struct is used for loading legacy config files (version 0).
// It is unexported since it's only used internally for migration.
type configV0 struct {
Agents agentsConfigV0 `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
Session SessionConfig `json:"session,omitempty"`
Channels ChannelsConfig `json:"channels"`
Providers ProvidersConfig `json:"providers,omitempty"`
ModelList []ModelConfig `json:"model_list"`
Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
}
func (c *configV0) migrateChannelConfigs() {
// Discord: mention_only -> group_trigger.mention_only
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
c.Channels.Discord.GroupTrigger.MentionOnly = true
}
// OneBot: group_trigger_prefix -> group_trigger.prefixes
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
}
}
func (c *configV0) Migrate() (*Config, error) {
// Migrate legacy channel config fields to new unified structures
cfg := DefaultConfig()
// Always copy user's Agents config to preserve settings like Provider, Model, MaxTokens
cfg.Agents.List = c.Agents.List
cfg.Agents.Defaults.Workspace = c.Agents.Defaults.Workspace
cfg.Agents.Defaults.RestrictToWorkspace = c.Agents.Defaults.RestrictToWorkspace
cfg.Agents.Defaults.AllowReadOutsideWorkspace = c.Agents.Defaults.AllowReadOutsideWorkspace
cfg.Agents.Defaults.Provider = c.Agents.Defaults.Provider
cfg.Agents.Defaults.ModelName = c.Agents.Defaults.GetModelName()
cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks
cfg.Agents.Defaults.ImageModel = c.Agents.Defaults.ImageModel
cfg.Agents.Defaults.ImageModelFallbacks = c.Agents.Defaults.ImageModelFallbacks
cfg.Agents.Defaults.MaxTokens = c.Agents.Defaults.MaxTokens
cfg.Agents.Defaults.Temperature = c.Agents.Defaults.Temperature
cfg.Agents.Defaults.MaxToolIterations = c.Agents.Defaults.MaxToolIterations
cfg.Agents.Defaults.SummarizeMessageThreshold = c.Agents.Defaults.SummarizeMessageThreshold
cfg.Agents.Defaults.SummarizeTokenPercent = c.Agents.Defaults.SummarizeTokenPercent
cfg.Agents.Defaults.MaxMediaSize = c.Agents.Defaults.MaxMediaSize
cfg.Agents.Defaults.Routing = c.Agents.Defaults.Routing
// Copy other top-level fields
cfg.Bindings = c.Bindings
cfg.Session = c.Session
cfg.Channels = c.Channels
cfg.Gateway = c.Gateway
cfg.Tools = c.Tools
cfg.Heartbeat = c.Heartbeat
cfg.Devices = c.Devices
// Only override ModelList if user provided values
if len(c.ModelList) > 0 {
cfg.ModelList = c.ModelList
}
cfg.Version = CurrentVersion
return cfg, nil
}

View file

@ -5,7 +5,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
) )
@ -207,15 +206,6 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
} }
} }
// TestDefaultConfig_Model verifies model is set
func TestDefaultConfig_Model(t *testing.T) {
cfg := DefaultConfig()
if cfg.Agents.Defaults.Model != "" {
t.Error("Model should be empty")
}
}
// TestDefaultConfig_MaxTokens verifies max tokens has default value // TestDefaultConfig_MaxTokens verifies max tokens has default value
func TestDefaultConfig_MaxTokens(t *testing.T) { func TestDefaultConfig_MaxTokens(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
@ -255,21 +245,6 @@ func TestDefaultConfig_Gateway(t *testing.T) {
} }
} }
// TestDefaultConfig_Providers verifies provider structure
func TestDefaultConfig_Providers(t *testing.T) {
cfg := DefaultConfig()
if cfg.Providers.Anthropic.APIKey != "" {
t.Error("Anthropic API key should be empty by default")
}
if cfg.Providers.OpenAI.APIKey != "" {
t.Error("OpenAI API key should be empty by default")
}
if cfg.Providers.OpenRouter.APIKey != "" {
t.Error("OpenRouter API key should be empty by default")
}
}
// TestDefaultConfig_Channels verifies channels are disabled by default // TestDefaultConfig_Channels verifies channels are disabled by default
func TestDefaultConfig_Channels(t *testing.T) { func TestDefaultConfig_Channels(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
@ -328,25 +303,6 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
} }
} }
func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "config.json")
cfg := DefaultConfig()
if err := SaveConfig(path, cfg); err != nil {
t.Fatalf("SaveConfig failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if !strings.Contains(string(data), `"model": ""`) {
t.Fatalf("saved config should include empty legacy model field, got: %s", string(data))
}
}
// TestConfig_Complete verifies all config fields are set // TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) { func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
@ -354,9 +310,6 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.Workspace == "" { if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty") t.Error("Workspace should not be empty")
} }
if cfg.Agents.Defaults.Model != "" {
t.Error("Model should be empty")
}
if cfg.Agents.Defaults.Temperature != nil { if cfg.Agents.Defaults.Temperature != nil {
t.Error("Temperature should be nil when not provided") t.Error("Temperature should be nil when not provided")
} }
@ -375,19 +328,23 @@ func TestConfig_Complete(t *testing.T) {
if !cfg.Heartbeat.Enabled { if !cfg.Heartbeat.Enabled {
t.Error("Heartbeat should be enabled by default") t.Error("Heartbeat should be enabled by default")
} }
if !cfg.Tools.Exec.AllowRemote {
t.Error("Exec.AllowRemote should be true by default")
}
} }
func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
if !cfg.Providers.OpenAI.WebSearch { if !cfg.Tools.Exec.AllowRemote {
t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true") t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true")
} }
} }
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
configPath := filepath.Join(dir, "config.json") configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil { if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"exec":{"enable_deny_patterns":true}}}`),
0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err) t.Fatalf("WriteFile() error: %v", err)
} }
@ -395,24 +352,8 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LoadConfig() error: %v", err) t.Fatalf("LoadConfig() error: %v", err)
} }
if !cfg.Providers.OpenAI.WebSearch { if !cfg.Tools.Exec.AllowRemote {
t.Fatal("OpenAI codex web search should remain true when unset in config file") t.Fatal("tools.exec.allow_remote should remain true when unset in config file")
}
}
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Providers.OpenAI.WebSearch {
t.Fatal("OpenAI codex web search should be false when disabled in config file")
} }
} }
@ -482,3 +423,119 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
} }
} }
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "English commas only",
input: "123,456,789",
expected: []string{"123", "456", "789"},
},
{
name: "Chinese commas only",
input: "123456789",
expected: []string{"123", "456", "789"},
},
{
name: "Mixed English and Chinese commas",
input: "123,456789",
expected: []string{"123", "456", "789"},
},
{
name: "Single value",
input: "123",
expected: []string{"123"},
},
{
name: "Values with whitespace",
input: " 123 , 456 , 789 ",
expected: []string{"123", "456", "789"},
},
{
name: "Empty string",
input: "",
expected: nil,
},
{
name: "Only commas - English",
input: ",,",
expected: []string{},
},
{
name: "Only commas - Chinese",
input: "",
expected: []string{},
},
{
name: "Mixed commas with empty parts",
input: "123,,456789",
expected: []string{"123", "456", "789"},
},
{
name: "Complex mixed values",
input: "user1@example.comuser2@test.com, admin@domain.org",
expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(tt.input))
if err != nil {
t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err)
}
if tt.expected == nil {
if f != nil {
t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f)
}
return
}
if len(f) != len(tt.expected) {
t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected))
return
}
for i, v := range tt.expected {
if f[i] != v {
t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v)
}
}
})
}
}
// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior
func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
t.Run("Empty string returns nil", func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(""))
if err != nil {
t.Fatalf("UnmarshalText error = %v", err)
}
if f != nil {
t.Errorf("Empty string should return nil, got %v", f)
}
})
t.Run("Commas only returns empty slice", func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(",,,"))
if err != nil {
t.Fatalf("UnmarshalText error = %v", err)
}
if f == nil {
t.Error("Commas only should return empty slice, not nil")
}
if len(f) != 0 {
t.Errorf("Expected empty slice, got %v", f)
}
})
}

View file

@ -8,6 +8,8 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/sipeed/picoclaw/pkg"
) )
// DefaultConfig returns the default configuration for PicoClaw. // DefaultConfig returns the default configuration for PicoClaw.
@ -15,21 +17,21 @@ func DefaultConfig() *Config {
// Determine the base path for the workspace. // Determine the base path for the workspace.
// Priority: $PICOCLAW_HOME > ~/.picoclaw // Priority: $PICOCLAW_HOME > ~/.picoclaw
var homePath string var homePath string
if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" { if picoclawHome := os.Getenv(pkg.PicoClawHome); picoclawHome != "" {
homePath = picoclawHome homePath = picoclawHome
} else { } else {
userHome, _ := os.UserHomeDir() userHome, _ := os.UserHomeDir()
homePath = filepath.Join(userHome, ".picoclaw") homePath = filepath.Join(userHome, pkg.DefaultPicoClawHome)
} }
workspacePath := filepath.Join(homePath, "workspace") workspacePath := filepath.Join(homePath, pkg.WorkspaceName)
return &Config{ return &Config{
Version: CurrentVersion,
Agents: AgentsConfig{ Agents: AgentsConfig{
Defaults: AgentDefaults{ Defaults: AgentDefaults{
Workspace: workspacePath, Workspace: workspacePath,
RestrictToWorkspace: true, RestrictToWorkspace: true,
Provider: "", Provider: "",
Model: "",
MaxTokens: 32768, MaxTokens: 32768,
Temperature: nil, // nil means use provider default Temperature: nil, // nil means use provider default
MaxToolIterations: 50, MaxToolIterations: 50,
@ -176,9 +178,6 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
}, },
}, },
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true},
},
ModelList: []ModelConfig{ ModelList: []ModelConfig{
// ============================================ // ============================================
// Add your API key to the model you want to use // Add your API key to the model you want to use
@ -355,6 +354,14 @@ func DefaultConfig() *Config {
APIKey: "", APIKey: "",
}, },
// LongCat - https://longcat.chat/platform
{
ModelName: "LongCat-Flash-Thinking",
Model: "longcat/LongCat-Flash-Thinking",
APIBase: "https://api.longcat.chat/openai",
APIKey: "",
},
// VLLM (local) - http://localhost:8000 // VLLM (local) - http://localhost:8000
{ {
ModelName: "local-model", ModelName: "local-model",
@ -427,6 +434,7 @@ func DefaultConfig() *Config {
Enabled: true, Enabled: true,
}, },
EnableDenyPatterns: true, EnableDenyPatterns: true,
AllowRemote: true,
TimeoutSeconds: 60, TimeoutSeconds: 60,
}, },
Skills: SkillsToolsConfig{ Skills: SkillsToolsConfig{
@ -510,6 +518,9 @@ func DefaultConfig() *Config {
Enabled: false, Enabled: false,
MonitorUSB: true, MonitorUSB: true,
}, },
Voice: VoiceConfig{
EchoTranscription: false,
},
BuildInfo: BuildInfo{ BuildInfo: BuildInfo{
Version: Version, Version: Version,
GitCommit: GitCommit, GitCommit: GitCommit,

View file

@ -6,10 +6,15 @@
package config package config
import ( import (
"encoding/json"
"slices" "slices"
"strings" "strings"
) )
type migratable interface {
Migrate() (*Config, error)
}
// buildModelWithProtocol constructs a model string with protocol prefix. // buildModelWithProtocol constructs a model string with protocol prefix.
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is. // If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
// Otherwise, the protocol prefix is added. // Otherwise, the protocol prefix is added.
@ -21,22 +26,22 @@ func buildModelWithProtocol(protocol, model string) string {
return protocol + "/" + model return protocol + "/" + model
} }
// providerMigrationConfig defines how to migrate a provider from old config to new format. // v0ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.
type providerMigrationConfig struct { // This enables backward compatibility with existing configurations.
// It preserves the user's configured model from agents.defaults.model when possible.
func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
if cfg == nil {
return nil
}
// providerMigrationConfig defines how to migrate a provider from old config to new format.
type providerMigrationConfig struct {
// providerNames are the possible names used in agents.defaults.provider // providerNames are the possible names used in agents.defaults.provider
providerNames []string providerNames []string
// protocol is the protocol prefix for the model field // protocol is the protocol prefix for the model field
protocol string protocol string
// buildConfig creates the ModelConfig from ProviderConfig // buildConfig creates the ModelConfig from ProviderConfig
buildConfig func(p ProvidersConfig) (ModelConfig, bool) buildConfig func(p ProvidersConfig) (ModelConfig, bool)
}
// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.
// This enables backward compatibility with existing configurations.
// It preserves the user's configured model from agents.defaults.model when possible.
func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
if cfg == nil {
return nil
} }
// Get user's configured provider and model // Get user's configured provider and model
@ -407,6 +412,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true }, true
}, },
}, },
{
providerNames: []string{"longcat"},
protocol: "longcat",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: p.LongCat.APIKey,
APIBase: p.LongCat.APIBase,
Proxy: p.LongCat.Proxy,
RequestTimeout: p.LongCat.RequestTimeout,
}, true
},
},
} }
// Process each provider migration // Process each provider migration
@ -434,3 +456,44 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return result return result
} }
// loadConfigV0 loads a legacy config (no version field)
func loadConfigV0(data []byte) (migratable, error) {
var v0 configV0
if err := json.Unmarshal(data, &v0); err != nil {
return nil, err
}
v0.migrateChannelConfigs()
// Auto-migrate: if only legacy providers config exists, convert to model_list
if len(v0.ModelList) == 0 && !v0.Providers.IsEmpty() {
v0.ModelList = v0ConvertProvidersToModelList(&v0)
}
return &v0, nil
}
// loadConfigV1 loads a version 1 config (current schema)
func loadConfig(data []byte) (*Config, error) {
cfg := DefaultConfig()
// Pre-scan the JSON to check how many model_list entries the user provided.
// Go's JSON decoder reuses existing slice backing-array elements rather than
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
// would silently inherit values from the DefaultConfig template at the same
// index position. We only reset cfg.ModelList when the user actually provides
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
var tmp Config
if err := json.Unmarshal(data, &tmp); err != nil {
return nil, err
}
if len(tmp.ModelList) > 0 {
cfg.ModelList = nil
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
return cfg, nil
}

View file

@ -11,7 +11,7 @@ import (
) )
func TestConvertProvidersToModelList_OpenAI(t *testing.T) { func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ OpenAI: OpenAIProviderConfig{
ProviderConfig: ProviderConfig{ ProviderConfig: ProviderConfig{
@ -22,7 +22,7 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -40,7 +40,7 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
} }
func TestConvertProvidersToModelList_Anthropic(t *testing.T) { func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
Anthropic: ProviderConfig{ Anthropic: ProviderConfig{
APIKey: "ant-key", APIKey: "ant-key",
@ -49,7 +49,7 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -64,7 +64,7 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
} }
func TestConvertProvidersToModelList_LiteLLM(t *testing.T) { func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
LiteLLM: ProviderConfig{ LiteLLM: ProviderConfig{
APIKey: "litellm-key", APIKey: "litellm-key",
@ -73,7 +73,7 @@ func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -91,7 +91,7 @@ func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
} }
func TestConvertProvidersToModelList_Multiple(t *testing.T) { func TestConvertProvidersToModelList_Multiple(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}}, OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
Groq: ProviderConfig{APIKey: "groq-key"}, Groq: ProviderConfig{APIKey: "groq-key"},
@ -99,7 +99,7 @@ func TestConvertProvidersToModelList_Multiple(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 3 { if len(result) != 3 {
t.Fatalf("len(result) = %d, want 3", len(result)) t.Fatalf("len(result) = %d, want 3", len(result))
@ -119,11 +119,11 @@ func TestConvertProvidersToModelList_Multiple(t *testing.T) {
} }
func TestConvertProvidersToModelList_Empty(t *testing.T) { func TestConvertProvidersToModelList_Empty(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Providers: ProvidersConfig{}, Providers: ProvidersConfig{},
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 0 { if len(result) != 0 {
t.Errorf("len(result) = %d, want 0", len(result)) t.Errorf("len(result) = %d, want 0", len(result))
@ -131,7 +131,7 @@ func TestConvertProvidersToModelList_Empty(t *testing.T) {
} }
func TestConvertProvidersToModelList_Nil(t *testing.T) { func TestConvertProvidersToModelList_Nil(t *testing.T) {
result := ConvertProvidersToModelList(nil) result := v0ConvertProvidersToModelList(nil)
if result != nil { if result != nil {
t.Errorf("result = %v, want nil", result) t.Errorf("result = %v, want nil", result)
@ -139,7 +139,7 @@ func TestConvertProvidersToModelList_Nil(t *testing.T) {
} }
func TestConvertProvidersToModelList_AllProviders(t *testing.T) { func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}}, OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"},
@ -162,19 +162,20 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
Qwen: ProviderConfig{APIKey: "key17"}, Qwen: ProviderConfig{APIKey: "key17"},
Mistral: ProviderConfig{APIKey: "key18"}, Mistral: ProviderConfig{APIKey: "key18"},
Avian: ProviderConfig{APIKey: "key19"}, Avian: ProviderConfig{APIKey: "key19"},
LongCat: ProviderConfig{APIKey: "key-longcat"},
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
// All 21 providers should be converted // All 22 providers should be converted
if len(result) != 21 { if len(result) != 22 {
t.Errorf("len(result) = %d, want 21", len(result)) t.Errorf("len(result) = %d, want 22", len(result))
} }
} }
func TestConvertProvidersToModelList_Proxy(t *testing.T) { func TestConvertProvidersToModelList_Proxy(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ OpenAI: OpenAIProviderConfig{
ProviderConfig: ProviderConfig{ ProviderConfig: ProviderConfig{
@ -185,7 +186,7 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -197,7 +198,7 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
} }
func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) { func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
Ollama: ProviderConfig{ Ollama: ProviderConfig{
APIKey: "ollama-key", APIKey: "ollama-key",
@ -206,7 +207,7 @@ func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -218,7 +219,7 @@ func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
} }
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) { func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ OpenAI: OpenAIProviderConfig{
ProviderConfig: ProviderConfig{ ProviderConfig: ProviderConfig{
@ -228,7 +229,7 @@ func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 0 { if len(result) != 0 {
t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result)) t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result))
@ -238,9 +239,9 @@ func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
// Tests for preserving user's configured model during migration // Tests for preserving user's configured model during migration
func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) { func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "deepseek", Provider: "deepseek",
Model: "deepseek-reasoner", Model: "deepseek-reasoner",
}, },
@ -250,7 +251,7 @@ func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -263,9 +264,9 @@ func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
} }
func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) { func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "openai", Provider: "openai",
Model: "gpt-4-turbo", Model: "gpt-4-turbo",
}, },
@ -275,7 +276,7 @@ func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -287,9 +288,9 @@ func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
} }
func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) { func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "claude", // alternative name Provider: "claude", // alternative name
Model: "claude-opus-4-20250514", Model: "claude-opus-4-20250514",
}, },
@ -299,7 +300,7 @@ func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T)
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -311,9 +312,9 @@ func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T)
} }
func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) { func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "qwen", Provider: "qwen",
Model: "qwen-plus", Model: "qwen-plus",
}, },
@ -323,7 +324,7 @@ func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -335,9 +336,9 @@ func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
} }
func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) { func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "deepseek", Provider: "deepseek",
Model: "", // no model specified Model: "", // no model specified
}, },
@ -347,7 +348,7 @@ func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -360,9 +361,9 @@ func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
} }
func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) { func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "deepseek", Provider: "deepseek",
Model: "deepseek-reasoner", Model: "deepseek-reasoner",
}, },
@ -373,7 +374,7 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 2 { if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result)) t.Fatalf("len(result) = %d, want 2", len(result))
@ -409,9 +410,9 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.providerAlias, func(t *testing.T) { t.Run(tt.providerAlias, func(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: tt.providerAlias, Provider: tt.providerAlias,
Model: strings.TrimPrefix( Model: strings.TrimPrefix(
tt.expectedModel, tt.expectedModel,
@ -442,7 +443,7 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1], tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
) )
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
} }
@ -464,9 +465,9 @@ func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T
// - No provider field set // - No provider field set
// - model = "glm-4.7" // - model = "glm-4.7"
// - Only zhipu has API key configured // - Only zhipu has API key configured
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "", // Not set Provider: "", // Not set
Model: "glm-4.7", Model: "glm-4.7",
}, },
@ -476,7 +477,7 @@ func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -497,9 +498,9 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin
// When multiple providers are configured but no provider field is set, // When multiple providers are configured but no provider field is set,
// the FIRST provider (in migration order) will use userModel as ModelName // the FIRST provider (in migration order) will use userModel as ModelName
// for backward compatibility with legacy implicit provider selection // for backward compatibility with legacy implicit provider selection
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "", // Not set Provider: "", // Not set
Model: "some-model", Model: "some-model",
}, },
@ -510,7 +511,7 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 2 { if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result)) t.Fatalf("len(result) = %d, want 2", len(result))
@ -530,9 +531,9 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin
func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) { func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
// Edge case: no provider, no model // Edge case: no provider, no model
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "", Provider: "",
Model: "", Model: "",
}, },
@ -542,7 +543,7 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result)) t.Fatalf("len(result) = %d, want 1", len(result))
@ -583,9 +584,9 @@ func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
// Test for legacy config with protocol prefix in model name // Test for legacy config with protocol prefix in model name
func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) { func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
cfg := &Config{ cfg := &configV0{
Agents: AgentsConfig{ Agents: agentsConfigV0{
Defaults: AgentDefaults{ Defaults: agentDefaultsV0{
Provider: "", // No explicit provider Provider: "", // No explicit provider
Model: "openrouter/auto", // Model already has protocol prefix Model: "openrouter/auto", // Model already has protocol prefix
}, },
@ -595,7 +596,7 @@ func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T)
}, },
} }
result := ConvertProvidersToModelList(cfg) result := v0ConvertProvidersToModelList(cfg)
if len(result) < 1 { if len(result) < 1 {
t.Fatalf("len(result) = %d, want at least 1", len(result)) t.Fatalf("len(result) = %d, want at least 1", len(result))

View file

@ -113,39 +113,7 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
} }
} }
func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) { func TestAgentDefaultsV0_JSON_BackwardCompat(t *testing.T) {
tests := []struct {
name string
defaults AgentDefaults
wantName string
}{
{
name: "new model_name field only",
defaults: AgentDefaults{ModelName: "new-model"},
wantName: "new-model",
},
{
name: "old model field only",
defaults: AgentDefaults{Model: "legacy-model"},
wantName: "legacy-model",
},
{
name: "both fields - model_name takes precedence",
defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
wantName: "new-model",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.defaults.GetModelName(); got != tt.wantName {
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
}
})
}
}
func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
json string json string
@ -170,7 +138,7 @@ func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var defaults AgentDefaults var defaults agentDefaultsV0
if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil { if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
t.Fatalf("Unmarshal error: %v", err) t.Fatalf("Unmarshal error: %v", err)
} }
@ -181,69 +149,6 @@ func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
} }
} }
func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
// Test complete config with both old and new formats
oldFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
newFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
for name, jsonStr := range map[string]string{
"old format (model)": oldFormat,
"new format (model_name)": newFormat,
} {
t.Run(name, func(t *testing.T) {
cfg := &Config{}
if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
// Check that GetModelName returns correct value
if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
}
// Check that GetModelConfig works
modelCfg, err := cfg.GetModelConfig("gpt4")
if err != nil {
t.Fatalf("GetModelConfig error: %v", err)
}
if modelCfg.Model != "openai/gpt-4o" {
t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
}
})
}
}
func TestModelConfig_Validate(t *testing.T) { func TestModelConfig_Validate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

13
pkg/env.go Normal file
View file

@ -0,0 +1,13 @@
// all environment variables including default values put here
package pkg
const (
Logo = "🦞"
// AppName is the name of the app
AppName = "PicoClaw"
PicoClawHome = "PICOCLAW_HOME"
DefaultPicoClawHome = ".picoclaw"
WorkspaceName = "workspace"
)

View file

@ -1,24 +1,24 @@
package logger package logger
import ( import (
"encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
"time"
"github.com/rs/zerolog"
) )
type LogLevel int type LogLevel = zerolog.Level
const ( const (
DEBUG LogLevel = iota DEBUG = zerolog.DebugLevel
INFO INFO = zerolog.InfoLevel
WARN WARN = zerolog.WarnLevel
ERROR ERROR = zerolog.ErrorLevel
FATAL FATAL = zerolog.FatalLevel
) )
var ( var (
@ -31,27 +31,24 @@ var (
} }
currentLevel = INFO currentLevel = INFO
logger *Logger logger zerolog.Logger
fileLogger zerolog.Logger
logFile *os.File
once sync.Once once sync.Once
mu sync.RWMutex mu sync.RWMutex
) )
type Logger struct {
file *os.File
}
type LogEntry struct {
Level string `json:"level"`
Timestamp string `json:"timestamp"`
Component string `json:"component,omitempty"`
Message string `json:"message"`
Fields map[string]any `json:"fields,omitempty"`
Caller string `json:"caller,omitempty"`
}
func init() { func init() {
once.Do(func() { once.Do(func() {
logger = &Logger{} zerolog.SetGlobalLevel(zerolog.InfoLevel)
consoleWriter := zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: "15:04:05", // TODO: make it configurable???
}
logger = zerolog.New(consoleWriter).With().Timestamp().Logger()
fileLogger = zerolog.Logger{}
}) })
} }
@ -59,6 +56,7 @@ func SetLevel(level LogLevel) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
currentLevel = level currentLevel = level
zerolog.SetGlobalLevel(level)
} }
func GetLevel() LogLevel { func GetLevel() LogLevel {
@ -71,17 +69,22 @@ func EnableFileLogging(filePath string) error {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
return fmt.Errorf("failed to create log directory: %w", err)
}
newFile, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil { if err != nil {
return fmt.Errorf("failed to open log file: %w", err) return fmt.Errorf("failed to open log file: %w", err)
} }
if logger.file != nil { // Close old file if exists
logger.file.Close() if logFile != nil {
logFile.Close()
} }
logger.file = file logFile = newFile
log.Println("File logging enabled:", filePath) fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger()
return nil return nil
} }
@ -89,10 +92,57 @@ func DisableFileLogging() {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if logger.file != nil { if logFile != nil {
logger.file.Close() logFile.Close()
logger.file = nil logFile = nil
log.Println("File logging disabled") }
fileLogger = zerolog.Logger{}
}
func getCallerInfo() (string, int, string) {
for i := 2; i < 15; i++ {
pc, file, line, ok := runtime.Caller(i)
if !ok {
continue
}
fn := runtime.FuncForPC(pc)
if fn == nil {
continue
}
// bypass common loggers
if strings.HasSuffix(file, "/logger.go") ||
strings.HasSuffix(file, "/log.go") {
continue
}
funcName := fn.Name()
if strings.HasPrefix(funcName, "runtime.") {
continue
}
return filepath.Base(file), line, filepath.Base(funcName)
}
return "???", 0, "???"
}
//nolint:zerologlint
func getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event {
switch level {
case zerolog.DebugLevel:
return logger.Debug()
case zerolog.InfoLevel:
return logger.Info()
case zerolog.WarnLevel:
return logger.Warn()
case zerolog.ErrorLevel:
return logger.Error()
case zerolog.FatalLevel:
return logger.Fatal()
default:
return logger.Info()
} }
} }
@ -101,65 +151,41 @@ func logMessage(level LogLevel, component string, message string, fields map[str
return return
} }
entry := LogEntry{ callerFile, callerLine, callerFunc := getCallerInfo()
Level: logLevelNames[level],
Timestamp: time.Now().UTC().Format(time.RFC3339),
Component: component,
Message: message,
Fields: fields,
}
if pc, file, line, ok := runtime.Caller(2); ok { event := getEvent(logger, level)
fn := runtime.FuncForPC(pc)
if fn != nil {
entry.Caller = fmt.Sprintf("%s:%d (%s)", file, line, fn.Name())
}
}
if logger.file != nil { // Build combined field with component and caller
jsonData, err := json.Marshal(entry) if component != "" {
if err == nil { event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc))
logger.file.Write(append(jsonData, '\n'))
}
}
var fieldStr string
if len(fields) > 0 {
fieldStr = " " + formatFields(fields)
} else { } else {
fieldStr = "" event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc))
} }
logLine := fmt.Sprintf("[%s] [%s]%s %s%s", for k, v := range fields {
entry.Timestamp, event.Interface(k, v)
logLevelNames[level], }
formatComponent(component),
message,
fieldStr,
)
log.Println(logLine) event.Msg(message)
// Also log to file if enabled
if fileLogger.GetLevel() != zerolog.NoLevel {
fileEvent := getEvent(fileLogger, level)
if component != "" {
fileEvent.Str("component", component)
}
for k, v := range fields {
fileEvent.Interface(k, v)
}
fileEvent.Msg(message)
}
if level == FATAL { if level == FATAL {
os.Exit(1) os.Exit(1)
} }
} }
func formatComponent(component string) string {
if component == "" {
return ""
}
return fmt.Sprintf(" %s:", component)
}
func formatFields(fields map[string]any) string {
parts := make([]string, 0, len(fields))
for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
}
return fmt.Sprintf("{%s}", strings.Join(parts, ", "))
}
func Debug(message string) { func Debug(message string) {
logMessage(DEBUG, "", message, nil) logMessage(DEBUG, "", message, nil)
} }
@ -232,6 +258,10 @@ func FatalC(component string, message string) {
logMessage(FATAL, component, message, nil) logMessage(FATAL, component, message, nil)
} }
func Fatalf(message string, ss ...any) {
logMessage(FATAL, "", fmt.Sprintf(message, ss...), nil)
}
func FatalF(message string, fields map[string]any) { func FatalF(message string, fields map[string]any) {
logMessage(FATAL, "", message, fields) logMessage(FATAL, "", message, fields)
} }

View file

@ -0,0 +1,95 @@
// this file is for compatible with 3rd party loggers, should not be called in PicoClaw project
package logger
import "fmt"
// Logger implements common Logger interface
type Logger struct {
component string
levels map[int]LogLevel
}
// Debug logs debug messages
func (b *Logger) Debug(v ...any) {
logMessage(DEBUG, b.component, fmt.Sprint(v...), nil)
}
// Info logs info messages
func (b *Logger) Info(v ...any) {
logMessage(INFO, b.component, fmt.Sprint(v...), nil)
}
// Warn logs warning messages
func (b *Logger) Warn(v ...any) {
logMessage(WARN, b.component, fmt.Sprint(v...), nil)
}
// Error logs error messages
func (b *Logger) Error(v ...any) {
logMessage(ERROR, b.component, fmt.Sprint(v...), nil)
}
// Debugf logs formatted debug messages
func (b *Logger) Debugf(format string, v ...any) {
logMessage(DEBUG, b.component, fmt.Sprintf(format, v...), nil)
}
// Infof logs formatted info messages
func (b *Logger) Infof(format string, v ...any) {
logMessage(INFO, b.component, fmt.Sprintf(format, v...), nil)
}
// Warnf logs formatted warning messages
func (b *Logger) Warnf(format string, v ...any) {
logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil)
}
// Warningf logs formatted warning messages
func (b *Logger) Warningf(format string, v ...any) {
logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil)
}
// Errorf logs formatted error messages
func (b *Logger) Errorf(format string, v ...any) {
logMessage(ERROR, b.component, fmt.Sprintf(format, v...), nil)
}
// Fatalf logs formatted fatal messages and exits
func (b *Logger) Fatalf(format string, v ...any) {
logMessage(FATAL, b.component, fmt.Sprintf(format, v...), nil)
}
// Log logs a message at a given level with caller information
// the func name must be this because 3rd party loggers expect this
// msgL: message level (DEBUG, INFO, WARN, ERROR, FATAL)
// caller: unused parameter reserved for compatibility
// format: format string
// a: format arguments
//
//nolint:goprintffuncname
func (b *Logger) Log(msgL, caller int, format string, a ...any) {
level := LogLevel(msgL)
if b.levels != nil {
if lvl, ok := b.levels[msgL]; ok {
level = lvl
}
}
logMessage(level, b.component, fmt.Sprintf(format, a...), nil)
}
// Sync flushes log buffer (no-op for this implementation)
func (b *Logger) Sync() error {
return nil
}
// WithLevels sets log levels mapping for this logger
func (b *Logger) WithLevels(levels map[int]LogLevel) *Logger {
b.levels = levels
return b
}
// NewLogger creates a new logger instance with optional component name
func NewLogger(component string) *Logger {
return &Logger{component: component}
}

View file

@ -48,6 +48,12 @@ func MigrateFromJSON(
if !strings.HasSuffix(name, ".json") { if !strings.HasSuffix(name, ".json") {
continue continue
} }
// Skip JSONL metadata files. They are part of the new storage format,
// not legacy session snapshots, and re-importing them would overwrite
// the paired .jsonl history with an empty message list.
if strings.HasSuffix(name, ".meta.json") {
continue
}
// Skip already-migrated files. // Skip already-migrated files.
if strings.HasSuffix(name, ".migrated") { if strings.HasSuffix(name, ".migrated") {
continue continue

View file

@ -382,3 +382,55 @@ func TestMigrateFromJSON_NonexistentDir(t *testing.T) {
t.Errorf("expected 0, got %d", count) t.Errorf("expected 0, got %d", count)
} }
} }
func TestMigrateFromJSON_SkipsMetaJSONFiles(t *testing.T) {
sessionsDir := t.TempDir()
store, err := NewJSONLStore(sessionsDir)
if err != nil {
t.Fatalf("NewJSONLStore: %v", err)
}
ctx := context.Background()
if addErr := store.AddMessage(ctx, "agent:main:pico:direct:pico:test", "user", "keep me"); addErr != nil {
t.Fatalf("AddMessage: %v", addErr)
}
if summaryErr := store.SetSummary(ctx, "agent:main:pico:direct:pico:test", "keep summary"); summaryErr != nil {
t.Fatalf("SetSummary: %v", summaryErr)
}
metaPath := filepath.Join(sessionsDir, "agent_main_pico_direct_pico_test.meta.json")
if _, statErr := os.Stat(metaPath); statErr != nil {
t.Fatalf("meta file missing before migration: %v", statErr)
}
count, err := MigrateFromJSON(ctx, sessionsDir, store)
if err != nil {
t.Fatalf("MigrateFromJSON: %v", err)
}
if count != 0 {
t.Fatalf("expected 0 migrated, got %d", count)
}
history, err := store.GetHistory(ctx, "agent:main:pico:direct:pico:test")
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(history) != 1 || history[0].Content != "keep me" {
t.Fatalf("history = %+v, want preserved single message", history)
}
summary, err := store.GetSummary(ctx, "agent:main:pico:direct:pico:test")
if err != nil {
t.Fatalf("GetSummary: %v", err)
}
if summary != "keep summary" {
t.Fatalf("summary = %q, want %q", summary, "keep summary")
}
if _, statErr := os.Stat(metaPath); statErr != nil {
t.Fatalf("meta file should remain in place: %v", statErr)
}
if _, statErr := os.Stat(metaPath + ".migrated"); !os.IsNotExist(statErr) {
t.Fatalf("meta file should not be renamed, stat err = %v", statErr)
}
}

View file

@ -5,20 +5,22 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"github.com/sipeed/picoclaw/pkg"
) )
func ResolveTargetHome(override string) (string, error) { func ResolveTargetHome(override string) (string, error) {
if override != "" { if override != "" {
return ExpandHome(override), nil return ExpandHome(override), nil
} }
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" { if envHome := os.Getenv(pkg.PicoClawHome); envHome != "" {
return ExpandHome(envHome), nil return ExpandHome(envHome), nil
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", fmt.Errorf("resolving home directory: %w", err) return "", fmt.Errorf("resolving home directory: %w", err)
} }
return filepath.Join(home, ".picoclaw"), nil return filepath.Join(home, pkg.DefaultPicoClawHome), nil
} }
func ExpandHome(path string) string { func ExpandHome(path string) string {

View file

@ -4,7 +4,6 @@ var migrateableFiles = []string{
"AGENTS.md", "AGENTS.md",
"SOUL.md", "SOUL.md",
"USER.md", "USER.md",
"TOOLS.md",
"HEARTBEAT.md", "HEARTBEAT.md",
} }

View file

@ -1111,6 +1111,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
Exec: config.ExecConfig{ Exec: config.ExecConfig{
EnableDenyPatterns: c.Exec.EnableDenyPatterns, EnableDenyPatterns: c.Exec.EnableDenyPatterns,
CustomDenyPatterns: c.Exec.CustomDenyPatterns, CustomDenyPatterns: c.Exec.CustomDenyPatterns,
AllowRemote: config.DefaultConfig().Tools.Exec.AllowRemote,
}, },
} }
} }

View file

@ -290,6 +290,20 @@ func TestConvertToPicoClaw(t *testing.T) {
} }
} }
func TestToStandardConfig_ExecAllowRemoteDefaultsTrue(t *testing.T) {
cfg := (&PicoClawConfig{
Tools: ToolsConfig{
Exec: ExecConfig{
EnableDenyPatterns: true,
},
},
}).ToStandardConfig()
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("ToStandardConfig() should preserve the default tools.exec.allow_remote=true")
}
}
func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) { func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json") configPath := filepath.Join(tmpDir, "openclaw.json")

View file

@ -416,7 +416,7 @@ func TestCreateProvider_ClaudeCli(t *testing.T) {
cfg.ModelList = []config.ModelConfig{ cfg.ModelList = []config.ModelConfig{
{ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"}, {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"},
} }
cfg.Agents.Defaults.Model = "claude-sonnet-4.6" cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
provider, _, err := CreateProvider(cfg) provider, _, err := CreateProvider(cfg)
if err != nil { if err != nil {
@ -437,7 +437,7 @@ func TestCreateProvider_ClaudeCode(t *testing.T) {
cfg.ModelList = []config.ModelConfig{ cfg.ModelList = []config.ModelConfig{
{ModelName: "claude-code", Model: "claude-cli/claude-code"}, {ModelName: "claude-code", Model: "claude-cli/claude-code"},
} }
cfg.Agents.Defaults.Model = "claude-code" cfg.Agents.Defaults.ModelName = "claude-code"
provider, _, err := CreateProvider(cfg) provider, _, err := CreateProvider(cfg)
if err != nil { if err != nil {
@ -453,7 +453,7 @@ func TestCreateProvider_ClaudeCodec(t *testing.T) {
cfg.ModelList = []config.ModelConfig{ cfg.ModelList = []config.ModelConfig{
{ModelName: "claudecode", Model: "claude-cli/claudecode"}, {ModelName: "claudecode", Model: "claude-cli/claudecode"},
} }
cfg.Agents.Defaults.Model = "claudecode" cfg.Agents.Defaults.ModelName = "claudecode"
provider, _, err := CreateProvider(cfg) provider, _, err := CreateProvider(cfg)
if err != nil { if err != nil {
@ -469,7 +469,7 @@ func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
cfg.ModelList = []config.ModelConfig{ cfg.ModelList = []config.ModelConfig{
{ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"}, {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"},
} }
cfg.Agents.Defaults.Model = "claude-cli" cfg.Agents.Defaults.ModelName = "claude-cli"
cfg.Agents.Defaults.Workspace = "" cfg.Agents.Defaults.Workspace = ""
provider, _, err := CreateProvider(cfg) provider, _, err := CreateProvider(cfg)

View file

@ -1,384 +1,7 @@
package providers package providers
import ( import (
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
) )
const defaultAnthropicAPIBase = "https://api.anthropic.com/v1"
var getCredential = auth.GetCredential var getCredential = auth.GetCredential
type providerType int
const (
providerTypeHTTPCompat providerType = iota
providerTypeClaudeAuth
providerTypeCodexAuth
providerTypeCodexCLIToken
providerTypeClaudeCLI
providerTypeCodexCLI
providerTypeGitHubCopilot
)
type providerSelection struct {
providerType providerType
apiKey string
apiBase string
proxy string
model string
workspace string
connectMode string
enableWebSearch bool
}
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
model := cfg.Agents.Defaults.GetModelName()
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
lowerModel := strings.ToLower(model)
if providerName == "" && model == "" {
return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty")
}
sel := providerSelection{
providerType: providerTypeHTTPCompat,
model: model,
}
// First, prefer explicit provider configuration.
if providerName != "" {
switch providerName {
case "groq":
if cfg.Providers.Groq.APIKey != "" {
sel.apiKey = cfg.Providers.Groq.APIKey
sel.apiBase = cfg.Providers.Groq.APIBase
sel.proxy = cfg.Providers.Groq.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.groq.com/openai/v1"
}
}
case "openai", "gpt":
if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
sel.providerType = providerTypeCodexCLIToken
return sel, nil
}
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
sel.providerType = providerTypeCodexAuth
return sel, nil
}
sel.apiKey = cfg.Providers.OpenAI.APIKey
sel.apiBase = cfg.Providers.OpenAI.APIBase
sel.proxy = cfg.Providers.OpenAI.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.openai.com/v1"
}
}
case "anthropic", "claude":
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
sel.apiBase = cfg.Providers.Anthropic.APIBase
if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase
}
sel.providerType = providerTypeClaudeAuth
return sel, nil
}
sel.apiKey = cfg.Providers.Anthropic.APIKey
sel.apiBase = cfg.Providers.Anthropic.APIBase
sel.proxy = cfg.Providers.Anthropic.Proxy
if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase
}
}
case "openrouter":
if cfg.Providers.OpenRouter.APIKey != "" {
sel.apiKey = cfg.Providers.OpenRouter.APIKey
sel.proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" {
sel.apiBase = cfg.Providers.OpenRouter.APIBase
} else {
sel.apiBase = "https://openrouter.ai/api/v1"
}
}
case "litellm":
if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" {
sel.apiKey = cfg.Providers.LiteLLM.APIKey
sel.apiBase = cfg.Providers.LiteLLM.APIBase
sel.proxy = cfg.Providers.LiteLLM.Proxy
if sel.apiBase == "" {
sel.apiBase = "http://localhost:4000/v1"
}
}
case "zhipu", "glm":
if cfg.Providers.Zhipu.APIKey != "" {
sel.apiKey = cfg.Providers.Zhipu.APIKey
sel.apiBase = cfg.Providers.Zhipu.APIBase
sel.proxy = cfg.Providers.Zhipu.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
}
}
case "gemini", "google":
if cfg.Providers.Gemini.APIKey != "" {
sel.apiKey = cfg.Providers.Gemini.APIKey
sel.apiBase = cfg.Providers.Gemini.APIBase
sel.proxy = cfg.Providers.Gemini.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
}
}
case "vllm":
if cfg.Providers.VLLM.APIBase != "" {
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase
sel.proxy = cfg.Providers.VLLM.Proxy
}
case "shengsuanyun":
if cfg.Providers.ShengSuanYun.APIKey != "" {
sel.apiKey = cfg.Providers.ShengSuanYun.APIKey
sel.apiBase = cfg.Providers.ShengSuanYun.APIBase
sel.proxy = cfg.Providers.ShengSuanYun.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://router.shengsuanyun.com/api/v1"
}
}
case "nvidia":
if cfg.Providers.Nvidia.APIKey != "" {
sel.apiKey = cfg.Providers.Nvidia.APIKey
sel.apiBase = cfg.Providers.Nvidia.APIBase
sel.proxy = cfg.Providers.Nvidia.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://integrate.api.nvidia.com/v1"
}
}
case "vivgrid":
if cfg.Providers.Vivgrid.APIKey != "" {
sel.apiKey = cfg.Providers.Vivgrid.APIKey
sel.apiBase = cfg.Providers.Vivgrid.APIBase
sel.proxy = cfg.Providers.Vivgrid.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.vivgrid.com/v1"
}
}
case "claude-cli", "claude-code", "claudecode":
workspace := cfg.WorkspacePath()
if workspace == "" {
workspace = "."
}
sel.providerType = providerTypeClaudeCLI
sel.workspace = workspace
return sel, nil
case "codex-cli", "codex-code":
workspace := cfg.WorkspacePath()
if workspace == "" {
workspace = "."
}
sel.providerType = providerTypeCodexCLI
sel.workspace = workspace
return sel, nil
case "deepseek":
if cfg.Providers.DeepSeek.APIKey != "" {
sel.apiKey = cfg.Providers.DeepSeek.APIKey
sel.apiBase = cfg.Providers.DeepSeek.APIBase
sel.proxy = cfg.Providers.DeepSeek.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.deepseek.com/v1"
}
if model != "deepseek-chat" && model != "deepseek-reasoner" {
sel.model = "deepseek-chat"
}
}
case "avian":
if cfg.Providers.Avian.APIKey != "" {
sel.apiKey = cfg.Providers.Avian.APIKey
sel.apiBase = cfg.Providers.Avian.APIBase
sel.proxy = cfg.Providers.Avian.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.avian.io/v1"
}
}
case "mistral":
if cfg.Providers.Mistral.APIKey != "" {
sel.apiKey = cfg.Providers.Mistral.APIKey
sel.apiBase = cfg.Providers.Mistral.APIBase
sel.proxy = cfg.Providers.Mistral.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.mistral.ai/v1"
}
}
case "minimax":
if cfg.Providers.Minimax.APIKey != "" {
sel.apiKey = cfg.Providers.Minimax.APIKey
sel.apiBase = cfg.Providers.Minimax.APIBase
sel.proxy = cfg.Providers.Minimax.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.minimaxi.com/v1"
}
}
case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" {
sel.apiBase = cfg.Providers.GitHubCopilot.APIBase
} else {
sel.apiBase = "localhost:4321"
}
sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode
return sel, nil
}
}
// Fallback: infer provider from model and configured keys.
if sel.apiKey == "" && sel.apiBase == "" {
switch {
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
sel.apiKey = cfg.Providers.Moonshot.APIKey
sel.apiBase = cfg.Providers.Moonshot.APIBase
sel.proxy = cfg.Providers.Moonshot.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.moonshot.cn/v1"
}
case strings.HasPrefix(model, "openrouter/") ||
strings.HasPrefix(model, "anthropic/") ||
strings.HasPrefix(model, "openai/") ||
strings.HasPrefix(model, "meta-llama/") ||
strings.HasPrefix(model, "deepseek/") ||
strings.HasPrefix(model, "google/"):
sel.apiKey = cfg.Providers.OpenRouter.APIKey
sel.proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" {
sel.apiBase = cfg.Providers.OpenRouter.APIBase
} else {
sel.apiBase = "https://openrouter.ai/api/v1"
}
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
(cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
sel.apiBase = cfg.Providers.Anthropic.APIBase
if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase
}
sel.providerType = providerTypeClaudeAuth
return sel, nil
}
sel.apiKey = cfg.Providers.Anthropic.APIKey
sel.apiBase = cfg.Providers.Anthropic.APIBase
sel.proxy = cfg.Providers.Anthropic.Proxy
if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase
}
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) &&
(cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
sel.providerType = providerTypeCodexCLIToken
return sel, nil
}
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
sel.providerType = providerTypeCodexAuth
return sel, nil
}
sel.apiKey = cfg.Providers.OpenAI.APIKey
sel.apiBase = cfg.Providers.OpenAI.APIBase
sel.proxy = cfg.Providers.OpenAI.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.openai.com/v1"
}
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
sel.apiKey = cfg.Providers.Gemini.APIKey
sel.apiBase = cfg.Providers.Gemini.APIBase
sel.proxy = cfg.Providers.Gemini.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
}
case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
sel.apiKey = cfg.Providers.Zhipu.APIKey
sel.apiBase = cfg.Providers.Zhipu.APIBase
sel.proxy = cfg.Providers.Zhipu.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
}
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
sel.apiKey = cfg.Providers.Groq.APIKey
sel.apiBase = cfg.Providers.Groq.APIBase
sel.proxy = cfg.Providers.Groq.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.groq.com/openai/v1"
}
case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
sel.apiKey = cfg.Providers.Nvidia.APIKey
sel.apiBase = cfg.Providers.Nvidia.APIBase
sel.proxy = cfg.Providers.Nvidia.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://integrate.api.nvidia.com/v1"
}
case strings.HasPrefix(model, "vivgrid/") && cfg.Providers.Vivgrid.APIKey != "":
sel.apiKey = cfg.Providers.Vivgrid.APIKey
sel.apiBase = cfg.Providers.Vivgrid.APIBase
sel.proxy = cfg.Providers.Vivgrid.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.vivgrid.com/v1"
}
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
sel.apiKey = cfg.Providers.Ollama.APIKey
sel.apiBase = cfg.Providers.Ollama.APIBase
sel.proxy = cfg.Providers.Ollama.Proxy
if sel.apiBase == "" {
sel.apiBase = "http://localhost:11434/v1"
}
case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
sel.apiKey = cfg.Providers.Mistral.APIKey
sel.apiBase = cfg.Providers.Mistral.APIBase
sel.proxy = cfg.Providers.Mistral.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.mistral.ai/v1"
}
case (strings.Contains(lowerModel, "minimax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.Minimax.APIKey != "":
sel.apiKey = cfg.Providers.Minimax.APIKey
sel.apiBase = cfg.Providers.Minimax.APIBase
sel.proxy = cfg.Providers.Minimax.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.minimaxi.com/v1"
}
case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "":
sel.apiKey = cfg.Providers.Avian.APIKey
sel.apiBase = cfg.Providers.Avian.APIBase
sel.proxy = cfg.Providers.Avian.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.avian.io/v1"
}
case cfg.Providers.VLLM.APIBase != "":
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase
sel.proxy = cfg.Providers.VLLM.Proxy
default:
if cfg.Providers.OpenRouter.APIKey != "" {
sel.apiKey = cfg.Providers.OpenRouter.APIKey
sel.proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" {
sel.apiBase = cfg.Providers.OpenRouter.APIBase
} else {
sel.apiBase = "https://openrouter.ai/api/v1"
}
} else {
return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model)
}
}
}
if sel.providerType == providerTypeHTTPCompat {
if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model)
}
if sel.apiBase == "" {
return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model)
}
}
return sel, nil
}

View file

@ -95,7 +95,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian", "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
"minimax": "minimax", "longcat":
// All other OpenAI-compatible HTTP providers // All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" { if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@ -215,6 +215,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.avian.io/v1" return "https://api.avian.io/v1"
case "minimax": case "minimax":
return "https://api.minimaxi.com/v1" return "https://api.minimaxi.com/v1"
case "longcat":
return "https://api.longcat.chat/openai"
default: default:
return "" return ""
} }

View file

@ -113,6 +113,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"}, {"vllm", "vllm"},
{"deepseek", "deepseek"}, {"deepseek", "deepseek"},
{"ollama", "ollama"}, {"ollama", "ollama"},
{"longcat", "longcat"},
} }
for _, tt := range tests { for _, tt := range tests {
@ -162,6 +163,29 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
} }
} }
func TestCreateProviderFromConfig_LongCat(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: "test-key",
APIBase: "https://api.longcat.chat/openai",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "LongCat-Flash-Thinking" {
t.Errorf("modelID = %q, want %q", modelID, "LongCat-Flash-Thinking")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_Anthropic(t *testing.T) { func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-anthropic", ModelName: "test-anthropic",

View file

@ -1,234 +1,15 @@
package providers package providers
import ( import (
"strings"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
func TestResolveProviderSelection(t *testing.T) {
tests := []struct {
name string
setup func(*config.Config)
wantType providerType
wantAPIBase string
wantProxy string
wantErrSubstr string
}{
{
name: "explicit litellm provider uses configured base",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "litellm"
cfg.Providers.LiteLLM.APIKey = "litellm-key"
cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1"
cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "http://localhost:4000/v1",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "explicit litellm provider defaults base when only key is configured",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "litellm"
cfg.Providers.LiteLLM.APIKey = "litellm-key"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "http://localhost:4000/v1",
},
{
name: "explicit claude-cli provider routes to cli provider type",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "claude-cli"
cfg.Agents.Defaults.Workspace = "/tmp/ws"
},
wantType: providerTypeClaudeCLI,
},
{
name: "explicit copilot provider routes to github copilot type",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "copilot"
},
wantType: providerTypeGitHubCopilot,
wantAPIBase: "localhost:4321",
},
{
name: "explicit deepseek provider uses deepseek defaults",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "deepseek"
cfg.Agents.Defaults.Model = "deepseek/deepseek-chat"
cfg.Providers.DeepSeek.APIKey = "deepseek-key"
cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://api.deepseek.com/v1",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "explicit shengsuanyun provider uses defaults",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "shengsuanyun"
cfg.Providers.ShengSuanYun.APIKey = "ssy-key"
cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://router.shengsuanyun.com/api/v1",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "explicit nvidia provider uses defaults",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "nvidia"
cfg.Providers.Nvidia.APIKey = "nvapi-test"
cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://integrate.api.nvidia.com/v1",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "explicit vivgrid provider uses defaults",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "vivgrid"
cfg.Providers.Vivgrid.APIKey = "vivgrid-key"
cfg.Providers.Vivgrid.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://api.vivgrid.com/v1",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "openrouter model uses openrouter defaults",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "openrouter/auto"
cfg.Providers.OpenRouter.APIKey = "sk-or-test"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://openrouter.ai/api/v1",
},
{
name: "anthropic oauth routes to claude auth provider",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "claude-sonnet-4.6"
cfg.Providers.Anthropic.AuthMethod = "oauth"
},
wantType: providerTypeClaudeAuth,
},
{
name: "openai oauth routes to codex auth provider",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "gpt-4o"
cfg.Providers.OpenAI.AuthMethod = "oauth"
},
wantType: providerTypeCodexAuth,
},
{
name: "openai codex-cli auth routes to codex cli token provider",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "gpt-4o"
cfg.Providers.OpenAI.AuthMethod = "codex-cli"
},
wantType: providerTypeCodexCLIToken,
},
{
name: "explicit codex-code provider routes to codex cli provider type",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "codex-code"
cfg.Agents.Defaults.Workspace = "/tmp/ws"
},
wantType: providerTypeCodexCLI,
},
{
name: "zhipu model uses zhipu base default",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "glm-4.7"
cfg.Providers.Zhipu.APIKey = "zhipu-key"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://open.bigmodel.cn/api/paas/v4",
},
{
name: "groq model uses groq base default",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "groq/llama-3.3-70b"
cfg.Providers.Groq.APIKey = "gsk-key"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://api.groq.com/openai/v1",
},
{
name: "ollama model uses ollama base default",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "ollama/qwen2.5:14b"
cfg.Providers.Ollama.APIKey = "ollama-key"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "http://localhost:11434/v1",
},
{
name: "moonshot model keeps proxy and default base",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5"
cfg.Providers.Moonshot.APIKey = "moonshot-key"
cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://api.moonshot.cn/v1",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "missing keys returns model config error",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "custom-model"
},
wantErrSubstr: "no API key configured for model",
},
{
name: "openrouter prefix without key returns provider key error",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "openrouter/auto"
},
wantErrSubstr: "no API key configured for provider",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.DefaultConfig()
tt.setup(cfg)
got, err := resolveProviderSelection(cfg)
if tt.wantErrSubstr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
}
if !strings.Contains(err.Error(), tt.wantErrSubstr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr)
}
return
}
if err != nil {
t.Fatalf("resolveProviderSelection() error = %v", err)
}
if got.providerType != tt.wantType {
t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType)
}
if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase {
t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase)
}
if tt.wantProxy != "" && got.proxy != tt.wantProxy {
t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy)
}
})
}
}
func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) { func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Agents.Defaults.Model = "test-openrouter" cfg.Agents.Defaults.ModelName = "test-openrouter"
cfg.ModelList = []config.ModelConfig{ cfg.ModelList = []config.ModelConfig{
{ {
ModelName: "test-openrouter", ModelName: "test-openrouter",
@ -250,7 +31,7 @@ func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) { func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Agents.Defaults.Model = "test-codex" cfg.Agents.Defaults.ModelName = "test-codex"
cfg.ModelList = []config.ModelConfig{ cfg.ModelList = []config.ModelConfig{
{ {
ModelName: "test-codex", ModelName: "test-codex",
@ -271,7 +52,7 @@ func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) { func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Agents.Defaults.Model = "test-claude-cli" cfg.Agents.Defaults.ModelName = "test-claude-cli"
cfg.ModelList = []config.ModelConfig{ cfg.ModelList = []config.ModelConfig{
{ {
ModelName: "test-claude-cli", ModelName: "test-claude-cli",
@ -304,7 +85,7 @@ func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) {
} }
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Agents.Defaults.Model = "test-claude-oauth" cfg.Agents.Defaults.ModelName = "test-claude-oauth"
cfg.ModelList = []config.ModelConfig{ cfg.ModelList = []config.ModelConfig{
{ {
ModelName: "test-claude-oauth", ModelName: "test-claude-oauth",

View file

@ -18,23 +18,6 @@ import (
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) { func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
model := cfg.Agents.Defaults.GetModelName() model := cfg.Agents.Defaults.GetModelName()
// Ensure model_list is populated from providers config if needed
// This handles two cases:
// 1. ModelList is empty - convert all providers
// 2. ModelList has some entries but not all providers - merge missing ones
if cfg.HasProvidersConfig() {
providerModels := config.ConvertProvidersToModelList(cfg)
existingModelNames := make(map[string]bool)
for _, m := range cfg.ModelList {
existingModelNames[m.ModelName] = true
}
for _, pm := range providerModels {
if !existingModelNames[pm.ModelName] {
cfg.ModelList = append(cfg.ModelList, pm)
}
}
}
// Must have model_list at this point // Must have model_list at this point
if len(cfg.ModelList) == 0 { if len(cfg.ModelList) == 0 {
return nil, "", fmt.Errorf("no providers configured. Please add entries to model_list in your config") return nil, "", fmt.Errorf("no providers configured. Please add entries to model_list in your config")

View file

@ -156,9 +156,10 @@ func (p *Provider) Chat(
// The key is typically the agent ID — stable per agent, shared across requests. // The key is typically the agent ID — stable per agent, shared across requests.
// See: https://platform.openai.com/docs/guides/prompt-caching // See: https://platform.openai.com/docs/guides/prompt-caching
// Prompt caching is only supported by OpenAI-native endpoints. // Prompt caching is only supported by OpenAI-native endpoints.
// Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs. // Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown
// fields with 422 errors, so only include it for OpenAI APIs.
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" { if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") { if supportsPromptCacheKey(p.apiBase) {
requestBody["prompt_cache_key"] = cacheKey requestBody["prompt_cache_key"] = cacheKey
} }
} }
@ -284,7 +285,7 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
Type string `json:"type"` Type string `json:"type"`
Function *struct { Function *struct {
Name string `json:"name"` Name string `json:"name"`
Arguments string `json:"arguments"` Arguments json.RawMessage `json:"arguments"`
} `json:"function"` } `json:"function"`
ExtraContent *struct { ExtraContent *struct {
Google *struct { Google *struct {
@ -323,12 +324,7 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
if tc.Function != nil { if tc.Function != nil {
name = tc.Function.Name name = tc.Function.Name
if tc.Function.Arguments != "" { arguments = decodeToolCallArguments(tc.Function.Arguments, name)
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
arguments["raw"] = tc.Function.Arguments
}
}
} }
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence // Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
@ -361,6 +357,39 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
}, nil }, nil
} }
func decodeToolCallArguments(raw json.RawMessage, name string) map[string]any {
arguments := make(map[string]any)
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return arguments
}
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments payload for %q: %v", name, err)
arguments["raw"] = string(raw)
return arguments
}
switch v := decoded.(type) {
case string:
if strings.TrimSpace(v) == "" {
return arguments
}
if err := json.Unmarshal([]byte(v), &arguments); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
arguments["raw"] = v
}
return arguments
case map[string]any:
return v
default:
log.Printf("openai_compat: unsupported tool call arguments type for %q: %T", name, decoded)
arguments["raw"] = string(raw)
return arguments
}
}
// openaiMessage is the wire-format message for OpenAI-compatible APIs. // openaiMessage is the wire-format message for OpenAI-compatible APIs.
// It mirrors protocoltypes.Message but omits SystemParts, which is an // It mirrors protocoltypes.Message but omits SystemParts, which is an
// internal field that would be unknown to third-party endpoints. // internal field that would be unknown to third-party endpoints.
@ -476,3 +505,16 @@ func asFloat(v any) (float64, bool) {
return 0, false return 0, false
} }
} }
// supportsPromptCacheKey reports whether the given API base is known to
// support the prompt_cache_key request field. Currently only OpenAI's own
// API and Azure OpenAI support this. All other OpenAI-compatible providers
// (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors.
func supportsPromptCacheKey(apiBase string) bool {
u, err := url.Parse(apiBase)
if err != nil {
return false
}
host := u.Hostname()
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com")
}

View file

@ -108,6 +108,55 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
} }
} }
func TestProviderChat_ParsesToolCallsWithObjectArguments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{
"content": "",
"tool_calls": []map[string]any{
{
"id": "call_1",
"type": "function",
"function": map[string]any{
"name": "get_weather",
"arguments": map[string]any{
"city": "SF",
"metric": true,
},
},
},
},
},
"finish_reason": "tool_calls",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if len(out.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
}
if out.ToolCalls[0].Name != "get_weather" {
t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
}
if out.ToolCalls[0].Arguments["city"] != "SF" {
t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"])
}
if out.ToolCalls[0].Arguments["metric"] != true {
t.Fatalf("ToolCalls[0].Arguments[metric] = %v, want true", out.ToolCalls[0].Arguments["metric"])
}
}
func TestProviderChat_ParsesReasoningContent(t *testing.T) { func TestProviderChat_ParsesReasoningContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{ resp := map[string]any{
@ -669,6 +718,111 @@ func TestSerializeMessages_MediaWithToolCallID(t *testing.T) {
} }
} }
// chatWithCacheKey sets up a test server, sends a Chat request with prompt_cache_key,
// and returns the decoded request body for assertion.
func chatWithCacheKey(t *testing.T, apiBase string) map[string]any {
t.Helper()
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
p.apiBase = apiBase
p.httpClient = &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
r.URL, _ = url.Parse(server.URL + r.URL.Path)
return http.DefaultTransport.RoundTrip(r)
}),
}
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
nil,
"test-model",
map[string]any{"prompt_cache_key": "agent-main"},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
return requestBody
}
func TestProviderChat_PromptCacheKeySentToOpenAI(t *testing.T) {
body := chatWithCacheKey(t, "https://api.openai.com/v1")
if body["prompt_cache_key"] != "agent-main" {
t.Fatalf("prompt_cache_key = %v, want %q", body["prompt_cache_key"], "agent-main")
}
}
func TestProviderChat_PromptCacheKeyOmittedForNonOpenAI(t *testing.T) {
tests := []struct {
name string
apiBase string
}{
{"mistral", "https://api.mistral.ai/v1"},
{"gemini", "https://generativelanguage.googleapis.com/v1beta"},
{"deepseek", "https://api.deepseek.com/v1"},
{"groq", "https://api.groq.com/openai/v1"},
{"minimax", "https://api.minimaxi.com/v1"},
{"ollama_local", "http://localhost:11434/v1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := chatWithCacheKey(t, tt.apiBase)
if _, exists := body["prompt_cache_key"]; exists {
t.Fatalf("prompt_cache_key should NOT be sent to %s, but was included in request", tt.name)
}
})
}
}
func TestSupportsPromptCacheKey(t *testing.T) {
tests := []struct {
apiBase string
want bool
}{
{"https://api.openai.com/v1", true},
{"https://api.openai.com/v1/", true},
{"https://myresource.openai.azure.com/openai/deployments/gpt-4", true},
{"https://eastus.openai.azure.com/v1", true},
{"https://api.mistral.ai/v1", false},
{"https://generativelanguage.googleapis.com/v1beta", false},
{"https://api.deepseek.com/v1", false},
{"https://api.groq.com/openai/v1", false},
{"http://localhost:11434/v1", false},
{"https://openrouter.ai/api/v1", false},
// Edge cases: proxy URLs with openai.com in path should NOT match
{"https://my-proxy.com/api.openai.com/v1", false},
{"https://proxy.example.com/openai.azure.com/v1", false},
// Malformed or empty
{"", false},
{"not-a-url", false},
}
for _, tt := range tests {
if got := supportsPromptCacheKey(tt.apiBase); got != tt.want {
t.Errorf("supportsPromptCacheKey(%q) = %v, want %v", tt.apiBase, got, tt.want)
}
}
}
func TestSerializeMessages_StripsSystemParts(t *testing.T) { func TestSerializeMessages_StripsSystemParts(t *testing.T) {
messages := []protocoltypes.Message{ messages := []protocoltypes.Message{
{ {

View file

@ -11,7 +11,7 @@ func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *co
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: "/tmp/picoclaw-test", Workspace: "/tmp/picoclaw-test",
Model: "gpt-4", ModelName: "gpt-4",
}, },
List: agents, List: agents,
}, },

View file

@ -32,7 +32,7 @@ func NewSessionManager(storage string) *SessionManager {
} }
if storage != "" { if storage != "" {
os.MkdirAll(storage, 0o755) os.MkdirAll(storage, 0o700)
sm.loadSessions() sm.loadSessions()
} }
@ -216,7 +216,7 @@ func (sm *SessionManager) Save(key string) error {
_ = tmpFile.Close() _ = tmpFile.Close()
return err return err
} }
if err := tmpFile.Chmod(0o644); err != nil { if err := tmpFile.Chmod(0o600); err != nil {
_ = tmpFile.Close() _ = tmpFile.Close()
return err return err
} }

View file

@ -10,14 +10,15 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
var ( var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
reFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
reStripFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
)
const ( const (
MaxNameLength = 64 MaxNameLength = 64
@ -226,11 +227,20 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
return nil return nil
} }
frontmatter := sl.extractFrontmatter(string(content)) frontmatter, bodyContent := splitFrontmatter(string(content))
if frontmatter == "" { dirName := filepath.Base(filepath.Dir(skillPath))
return &SkillMetadata{ title, bodyDescription := extractMarkdownMetadata(bodyContent)
Name: filepath.Base(filepath.Dir(skillPath)),
metadata := &SkillMetadata{
Name: dirName,
Description: bodyDescription,
} }
if title != "" && namePattern.MatchString(title) && len(title) <= MaxNameLength {
metadata.Name = title
}
if frontmatter == "" {
return metadata
} }
// Try JSON first (for backward compatibility) // Try JSON first (for backward compatibility)
@ -239,60 +249,133 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
Description string `json:"description"` Description string `json:"description"`
} }
if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil { if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil {
return &SkillMetadata{ if jsonMeta.Name != "" {
Name: jsonMeta.Name, metadata.Name = jsonMeta.Name
Description: jsonMeta.Description,
} }
if jsonMeta.Description != "" {
metadata.Description = jsonMeta.Description
}
return metadata
} }
// Fall back to simple YAML parsing // Fall back to simple YAML parsing
yamlMeta := sl.parseSimpleYAML(frontmatter) yamlMeta := sl.parseSimpleYAML(frontmatter)
return &SkillMetadata{ if name := yamlMeta["name"]; name != "" {
Name: yamlMeta["name"], metadata.Name = name
Description: yamlMeta["description"],
} }
if description := yamlMeta["description"]; description != "" {
metadata.Description = description
}
return metadata
} }
// parseSimpleYAML parses simple key: value YAML format func extractMarkdownMetadata(content string) (title, description string) {
// Example: name: github\n description: "..." p := parser.NewWithExtensions(parser.CommonExtensions)
// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac) doc := markdown.Parse([]byte(content), p)
if doc == nil {
return "", ""
}
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if !entering {
return ast.GoToNext
}
switch n := node.(type) {
case *ast.Heading:
if title == "" && n.Level == 1 {
title = nodeText(n)
if title != "" && description != "" {
return ast.Terminate
}
}
case *ast.Paragraph:
if description == "" {
description = nodeText(n)
if title != "" && description != "" {
return ast.Terminate
}
}
}
return ast.GoToNext
})
return title, description
}
func nodeText(n ast.Node) string {
var b strings.Builder
ast.WalkFunc(n, func(node ast.Node, entering bool) ast.WalkStatus {
if !entering {
return ast.GoToNext
}
switch t := node.(type) {
case *ast.Text:
b.Write(t.Literal)
case *ast.Code:
b.Write(t.Literal)
case *ast.Softbreak, *ast.Hardbreak, *ast.NonBlockingSpace:
b.WriteByte(' ')
}
return ast.GoToNext
})
return strings.Join(strings.Fields(b.String()), " ")
}
// parseSimpleYAML parses YAML frontmatter and extracts known metadata fields.
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
result := make(map[string]string) result := make(map[string]string)
// Normalize line endings: convert \r\n and \r to \n var meta struct {
normalized := strings.ReplaceAll(content, "\r\n", "\n") Name string `yaml:"name"`
normalized = strings.ReplaceAll(normalized, "\r", "\n") Description string `yaml:"description"`
for line := range strings.SplitSeq(normalized, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
} }
if err := yaml.Unmarshal([]byte(content), &meta); err != nil {
parts := strings.SplitN(line, ":", 2) return result
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Remove quotes if present
value = strings.Trim(value, "\"'")
result[key] = value
} }
if meta.Name != "" {
result["name"] = meta.Name
}
if meta.Description != "" {
result["description"] = meta.Description
} }
return result return result
} }
func (sl *SkillsLoader) extractFrontmatter(content string) string { func (sl *SkillsLoader) extractFrontmatter(content string) string {
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks frontmatter, _ := splitFrontmatter(content)
match := reFrontmatter.FindStringSubmatch(content) return frontmatter
if len(match) > 1 {
return match[1]
}
return ""
} }
func (sl *SkillsLoader) stripFrontmatter(content string) string { func (sl *SkillsLoader) stripFrontmatter(content string) string {
return reStripFrontmatter.ReplaceAllString(content, "") _, body := splitFrontmatter(content)
return body
}
func splitFrontmatter(content string) (frontmatter, body string) {
normalized := string(parser.NormalizeNewlines([]byte(content)))
lines := strings.Split(normalized, "\n")
if len(lines) == 0 || lines[0] != "---" {
return "", content
}
end := -1
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
end = i
break
}
}
if end == -1 {
return "", content
}
frontmatter = strings.Join(lines[1:end], "\n")
body = strings.Join(lines[end+1:], "\n")
body = strings.TrimLeft(body, "\n")
return frontmatter, body
} }
func escapeXML(s string) string { func escapeXML(s string) string {

View file

@ -342,3 +342,78 @@ func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) {
builtin, builtin,
}, roots) }, roots)
} }
func TestGetSkillMetadata_UsesMarkdownParagraphWhenNoFrontmatter(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "# Plain Skill\n\nThis is parsed from markdown paragraph.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "plain-skill", meta.Name)
assert.Equal(t, "This is parsed from markdown paragraph.", meta.Description)
}
func TestGetSkillMetadata_FrontmatterOverridesMarkdown(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "---\nname: frontmatter-skill\ndescription: frontmatter description\n---\n\n# Plain Skill\n\nBody description.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "frontmatter-skill", meta.Name)
assert.Equal(t, "frontmatter description", meta.Description)
}
func TestGetSkillMetadata_YAMLMultilineDescription(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "---\nname: frontmatter-skill\ndescription: |\n line 1: with colon\n line 2\n---\n\n# Plain Skill\n\nBody description.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "frontmatter-skill", meta.Name)
assert.Equal(t, "line 1: with colon\nline 2", meta.Description)
}
func TestGetSkillMetadata_InvalidHeadingNameFallsBackToDirName(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "valid-name")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "# Invalid Heading Name\n\nBody description.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "valid-name", meta.Name)
assert.Equal(t, "Body description.", meta.Description)
}
func TestGetSkillMetadata_IgnoresHTMLCommentBlocks(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "biomed-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "<!--\n# COPYRIGHT NOTICE\n# This file is part of the \"Universal Biomedical Skills\" project.\n# Copyright (c) 2026 MD BABU MIA, PhD <md.babu.mia@mssm.edu>\n# All Rights Reserved.\n#\n# This code is proprietary and confidential.\n# Unauthorized copying of this file, via any medium is strictly prohibited.\n#\n# Provenance: Authenticated by MD BABU MIA\n\n-->\n\n# Biomed Skill\n\nSummarize biomedical papers.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "biomed-skill", meta.Name)
assert.Equal(t, "Summarize biomedical papers.", meta.Description)
}

View file

@ -40,8 +40,8 @@ func NewManager(workspace string) *Manager {
oldStateFile := filepath.Join(workspace, "state.json") oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist // Create state directory if it doesn't exist
if err := os.MkdirAll(stateDir, 0o755); err != nil { if err := os.MkdirAll(stateDir, 0o700); err != nil {
log.Fatalf("[FATAL] state: failed to create state directory: %v", err) log.Printf("[WARN] state: failed to create state directory %s: %v", stateDir, err)
} }
sm := &Manager{ sm := &Manager{

View file

@ -2,7 +2,6 @@ package state
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@ -217,10 +216,7 @@ func TestNewManager_EmptyWorkspace(t *testing.T) {
} }
} }
func TestNewManager_MkdirFailureCrashes(t *testing.T) { func TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) {
// Since log.Fatalf calls os.Exit(1), we cannot test it normally
// Otherwise, the test suite would stop altogether.
// We use the standard pattern of Go: rerun this test in a subprocess.
if os.Getenv("BE_CRASHER") == "1" { if os.Getenv("BE_CRASHER") == "1" {
tmpDir := os.Getenv("CRASH_DIR") tmpDir := os.Getenv("CRASH_DIR")
@ -240,15 +236,11 @@ func TestNewManager_MkdirFailureCrashes(t *testing.T) {
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cmd := exec.Command(os.Args[0], "-test.run=TestNewManager_MkdirFailureCrashes") cmd := exec.Command(os.Args[0], "-test.run=TestNewManager_MkdirFailureDoesNotCrash")
cmd.Env = append(os.Environ(), "BE_CRASHER=1", "CRASH_DIR="+tmpDir) cmd.Env = append(os.Environ(), "BE_CRASHER=1", "CRASH_DIR="+tmpDir)
err = cmd.Run() err = cmd.Run()
if err != nil {
var e *exec.ExitError t.Fatalf("NewManager should not crash when state dir creation fails, got: %v", err)
if errors.As(err, &e) && !e.Success() {
return
} }
t.Fatalf("The process ended without error, a crash was expected via os.Exit(1). Err: %v", err)
} }

View file

@ -8,6 +8,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
@ -73,6 +74,10 @@ func (t *CronTool) Parameters() map[string]any {
"type": "string", "type": "string",
"description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.", "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.",
}, },
"command_confirm": map[string]any{
"type": "boolean",
"description": "Required when using command=true. Must be true to explicitly confirm scheduling a shell command.",
},
"at_seconds": map[string]any{ "at_seconds": map[string]any{
"type": "integer", "type": "integer",
"description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.", "description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.",
@ -175,12 +180,17 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
deliver = d deliver = d
} }
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel + explicit confirm.
// Non-command reminders (plain messages) remain open to all channels.
command, _ := args["command"].(string) command, _ := args["command"].(string)
commandConfirm, _ := args["command_confirm"].(bool)
if command != "" { if command != "" {
// Commands must be processed by agent/exec tool, so deliver must be false (or handled specifically) if !constants.IsInternalChannel(channel) {
// Actually, let's keep deliver=false to let the system know it's not a simple chat message return ErrorResult("scheduling command execution is restricted to internal channels")
// But for our new logic in ExecuteJob, we can handle it regardless of deliver flag if Payload.Command is set. }
// However, logically, it's not "delivered" to chat directly as is. if !commandConfirm {
return ErrorResult("command_confirm=true is required to schedule command execution")
}
deliver = false deliver = false
} }
@ -282,6 +292,8 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
if job.Payload.Command != "" { if job.Payload.Command != "" {
args := map[string]any{ args := map[string]any{
"command": job.Payload.Command, "command": job.Payload.Command,
"__channel": channel,
"__chat_id": chatID,
} }
result := t.execTool.Execute(ctx, args) result := t.execTool.Execute(ctx, args)

116
pkg/tools/cron_test.go Normal file
View file

@ -0,0 +1,116 @@
package tools
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
)
func newTestCronTool(t *testing.T) *CronTool {
t.Helper()
storePath := filepath.Join(t.TempDir(), "cron.json")
cronService := cron.NewCronService(storePath, nil)
msgBus := bus.NewMessageBus()
cfg := config.DefaultConfig()
tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg)
if err != nil {
t.Fatalf("NewCronTool() error: %v", err)
}
return tool
}
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"command_confirm": true,
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected command scheduling to be blocked from remote channel")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels") {
t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM)
}
}
// TestCronTool_CommandRequiresConfirm verifies command_confirm=true is required
func TestCronTool_CommandRequiresConfirm(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "cli", "direct")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected error when command_confirm is missing")
}
if !strings.Contains(result.ForLLM, "command_confirm=true") {
t.Errorf("expected 'command_confirm=true' message, got: %s", result.ForLLM)
}
}
// TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels
func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "cli", "direct")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"command_confirm": true,
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected command scheduling to succeed from internal channel, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Cron job added") {
t.Errorf("expected 'Cron job added', got: %s", result.ForLLM)
}
}
// TestCronTool_AddJobRequiresSessionContext verifies fail-closed when channel/chatID missing
func TestCronTool_AddJobRequiresSessionContext(t *testing.T) {
tool := newTestCronTool(t)
result := tool.Execute(context.Background(), map[string]any{
"action": "add",
"message": "reminder",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected error when session context is missing")
}
if !strings.Contains(result.ForLLM, "no session context") {
t.Errorf("expected 'no session context' message, got: %s", result.ForLLM)
}
}
// TestCronTool_NonCommandJobAllowedFromRemoteChannel verifies regular reminders work from any channel
func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "time to stretch",
"at_seconds": float64(600),
})
if result.IsError {
t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM)
}
}

View file

@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
) )
type ExecTool struct { type ExecTool struct {
@ -23,6 +24,7 @@ type ExecTool struct {
allowPatterns []*regexp.Regexp allowPatterns []*regexp.Regexp
customAllowPatterns []*regexp.Regexp customAllowPatterns []*regexp.Regexp
restrictToWorkspace bool restrictToWorkspace bool
allowRemote bool
} }
var ( var (
@ -100,10 +102,12 @@ func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) { func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0) denyPatterns := make([]*regexp.Regexp, 0)
customAllowPatterns := make([]*regexp.Regexp, 0) customAllowPatterns := make([]*regexp.Regexp, 0)
allowRemote := true
if config != nil { if config != nil {
execConfig := config.Tools.Exec execConfig := config.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns enableDenyPatterns := execConfig.EnableDenyPatterns
allowRemote = execConfig.AllowRemote
if enableDenyPatterns { if enableDenyPatterns {
denyPatterns = append(denyPatterns, defaultDenyPatterns...) denyPatterns = append(denyPatterns, defaultDenyPatterns...)
if len(execConfig.CustomDenyPatterns) > 0 { if len(execConfig.CustomDenyPatterns) > 0 {
@ -143,6 +147,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
allowPatterns: nil, allowPatterns: nil,
customAllowPatterns: customAllowPatterns, customAllowPatterns: customAllowPatterns,
restrictToWorkspace: restrict, restrictToWorkspace: restrict,
allowRemote: allowRemote,
}, nil }, nil
} }
@ -177,6 +182,19 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
return ErrorResult("command is required") return ErrorResult("command is required")
} }
// GHSA-pv8c-p6jf-3fpp: block exec from remote channels (e.g. Telegram webhooks)
// unless explicitly opted-in via config. Fail-closed: empty channel = blocked.
if !t.allowRemote {
channel := ToolChannel(ctx)
if channel == "" {
channel, _ = args["__channel"].(string)
}
channel = strings.TrimSpace(channel)
if channel == "" || !constants.IsInternalChannel(channel) {
return ErrorResult("exec is restricted to internal channels")
}
}
cwd := t.workingDir cwd := t.workingDir
if wd, ok := args["working_dir"].(string); ok && wd != "" { if wd, ok := args["working_dir"].(string); ok && wd != "" {
if t.restrictToWorkspace && t.workingDir != "" { if t.restrictToWorkspace && t.workingDir != "" {
@ -201,6 +219,25 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
return ErrorResult(guardError) return ErrorResult(guardError)
} }
// Re-resolve symlinks immediately before execution to shrink the TOCTOU window
// between validation and cmd.Dir assignment.
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
resolved, err := filepath.EvalSymlinks(cwd)
if err != nil {
return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err))
}
absWorkspace, _ := filepath.Abs(t.workingDir)
wsResolved, _ := filepath.EvalSymlinks(absWorkspace)
if wsResolved == "" {
wsResolved = absWorkspace
}
rel, err := filepath.Rel(wsResolved, resolved)
if err != nil || !filepath.IsLocal(rel) {
return ErrorResult("Command blocked by safety guard (working directory escaped workspace)")
}
cwd = resolved
}
// timeout == 0 means no timeout // timeout == 0 means no timeout
var cmdCtx context.Context var cmdCtx context.Context
var cancel context.CancelFunc var cancel context.CancelFunc

View file

@ -301,6 +301,85 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
} }
} }
// TestShellTool_RemoteChannelBlockedByDefault verifies exec is blocked for remote channels
func TestShellTool_RemoteChannelBlockedByDefault(t *testing.T) {
cfg := &config.Config{}
cfg.Tools.Exec.EnableDenyPatterns = true
cfg.Tools.Exec.AllowRemote = false
tool, err := NewExecToolWithConfig("", false, cfg)
if err != nil {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
if !result.IsError {
t.Fatal("expected remote-channel exec to be blocked")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels") {
t.Errorf("expected 'restricted to internal channels' message, got: %s", result.ForLLM)
}
}
// TestShellTool_InternalChannelAllowed verifies exec is allowed for internal channels
func TestShellTool_InternalChannelAllowed(t *testing.T) {
cfg := &config.Config{}
cfg.Tools.Exec.EnableDenyPatterns = true
cfg.Tools.Exec.AllowRemote = false
tool, err := NewExecToolWithConfig("", false, cfg)
if err != nil {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "cli", "direct")
result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
if result.IsError {
t.Fatalf("expected internal channel exec to succeed, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "hi") {
t.Errorf("expected output to contain 'hi', got: %s", result.ForLLM)
}
}
// TestShellTool_EmptyChannelBlockedWhenNotAllowRemote verifies fail-closed when no channel context
func TestShellTool_EmptyChannelBlockedWhenNotAllowRemote(t *testing.T) {
cfg := &config.Config{}
cfg.Tools.Exec.EnableDenyPatterns = true
cfg.Tools.Exec.AllowRemote = false
tool, err := NewExecToolWithConfig("", false, cfg)
if err != nil {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"command": "echo hi",
})
if !result.IsError {
t.Fatal("expected exec with empty channel to be blocked when allowRemote=false")
}
}
// TestShellTool_AllowRemoteBypassesChannelCheck verifies allowRemote=true permits any channel
func TestShellTool_AllowRemoteBypassesChannelCheck(t *testing.T) {
cfg := &config.Config{}
cfg.Tools.Exec.EnableDenyPatterns = true
cfg.Tools.Exec.AllowRemote = true
tool, err := NewExecToolWithConfig("", false, cfg)
if err != nil {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
if result.IsError {
t.Fatalf("expected allowRemote=true to permit remote channel, got: %s", result.ForLLM)
}
}
// TestShellTool_RestrictToWorkspace verifies workspace restriction // TestShellTool_RestrictToWorkspace verifies workspace restriction
func TestShellTool_RestrictToWorkspace(t *testing.T) { func TestShellTool_RestrictToWorkspace(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()

View file

@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
@ -818,6 +819,10 @@ func NewWebFetchTool(maxChars int, fetchLimitBytes int64) (*WebFetchTool, error)
return NewWebFetchToolWithProxy(maxChars, "", fetchLimitBytes) return NewWebFetchToolWithProxy(maxChars, "", fetchLimitBytes)
} }
// allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed.
// This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily.
var allowPrivateWebFetchHosts atomic.Bool
func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) (*WebFetchTool, error) { func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) (*WebFetchTool, error) {
if maxChars <= 0 { if maxChars <= 0 {
maxChars = defaultMaxChars maxChars = defaultMaxChars
@ -826,10 +831,20 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
} }
if transport, ok := client.Transport.(*http.Transport); ok {
dialer := &net.Dialer{
Timeout: 15 * time.Second,
KeepAlive: 30 * time.Second,
}
transport.DialContext = newSafeDialContext(dialer)
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error { client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects { if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects) return fmt.Errorf("stopped after %d redirects", maxRedirects)
} }
if isObviousPrivateHost(req.URL.Hostname()) {
return fmt.Errorf("redirect target is private or local network host")
}
return nil return nil
} }
if fetchLimitBytes <= 0 { if fetchLimitBytes <= 0 {
@ -888,6 +903,13 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("missing domain in URL") return ErrorResult("missing domain in URL")
} }
// Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution.
// The real SSRF guard is newSafeDialContext at connect time.
hostname := parsedURL.Hostname()
if isObviousPrivateHost(hostname) {
return ErrorResult("fetching private or local network hosts is not allowed")
}
maxChars := t.maxChars maxChars := t.maxChars
if mc, ok := args["maxChars"].(float64); ok { if mc, ok := args["maxChars"].(float64); ok {
if int(mc) > 100 { if int(mc) > 100 {
@ -901,7 +923,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
resp, err := t.client.Do(req) resp, err := t.client.Do(req)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("request failed: %v", err)) return ErrorResult(fmt.Sprintf("request failed: %v", err))
@ -992,3 +1013,127 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
return strings.Join(cleanLines, "\n") return strings.Join(cleanLines, "\n")
} }
// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU)
// where a hostname resolves to a public IP during pre-flight but a private IP at connect time.
func newSafeDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
if allowPrivateWebFetchHosts.Load() {
return dialer.DialContext(ctx, network, address)
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid target address %q: %w", address, err)
}
if host == "" {
return nil, fmt.Errorf("empty target host")
}
if ip := net.ParseIP(host); ip != nil {
if isPrivateOrRestrictedIP(ip) {
return nil, fmt.Errorf("blocked private or local target: %s", host)
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("failed to resolve %s: %w", host, err)
}
attempted := 0
var lastErr error
for _, ipAddr := range ipAddrs {
if isPrivateOrRestrictedIP(ipAddr.IP) {
continue
}
attempted++
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if attempted == 0 {
return nil, fmt.Errorf("all resolved addresses for %s are private or restricted", host)
}
if lastErr != nil {
return nil, fmt.Errorf("failed connecting to public addresses for %s: %w", host, lastErr)
}
return nil, fmt.Errorf("failed connecting to public addresses for %s", host)
}
}
// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts.
// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS —
// the real SSRF guard is newSafeDialContext which checks IPs at connect time.
func isObviousPrivateHost(host string) bool {
if allowPrivateWebFetchHosts.Load() {
return false
}
h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimSuffix(h, ".")
if h == "" {
return true
}
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
return true
}
if ip := net.ParseIP(h); ip != nil {
return isPrivateOrRestrictedIP(ip)
}
return false
}
// isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch:
// RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT,
// IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and Teredo (2001:0000::/32).
func isPrivateOrRestrictedIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() {
return true
}
if ip4 := ip.To4(); ip4 != nil {
// IPv4 private, loopback, link-local, and carrier-grade NAT ranges.
if ip4[0] == 10 ||
ip4[0] == 127 ||
ip4[0] == 0 ||
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
(ip4[0] == 192 && ip4[1] == 168) ||
(ip4[0] == 169 && ip4[1] == 254) ||
(ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) {
return true
}
return false
}
if len(ip) == net.IPv6len {
// IPv6 unique local addresses (fc00::/7)
if (ip[0] & 0xfe) == 0xfc {
return true
}
// 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6].
if ip[0] == 0x20 && ip[1] == 0x02 {
embedded := net.IPv4(ip[2], ip[3], ip[4], ip[5])
return isPrivateOrRestrictedIP(embedded)
}
// Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted.
if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 {
client := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff)
return isPrivateOrRestrictedIP(client)
}
}
return false
}

View file

@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@ -18,6 +19,8 @@ const testFetchLimit = int64(10 * 1024 * 1024)
// TestWebTool_WebFetch_Success verifies successful URL fetching // TestWebTool_WebFetch_Success verifies successful URL fetching
func TestWebTool_WebFetch_Success(t *testing.T) { func TestWebTool_WebFetch_Success(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@ -55,6 +58,8 @@ func TestWebTool_WebFetch_Success(t *testing.T) {
// TestWebTool_WebFetch_JSON verifies JSON content handling // TestWebTool_WebFetch_JSON verifies JSON content handling
func TestWebTool_WebFetch_JSON(t *testing.T) { func TestWebTool_WebFetch_JSON(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
testData := map[string]string{"key": "value", "number": "123"} testData := map[string]string{"key": "value", "number": "123"}
expectedJSON, _ := json.MarshalIndent(testData, "", " ") expectedJSON, _ := json.MarshalIndent(testData, "", " ")
@ -163,6 +168,8 @@ func TestWebTool_WebFetch_MissingURL(t *testing.T) {
// TestWebTool_WebFetch_Truncation verifies content truncation // TestWebTool_WebFetch_Truncation verifies content truncation
func TestWebTool_WebFetch_Truncation(t *testing.T) { func TestWebTool_WebFetch_Truncation(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
longContent := strings.Repeat("x", 20000) longContent := strings.Repeat("x", 20000)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -205,6 +212,8 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
} }
func TestWebFetchTool_PayloadTooLarge(t *testing.T) { func TestWebFetchTool_PayloadTooLarge(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
// Create a mock HTTP server // Create a mock HTTP server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
@ -290,6 +299,8 @@ func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction // TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction
func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@ -404,6 +415,205 @@ func TestWebFetchTool_extractText(t *testing.T) {
} }
} }
func withPrivateWebFetchHostsAllowed(t *testing.T) {
t.Helper()
previous := allowPrivateWebFetchHosts.Load()
allowPrivateWebFetchHosts.Store(true)
t.Cleanup(func() {
allowPrivateWebFetchHosts.Store(previous)
})
}
func TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": "http://127.0.0.1:0",
})
if !result.IsError {
t.Errorf("expected error for private host URL, got success")
}
if !strings.Contains(result.ForLLM, "private or local network") &&
!strings.Contains(result.ForUser, "private or local network") {
t.Errorf("expected private host block message, got %q", result.ForLLM)
}
}
func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
defer server.Close()
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": server.URL,
})
if result.IsError {
t.Errorf("expected success when private host access is allowed in tests, got %q", result.ForLLM)
}
}
// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked
func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[::ffff:127.0.0.1]:0",
})
if !result.IsError {
t.Error("expected error for IPv4-mapped IPv6 loopback URL, got success")
}
}
// TestWebFetch_BlocksMetadataIP verifies 169.254.169.254 is blocked
func TestWebFetch_BlocksMetadataIP(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": "http://169.254.169.254/latest/meta-data",
})
if !result.IsError {
t.Error("expected error for cloud metadata IP, got success")
}
}
// TestWebFetch_BlocksIPv6UniqueLocal verifies fc00::/7 addresses are blocked
func TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[fd00::1]:0",
})
if !result.IsError {
t.Error("expected error for IPv6 unique local address, got success")
}
}
// TestWebFetch_Blocks6to4WithPrivateEmbed verifies 6to4 with private embedded IPv4 is blocked
func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
// 2002:7f00:0001::1 embeds 127.0.0.1
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[2002:7f00:0001::1]:0",
})
if !result.IsError {
t.Error("expected error for 6to4 with private embedded IPv4, got success")
}
}
// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked
func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
// 2002:0801:0101::1 embeds 8.1.1.1 (public) — pre-flight should pass,
// connection will fail (no listener) but that's after the SSRF check.
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[2002:0801:0101::1]:0",
})
// Should NOT be blocked by SSRF check — error should be connection failure, not "private"
if result.IsError && strings.Contains(result.ForLLM, "private") {
t.Error("6to4 with public embedded IPv4 should not be blocked as private")
}
}
// TestWebFetch_RedirectToPrivateBlocked verifies redirects to private IPs are blocked
func TestWebFetch_RedirectToPrivateBlocked(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Redirect to a private IP
http.Redirect(w, r, "http://10.0.0.1/secret", http.StatusFound)
}))
defer server.Close()
// Temporarily disable private host allowance for the redirect check
allowPrivateWebFetchHosts.Store(false)
defer allowPrivateWebFetchHosts.Store(true)
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": server.URL,
})
if !result.IsError {
t.Error("expected error when redirecting to private IP, got success")
}
}
// TestIsPrivateOrRestrictedIP_Table tests IP classification logic
func TestIsPrivateOrRestrictedIP_Table(t *testing.T) {
tests := []struct {
ip string
blocked bool
desc string
}{
{"127.0.0.1", true, "IPv4 loopback"},
{"10.0.0.1", true, "IPv4 private class A"},
{"172.16.0.1", true, "IPv4 private class B"},
{"192.168.1.1", true, "IPv4 private class C"},
{"169.254.169.254", true, "link-local / cloud metadata"},
{"100.64.0.1", true, "carrier-grade NAT"},
{"0.0.0.0", true, "unspecified"},
{"8.8.8.8", false, "public DNS"},
{"1.1.1.1", false, "public DNS"},
{"::1", true, "IPv6 loopback"},
{"::ffff:127.0.0.1", true, "IPv4-mapped IPv6 loopback"},
{"::ffff:10.0.0.1", true, "IPv4-mapped IPv6 private"},
{"fc00::1", true, "IPv6 unique local"},
{"fd00::1", true, "IPv6 unique local"},
{"2002:7f00:0001::1", true, "6to4 with embedded 127.x (private)"},
{"2002:0a00:0001::1", true, "6to4 with embedded 10.0.0.1 (private)"},
{"2002:0801:0101::1", false, "6to4 with embedded 8.1.1.1 (public)"},
{"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true, "Teredo with client 10.0.0.1 (private)"},
{"2001:0000:4136:e378:8000:63bf:f7f6:fefe", false, "Teredo with client 8.9.1.1 (public)"},
{"2607:f8b0:4004:800::200e", false, "public IPv6 (Google)"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("failed to parse IP: %s", tt.ip)
}
got := isPrivateOrRestrictedIP(ip)
if got != tt.blocked {
t.Errorf("isPrivateOrRestrictedIP(%s) = %v, want %v", tt.ip, got, tt.blocked)
}
})
}
}
// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain // TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain
func TestWebTool_WebFetch_MissingDomain(t *testing.T) { func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit) tool, err := NewWebFetchTool(50000, testFetchLimit)

View file

@ -166,11 +166,7 @@ func (t *GroqTranscriber) Name() string {
// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or // DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
// nil if no supported transcription provider is configured. // nil if no supported transcription provider is configured.
func DetectTranscriber(cfg *config.Config) Transcriber { func DetectTranscriber(cfg *config.Config) Transcriber {
// Direct Groq provider config takes priority. // return any model-list entry that uses the groq/ protocol.
if key := cfg.Providers.Groq.APIKey; key != "" {
return NewGroqTranscriber(key)
}
// Fall back to any model-list entry that uses the groq/ protocol.
for _, mc := range cfg.ModelList { for _, mc := range cfg.ModelList {
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
return NewGroqTranscriber(mc.APIKey) return NewGroqTranscriber(mc.APIKey)

View file

@ -34,15 +34,6 @@ func TestDetectTranscriber(t *testing.T) {
cfg: &config.Config{}, cfg: &config.Config{},
wantNil: true, wantNil: true,
}, },
{
name: "groq provider key",
cfg: &config.Config{
Providers: config.ProvidersConfig{
Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
},
},
wantName: "groq",
},
{ {
name: "groq via model list", name: "groq via model list",
cfg: &config.Config{ cfg: &config.Config{
@ -65,9 +56,6 @@ func TestDetectTranscriber(t *testing.T) {
{ {
name: "provider key takes priority over model list", name: "provider key takes priority over model list",
cfg: &config.Config{ cfg: &config.Config{
Providers: config.ProvidersConfig{
Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
},
ModelList: []config.ModelConfig{ ModelList: []config.ModelConfig{
{Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
}, },

View file

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
@ -17,36 +16,11 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig) mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
} }
// loadFilteredConfig loads the configuration and filters out default placeholder credentials
// (like API limits/keys) if the configuration file has not been created yet by the user.
func (h *Handler) loadFilteredConfig() (*config.Config, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return nil, err
}
configExists := false
if h.configPath != "" {
if _, err := os.Stat(h.configPath); err == nil {
configExists = true
}
}
if !configExists {
for i := range cfg.ModelList {
cfg.ModelList[i].APIKey = ""
cfg.ModelList[i].AuthMethod = ""
}
}
return cfg, nil
}
// handleGetConfig returns the complete system configuration. // handleGetConfig returns the complete system configuration.
// //
// GET /api/config // GET /api/config
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadFilteredConfig() cfg, err := config.LoadConfig(h.configPath)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return return
@ -74,6 +48,9 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return return
} }
if execAllowRemoteOmitted(body) {
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
}
if errs := validateConfig(&cfg); len(errs) > 0 { if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -94,6 +71,20 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
} }
func execAllowRemoteOmitted(body []byte) bool {
var raw struct {
Tools *struct {
Exec *struct {
AllowRemote *bool `json:"allow_remote"`
} `json:"exec"`
} `json:"tools"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return false
}
return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil
}
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396). // handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
// Only the fields present in the request body will be updated; all other fields remain unchanged. // Only the fields present in the request body will be updated; all other fields remain unchanged.
// //

View file

@ -0,0 +1,88 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace"
}
},
"model_list": [
{
"model_name": "custom-default",
"model": "openai/gpt-4o",
"api_key": "sk-default"
}
]
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("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)
}
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config")
}
}
func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace"
}
},
"model_list": [
{
"model_name": "custom-default",
"model": "openai/gpt-4o",
"api_key": "sk-default"
}
]
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("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)
}
if got := cfg.ModelList[0].APIBase; got != "" {
t.Fatalf("model_list[0].api_base = %q, want empty string", got)
}
}

View file

@ -10,7 +10,6 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
@ -19,6 +18,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/utils"
) )
// gateway holds the state for the managed gateway process. // gateway holds the state for the managed gateway process.
@ -36,6 +36,7 @@ var gateway = struct {
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus) mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents) mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs)
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart) mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop) mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart) mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart)
@ -89,11 +90,12 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
return false, fmt.Sprintf("default model %q is invalid", modelName), nil return false, fmt.Sprintf("default model %q is invalid", modelName), nil
} }
hasCredential := strings.TrimSpace(modelCfg.APIKey) != "" || if !hasModelConfiguration(*modelCfg) {
strings.TrimSpace(modelCfg.AuthMethod) != ""
if !hasCredential {
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
} }
if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) {
return false, fmt.Sprintf("default model %q is not reachable", modelName), nil
}
return true, "", nil return true, "", nil
} }
@ -131,14 +133,18 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
func (h *Handler) startGatewayLocked() (int, error) { func (h *Handler) startGatewayLocked() (int, error) {
// Locate the picoclaw executable // Locate the picoclaw executable
execPath := findPicoclawBinary() execPath := utils.FindPicoclawBinary()
cmd := exec.Command(execPath, "gateway") cmd := exec.Command(execPath, "gateway")
cmd.Env = os.Environ()
// Forward the launcher's config path via the environment variable that // Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same // GetConfigPath() already reads, so the gateway sub-process uses the same
// config file without requiring a --config flag on the gateway subcommand. // config file without requiring a --config flag on the gateway subcommand.
if h.configPath != "" { if h.configPath != "" {
cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+h.configPath) cmd.Env = append(cmd.Env, "PICOCLAW_CONFIG="+h.configPath)
}
if host := h.gatewayHostOverride(); host != "" {
cmd.Env = append(cmd.Env, "PICOCLAW_GATEWAY_HOST="+host)
} }
stdoutPipe, err := cmd.StdoutPipe() stdoutPipe, err := cmd.StdoutPipe()
@ -207,10 +213,7 @@ func (h *Handler) startGatewayLocked() (int, error) {
if err != nil { if err != nil {
continue continue
} }
healthHost := "127.0.0.1" healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
healthHost = cfg.Gateway.Host
}
healthPort := cfg.Gateway.Port healthPort := cfg.Gateway.Port
if healthPort == 0 { if healthPort == 0 {
healthPort = 18790 healthPort = 18790
@ -353,6 +356,20 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
h.handleGatewayStart(w, r) h.handleGatewayStart(w, r)
} }
// handleGatewayClearLogs clears the in-memory gateway log buffer.
//
// POST /api/gateway/logs/clear
func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) {
gateway.logs.Clear()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "cleared",
"log_total": 0,
"log_run_id": gateway.logs.RunID(),
})
}
// handleGatewayStatus returns the gateway run status, health info, and logs. // handleGatewayStatus returns the gateway run status, health info, and logs.
// //
// GET /api/gateway/status // GET /api/gateway/status
@ -375,9 +392,7 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
host := "127.0.0.1" host := "127.0.0.1"
port := 18790 port := 18790
if err == nil && cfg != nil { if err == nil && cfg != nil {
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" { host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
host = cfg.Gateway.Host
}
if cfg.Gateway.Port != 0 { if cfg.Gateway.Port != 0 {
port = cfg.Gateway.Port port = cfg.Gateway.Port
} }
@ -535,36 +550,6 @@ func (h *Handler) currentGatewayStatus() string {
return string(encoded) return string(encoded)
} }
// findPicoclawBinary locates the picoclaw executable.
// Search order:
// 1. PICOCLAW_BINARY environment variable (explicit override)
// 2. Same directory as the current executable
// 3. Falls back to "picoclaw" and relies on $PATH
func findPicoclawBinary() string {
binaryName := "picoclaw"
if runtime.GOOS == "windows" {
binaryName = "picoclaw.exe"
}
// 1. Explicit override via environment variable
if p := os.Getenv("PICOCLAW_BINARY"); p != "" {
if info, _ := os.Stat(p); info != nil && !info.IsDir() {
return p
}
}
// 2. Same directory as the launcher executable
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), binaryName)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
// 3. Fall back to PATH lookup
return "picoclaw"
}
// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF. // scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.
func scanPipe(r io.Reader, buf *LogBuffer) { func scanPipe(r io.Reader, buf *LogBuffer) {
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)

View file

@ -0,0 +1,66 @@
package api
import (
"net"
"net/http"
"strconv"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
)
func (h *Handler) effectiveLauncherPublic() bool {
if h.serverPublicExplicit {
return h.serverPublic
}
cfg, err := h.loadLauncherConfig()
if err == nil {
return cfg.Public
}
return h.serverPublic
}
func (h *Handler) gatewayHostOverride() string {
if h.effectiveLauncherPublic() {
return "0.0.0.0"
}
return ""
}
func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
if override := h.gatewayHostOverride(); override != "" {
return override
}
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.Gateway.Host)
}
func gatewayProbeHost(bindHost string) string {
if bindHost == "" || bindHost == "0.0.0.0" {
return "127.0.0.1"
}
return bindHost
}
func requestHostName(r *http.Request) string {
reqHost, _, err := net.SplitHostPort(r.Host)
if err == nil {
return reqHost
}
if strings.TrimSpace(r.Host) != "" {
return r.Host
}
return "127.0.0.1"
}
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
host := h.effectiveGatewayBindHost(cfg)
if host == "" || host == "0.0.0.0" {
host = requestHostName(r)
}
return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
}

View file

@ -0,0 +1,59 @@
package api
import (
"net/http/httptest"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
launcherPath := launcherconfig.PathForAppConfig(configPath)
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
Port: 18800,
Public: false,
}); err != nil {
t.Fatalf("launcherconfig.Save() error = %v", err)
}
h := NewHandler(configPath)
h.SetServerOptions(18800, true, true, nil)
if got := h.gatewayHostOverride(); got != "0.0.0.0" {
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
}
}
func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
launcherPath := launcherconfig.PathForAppConfig(configPath)
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
Port: 18800,
Public: true,
}); err != nil {
t.Fatalf("launcherconfig.Save() error = %v", err)
}
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = 18790
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
req.Host = "192.168.1.9:18800"
if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18790/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18790/pico/ws")
}
}
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" {
t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
}
}

View file

@ -6,10 +6,13 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/utils"
) )
func TestGatewayStartReady_NoDefaultModel(t *testing.T) { func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
@ -31,8 +34,9 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) { func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json") configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Agents.Defaults.Model = "missing-model" cfg.Agents.Defaults.ModelName = "missing-model"
if err := config.SaveConfig(configPath, cfg); err != nil { err := config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err) t.Fatalf("SaveConfig() error = %v", err)
} }
@ -54,7 +58,8 @@ func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key" cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil { err := config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err) t.Fatalf("SaveConfig() error = %v", err)
} }
@ -74,7 +79,8 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "" cfg.ModelList[0].APIKey = ""
cfg.ModelList[0].AuthMethod = "" cfg.ModelList[0].AuthMethod = ""
if err := config.SaveConfig(configPath, cfg); err != nil { err := config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err) t.Fatalf("SaveConfig() error = %v", err)
} }
@ -91,6 +97,195 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
} }
} }
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetModelProbeHooks(t)
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
return false
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://localhost:8000/v1",
}}
cfg.Agents.Defaults.ModelName = "local-vllm"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false without a running local service")
}
if !strings.Contains(reason, "not reachable") {
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "not reachable")
}
}
func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetModelProbeHooks(t)
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
}}
cfg.Agents.Defaults.ModelName = "local-vllm"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true with a running local service (reason=%q)", reason)
}
}
func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetModelProbeHooks(t)
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
t.Fatalf("unexpected OpenAI-compatible probe for %q (%q)", apiBase, modelID)
return false
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "remote-vllm",
Model: "vllm/custom-model",
APIBase: "https://models.example.com/v1",
APIKey: "remote-key",
}}
cfg.Agents.Defaults.ModelName = "remote-vllm"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true for remote vllm with api key (reason=%q)", reason)
}
}
func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetModelProbeHooks(t)
probeOllamaModelFunc = func(apiBase, modelID string) bool {
return apiBase == "http://localhost:11434/v1" && modelID == "llama3"
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "local-ollama",
Model: "ollama/llama3",
}}
cfg.Agents.Defaults.ModelName = "local-ollama"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true with default Ollama probe base (reason=%q)", reason)
}
}
func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "openai-oauth",
Model: "openai/gpt-5.2",
AuthMethod: "oauth",
}}
cfg.Agents.Defaults.ModelName = "openai-oauth"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false without stored credential")
}
if !strings.Contains(reason, "no credentials configured") {
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured")
}
err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
AccessToken: "openai-token",
Provider: oauthProviderOpenAI,
AuthMethod: "oauth",
})
if err != nil {
t.Fatalf("SetCredential() error = %v", err)
}
ready, reason, err = h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true with stored credential (reason=%q)", reason)
}
}
func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) { func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json") configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath) h := NewHandler(configPath)
@ -122,6 +317,71 @@ func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
} }
} }
func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
gateway.logs.Clear()
gateway.logs.Append("first line")
gateway.logs.Append("second line")
previousRunID := gateway.logs.RunID()
clearRec := httptest.NewRecorder()
clearReq := httptest.NewRequest(http.MethodPost, "/api/gateway/logs/clear", nil)
mux.ServeHTTP(clearRec, clearReq)
if clearRec.Code != http.StatusOK {
t.Fatalf("clear status = %d, want %d", clearRec.Code, http.StatusOK)
}
var clearBody map[string]any
if err := json.Unmarshal(clearRec.Body.Bytes(), &clearBody); err != nil {
t.Fatalf("unmarshal clear response: %v", err)
}
if got := clearBody["status"]; got != "cleared" {
t.Fatalf("clear status body = %#v, want %q", got, "cleared")
}
clearRunID, ok := clearBody["log_run_id"].(float64)
if !ok {
t.Fatalf("log_run_id missing or not number: %#v", clearBody["log_run_id"])
}
if int(clearRunID) <= previousRunID {
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
}
statusRec := httptest.NewRecorder()
statusReq := httptest.NewRequest(
http.MethodGet,
"/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
nil,
)
mux.ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK)
}
var statusBody map[string]any
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil {
t.Fatalf("unmarshal status response: %v", err)
}
logs, ok := statusBody["logs"].([]any)
if !ok {
t.Fatalf("logs missing or not array: %#v", statusBody["logs"])
}
if len(logs) != 0 {
t.Fatalf("logs len = %d, want 0", len(logs))
}
if got := statusBody["log_total"]; got != float64(0) {
t.Fatalf("log_total = %#v, want 0", got)
}
}
func TestFindPicoclawBinary_EnvOverride(t *testing.T) { func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
// Create a temporary file to act as the mock binary // Create a temporary file to act as the mock binary
tmpDir := t.TempDir() tmpDir := t.TempDir()
@ -132,9 +392,9 @@ func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
t.Setenv("PICOCLAW_BINARY", mockBinary) t.Setenv("PICOCLAW_BINARY", mockBinary)
got := findPicoclawBinary() got := utils.FindPicoclawBinary()
if got != mockBinary { if got != mockBinary {
t.Errorf("findPicoclawBinary() = %q, want %q", got, mockBinary) t.Errorf("FindPicoclawBinary() = %q, want %q", got, mockBinary)
} }
} }
@ -142,9 +402,9 @@ func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) {
// When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy // When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy
t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary") t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary")
got := findPicoclawBinary() got := utils.FindPicoclawBinary()
// Should not return the invalid path; falls back to "picoclaw" or another found path // Should not return the invalid path; falls back to "picoclaw" or another found path
if got == "/nonexistent/picoclaw-binary" { if got == "/nonexistent/picoclaw-binary" {
t.Errorf("findPicoclawBinary() returned invalid env path %q, expected fallback", got) t.Errorf("FindPicoclawBinary() returned invalid env path %q, expected fallback", got)
} }
} }

View file

@ -14,7 +14,7 @@ import (
func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json") configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath) h := NewHandler(configPath)
h.SetServerOptions(19999, true, []string{"192.168.1.0/24"}) h.SetServerOptions(19999, true, false, []string{"192.168.1.0/24"})
mux := http.NewServeMux() mux := http.NewServeMux()
h.RegisterRoutes(mux) h.RegisterRoutes(mux)

View file

@ -4,7 +4,7 @@ import "sync"
// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines. // LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
// It supports incremental reads via LinesSince and tracks a runID that increments // It supports incremental reads via LinesSince and tracks a runID that increments
// on each Reset (used to detect gateway restarts). // whenever the buffer is reset or cleared so clients can detect log history resets.
type LogBuffer struct { type LogBuffer struct {
mu sync.RWMutex mu sync.RWMutex
lines []string lines []string
@ -45,6 +45,12 @@ func (b *LogBuffer) Reset() {
b.runID++ b.runID++
} }
// Clear removes all buffered lines and increments the runID so clients treat
// subsequent reads as a new log stream.
func (b *LogBuffer) Clear() {
b.Reset()
}
// LinesSince returns lines appended after the given offset, the current total count, and the runID. // LinesSince returns lines appended after the given offset, the current total count, and the runID.
// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned. // If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned.
func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) { func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {

View file

@ -0,0 +1,324 @@
package api
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
const modelProbeTimeout = 800 * time.Millisecond
var (
probeTCPServiceFunc = probeTCPService
probeOllamaModelFunc = probeOllamaModel
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
)
func hasModelConfiguration(m config.ModelConfig) bool {
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
apiKey := strings.TrimSpace(m.APIKey)
if authMethod == "oauth" || authMethod == "token" {
if provider, ok := oauthProviderForModel(m.Model); ok {
cred, err := oauthGetCredential(provider)
if err != nil || cred == nil {
return false
}
return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != ""
}
return true
}
if requiresRuntimeProbe(m) {
return true
}
return apiKey != ""
}
// isModelConfigured reports whether a model is currently available to use.
// Local models must be reachable; remote/API-key models only need saved config.
func isModelConfigured(m config.ModelConfig) bool {
if !hasModelConfiguration(m) {
return false
}
if requiresRuntimeProbe(m) {
return probeLocalModelAvailability(m)
}
return true
}
func requiresRuntimeProbe(m config.ModelConfig) bool {
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
if authMethod == "local" {
return true
}
switch modelProtocol(m.Model) {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
return true
case "ollama", "vllm":
apiBase := strings.TrimSpace(m.APIBase)
return apiBase == "" || hasLocalAPIBase(apiBase)
}
if hasLocalAPIBase(m.APIBase) {
return true
}
return false
}
func probeLocalModelAvailability(m config.ModelConfig) bool {
apiBase := modelProbeAPIBase(m)
protocol, modelID := splitModel(m.Model)
switch protocol {
case "ollama":
return probeOllamaModelFunc(apiBase, modelID)
case "vllm":
return probeOpenAICompatibleModelFunc(apiBase, modelID)
case "github-copilot", "copilot":
return probeTCPServiceFunc(apiBase)
case "claude-cli", "claudecli", "codex-cli", "codexcli":
return true
default:
if hasLocalAPIBase(apiBase) {
return probeOpenAICompatibleModelFunc(apiBase, modelID)
}
return false
}
}
func modelProbeAPIBase(m config.ModelConfig) string {
if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" {
return normalizeModelProbeAPIBase(apiBase)
}
switch modelProtocol(m.Model) {
case "ollama":
return "http://localhost:11434/v1"
case "vllm":
return "http://localhost:8000/v1"
case "github-copilot", "copilot":
return "localhost:4321"
default:
return ""
}
}
func normalizeModelProbeAPIBase(raw string) string {
u, err := parseAPIBase(raw)
if err != nil {
return strings.TrimSpace(raw)
}
switch strings.ToLower(u.Hostname()) {
case "0.0.0.0":
u.Host = net.JoinHostPort("127.0.0.1", u.Port())
case "::":
u.Host = net.JoinHostPort("::1", u.Port())
default:
return strings.TrimSpace(raw)
}
if u.Port() == "" {
u.Host = u.Hostname()
}
return u.String()
}
func oauthProviderForModel(model string) (string, bool) {
switch modelProtocol(model) {
case "openai":
return oauthProviderOpenAI, true
case "anthropic":
return oauthProviderAnthropic, true
case "antigravity", "google-antigravity":
return oauthProviderGoogleAntigravity, true
default:
return "", false
}
}
func modelProtocol(model string) string {
protocol, _ := splitModel(model)
return protocol
}
func splitModel(model string) (protocol, modelID string) {
model = strings.ToLower(strings.TrimSpace(model))
protocol, _, found := strings.Cut(model, "/")
if !found {
return "openai", model
}
return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:])
}
func hasLocalAPIBase(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
u, err = url.Parse("//" + raw)
if err != nil {
return false
}
}
switch strings.ToLower(u.Hostname()) {
case "localhost", "127.0.0.1", "::1", "0.0.0.0":
return true
default:
return false
}
}
func probeTCPService(raw string) bool {
hostPort, err := hostPortFromAPIBase(raw)
if err != nil {
return false
}
conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout)
if err != nil {
return false
}
_ = conn.Close()
return true
}
func probeOllamaModel(apiBase, modelID string) bool {
root, err := apiRootFromAPIBase(apiBase)
if err != nil {
return false
}
var resp struct {
Models []struct {
Name string `json:"name"`
Model string `json:"model"`
} `json:"models"`
}
if err := getJSON(root+"/api/tags", &resp); err != nil {
return false
}
for _, model := range resp.Models {
if ollamaModelMatches(model.Name, modelID) || ollamaModelMatches(model.Model, modelID) {
return true
}
}
return false
}
func probeOpenAICompatibleModel(apiBase, modelID string) bool {
if strings.TrimSpace(apiBase) == "" {
return false
}
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp); err != nil {
return false
}
for _, model := range resp.Data {
if strings.EqualFold(strings.TrimSpace(model.ID), modelID) {
return true
}
}
return false
}
func getJSON(rawURL string, out any) error {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: modelProbeTimeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
func apiRootFromAPIBase(raw string) (string, error) {
u, err := parseAPIBase(raw)
if err != nil {
return "", err
}
return (&url.URL{Scheme: u.Scheme, Host: u.Host}).String(), nil
}
func hostPortFromAPIBase(raw string) (string, error) {
u, err := parseAPIBase(raw)
if err != nil {
return "", err
}
if port := u.Port(); port != "" {
return u.Host, nil
}
switch strings.ToLower(u.Scheme) {
case "https":
return net.JoinHostPort(u.Hostname(), "443"), nil
default:
return net.JoinHostPort(u.Hostname(), "80"), nil
}
}
func parseAPIBase(raw string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("empty api base")
}
u, err := url.Parse(raw)
if err == nil && u.Hostname() != "" {
return u, nil
}
u, err = url.Parse("//" + raw)
if err != nil || u.Hostname() == "" {
return nil, fmt.Errorf("invalid api base %q", raw)
}
if u.Scheme == "" {
u.Scheme = "http"
}
return u, nil
}
func ollamaModelMatches(candidate, want string) bool {
candidate = strings.TrimSpace(candidate)
want = strings.TrimSpace(want)
if candidate == "" || want == "" {
return false
}
if strings.EqualFold(candidate, want) {
return true
}
base, _, _ := strings.Cut(candidate, ":")
return strings.EqualFold(base, want)
}

View file

@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"sync"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
@ -45,13 +46,24 @@ type modelResponse struct {
// //
// GET /api/models // GET /api/models
func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadFilteredConfig() cfg, err := config.LoadConfig(h.configPath)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return return
} }
defaultModel := cfg.Agents.Defaults.GetModelName() defaultModel := cfg.Agents.Defaults.GetModelName()
configured := make([]bool, len(cfg.ModelList))
var wg sync.WaitGroup
wg.Add(len(cfg.ModelList))
for i, m := range cfg.ModelList {
go func(i int, m config.ModelConfig) {
defer wg.Done()
configured[i] = isModelConfigured(m)
}(i, m)
}
wg.Wait()
models := make([]modelResponse, 0, len(cfg.ModelList)) models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList { for i, m := range cfg.ModelList {
@ -69,7 +81,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
MaxTokensField: m.MaxTokensField, MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout, RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel, ThinkingLevel: m.ThinkingLevel,
Configured: m.APIKey != "" || m.AuthMethod != "", Configured: configured[i],
IsDefault: m.ModelName == defaultModel, IsDefault: m.ModelName == defaultModel,
}) })
} }
@ -212,9 +224,6 @@ func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) {
if cfg.Agents.Defaults.ModelName == deletedModelName { if cfg.Agents.Defaults.ModelName == deletedModelName {
cfg.Agents.Defaults.ModelName = "" cfg.Agents.Defaults.ModelName = ""
} }
if cfg.Agents.Defaults.Model == deletedModelName {
cfg.Agents.Defaults.Model = ""
}
if err := config.SaveConfig(h.configPath, cfg); err != nil { if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)

View file

@ -0,0 +1,313 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
func resetModelProbeHooks(t *testing.T) {
t.Helper()
origTCPProbe := probeTCPServiceFunc
origOllamaProbe := probeOllamaModelFunc
origOpenAIProbe := probeOpenAICompatibleModelFunc
t.Cleanup(func() {
probeTCPServiceFunc = origTCPProbe
probeOllamaModelFunc = origOllamaProbe
probeOpenAICompatibleModelFunc = origOpenAIProbe
})
}
func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
resetModelProbeHooks(t)
var mu sync.Mutex
var openAIProbes []string
var ollamaProbes []string
var tcpProbes []string
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
mu.Lock()
openAIProbes = append(openAIProbes, apiBase+"|"+modelID)
mu.Unlock()
return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
}
probeOllamaModelFunc = func(apiBase, modelID string) bool {
mu.Lock()
ollamaProbes = append(ollamaProbes, apiBase+"|"+modelID)
mu.Unlock()
return apiBase == "http://localhost:11434/v1" && modelID == "llama3"
}
probeTCPServiceFunc = func(apiBase string) bool {
mu.Lock()
tcpProbes = append(tcpProbes, apiBase)
mu.Unlock()
return apiBase == "http://127.0.0.1:4321"
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{
{
ModelName: "openai-oauth",
Model: "openai/gpt-5.2",
AuthMethod: "oauth",
},
{
ModelName: "vllm-local",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
},
{
ModelName: "ollama-default",
Model: "ollama/llama3",
},
{
ModelName: "vllm-remote",
Model: "vllm/custom-model",
APIBase: "https://models.example.com/v1",
APIKey: "remote-key",
},
{
ModelName: "copilot-gpt-5.2",
Model: "github-copilot/gpt-5.2",
APIBase: "http://127.0.0.1:4321",
AuthMethod: "oauth",
},
}
cfg.Agents.Defaults.ModelName = "openai-oauth"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Models []modelResponse `json:"models"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
got := make(map[string]bool, len(resp.Models))
for _, model := range resp.Models {
got[model.ModelName] = model.Configured
}
if got["openai-oauth"] {
t.Fatalf("openai oauth model configured = true, want false without stored credential")
}
if !got["vllm-local"] {
t.Fatalf("vllm local model configured = false, want true when local probe succeeds")
}
if !got["ollama-default"] {
t.Fatalf("ollama default model configured = false, want true when default local probe succeeds")
}
if !got["vllm-remote"] {
t.Fatalf("remote vllm model configured = false, want true with api_key")
}
if !got["copilot-gpt-5.2"] {
t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
}
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" {
t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes)
}
if len(ollamaProbes) != 1 || ollamaProbes[0] != "http://localhost:11434/v1|llama3" {
t.Fatalf("ollama probes = %#v, want default local probe", ollamaProbes)
}
if len(tcpProbes) != 1 || tcpProbes[0] != "http://127.0.0.1:4321" {
t.Fatalf("tcp probes = %#v, want only local copilot probe", tcpProbes)
}
}
func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
resetModelProbeHooks(t)
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "claude-oauth",
Model: "anthropic/claude-sonnet-4.6",
AuthMethod: "oauth",
}}
cfg.Agents.Defaults.ModelName = "claude-oauth"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{
AccessToken: "anthropic-token",
Provider: oauthProviderAnthropic,
AuthMethod: "oauth",
}); err != nil {
t.Fatalf("SetCredential() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Models []modelResponse `json:"models"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
if !resp.Models[0].Configured {
t.Fatalf("oauth model configured = false, want true with stored credential")
}
}
func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
resetModelProbeHooks(t)
started := make(chan string, 2)
release := make(chan struct{})
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
started <- apiBase + "|" + modelID
<-release
return true
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{
{
ModelName: "local-vllm-a",
Model: "vllm/custom-a",
APIBase: "http://127.0.0.1:8000/v1",
},
{
ModelName: "local-vllm-b",
Model: "vllm/custom-b",
APIBase: "http://127.0.0.1:8001/v1",
},
}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
recCh := make(chan *httptest.ResponseRecorder, 1)
go func() {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
recCh <- rec
}()
for i := 0; i < 2; i++ {
select {
case <-started:
case <-time.After(200 * time.Millisecond):
t.Fatal("expected both local probes to start before the first one completed")
}
}
close(release)
rec := <-recCh
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
resetModelProbeHooks(t)
var gotProbe string
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
gotProbe = apiBase + "|" + modelID
return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "vllm-local",
Model: "vllm/custom-model",
APIBase: "http://0.0.0.0:8000/v1",
}}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Models []modelResponse `json:"models"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
if !resp.Models[0].Configured {
t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization")
}
if gotProbe != "http://127.0.0.1:8000/v1|custom-model" {
t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model")
}
}

View file

@ -744,17 +744,6 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
return err return err
} }
switch provider {
case oauthProviderOpenAI:
cfg.Providers.OpenAI.AuthMethod = authMethod
case oauthProviderAnthropic:
cfg.Providers.Anthropic.AuthMethod = authMethod
case oauthProviderGoogleAntigravity:
cfg.Providers.Antigravity.AuthMethod = authMethod
default:
return fmt.Errorf("unsupported provider %q", provider)
}
found := false found := false
for i := range cfg.ModelList { for i := range cfg.ModelList {
if modelBelongsToProvider(provider, cfg.ModelList[i].Model) { if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {

View file

@ -166,7 +166,6 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LoadConfig error: %v", err) t.Fatalf("LoadConfig error: %v", err)
} }
cfg.Providers.OpenAI.AuthMethod = "oauth"
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2", ModelName: "gpt-5.2",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.2",
@ -208,9 +207,6 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LoadConfig error: %v", err) t.Fatalf("LoadConfig error: %v", err)
} }
if updated.Providers.OpenAI.AuthMethod != "" {
t.Fatalf("providers.openai.auth_method = %q, want empty", updated.Providers.OpenAI.AuthMethod)
}
for _, m := range updated.ModelList { for _, m := range updated.ModelList {
if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" { if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" {
t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod) t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod)

View file

@ -5,9 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -30,7 +28,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
return return
} }
wsURL := buildWsURL(r, cfg) wsURL := h.buildWsURL(r, cfg)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
@ -58,7 +56,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
return return
} }
wsURL := fmt.Sprintf("ws://%s/pico/ws", net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port))) wsURL := h.buildWsURL(r, cfg)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
@ -123,7 +121,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
return return
} }
wsURL := buildWsURL(r, cfg) wsURL := h.buildWsURL(r, cfg)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
@ -134,22 +132,6 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
}) })
} }
// buildWsURL creates a WebSocket URL for the Pico Channel.
// When the gateway host is "0.0.0.0" or empty, it uses the hostname from the
// incoming HTTP request so the browser gets a connectable address.
func buildWsURL(r *http.Request, cfg *config.Config) string {
host := cfg.Gateway.Host
if host == "" || host == "0.0.0.0" {
// Use the hostname the browser used to reach this backend
reqHost, _, err := net.SplitHostPort(r.Host)
if err != nil {
reqHost = r.Host // r.Host might not have a port
}
host = reqHost
}
return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
}
// generateSecureToken creates a random 32-character hex string. // generateSecureToken creates a random 32-character hex string.
func generateSecureToken() string { func generateSecureToken() string {
b := make([]byte, 16) b := make([]byte, 16)

View file

@ -12,6 +12,7 @@ type Handler struct {
configPath string configPath string
serverPort int serverPort int
serverPublic bool serverPublic bool
serverPublicExplicit bool
serverCIDRs []string serverCIDRs []string
oauthMu sync.Mutex oauthMu sync.Mutex
oauthFlows map[string]*oauthFlow oauthFlows map[string]*oauthFlow
@ -29,9 +30,10 @@ func NewHandler(configPath string) *Handler {
} }
// SetServerOptions stores current backend listen options for fallback behavior. // SetServerOptions stores current backend listen options for fallback behavior.
func (h *Handler) SetServerOptions(port int, public bool, allowedCIDRs []string) { func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, allowedCIDRs []string) {
h.serverPort = port h.serverPort = port
h.serverPublic = public h.serverPublic = public
h.serverPublicExplicit = publicExplicit
h.serverCIDRs = append([]string(nil), allowedCIDRs...) h.serverCIDRs = append([]string(nil), allowedCIDRs...)
} }
@ -58,6 +60,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Channel catalog (for frontend navigation/config pages) // Channel catalog (for frontend navigation/config pages)
h.registerChannelRoutes(mux) h.registerChannelRoutes(mux)
// Skills and tools support/actions
h.registerSkillRoutes(mux)
h.registerToolRoutes(mux)
// OS startup / launch-at-login // OS startup / launch-at-login
h.registerStartupRoutes(mux) h.registerStartupRoutes(mux)

View file

@ -1,7 +1,9 @@
package api package api
import ( import (
"bufio"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -33,12 +35,22 @@ type sessionFile struct {
// sessionListItem is a lightweight summary returned by GET /api/sessions. // sessionListItem is a lightweight summary returned by GET /api/sessions.
type sessionListItem struct { type sessionListItem struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
Preview string `json:"preview"` Preview string `json:"preview"`
MessageCount int `json:"message_count"` MessageCount int `json:"message_count"`
Created string `json:"created"` Created string `json:"created"`
Updated string `json:"updated"` Updated string `json:"updated"`
} }
type sessionMetaFile struct {
Key string `json:"key"`
Summary string `json:"summary"`
Skip int `json:"skip"`
Count int `json:"count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// picoSessionPrefix is the key prefix used by the gateway's routing for Pico // picoSessionPrefix is the key prefix used by the gateway's routing for Pico
// channel sessions. The full key format is: // channel sessions. The full key format is:
// //
@ -47,7 +59,12 @@ type sessionListItem struct {
// The sanitized filename replaces ':' with '_', so on disk it becomes: // The sanitized filename replaces ':' with '_', so on disk it becomes:
// //
// agent_main_pico_direct_pico_<session-uuid>.json // agent_main_pico_direct_pico_<session-uuid>.json
const picoSessionPrefix = "agent:main:pico:direct:pico:" const (
picoSessionPrefix = "agent:main:pico:direct:pico:"
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB
maxSessionTitleRunes = 60
)
// extractPicoSessionID extracts the session UUID from a full session key. // extractPicoSessionID extracts the session UUID from a full session key.
// Returns the UUID and true if the key matches the Pico session pattern. // Returns the UUID and true if the key matches the Pico session pattern.
@ -58,6 +75,178 @@ func extractPicoSessionID(key string) (string, bool) {
return "", false return "", false
} }
func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
if strings.HasPrefix(key, sanitizedPicoSessionPrefix) {
return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true
}
return "", false
}
func sanitizeSessionKey(key string) string {
return strings.ReplaceAll(key, ":", "_")
}
func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) {
path := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
data, err := os.ReadFile(path)
if err != nil {
return sessionFile{}, err
}
var sess sessionFile
if err := json.Unmarshal(data, &sess); err != nil {
return sessionFile{}, err
}
return sess, nil
}
func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return sessionMetaFile{Key: sessionKey}, nil
}
if err != nil {
return sessionMetaFile{}, err
}
var meta sessionMetaFile
if err := json.Unmarshal(data, &meta); err != nil {
return sessionMetaFile{}, err
}
if meta.Key == "" {
meta.Key = sessionKey
}
return meta, nil
}
func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Message, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
msgs := make([]providers.Message, 0)
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize)
seen := 0
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
seen++
if seen <= skip {
continue
}
var msg providers.Message
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
msgs = append(msgs, msg)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return msgs, nil
}
func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
sessionKey := picoSessionPrefix + sessionID
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
meta, err := h.readSessionMeta(metaPath, sessionKey)
if err != nil {
return sessionFile{}, err
}
messages, err := h.readSessionMessages(jsonlPath, meta.Skip)
if err != nil {
return sessionFile{}, err
}
updated := meta.UpdatedAt
created := meta.CreatedAt
if created.IsZero() || updated.IsZero() {
if info, statErr := os.Stat(jsonlPath); statErr == nil {
if created.IsZero() {
created = info.ModTime()
}
if updated.IsZero() {
updated = info.ModTime()
}
}
}
return sessionFile{
Key: meta.Key,
Messages: messages,
Summary: meta.Summary,
Created: created,
Updated: updated,
}, nil
}
func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
preview := ""
for _, msg := range sess.Messages {
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
preview = msg.Content
break
}
}
title := strings.TrimSpace(sess.Summary)
if title == "" {
title = preview
}
title = truncateRunes(title, maxSessionTitleRunes)
preview = truncateRunes(preview, maxSessionTitleRunes)
if preview == "" {
preview = "(empty)"
}
if title == "" {
title = preview
}
validMessageCount := 0
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
validMessageCount++
}
}
return sessionListItem{
ID: sessionID,
Title: title,
Preview: preview,
MessageCount: validMessageCount,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
}
}
func isEmptySession(sess sessionFile) bool {
return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == ""
}
func truncateRunes(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(s))
if len(runes) <= maxLen {
return string(runes)
}
return string(runes[:maxLen]) + "..."
}
// sessionsDir resolves the path to the gateway's session storage directory. // sessionsDir resolves the path to the gateway's session storage directory.
// It reads the workspace from config, falling back to ~/.picoclaw/workspace. // It reads the workspace from config, falling back to ~/.picoclaw/workspace.
func (h *Handler) sessionsDir() (string, error) { func (h *Handler) sessionsDir() (string, error) {
@ -104,58 +293,76 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
} }
items := []sessionListItem{} items := []sessionListItem{}
seen := make(map[string]struct{})
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { if entry.IsDir() {
continue continue
} }
data, err := os.ReadFile(filepath.Join(dir, entry.Name())) name := entry.Name()
if err != nil { var (
continue sessionID string
} sess sessionFile
loadErr error
ok bool
)
var sess sessionFile switch {
if err := json.Unmarshal(data, &sess); err != nil { case strings.HasSuffix(name, ".jsonl"):
continue sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
}
// Only include Pico channel sessions
sessionID, ok := extractPicoSessionID(sess.Key)
if !ok { if !ok {
continue continue
} }
sess, loadErr = h.readJSONLSession(dir, sessionID)
// Build a preview from the first user message if loadErr == nil && isEmptySession(sess) {
preview := "" continue
for _, msg := range sess.Messages { }
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" { case strings.HasSuffix(name, ".meta.json"):
preview = msg.Content continue
break case filepath.Ext(name) == ".json":
base := strings.TrimSuffix(name, ".json")
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
if jsonlSess, jsonlErr := h.readJSONLSession(
dir,
jsonlSessionID,
); jsonlErr == nil &&
!isEmptySession(jsonlSess) {
continue
} }
} }
if len([]rune(preview)) > 60 {
preview = string([]rune(preview)[:60]) + "..."
} }
if preview == "" { data, err := os.ReadFile(filepath.Join(dir, name))
preview = "(empty)" if err != nil {
continue
}
if err := json.Unmarshal(data, &sess); err != nil {
continue
}
if isEmptySession(sess) {
continue
}
sessionID, ok = extractPicoSessionID(sess.Key)
if !ok {
continue
}
if _, exists := seen[sessionID]; exists {
continue
}
default:
continue
} }
// Only count non-empty user and assistant messages if loadErr != nil {
validMessageCount := 0 continue
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
validMessageCount++
} }
if _, exists := seen[sessionID]; exists {
continue
} }
items = append(items, sessionListItem{ seen[sessionID] = struct{}{}
ID: sessionID, items = append(items, buildSessionListItem(sessionID, sess))
Preview: preview,
MessageCount: validMessageCount,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
})
} }
// Sort by updated descending (most recent first) // Sort by updated descending (most recent first)
@ -209,20 +416,25 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
return return
} }
// The sanitized filename replaces ':' with '_': sess, err := h.readJSONLSession(dir, sessionID)
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json if err == nil && isEmptySession(sess) {
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json" err = os.ErrNotExist
}
data, err := os.ReadFile(filepath.Join(dir, filename))
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) {
sess, err = h.readLegacySession(dir, sessionID)
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "session not found", http.StatusNotFound) http.Error(w, "session not found", http.StatusNotFound)
} else {
http.Error(w, "failed to parse session", http.StatusInternalServerError)
}
return return
} }
var sess sessionFile
if err := json.Unmarshal(data, &sess); err != nil {
http.Error(w, "failed to parse session", http.StatusInternalServerError)
return
} }
// Convert to a simpler format for the frontend // Convert to a simpler format for the frontend
@ -268,17 +480,25 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
return return
} }
// The sanitized filename replaces ':' with '_': base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json jsonlPath := base + ".jsonl"
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json" metaPath := base + ".meta.json"
filePath := filepath.Join(dir, filename) legacyPath := base + ".json"
if err := os.Remove(filePath); err != nil { removed := false
for _, path := range []string{jsonlPath, metaPath, legacyPath} {
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.Error(w, "session not found", http.StatusNotFound) continue
} else {
http.Error(w, "failed to delete session", http.StatusInternalServerError)
} }
http.Error(w, "failed to delete session", http.StatusInternalServerError)
return
}
removed = true
}
if !removed {
http.Error(w, "session not found", http.StatusNotFound)
return return
} }

View file

@ -0,0 +1,322 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
)
func sessionsTestDir(t *testing.T, configPath string) string {
t.Helper()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
dir := filepath.Join(cfg.Agents.Defaults.Workspace, "sessions")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
return dir
}
func TestHandleListSessions_JSONLStorage(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "history-jsonl"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "user",
Content: "Explain why the history API is empty after migration.",
}); err != nil {
t.Fatalf("AddFullMessage(user) error = %v", err)
}
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "assistant",
Content: "Because the API still reads only legacy JSON session files.",
}); err != nil {
t.Fatalf("AddFullMessage(assistant) error = %v", err)
}
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "tool",
Content: "ignored",
}); err != nil {
t.Fatalf("AddFullMessage(tool) error = %v", err)
}
if err := store.SetSummary(nil, sessionKey, "JSONL-backed session"); err != nil {
t.Fatalf("SetSummary() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var items []sessionListItem
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
if items[0].ID != "history-jsonl" {
t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl")
}
if items[0].MessageCount != 2 {
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
}
if items[0].Title != "JSONL-backed session" {
t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session")
}
if items[0].Preview != "Explain why the history API is empty after migration." {
t.Fatalf("items[0].Preview = %q", items[0].Preview)
}
}
func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "summary-title"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "user",
Content: "fallback preview",
}); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
if err := store.SetSummary(
nil,
sessionKey,
" This summary is intentionally longer than sixty characters so it must be truncated in the history menu. ",
); err != nil {
t.Fatalf("SetSummary() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var items []sessionListItem
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
expectedTitle := truncateRunes(
"This summary is intentionally longer than sixty characters so it must be truncated in the history menu.",
maxSessionTitleRunes,
)
if items[0].Title != expectedTitle {
t.Fatalf("items[0].Title = %q", items[0].Title)
}
if items[0].Preview != "fallback preview" {
t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "fallback preview")
}
}
func TestHandleGetSession_JSONLStorage(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "detail-jsonl"
for _, msg := range []providers.Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "second"},
{Role: "tool", Content: "ignored"},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
if err := store.SetSummary(nil, sessionKey, "detail summary"); err != nil {
t.Fatalf("SetSummary() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-jsonl", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
ID string `json:"id"`
Summary string `json:"summary"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.ID != "detail-jsonl" {
t.Fatalf("resp.ID = %q, want %q", resp.ID, "detail-jsonl")
}
if resp.Summary != "detail summary" {
t.Fatalf("resp.Summary = %q, want %q", resp.Summary, "detail summary")
}
if len(resp.Messages) != 2 {
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
}
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "first" {
t.Fatalf("first message = %#v, want user/first", resp.Messages[0])
}
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "second" {
t.Fatalf("second message = %#v, want assistant/second", resp.Messages[1])
}
}
func TestHandleDeleteSession_JSONLStorage(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "delete-jsonl"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "user",
Content: "delete me",
}); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
if err := store.SetSummary(nil, sessionKey, "delete summary"); err != nil {
t.Fatalf("SetSummary() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/sessions/delete-jsonl", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
}
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
for _, path := range []string{base + ".jsonl", base + ".meta.json"} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected %s to be removed, stat err = %v", path, err)
}
}
}
func TestHandleGetSession_LegacyJSONFallback(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
manager := session.NewSessionManager(dir)
sessionKey := picoSessionPrefix + "legacy-json"
manager.AddMessage(sessionKey, "user", "legacy user")
manager.AddMessage(sessionKey, "assistant", "legacy assistant")
if err := manager.Save(sessionKey); err != nil {
t.Fatalf("Save() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/legacy-json", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+"empty-jsonl"))
if err := os.WriteFile(base+".jsonl", []byte{}, 0o644); err != nil {
t.Fatalf("WriteFile(jsonl) error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
listRec := httptest.NewRecorder()
listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
mux.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
}
var items []sessionListItem
if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
t.Fatalf("Unmarshal(list) error = %v", err)
}
if len(items) != 0 {
t.Fatalf("len(items) = %d, want 0", len(items))
}
detailRec := httptest.NewRecorder()
detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/empty-jsonl", nil)
mux.ServeHTTP(detailRec, detailReq)
if detailRec.Code != http.StatusNotFound {
t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusNotFound, detailRec.Body.String())
}
}

331
web/backend/api/skills.go Normal file
View file

@ -0,0 +1,331 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
)
type skillSupportResponse struct {
Skills []skills.SkillInfo `json:"skills"`
}
type skillDetailResponse struct {
Name string `json:"name"`
Path string `json:"path"`
Source string `json:"source"`
Description string `json:"description"`
Content string `json:"content"`
}
var (
skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`)
importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
)
func (h *Handler) registerSkillRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/skills", h.handleListSkills)
mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill)
mux.HandleFunc("POST /api/skills/import", h.handleImportSkill)
mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill)
}
func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
loader := newSkillsLoader(cfg.WorkspacePath())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillSupportResponse{
Skills: loader.ListSkills(),
})
}
func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
loader := newSkillsLoader(cfg.WorkspacePath())
name := r.PathValue("name")
allSkills := loader.ListSkills()
for _, skill := range allSkills {
if skill.Name != name {
continue
}
content, err := loadSkillContent(skill.Path)
if err != nil {
http.Error(w, "Skill content not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillDetailResponse{
Name: skill.Name,
Path: skill.Path,
Source: skill.Source,
Description: skill.Description,
Content: content,
})
return
}
http.Error(w, "Skill not found", http.StatusNotFound)
}
func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
err = r.ParseMultipartForm(2 << 20)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid multipart form: %v", err), http.StatusBadRequest)
return
}
uploadedFile, fileHeader, err := r.FormFile("file")
if err != nil {
http.Error(w, "file is required", http.StatusBadRequest)
return
}
defer uploadedFile.Close()
content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1))
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest)
return
}
if len(content) > 1<<20 {
http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest)
return
}
skillName, err := normalizeImportedSkillName(fileHeader.Filename, content)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
content = normalizeImportedSkillContent(content, skillName)
workspace := cfg.WorkspacePath()
skillDir := filepath.Join(workspace, "skills", skillName)
skillFile := filepath.Join(skillDir, "SKILL.md")
if _, err := os.Stat(skillDir); err == nil {
http.Error(w, "skill already exists", http.StatusConflict)
return
}
if err := os.MkdirAll(skillDir, 0o755); err != nil {
http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError)
return
}
if err := os.WriteFile(skillFile, content, 0o644); err != nil {
http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError)
return
}
loader := newSkillsLoader(workspace)
for _, skill := range loader.ListSkills() {
if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skill)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"name": skillName,
"path": skillFile,
})
}
func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
loader := newSkillsLoader(cfg.WorkspacePath())
name := r.PathValue("name")
for _, skill := range loader.ListSkills() {
if skill.Name != name {
continue
}
if skill.Source != "workspace" {
http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest)
return
}
if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil {
http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
return
}
http.Error(w, "Skill not found", http.StatusNotFound)
}
func newSkillsLoader(workspace string) *skills.SkillsLoader {
return skills.NewSkillsLoader(
workspace,
filepath.Join(globalConfigDir(), "skills"),
builtinSkillsDir(),
)
}
func normalizeImportedSkillName(filename string, content []byte) (string, error) {
rawContent := strings.ReplaceAll(string(content), "\r\n", "\n")
rawContent = strings.ReplaceAll(rawContent, "\r", "\n")
metadata, _ := extractImportedSkillMetadata(rawContent)
raw := strings.TrimSpace(metadata["name"])
if raw == "" {
raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))
}
raw = strings.ToLower(raw)
raw = strings.ReplaceAll(raw, "_", "-")
raw = strings.ReplaceAll(raw, " ", "-")
raw = skillNameSanitizer.ReplaceAllString(raw, "-")
raw = strings.Trim(raw, "-")
raw = strings.Join(strings.FieldsFunc(raw, func(r rune) bool { return r == '-' }), "-")
if raw == "" {
return "", fmt.Errorf("skill name is required in frontmatter or filename")
}
if len(raw) > 64 {
return "", fmt.Errorf("skill name exceeds 64 characters")
}
matched, err := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, raw)
if err != nil || !matched {
return "", fmt.Errorf("skill name must be alphanumeric with hyphens")
}
return raw, nil
}
func normalizeImportedSkillContent(content []byte, skillName string) []byte {
raw := strings.ReplaceAll(string(content), "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n")
metadata, body := extractImportedSkillMetadata(raw)
description := strings.TrimSpace(metadata["description"])
if description == "" {
description = inferImportedSkillDescription(body)
}
if description == "" {
description = "Imported skill"
}
if len(description) > 1024 {
description = strings.TrimSpace(description[:1024])
}
body = strings.TrimLeft(body, "\n")
var builder strings.Builder
builder.WriteString("---\n")
builder.WriteString("name: ")
builder.WriteString(skillName)
builder.WriteString("\n")
builder.WriteString("description: ")
builder.WriteString(description)
builder.WriteString("\n")
builder.WriteString("---\n\n")
builder.WriteString(body)
if !strings.HasSuffix(builder.String(), "\n") {
builder.WriteString("\n")
}
return []byte(builder.String())
}
func extractImportedSkillMetadata(raw string) (map[string]string, string) {
matches := importedSkillFrontmatter.FindStringSubmatch(raw)
if len(matches) != 2 {
return map[string]string{}, raw
}
meta := parseImportedSkillYAML(matches[1])
body := importedSkillFrontmatter.ReplaceAllString(raw, "")
return meta, body
}
func parseImportedSkillYAML(frontmatter string) map[string]string {
result := make(map[string]string)
for _, line := range strings.Split(frontmatter, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
result[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
}
return result
}
func inferImportedSkillDescription(body string) string {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
line = strings.TrimLeft(line, "#-*0123456789. ")
line = strings.TrimSpace(line)
if line != "" {
return line
}
}
return ""
}
func loadSkillContent(path string) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
return skillFrontmatterStripper.ReplaceAllString(string(content), ""), nil
}
func globalConfigDir() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
return home
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".picoclaw")
}
func builtinSkillsDir() string {
if path := os.Getenv("PICOCLAW_BUILTIN_SKILLS"); path != "" {
return path
}
wd, err := os.Getwd()
if err != nil {
return ""
}
return filepath.Join(wd, "skills")
}

View file

@ -0,0 +1,336 @@
package api
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestHandleListSkills(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
if err := os.MkdirAll(filepath.Join(workspace, "skills", "workspace-skill"), 0o755); err != nil {
t.Fatalf("MkdirAll(workspace skill) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(workspace, "skills", "workspace-skill", "SKILL.md"),
[]byte("---\nname: workspace-skill\ndescription: Workspace skill\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(workspace skill) error = %v", err)
}
globalSkillDir := filepath.Join(globalConfigDir(), "skills", "global-skill")
if err := os.MkdirAll(globalSkillDir, 0o755); err != nil {
t.Fatalf("MkdirAll(global skill) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(globalSkillDir, "SKILL.md"),
[]byte("---\nname: global-skill\ndescription: Global skill\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(global skill) error = %v", err)
}
builtinRoot := filepath.Join(t.TempDir(), "builtin-skills")
oldBuiltin := os.Getenv("PICOCLAW_BUILTIN_SKILLS")
if err := os.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot); err != nil {
t.Fatalf("Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v", err)
}
defer func() {
if oldBuiltin == "" {
_ = os.Unsetenv("PICOCLAW_BUILTIN_SKILLS")
} else {
_ = os.Setenv("PICOCLAW_BUILTIN_SKILLS", oldBuiltin)
}
}()
builtinSkillDir := filepath.Join(builtinRoot, "builtin-skill")
if err := os.MkdirAll(builtinSkillDir, 0o755); err != nil {
t.Fatalf("MkdirAll(builtin skill) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(builtinSkillDir, "SKILL.md"),
[]byte("---\nname: builtin-skill\ndescription: Builtin skill\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(builtin skill) error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSupportResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Skills) != 3 {
t.Fatalf("skills count = %d, want 3", len(resp.Skills))
}
gotSkills := make(map[string]string, len(resp.Skills))
for _, skill := range resp.Skills {
gotSkills[skill.Name] = skill.Source
}
if gotSkills["workspace-skill"] != "workspace" {
t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"])
}
if gotSkills["global-skill"] != "global" {
t.Fatalf("global-skill source = %q, want global", gotSkills["global-skill"])
}
if gotSkills["builtin-skill"] != "builtin" {
t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"])
}
}
func TestHandleGetSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
skillDir := filepath.Join(workspace, "skills", "viewer-skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte(
"---\nname: viewer-skill\ndescription: Viewable skill\n---\n# Viewer Skill\n\nThis is visible content.\n",
),
0o644,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/viewer-skill", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillDetailResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" {
t.Fatalf("unexpected response: %#v", resp)
}
if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" {
t.Fatalf("content = %q", resp.Content)
}
}
func TestHandleGetSkillUsesResolvedPath(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
skillDir := filepath.Join(workspace, "skills", "folder-name")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: display-name\ndescription: Mismatched path skill\n---\n# Display Name\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/display-name", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillDetailResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Name != "display-name" {
t.Fatalf("resp.Name = %q, want display-name", resp.Name)
}
if resp.Content != "# Display Name\n" {
t.Fatalf("content = %q", resp.Content)
}
}
func TestHandleImportSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "Plain Skill.md")
if err != nil {
t.Fatalf("CreateFormFile() error = %v", err)
}
_, err = io.WriteString(part, "# Plain Skill\n\nUse this skill to test imports.\n")
if err != nil {
t.Fatalf("WriteString() error = %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("Close() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
skillFile := filepath.Join(workspace, "skills", "plain-skill", "SKILL.md")
content, err := os.ReadFile(skillFile)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
expected := "---\nname: plain-skill\ndescription: Plain Skill\n---\n\n# Plain Skill\n\nUse this skill to test imports.\n"
if string(content) != expected {
t.Fatalf("saved skill content mismatch:\n%s", string(content))
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("list status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
}
var listResp skillSupportResponse
if err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil {
t.Fatalf("Unmarshal list response error = %v", err)
}
found := false
for _, skill := range listResp.Skills {
if skill.Name == "plain-skill" && skill.Source == "workspace" && skill.Description == "Plain Skill" {
found = true
}
}
if !found {
t.Fatalf("plain-skill should be listed after import, got %#v", listResp.Skills)
}
}
func TestHandleDeleteSkill(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
skillDir := filepath.Join(workspace, "skills", "delete-me")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: delete-me\ndescription: delete me\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
t.Fatalf("skill directory should be removed, stat err=%v", err)
}
}

323
web/backend/api/tools.go Normal file
View file

@ -0,0 +1,323 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"github.com/sipeed/picoclaw/pkg/config"
)
type toolCatalogEntry struct {
Name string
Description string
Category string
ConfigKey string
}
type toolSupportItem struct {
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
ConfigKey string `json:"config_key"`
Status string `json:"status"`
ReasonCode string `json:"reason_code,omitempty"`
}
type toolSupportResponse struct {
Tools []toolSupportItem `json:"tools"`
}
type toolStateRequest struct {
Enabled bool `json:"enabled"`
}
var toolCatalog = []toolCatalogEntry{
{
Name: "read_file",
Description: "Read file content from the workspace or explicitly allowed paths.",
Category: "filesystem",
ConfigKey: "read_file",
},
{
Name: "write_file",
Description: "Create or overwrite files within the writable workspace scope.",
Category: "filesystem",
ConfigKey: "write_file",
},
{
Name: "list_dir",
Description: "Inspect directories and enumerate files available to the agent.",
Category: "filesystem",
ConfigKey: "list_dir",
},
{
Name: "edit_file",
Description: "Apply targeted edits to existing files without rewriting everything.",
Category: "filesystem",
ConfigKey: "edit_file",
},
{
Name: "append_file",
Description: "Append content to the end of an existing file.",
Category: "filesystem",
ConfigKey: "append_file",
},
{
Name: "exec",
Description: "Run shell commands inside the configured workspace sandbox.",
Category: "filesystem",
ConfigKey: "exec",
},
{
Name: "cron",
Description: "Schedule one-time or recurring reminders, jobs, and shell commands.",
Category: "automation",
ConfigKey: "cron",
},
{
Name: "web_search",
Description: "Search the web using the configured providers.",
Category: "web",
ConfigKey: "web",
},
{
Name: "web_fetch",
Description: "Fetch and summarize the contents of a webpage.",
Category: "web",
ConfigKey: "web_fetch",
},
{
Name: "message",
Description: "Send a follow-up message back to the active user or chat.",
Category: "communication",
ConfigKey: "message",
},
{
Name: "send_file",
Description: "Send an outbound file or media attachment to the active chat.",
Category: "communication",
ConfigKey: "send_file",
},
{
Name: "find_skills",
Description: "Search external skill registries for installable skills.",
Category: "skills",
ConfigKey: "find_skills",
},
{
Name: "install_skill",
Description: "Install a skill into the current workspace from a registry.",
Category: "skills",
ConfigKey: "install_skill",
},
{
Name: "spawn",
Description: "Launch a background subagent for long-running or delegated work.",
Category: "agents",
ConfigKey: "spawn",
},
{
Name: "i2c",
Description: "Interact with I2C hardware devices exposed on the host.",
Category: "hardware",
ConfigKey: "i2c",
},
{
Name: "spi",
Description: "Interact with SPI hardware devices exposed on the host.",
Category: "hardware",
ConfigKey: "spi",
},
{
Name: "tool_search_tool_regex",
Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.",
Category: "discovery",
ConfigKey: "mcp.discovery.use_regex",
},
{
Name: "tool_search_tool_bm25",
Description: "Discover hidden MCP tools by semantic ranking when tool discovery is enabled.",
Category: "discovery",
ConfigKey: "mcp.discovery.use_bm25",
},
}
func (h *Handler) registerToolRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/tools", h.handleListTools)
mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState)
}
func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(toolSupportResponse{
Tools: buildToolSupport(cfg),
})
}
func (h *Handler) handleUpdateToolState(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
var req toolStateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err := applyToolState(cfg, r.PathValue("name"), req.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func buildToolSupport(cfg *config.Config) []toolSupportItem {
items := make([]toolSupportItem, 0, len(toolCatalog))
for _, entry := range toolCatalog {
status := "disabled"
reasonCode := ""
switch entry.Name {
case "find_skills", "install_skill":
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
if cfg.Tools.IsToolEnabled("skills") {
status = "enabled"
} else {
status = "blocked"
reasonCode = "requires_skills"
}
}
case "spawn":
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
if cfg.Tools.IsToolEnabled("subagent") {
status = "enabled"
} else {
status = "blocked"
reasonCode = "requires_subagent"
}
}
case "tool_search_tool_regex":
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex)
case "tool_search_tool_bm25":
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25)
case "i2c", "spi":
status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))
default:
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
status = "enabled"
}
}
items = append(items, toolSupportItem{
Name: entry.Name,
Description: entry.Description,
Category: entry.Category,
ConfigKey: entry.ConfigKey,
Status: status,
ReasonCode: reasonCode,
})
}
return items
}
func resolveHardwareToolSupport(enabled bool) (string, string) {
if !enabled {
return "disabled", ""
}
if runtime.GOOS != "linux" {
return "blocked", "requires_linux"
}
return "enabled", ""
}
func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) {
if !cfg.Tools.IsToolEnabled("mcp") {
return "disabled", ""
}
if !cfg.Tools.MCP.Discovery.Enabled {
return "blocked", "requires_mcp_discovery"
}
if !methodEnabled {
return "disabled", ""
}
return "enabled", ""
}
func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
switch toolName {
case "read_file":
cfg.Tools.ReadFile.Enabled = enabled
case "write_file":
cfg.Tools.WriteFile.Enabled = enabled
case "list_dir":
cfg.Tools.ListDir.Enabled = enabled
case "edit_file":
cfg.Tools.EditFile.Enabled = enabled
case "append_file":
cfg.Tools.AppendFile.Enabled = enabled
case "exec":
cfg.Tools.Exec.Enabled = enabled
case "cron":
cfg.Tools.Cron.Enabled = enabled
case "web_search":
cfg.Tools.Web.Enabled = enabled
case "web_fetch":
cfg.Tools.WebFetch.Enabled = enabled
case "message":
cfg.Tools.Message.Enabled = enabled
case "send_file":
cfg.Tools.SendFile.Enabled = enabled
case "find_skills":
cfg.Tools.FindSkills.Enabled = enabled
if enabled {
cfg.Tools.Skills.Enabled = true
}
case "install_skill":
cfg.Tools.InstallSkill.Enabled = enabled
if enabled {
cfg.Tools.Skills.Enabled = true
}
case "spawn":
cfg.Tools.Spawn.Enabled = enabled
if enabled {
cfg.Tools.Subagent.Enabled = true
}
case "i2c":
cfg.Tools.I2C.Enabled = enabled
case "spi":
cfg.Tools.SPI.Enabled = enabled
case "tool_search_tool_regex":
cfg.Tools.MCP.Discovery.UseRegex = enabled
if enabled {
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Discovery.Enabled = true
}
case "tool_search_tool_bm25":
cfg.Tools.MCP.Discovery.UseBM25 = enabled
if enabled {
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Discovery.Enabled = true
}
default:
return fmt.Errorf("tool %q cannot be updated", toolName)
}
return nil
}

View file

@ -0,0 +1,198 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestHandleListTools(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.Tools.ReadFile.Enabled = true
cfg.Tools.WriteFile.Enabled = false
cfg.Tools.Cron.Enabled = true
cfg.Tools.FindSkills.Enabled = true
cfg.Tools.Skills.Enabled = true
cfg.Tools.Spawn.Enabled = true
cfg.Tools.Subagent.Enabled = false
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Discovery.Enabled = true
cfg.Tools.MCP.Discovery.UseRegex = true
cfg.Tools.MCP.Discovery.UseBM25 = false
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/tools", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp toolSupportResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
gotTools := make(map[string]toolSupportItem, len(resp.Tools))
for _, tool := range resp.Tools {
gotTools[tool.Name] = tool
}
if gotTools["read_file"].Status != "enabled" {
t.Fatalf("read_file status = %q, want enabled", gotTools["read_file"].Status)
}
if gotTools["write_file"].Status != "disabled" {
t.Fatalf("write_file status = %q, want disabled", gotTools["write_file"].Status)
}
if gotTools["cron"].Status != "enabled" {
t.Fatalf("cron status = %q, want enabled", gotTools["cron"].Status)
}
if gotTools["spawn"].Status != "blocked" || gotTools["spawn"].ReasonCode != "requires_subagent" {
t.Fatalf("spawn = %#v, want blocked/requires_subagent", gotTools["spawn"])
}
if gotTools["find_skills"].Status != "enabled" {
t.Fatalf("find_skills status = %q, want enabled", gotTools["find_skills"].Status)
}
if gotTools["tool_search_tool_regex"].Status != "enabled" {
t.Fatalf("tool_search_tool_regex status = %q, want enabled", gotTools["tool_search_tool_regex"].Status)
}
if gotTools["tool_search_tool_regex"].ConfigKey != "mcp.discovery.use_regex" {
t.Fatalf(
"tool_search_tool_regex config_key = %q, want mcp.discovery.use_regex",
gotTools["tool_search_tool_regex"].ConfigKey,
)
}
if gotTools["tool_search_tool_bm25"].Status != "disabled" {
t.Fatalf("tool_search_tool_bm25 status = %q, want disabled", gotTools["tool_search_tool_bm25"].Status)
}
if gotTools["tool_search_tool_bm25"].ConfigKey != "mcp.discovery.use_bm25" {
t.Fatalf(
"tool_search_tool_bm25 config_key = %q, want mcp.discovery.use_bm25",
gotTools["tool_search_tool_bm25"].ConfigKey,
)
}
if runtime.GOOS == "linux" {
if gotTools["i2c"].Status != "disabled" {
t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status)
}
} else {
cfg.Tools.I2C.Enabled = true
cfg.Tools.SPI.Enabled = true
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/tools", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
gotTools = make(map[string]toolSupportItem, len(resp.Tools))
for _, tool := range resp.Tools {
gotTools[tool.Name] = tool
}
if gotTools["i2c"].Status != "blocked" || gotTools["i2c"].ReasonCode != "requires_linux" {
t.Fatalf("i2c = %#v, want blocked/requires_linux", gotTools["i2c"])
}
if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" {
t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"])
}
}
}
func TestHandleUpdateToolState(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.Tools.Spawn.Enabled = false
cfg.Tools.Subagent.Enabled = false
cfg.Tools.Cron.Enabled = false
cfg.Tools.MCP.Enabled = false
cfg.Tools.MCP.Discovery.Enabled = false
cfg.Tools.MCP.Discovery.UseRegex = false
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPut,
"/api/tools/spawn/state",
bytes.NewBufferString(`{"enabled":true}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("spawn status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(
http.MethodPut,
"/api/tools/tool_search_tool_regex/state",
bytes.NewBufferString(`{"enabled":true}`),
)
req2.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("regex status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
}
rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(
http.MethodPut,
"/api/tools/cron/state",
bytes.NewBufferString(`{"enabled":true}`),
)
req3.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("cron status = %d, want %d, body=%s", rec3.Code, http.StatusOK, rec3.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig(updated) error = %v", err)
}
if !updated.Tools.Spawn.Enabled || !updated.Tools.Subagent.Enabled {
t.Fatalf("spawn/subagent should both be enabled: %#v", updated.Tools)
}
if !updated.Tools.MCP.Enabled || !updated.Tools.MCP.Discovery.Enabled || !updated.Tools.MCP.Discovery.UseRegex {
t.Fatalf("mcp regex discovery should be enabled: %#v", updated.Tools.MCP)
}
if !updated.Tools.Cron.Enabled {
t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron)
}
}

View file

@ -0,0 +1 @@
# Keep the embedded web backend dist directory in version control.

View file

@ -25,6 +25,7 @@ import (
"github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware" "github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/utils"
) )
func main() { func main() {
@ -51,7 +52,7 @@ func main() {
flag.Parse() flag.Parse()
// Resolve config path // Resolve config path
configPath := getDefaultConfigPath() configPath := utils.GetDefaultConfigPath()
if flag.NArg() > 0 { if flag.NArg() > 0 {
configPath = flag.Arg(0) configPath = flag.Arg(0)
} }
@ -60,6 +61,10 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Failed to resolve config path: %v", err) log.Fatalf("Failed to resolve config path: %v", err)
} }
err = utils.EnsureOnboarded(absPath)
if err != nil {
log.Printf("Warning: Failed to initialize PicoClaw config automatically: %v", err)
}
var explicitPort bool var explicitPort bool
var explicitPublic bool var explicitPublic bool
@ -109,7 +114,7 @@ func main() {
// API Routes (e.g. /api/status) // API Routes (e.g. /api/status)
apiHandler := api.NewHandler(absPath) apiHandler := api.NewHandler(absPath)
apiHandler.SetServerOptions(portNum, effectivePublic, launcherCfg.AllowedCIDRs) apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
apiHandler.RegisterRoutes(mux) apiHandler.RegisterRoutes(mux)
// Frontend Embedded Assets // Frontend Embedded Assets
@ -128,13 +133,13 @@ func main() {
) )
// Print startup banner // Print startup banner
fmt.Print(banner) fmt.Print(utils.Banner)
fmt.Println() fmt.Println()
fmt.Println(" Open the following URL in your browser:") fmt.Println(" Open the following URL in your browser:")
fmt.Println() fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort) fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
if effectivePublic { if effectivePublic {
if ip := getLocalIP(); ip != "" { if ip := utils.GetLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort) fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
} }
} }
@ -145,7 +150,7 @@ func main() {
go func() { go func() {
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
url := "http://localhost:" + effectivePort url := "http://localhost:" + effectivePort
if err := openBrowser(url); err != nil { if err := utils.OpenBrowser(url); err != nil {
log.Printf("Warning: Failed to auto-open browser: %v", err) log.Printf("Warning: Failed to auto-open browser: %v", err)
} }
}() }()

Some files were not shown because too many files have changed in this diff Show more