diff --git a/README.fr.md b/README.fr.md
index 08a1926b6..574402a3e 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -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)
├── IDENTITY.md # Identité de l'Agent
├── SOUL.md # Âme de l'Agent
-├── TOOLS.md # Description des outils
└── 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) |
| **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 | - |
+| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
diff --git a/README.ja.md b/README.ja.md
index c4c5b27a0..1eb47cfdc 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -610,7 +610,6 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
├── IDENTITY.md # エージェントのアイデンティティ
├── SOUL.md # エージェントのソウル
-├── TOOLS.md # ツールの説明
└── USER.md # ユーザー設定
```
@@ -921,6 +920,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| **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) |
| **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のみ |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
diff --git a/README.md b/README.md
index bae3fa681..55e9fb187 100644
--- a/README.md
+++ b/README.md
@@ -787,7 +787,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
├── IDENTITY.md # Agent identity
├── SOUL.md # Agent soul
-├── TOOLS.md # Tool descriptions
└── 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) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **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 |
| **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) |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
+| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
diff --git a/README.pt-br.md b/README.pt-br.md
index 5f37ba457..066d71d6a 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -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)
├── IDENTITY.md # Identidade do Agente
├── SOUL.md # Alma do Agente
-├── TOOLS.md # Descrição das ferramentas
└── 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) |
| **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 | - |
+| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
diff --git a/README.vi.md b/README.vi.md
index 92c6ecbae..66573a1c5 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -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)
├── IDENTITY.md # Danh tính 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
```
@@ -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) |
| **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 | - |
+| **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 |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
diff --git a/README.zh.md b/README.zh.md
index c744e0d20..a3a4c7f5f 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -365,7 +365,6 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
├── IDENTITY.md # Agent 身份设定
├── SOUL.md # Agent 灵魂/性格
-├── TOOLS.md # 工具描述
└── USER.md # 用户偏好
```
@@ -517,6 +516,7 @@ Agent 读取 HEARTBEAT.md
| **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) |
| **神算云** | `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 |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@@ -879,3 +879,4 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
+| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
diff --git a/assets/wechat.png b/assets/wechat.png
index 4442ef2c7..4cfcbbb1a 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/config/config.example.json b/config/config.example.json
index 3754c6814..1eea37683 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -35,6 +35,11 @@
"model": "deepseek/deepseek-chat",
"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": "openai/gpt-5.2",
@@ -274,6 +279,10 @@
"avian": {
"api_key": "",
"api_base": "https://api.avian.io/v1"
+ },
+ "longcat": {
+ "api_key": "",
+ "api_base": "https://api.longcat.chat/openai"
}
},
"tools": {
diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md
index c213aa80b..233f5c0a3 100644
--- a/docs/channels/matrix/README.md
+++ b/docs/channels/matrix/README.md
@@ -22,7 +22,8 @@ Add this to `config.json`:
"enabled": true,
"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`) |
| placeholder | object | No | Placeholder message config |
| 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
-- 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 audio normalization into existing transcription flow (`[audio: ...]`)
- Outgoing image/audio/video/file upload and send
diff --git a/go.mod b/go.mod
index f60be046f..3762015e9 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/ergochat/irc-go v0.5.0
github.com/gdamore/tcell/v2 v2.13.8
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/h2non/filetype v1.1.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/openai/openai-go/v3 v3.22.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/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
@@ -49,7 +51,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.10 // indirect
diff --git a/go.sum b/go.sum
index 4060997f8..2e2b1a1ec 100644
--- a/go.sum
+++ b/go.sum
@@ -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.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
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.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index bee8d91a7..28e549ce0 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -25,7 +25,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
@@ -48,6 +47,7 @@ type AgentLoop struct {
mediaStore media.MediaStore
transcriber voice.Transcriber
cmdRegistry *commands.Registry
+ mcp mcpRuntime
}
// processOptions configures how a message is processed
@@ -239,119 +239,8 @@ func registerSharedTools(
func (al *AgentLoop) Run(ctx context.Context) error {
al.running.Store(true)
-
- // Initialize MCP servers for all agents
- 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))
- }
- }
- }
- }
+ if err := al.ensureMCPInitialized(ctx); err != nil {
+ return err
}
for al.running.Load() {
@@ -431,6 +320,17 @@ func (al *AgentLoop) Stop() {
// Close releases resources held by agent session stores. Call after Stop.
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()
}
@@ -619,6 +519,10 @@ func (al *AgentLoop) ProcessDirectWithChannel(
ctx context.Context,
content, sessionKey, channel, chatID string,
) (string, error) {
+ if err := al.ensureMCPInitialized(ctx); err != nil {
+ return "", err
+ }
+
msg := bus.InboundMessage{
Channel: channel,
SenderID: "cron",
diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go
new file mode 100644
index 000000000..2795db52a
--- /dev/null
+++ b/pkg/agent/loop_mcp.go
@@ -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()
+}
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 2e456fa60..cab82e176 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -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,
+ Model: "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) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go
index 8642ad362..c03122892 100644
--- a/pkg/channels/dingtalk/dingtalk.go
+++ b/pkg/channels/dingtalk/dingtalk.go
@@ -10,6 +10,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"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/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")
}
+ // Set the logger for the Stream SDK
+ dinglog.SetLogger(logger.NewLogger("dingtalk"))
+
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger),
diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go
index fbfcad151..83a04907c 100644
--- a/pkg/channels/discord/discord.go
+++ b/pkg/channels/discord/discord.go
@@ -45,6 +45,14 @@ type DiscordChannel struct {
}
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)
if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err)
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 472895a7a..df430e4d3 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -127,7 +127,12 @@ func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) b
// Implements PlaceholderRecorder.
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
key := channel + ":" + chatID
- m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
+ entry := typingEntry{stop: stop, createdAt: time.Now()}
+ if previous, loaded := m.typingStops.Swap(key, entry); loaded {
+ if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil {
+ oldEntry.stop()
+ }
+ }
}
// RecordReactionUndo registers a reaction undo function for later invocation.
diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go
index 1f3a628c2..e0f55288a 100644
--- a/pkg/channels/manager_test.go
+++ b/pkg/channels/manager_test.go
@@ -616,6 +616,37 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
wg.Wait()
}
+func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) {
+ m := newTestManager()
+ var oldStopCalls int
+ var newStopCalls int
+
+ m.RecordTypingStop("test", "123", func() {
+ oldStopCalls++
+ })
+
+ m.RecordTypingStop("test", "123", func() {
+ newStopCalls++
+ })
+
+ if oldStopCalls != 1 {
+ t.Fatalf("expected previous typing stop to be called once when replaced, got %d", oldStopCalls)
+ }
+ if newStopCalls != 0 {
+ t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls)
+ }
+
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
+ m.preSend(context.Background(), "test", msg, &mockChannel{})
+
+ if newStopCalls != 1 {
+ t.Fatalf("expected replacement typing stop to be called by preSend, got %d", newStopCalls)
+ }
+ if oldStopCalls != 1 {
+ t.Fatalf("expected previous typing stop to not be called again, got %d", oldStopCalls)
+ }
+}
+
func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
m := newTestManager()
var sendCalled bool
diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go
index d51eee8fb..a45207f12 100644
--- a/pkg/channels/matrix/matrix.go
+++ b/pkg/channels/matrix/matrix.go
@@ -13,6 +13,9 @@ import (
"sync"
"time"
+ "github.com/gomarkdown/markdown"
+ mdhtml "github.com/gomarkdown/markdown/html"
+ "github.com/gomarkdown/markdown/parser"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@@ -268,6 +271,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error {
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 {
if !c.IsRunning() {
return channels.ErrNotRunning
@@ -283,16 +292,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil
}
- _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
- MsgType: event.MsgText,
- Body: content,
- })
+ _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content))
if err != nil {
return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
}
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.
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
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")
}
- editContent := &event.MessageEventContent{
- MsgType: event.MsgText,
- Body: content,
- }
+ editContent := c.messageContent(content)
editContent.SetEdit(id.EventID(messageID))
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)
diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go
index e76db0d3e..806a98739 100644
--- a/pkg/channels/matrix/matrix_test.go
+++ b/pkg/channels/matrix/matrix_test.go
@@ -4,12 +4,15 @@ import (
"context"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
+
+ "github.com/sipeed/picoclaw/pkg/config"
)
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
@@ -289,3 +292,50 @@ func TestMatrixOutboundContent(t *testing.T) {
t.Fatalf("unexpected fallback body: %q", noCaption.Body)
}
}
+
+func TestMarkdownToHTML(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ contains string
+ }{
+ {"bold", "**hello**", "hello"},
+ {"italic", "_world_", "world"},
+ {"header", "### Title", "
"},
+ {"inline code", "`x`", "x"},
+ {"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, "hi") {
+ t.Errorf("format %q: FormattedBody %q missing ", 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)
+ }
+}
diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go
index 540e3b7af..73200f64e 100644
--- a/pkg/channels/qq/qq.go
+++ b/pkg/channels/qq/qq.go
@@ -78,6 +78,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
return fmt.Errorf("QQ app_id and app_secret not configured")
}
+ botgo.SetLogger(logger.NewLogger("botgo"))
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// Reinitialize shutdown signal for clean restart.
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 4a8d34a9f..34ee46b7b 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -77,6 +77,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
opts = append(opts, telego.WithAPIServer(baseURL))
}
+ opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil {
diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go
index 7f230494f..7d07041ad 100644
--- a/pkg/channels/wecom/app_test.go
+++ b/pkg/channels/wecom/app_test.go
@@ -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{
CorpID: "test_corp_id",
CorpSecret: "test_secret",
@@ -218,8 +218,8 @@ func TestWeComAppVerifySignature(t *testing.T) {
}
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
- if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
- t.Error("empty token should skip verification and return true")
+ if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
+ t.Error("empty token should reject verification (fail-closed)")
}
})
}
diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go
index c053578b1..d223bb6b6 100644
--- a/pkg/channels/wecom/bot_test.go
+++ b/pkg/channels/wecom/bot_test.go
@@ -189,8 +189,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
}
})
- t.Run("empty token skips verification", func(t *testing.T) {
- // Create a channel manually with empty token to test the behavior
+ t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
cfgEmpty := config.WeComConfig{
Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
@@ -199,8 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty,
}
- if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
- t.Error("empty token should skip verification and return true")
+ if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
+ t.Error("empty token should reject verification (fail-closed)")
}
})
}
diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go
index 6510e6f81..9a622a2fc 100644
--- a/pkg/channels/wecom/common.go
+++ b/pkg/channels/wecom/common.go
@@ -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
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" {
- return true // Skip verification if token is not set
+ return false
}
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 3d0f4c877..4e6b7894d 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -17,6 +17,8 @@ var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// 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
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
@@ -48,6 +50,30 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
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
+}
+
type Config struct {
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
@@ -350,16 +376,17 @@ type SlackConfig struct {
}
type MatrixConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
- Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
- UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
- 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"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
+ Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
+ UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
+ 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"`
+ MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
}
type LINEConfig struct {
@@ -500,6 +527,7 @@ type ProvidersConfig struct {
Mistral ProviderConfig `json:"mistral"`
Avian ProviderConfig `json:"avian"`
Minimax ProviderConfig `json:"minimax"`
+ LongCat ProviderConfig `json:"longcat"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@@ -526,7 +554,8 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.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
@@ -673,6 +702,7 @@ type CronToolsConfig struct {
type ExecConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
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"`
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)
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 8baf3e6fd..ad89d6d2e 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -384,6 +384,13 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
}
}
+func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
+ cfg := DefaultConfig()
+ if !cfg.Tools.Exec.AllowRemote {
+ t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true")
+ }
+}
+
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
@@ -400,6 +407,22 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
}
}
+func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ cfg, err := 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 unset in config file")
+ }
+}
+
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
@@ -482,3 +505,119 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
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: "123,456,789",
+ expected: []string{"123", "456", "789"},
+ },
+ {
+ name: "Mixed English and Chinese commas",
+ input: "123,456,789",
+ 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,,456,,789",
+ expected: []string{"123", "456", "789"},
+ },
+ {
+ name: "Complex mixed values",
+ input: "user1@example.com,user2@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)
+ }
+ })
+}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 3b1bb1aef..492b22e3a 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -355,6 +355,14 @@ func DefaultConfig() *Config {
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
{
ModelName: "local-model",
@@ -427,6 +435,7 @@ func DefaultConfig() *Config {
Enabled: true,
},
EnableDenyPatterns: true,
+ AllowRemote: true,
TimeoutSeconds: 60,
},
Skills: SkillsToolsConfig{
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 51f21e4f4..8e693506b 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -407,6 +407,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, 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
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index d3019aab0..807d93e49 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -162,14 +162,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
Qwen: ProviderConfig{APIKey: "key17"},
Mistral: ProviderConfig{APIKey: "key18"},
Avian: ProviderConfig{APIKey: "key19"},
+ LongCat: ProviderConfig{APIKey: "key-longcat"},
},
}
result := ConvertProvidersToModelList(cfg)
- // All 21 providers should be converted
- if len(result) != 21 {
- t.Errorf("len(result) = %d, want 21", len(result))
+ // All 22 providers should be converted
+ if len(result) != 22 {
+ t.Errorf("len(result) = %d, want 22", len(result))
}
}
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 56dc87a53..80adcf86c 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -1,24 +1,24 @@
package logger
import (
- "encoding/json"
"fmt"
- "log"
"os"
+ "path/filepath"
"runtime"
"strings"
"sync"
- "time"
+
+ "github.com/rs/zerolog"
)
-type LogLevel int
+type LogLevel = zerolog.Level
const (
- DEBUG LogLevel = iota
- INFO
- WARN
- ERROR
- FATAL
+ DEBUG = zerolog.DebugLevel
+ INFO = zerolog.InfoLevel
+ WARN = zerolog.WarnLevel
+ ERROR = zerolog.ErrorLevel
+ FATAL = zerolog.FatalLevel
)
var (
@@ -31,27 +31,24 @@ var (
}
currentLevel = INFO
- logger *Logger
+ logger zerolog.Logger
+ fileLogger zerolog.Logger
+ logFile *os.File
once sync.Once
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() {
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()
defer mu.Unlock()
currentLevel = level
+ zerolog.SetGlobalLevel(level)
}
func GetLevel() LogLevel {
@@ -71,17 +69,22 @@ func EnableFileLogging(filePath string) error {
mu.Lock()
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 {
return fmt.Errorf("failed to open log file: %w", err)
}
- if logger.file != nil {
- logger.file.Close()
+ // Close old file if exists
+ if logFile != nil {
+ logFile.Close()
}
- logger.file = file
- log.Println("File logging enabled:", filePath)
+ logFile = newFile
+ fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger()
return nil
}
@@ -89,10 +92,57 @@ func DisableFileLogging() {
mu.Lock()
defer mu.Unlock()
- if logger.file != nil {
- logger.file.Close()
- logger.file = nil
- log.Println("File logging disabled")
+ if logFile != nil {
+ logFile.Close()
+ logFile = nil
+ }
+ 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
}
- entry := LogEntry{
- Level: logLevelNames[level],
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- Component: component,
- Message: message,
- Fields: fields,
- }
+ callerFile, callerLine, callerFunc := getCallerInfo()
- if pc, file, line, ok := runtime.Caller(2); ok {
- fn := runtime.FuncForPC(pc)
- if fn != nil {
- entry.Caller = fmt.Sprintf("%s:%d (%s)", file, line, fn.Name())
- }
- }
+ event := getEvent(logger, level)
- if logger.file != nil {
- jsonData, err := json.Marshal(entry)
- if err == nil {
- logger.file.Write(append(jsonData, '\n'))
- }
- }
-
- var fieldStr string
- if len(fields) > 0 {
- fieldStr = " " + formatFields(fields)
+ // Build combined field with component and caller
+ if component != "" {
+ event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc))
} else {
- fieldStr = ""
+ event.Str("caller", fmt.Sprintf(" %s:%d (%s)", callerFile, callerLine, callerFunc))
}
- logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
- entry.Timestamp,
- logLevelNames[level],
- formatComponent(component),
- message,
- fieldStr,
- )
+ for k, v := range fields {
+ event.Interface(k, v)
+ }
- 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 {
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) {
logMessage(DEBUG, "", message, nil)
}
@@ -232,6 +258,10 @@ func FatalC(component string, message string) {
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) {
logMessage(FATAL, "", message, fields)
}
diff --git a/pkg/logger/logger_3rd_party.go b/pkg/logger/logger_3rd_party.go
new file mode 100644
index 000000000..da50d686a
--- /dev/null
+++ b/pkg/logger/logger_3rd_party.go
@@ -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}
+}
diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go
index d57dbe34f..337c950d0 100644
--- a/pkg/migrate/sources/openclaw/common.go
+++ b/pkg/migrate/sources/openclaw/common.go
@@ -4,7 +4,6 @@ var migrateableFiles = []string{
"AGENTS.md",
"SOUL.md",
"USER.md",
- "TOOLS.md",
"HEARTBEAT.md",
}
diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go
index e272d17a9..e95c2f3ec 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config.go
@@ -1111,6 +1111,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
Exec: config.ExecConfig{
EnableDenyPatterns: c.Exec.EnableDenyPatterns,
CustomDenyPatterns: c.Exec.CustomDenyPatterns,
+ AllowRemote: config.DefaultConfig().Tools.Exec.AllowRemote,
},
}
}
diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go
index 3a7d0c686..802693825 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config_test.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go
@@ -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) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
index ee9c11899..d2afe2943 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -221,6 +221,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.apiBase = "https://api.minimaxi.com/v1"
}
}
+ case "longcat":
+ if cfg.Providers.LongCat.APIKey != "" {
+ sel.apiKey = cfg.Providers.LongCat.APIKey
+ sel.apiBase = cfg.Providers.LongCat.APIBase
+ sel.proxy = cfg.Providers.LongCat.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.longcat.chat/openai"
+ }
+ }
case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" {
@@ -352,6 +361,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
if sel.apiBase == "" {
sel.apiBase = "https://api.avian.io/v1"
}
+ case (strings.Contains(lowerModel, "longcat") || strings.HasPrefix(model, "longcat/")) && cfg.Providers.LongCat.APIKey != "":
+ sel.apiKey = cfg.Providers.LongCat.APIKey
+ sel.apiBase = cfg.Providers.LongCat.APIBase
+ sel.proxy = cfg.Providers.LongCat.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.longcat.chat/openai"
+ }
case cfg.Providers.VLLM.APIBase != "":
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index a798154cb..9749e7a15 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -95,7 +95,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
- "minimax":
+ "minimax", "longcat":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" {
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"
case "minimax":
return "https://api.minimaxi.com/v1"
+ case "longcat":
+ return "https://api.longcat.chat/openai"
default:
return ""
}
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index 17bc55d25..6c7bb4795 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -113,6 +113,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"},
{"deepseek", "deepseek"},
{"ollama", "ollama"},
+ {"longcat", "longcat"},
}
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) {
cfg := &config.ModelConfig{
ModelName: "test-anthropic",
diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go
index 36ccda4a1..91469f25b 100644
--- a/pkg/providers/factory_test.go
+++ b/pkg/providers/factory_test.go
@@ -178,6 +178,26 @@ func TestResolveProviderSelection(t *testing.T) {
wantAPIBase: "https://api.moonshot.cn/v1",
wantProxy: "http://127.0.0.1:7890",
},
+ {
+ name: "explicit longcat provider uses defaults",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "longcat"
+ cfg.Providers.LongCat.APIKey = "longcat-key"
+ cfg.Providers.LongCat.Proxy = "http://127.0.0.1:7890"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://api.longcat.chat/openai",
+ wantProxy: "http://127.0.0.1:7890",
+ },
+ {
+ name: "longcat model fallback uses longcat base default",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "longcat/LongCat-Flash-Thinking"
+ cfg.Providers.LongCat.APIKey = "longcat-key"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://api.longcat.chat/openai",
+ },
{
name: "missing keys returns model config error",
setup: func(cfg *config.Config) {
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 0e8db7409..f97bf3acd 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -156,9 +156,10 @@ func (p *Provider) Chat(
// The key is typically the agent ID — stable per agent, shared across requests.
// See: https://platform.openai.com/docs/guides/prompt-caching
// 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 !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") {
+ if supportsPromptCacheKey(p.apiBase) {
requestBody["prompt_cache_key"] = cacheKey
}
}
@@ -283,8 +284,8 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
- Name string `json:"name"`
- Arguments string `json:"arguments"`
+ Name string `json:"name"`
+ Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
ExtraContent *struct {
Google *struct {
@@ -323,12 +324,7 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
if tc.Function != nil {
name = tc.Function.Name
- if tc.Function.Arguments != "" {
- 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
- }
- }
+ arguments = decodeToolCallArguments(tc.Function.Arguments, name)
}
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
@@ -361,6 +357,39 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
}, 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.
// It mirrors protocoltypes.Message but omits SystemParts, which is an
// internal field that would be unknown to third-party endpoints.
@@ -476,3 +505,16 @@ func asFloat(v any) (float64, bool) {
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")
+}
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index 9a3a7acc5..41f278a1b 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -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) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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) {
messages := []protocoltypes.Message{
{
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index a31dbd55c..ef720b7c5 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -32,7 +32,7 @@ func NewSessionManager(storage string) *SessionManager {
}
if storage != "" {
- os.MkdirAll(storage, 0o755)
+ os.MkdirAll(storage, 0o700)
sm.loadSessions()
}
@@ -216,7 +216,7 @@ func (sm *SessionManager) Save(key string) error {
_ = tmpFile.Close()
return err
}
- if err := tmpFile.Chmod(0o644); err != nil {
+ if err := tmpFile.Chmod(0o600); err != nil {
_ = tmpFile.Close()
return err
}
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index 30d84635a..f5985a662 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -10,14 +10,15 @@ import (
"regexp"
"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"
)
-var (
- 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)*`)
-)
+var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
const (
MaxNameLength = 64
@@ -226,11 +227,20 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
return nil
}
- frontmatter := sl.extractFrontmatter(string(content))
+ frontmatter, bodyContent := splitFrontmatter(string(content))
+ dirName := filepath.Base(filepath.Dir(skillPath))
+ title, bodyDescription := extractMarkdownMetadata(bodyContent)
+
+ metadata := &SkillMetadata{
+ Name: dirName,
+ Description: bodyDescription,
+ }
+ if title != "" && namePattern.MatchString(title) && len(title) <= MaxNameLength {
+ metadata.Name = title
+ }
+
if frontmatter == "" {
- return &SkillMetadata{
- Name: filepath.Base(filepath.Dir(skillPath)),
- }
+ return metadata
}
// Try JSON first (for backward compatibility)
@@ -239,60 +249,133 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
Description string `json:"description"`
}
if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil {
- return &SkillMetadata{
- Name: jsonMeta.Name,
- Description: jsonMeta.Description,
+ if jsonMeta.Name != "" {
+ metadata.Name = jsonMeta.Name
}
+ if jsonMeta.Description != "" {
+ metadata.Description = jsonMeta.Description
+ }
+ return metadata
}
// Fall back to simple YAML parsing
yamlMeta := sl.parseSimpleYAML(frontmatter)
- return &SkillMetadata{
- Name: yamlMeta["name"],
- Description: yamlMeta["description"],
+ if name := yamlMeta["name"]; name != "" {
+ metadata.Name = name
}
+ if description := yamlMeta["description"]; description != "" {
+ metadata.Description = description
+ }
+ return metadata
}
-// parseSimpleYAML parses simple key: value YAML format
-// Example: name: github\n description: "..."
-// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac)
+func extractMarkdownMetadata(content string) (title, description string) {
+ p := parser.NewWithExtensions(parser.CommonExtensions)
+ 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 {
result := make(map[string]string)
- // Normalize line endings: convert \r\n and \r to \n
- normalized := strings.ReplaceAll(content, "\r\n", "\n")
- normalized = strings.ReplaceAll(normalized, "\r", "\n")
-
- for line := range strings.SplitSeq(normalized, "\n") {
- line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
-
- parts := strings.SplitN(line, ":", 2)
- 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
- }
+ var meta struct {
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ }
+ if err := yaml.Unmarshal([]byte(content), &meta); err != nil {
+ return result
+ }
+ if meta.Name != "" {
+ result["name"] = meta.Name
+ }
+ if meta.Description != "" {
+ result["description"] = meta.Description
}
return result
}
func (sl *SkillsLoader) extractFrontmatter(content string) string {
- // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
- match := reFrontmatter.FindStringSubmatch(content)
- if len(match) > 1 {
- return match[1]
- }
- return ""
+ frontmatter, _ := splitFrontmatter(content)
+ return frontmatter
}
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 {
diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go
index 31619f9c2..645d8b7ac 100644
--- a/pkg/skills/loader_test.go
+++ b/pkg/skills/loader_test.go
@@ -342,3 +342,78 @@ func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) {
builtin,
}, 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\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)
+}
diff --git a/pkg/state/state.go b/pkg/state/state.go
index 57f371f12..5da7bbde1 100644
--- a/pkg/state/state.go
+++ b/pkg/state/state.go
@@ -40,8 +40,8 @@ func NewManager(workspace string) *Manager {
oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist
- if err := os.MkdirAll(stateDir, 0o755); err != nil {
- log.Fatalf("[FATAL] state: failed to create state directory: %v", err)
+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
+ log.Printf("[WARN] state: failed to create state directory %s: %v", stateDir, err)
}
sm := &Manager{
diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go
index e5e116ef6..3924e5533 100644
--- a/pkg/state/state_test.go
+++ b/pkg/state/state_test.go
@@ -2,7 +2,6 @@ package state
import (
"encoding/json"
- "errors"
"fmt"
"os"
"os/exec"
@@ -217,10 +216,7 @@ func TestNewManager_EmptyWorkspace(t *testing.T) {
}
}
-func TestNewManager_MkdirFailureCrashes(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.
+func TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) {
if os.Getenv("BE_CRASHER") == "1" {
tmpDir := os.Getenv("CRASH_DIR")
@@ -240,15 +236,11 @@ func TestNewManager_MkdirFailureCrashes(t *testing.T) {
}
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)
err = cmd.Run()
-
- var e *exec.ExitError
- if errors.As(err, &e) && !e.Success() {
- return
+ if err != nil {
+ t.Fatalf("NewManager should not crash when state dir creation fails, got: %v", err)
}
-
- t.Fatalf("The process ended without error, a crash was expected via os.Exit(1). Err: %v", err)
}
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 6af0aa9e1..648cc3c6c 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -8,6 +8,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -73,6 +74,10 @@ func (t *CronTool) Parameters() map[string]any {
"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.",
},
+ "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{
"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'.",
@@ -175,12 +180,17 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
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)
+ commandConfirm, _ := args["command_confirm"].(bool)
if command != "" {
- // Commands must be processed by agent/exec tool, so deliver must be false (or handled specifically)
- // Actually, let's keep deliver=false to let the system know it's not a simple chat message
- // 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 !constants.IsInternalChannel(channel) {
+ return ErrorResult("scheduling command execution is restricted to internal channels")
+ }
+ if !commandConfirm {
+ return ErrorResult("command_confirm=true is required to schedule command execution")
+ }
deliver = false
}
@@ -281,7 +291,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
// Execute command if present
if job.Payload.Command != "" {
args := map[string]any{
- "command": job.Payload.Command,
+ "command": job.Payload.Command,
+ "__channel": channel,
+ "__chat_id": chatID,
}
result := t.execTool.Execute(ctx, args)
diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go
new file mode 100644
index 000000000..1776abc65
--- /dev/null
+++ b/pkg/tools/cron_test.go
@@ -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)
+ }
+}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index b8a811d03..67e2ad257 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/constants"
)
type ExecTool struct {
@@ -23,6 +24,7 @@ type ExecTool struct {
allowPatterns []*regexp.Regexp
customAllowPatterns []*regexp.Regexp
restrictToWorkspace bool
+ allowRemote bool
}
var (
@@ -100,10 +102,12 @@ func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0)
customAllowPatterns := make([]*regexp.Regexp, 0)
+ allowRemote := true
if config != nil {
execConfig := config.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns
+ allowRemote = execConfig.AllowRemote
if enableDenyPatterns {
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
if len(execConfig.CustomDenyPatterns) > 0 {
@@ -143,6 +147,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
allowPatterns: nil,
customAllowPatterns: customAllowPatterns,
restrictToWorkspace: restrict,
+ allowRemote: allowRemote,
}, nil
}
@@ -177,6 +182,19 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
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
if wd, ok := args["working_dir"].(string); ok && wd != "" {
if t.restrictToWorkspace && t.workingDir != "" {
@@ -201,6 +219,25 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
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
var cmdCtx context.Context
var cancel context.CancelFunc
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index ff9ea4a15..90265e5bd 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -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
func TestShellTool_RestrictToWorkspace(t *testing.T) {
tmpDir := t.TempDir()
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 157ccefc3..e5036d3a8 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
+ "net"
"net/http"
"net/url"
"regexp"
@@ -783,6 +784,10 @@ func NewWebFetchTool(maxChars int, fetchLimitBytes int64) (*WebFetchTool, error)
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) {
if maxChars <= 0 {
maxChars = defaultMaxChars
@@ -791,10 +796,20 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64)
if err != nil {
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 {
if len(via) >= 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
}
if fetchLimitBytes <= 0 {
@@ -853,6 +868,13 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
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
if mc, ok := args["maxChars"].(float64); ok {
if int(mc) > 100 {
@@ -866,7 +888,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
}
req.Header.Set("User-Agent", userAgent)
-
resp, err := t.client.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("request failed: %v", err))
@@ -957,3 +978,127 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
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
+}
diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go
index 189b83a38..41d83e6f5 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/web_test.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "net"
"net/http"
"net/http/httptest"
"strings"
@@ -17,6 +18,8 @@ const testFetchLimit = int64(10 * 1024 * 1024)
// TestWebTool_WebFetch_Success verifies successful URL fetching
func TestWebTool_WebFetch_Success(t *testing.T) {
+ withPrivateWebFetchHostsAllowed(t)
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
@@ -54,6 +57,8 @@ func TestWebTool_WebFetch_Success(t *testing.T) {
// TestWebTool_WebFetch_JSON verifies JSON content handling
func TestWebTool_WebFetch_JSON(t *testing.T) {
+ withPrivateWebFetchHostsAllowed(t)
+
testData := map[string]string{"key": "value", "number": "123"}
expectedJSON, _ := json.MarshalIndent(testData, "", " ")
@@ -162,6 +167,8 @@ func TestWebTool_WebFetch_MissingURL(t *testing.T) {
// TestWebTool_WebFetch_Truncation verifies content truncation
func TestWebTool_WebFetch_Truncation(t *testing.T) {
+ withPrivateWebFetchHostsAllowed(t)
+
longContent := strings.Repeat("x", 20000)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -204,6 +211,8 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
}
func TestWebFetchTool_PayloadTooLarge(t *testing.T) {
+ withPrivateWebFetchHostsAllowed(t)
+
// Create a mock HTTP server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
@@ -289,6 +298,8 @@ func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction
func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
+ withPrivateWebFetchHostsAllowed(t)
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
@@ -403,6 +414,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
func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
diff --git a/web/backend/api/config.go b/web/backend/api/config.go
index f160b42b6..091e3fbae 100644
--- a/web/backend/api/config.go
+++ b/web/backend/api/config.go
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net/http"
- "os"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -17,36 +16,11 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
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.
//
// GET /api/config
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
- cfg, err := h.loadFilteredConfig()
+ cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
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)
return
}
+ if execAllowRemoteOmitted(body) {
+ cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
+ }
if errs := validateConfig(&cfg); len(errs) > 0 {
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"})
}
+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).
// Only the fields present in the request body will be updated; all other fields remain unchanged.
//
diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go
new file mode 100644
index 000000000..29811e37e
--- /dev/null
+++ b/web/backend/api/config_test.go
@@ -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)
+ }
+}
diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go
index 8f86dd73d..41f702e32 100644
--- a/web/backend/api/gateway.go
+++ b/web/backend/api/gateway.go
@@ -10,7 +10,6 @@ import (
"net/http"
"os"
"os/exec"
- "path/filepath"
"runtime"
"strconv"
"strings"
@@ -19,6 +18,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/web/backend/utils"
)
// gateway holds the state for the managed gateway process.
@@ -36,6 +36,7 @@ var gateway = struct {
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
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/stop", h.handleGatewayStop)
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
}
- hasCredential := strings.TrimSpace(modelCfg.APIKey) != "" ||
- strings.TrimSpace(modelCfg.AuthMethod) != ""
- if !hasCredential {
+ if !hasModelConfiguration(*modelCfg) {
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
}
@@ -131,14 +133,18 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
func (h *Handler) startGatewayLocked() (int, error) {
// Locate the picoclaw executable
- execPath := findPicoclawBinary()
+ execPath := utils.FindPicoclawBinary()
cmd := exec.Command(execPath, "gateway")
+ cmd.Env = os.Environ()
// Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same
// config file without requiring a --config flag on the gateway subcommand.
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()
@@ -207,10 +213,7 @@ func (h *Handler) startGatewayLocked() (int, error) {
if err != nil {
continue
}
- healthHost := "127.0.0.1"
- if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
- healthHost = cfg.Gateway.Host
- }
+ healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
healthPort := cfg.Gateway.Port
if healthPort == 0 {
healthPort = 18790
@@ -353,6 +356,20 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
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.
//
// GET /api/gateway/status
@@ -375,9 +392,7 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
host := "127.0.0.1"
port := 18790
if err == nil && cfg != nil {
- if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
- host = cfg.Gateway.Host
- }
+ host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
if cfg.Gateway.Port != 0 {
port = cfg.Gateway.Port
}
@@ -535,36 +550,6 @@ func (h *Handler) currentGatewayStatus() string {
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.
func scanPipe(r io.Reader, buf *LogBuffer) {
scanner := bufio.NewScanner(r)
diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go
new file mode 100644
index 000000000..a499c1ea2
--- /dev/null
+++ b/web/backend/api/gateway_host.go
@@ -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"
+}
diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go
new file mode 100644
index 000000000..afd600359
--- /dev/null
+++ b/web/backend/api/gateway_host_test.go
@@ -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")
+ }
+}
diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go
index 998c133b5..84d784a5a 100644
--- a/web/backend/api/gateway_test.go
+++ b/web/backend/api/gateway_test.go
@@ -6,10 +6,13 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
+ "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/web/backend/utils"
)
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
@@ -32,7 +35,8 @@ func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Model = "missing-model"
- if err := config.SaveConfig(configPath, cfg); err != nil {
+ err := config.SaveConfig(configPath, cfg)
+ if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -54,7 +58,8 @@ func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
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)
}
@@ -74,7 +79,8 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = ""
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)
}
@@ -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) {
configPath := filepath.Join(t.TempDir(), "config.json")
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) {
// Create a temporary file to act as the mock binary
tmpDir := t.TempDir()
@@ -132,9 +392,9 @@ func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
t.Setenv("PICOCLAW_BINARY", mockBinary)
- got := findPicoclawBinary()
+ got := utils.FindPicoclawBinary()
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
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
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)
}
}
diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go
index 5049dd88f..0d6af823c 100644
--- a/web/backend/api/launcher_config_test.go
+++ b/web/backend/api/launcher_config_test.go
@@ -14,7 +14,7 @@ import (
func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
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()
h.RegisterRoutes(mux)
diff --git a/web/backend/api/log.go b/web/backend/api/log.go
index ecf7d422f..f83f6f34c 100644
--- a/web/backend/api/log.go
+++ b/web/backend/api/log.go
@@ -4,7 +4,7 @@ import "sync"
// 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
-// 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 {
mu sync.RWMutex
lines []string
@@ -45,6 +45,12 @@ func (b *LogBuffer) Reset() {
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.
// 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) {
diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go
new file mode 100644
index 000000000..22bf5c15b
--- /dev/null
+++ b/web/backend/api/model_status.go
@@ -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)
+}
diff --git a/web/backend/api/models.go b/web/backend/api/models.go
index cb57d6f2e..7f3d29c77 100644
--- a/web/backend/api/models.go
+++ b/web/backend/api/models.go
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"strconv"
+ "sync"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -45,13 +46,24 @@ type modelResponse struct {
//
// GET /api/models
func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
- cfg, err := h.loadFilteredConfig()
+ cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
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))
for i, m := range cfg.ModelList {
@@ -69,7 +81,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
- Configured: m.APIKey != "" || m.AuthMethod != "",
+ Configured: configured[i],
IsDefault: m.ModelName == defaultModel,
})
}
diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go
new file mode 100644
index 000000000..7061eb3f7
--- /dev/null
+++ b/web/backend/api/models_test.go
@@ -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")
+ }
+}
diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go
index fc942d51c..a4590dcde 100644
--- a/web/backend/api/pico.go
+++ b/web/backend/api/pico.go
@@ -5,9 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
- "net"
"net/http"
- "strconv"
"time"
"github.com/sipeed/picoclaw/pkg/config"
@@ -30,7 +28,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
return
}
- wsURL := buildWsURL(r, cfg)
+ wsURL := h.buildWsURL(r, cfg)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
@@ -58,7 +56,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
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")
json.NewEncoder(w).Encode(map[string]any{
@@ -123,7 +121,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
return
}
- wsURL := buildWsURL(r, cfg)
+ wsURL := h.buildWsURL(r, cfg)
w.Header().Set("Content-Type", "application/json")
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.
func generateSecureToken() string {
b := make([]byte, 16)
diff --git a/web/backend/api/router.go b/web/backend/api/router.go
index c250724d1..5f081dee9 100644
--- a/web/backend/api/router.go
+++ b/web/backend/api/router.go
@@ -9,13 +9,14 @@ import (
// Handler serves HTTP API requests.
type Handler struct {
- configPath string
- serverPort int
- serverPublic bool
- serverCIDRs []string
- oauthMu sync.Mutex
- oauthFlows map[string]*oauthFlow
- oauthState map[string]string
+ configPath string
+ serverPort int
+ serverPublic bool
+ serverPublicExplicit bool
+ serverCIDRs []string
+ oauthMu sync.Mutex
+ oauthFlows map[string]*oauthFlow
+ oauthState map[string]string
}
// NewHandler creates an instance of the API handler.
@@ -29,9 +30,10 @@ func NewHandler(configPath string) *Handler {
}
// 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.serverPublic = public
+ h.serverPublicExplicit = publicExplicit
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
}
@@ -58,6 +60,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Channel catalog (for frontend navigation/config pages)
h.registerChannelRoutes(mux)
+ // Skills and tools support/actions
+ h.registerSkillRoutes(mux)
+ h.registerToolRoutes(mux)
+
// OS startup / launch-at-login
h.registerStartupRoutes(mux)
diff --git a/web/backend/api/session.go b/web/backend/api/session.go
index e3cf674fc..42d451a05 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -1,7 +1,9 @@
package api
import (
+ "bufio"
"encoding/json"
+ "errors"
"net/http"
"os"
"path/filepath"
@@ -33,12 +35,22 @@ type sessionFile struct {
// sessionListItem is a lightweight summary returned by GET /api/sessions.
type sessionListItem struct {
ID string `json:"id"`
+ Title string `json:"title"`
Preview string `json:"preview"`
MessageCount int `json:"message_count"`
Created string `json:"created"`
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
// channel sessions. The full key format is:
//
@@ -47,7 +59,12 @@ type sessionListItem struct {
// The sanitized filename replaces ':' with '_', so on disk it becomes:
//
// agent_main_pico_direct_pico_.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.
// 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
}
+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.
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
func (h *Handler) sessionsDir() (string, error) {
@@ -104,58 +293,76 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
}
items := []sessionListItem{}
+ seen := make(map[string]struct{})
for _, entry := range entries {
- if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
+ if entry.IsDir() {
continue
}
- data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
- if err != nil {
- continue
- }
+ name := entry.Name()
+ var (
+ sessionID string
+ sess sessionFile
+ loadErr error
+ ok bool
+ )
- var sess sessionFile
- if err := json.Unmarshal(data, &sess); err != nil {
- continue
- }
-
- // Only include Pico channel sessions
- sessionID, ok := extractPicoSessionID(sess.Key)
- if !ok {
- continue
- }
-
- // Build a preview from the first user message
- preview := ""
- for _, msg := range sess.Messages {
- if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
- preview = msg.Content
- break
+ switch {
+ case strings.HasSuffix(name, ".jsonl"):
+ sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
+ if !ok {
+ continue
}
- }
- if len([]rune(preview)) > 60 {
- preview = string([]rune(preview)[:60]) + "..."
- }
- if preview == "" {
- preview = "(empty)"
- }
-
- // Only count non-empty user and assistant messages
- validMessageCount := 0
- for _, msg := range sess.Messages {
- if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
- validMessageCount++
+ sess, loadErr = h.readJSONLSession(dir, sessionID)
+ if loadErr == nil && isEmptySession(sess) {
+ continue
}
+ case strings.HasSuffix(name, ".meta.json"):
+ continue
+ 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
+ }
+ }
+ }
+ data, err := os.ReadFile(filepath.Join(dir, name))
+ 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
}
- items = append(items, sessionListItem{
- ID: sessionID,
- Preview: preview,
- MessageCount: validMessageCount,
- Created: sess.Created.Format(time.RFC3339),
- Updated: sess.Updated.Format(time.RFC3339),
- })
+ if loadErr != nil {
+ continue
+ }
+ if _, exists := seen[sessionID]; exists {
+ continue
+ }
+
+ seen[sessionID] = struct{}{}
+ items = append(items, buildSessionListItem(sessionID, sess))
}
// Sort by updated descending (most recent first)
@@ -209,20 +416,25 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
return
}
- // The sanitized filename replaces ':' with '_':
- // agent:main:pico:direct:pico: -> agent_main_pico_direct_pico_.json
- filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
-
- data, err := os.ReadFile(filepath.Join(dir, filename))
- if err != nil {
- http.Error(w, "session not found", http.StatusNotFound)
- return
+ sess, err := h.readJSONLSession(dir, sessionID)
+ if err == nil && isEmptySession(sess) {
+ err = os.ErrNotExist
}
-
- var sess sessionFile
- if err := json.Unmarshal(data, &sess); err != nil {
- http.Error(w, "failed to parse session", http.StatusInternalServerError)
- return
+ 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)
+ } else {
+ http.Error(w, "failed to parse session", http.StatusInternalServerError)
+ }
+ return
+ }
}
// Convert to a simpler format for the frontend
@@ -268,17 +480,25 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
return
}
- // The sanitized filename replaces ':' with '_':
- // agent:main:pico:direct:pico: -> agent_main_pico_direct_pico_.json
- filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
- filePath := filepath.Join(dir, filename)
+ base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
+ jsonlPath := base + ".jsonl"
+ metaPath := base + ".meta.json"
+ legacyPath := base + ".json"
- if err := os.Remove(filePath); err != nil {
- if os.IsNotExist(err) {
- http.Error(w, "session not found", http.StatusNotFound)
- } else {
+ removed := false
+ for _, path := range []string{jsonlPath, metaPath, legacyPath} {
+ if err := os.Remove(path); err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
http.Error(w, "failed to delete session", http.StatusInternalServerError)
+ return
}
+ removed = true
+ }
+
+ if !removed {
+ http.Error(w, "session not found", http.StatusNotFound)
return
}
diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go
new file mode 100644
index 000000000..21ef5b5b8
--- /dev/null
+++ b/web/backend/api/session_test.go
@@ -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())
+ }
+}
diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go
new file mode 100644
index 000000000..936074fee
--- /dev/null
+++ b/web/backend/api/skills.go
@@ -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")
+}
diff --git a/web/backend/api/skills_test.go b/web/backend/api/skills_test.go
new file mode 100644
index 000000000..3289d5b33
--- /dev/null
+++ b/web/backend/api/skills_test.go
@@ -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)
+ }
+}
diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go
new file mode 100644
index 000000000..373a3be12
--- /dev/null
+++ b/web/backend/api/tools.go
@@ -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
+}
diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go
new file mode 100644
index 000000000..646cefbe2
--- /dev/null
+++ b/web/backend/api/tools_test.go
@@ -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)
+ }
+}
diff --git a/web/backend/dist/.gitkeep b/web/backend/dist/.gitkeep
index e69de29bb..4b533f03a 100644
--- a/web/backend/dist/.gitkeep
+++ b/web/backend/dist/.gitkeep
@@ -0,0 +1 @@
+# Keep the embedded web backend dist directory in version control.
diff --git a/web/backend/main.go b/web/backend/main.go
index b8c4dc2bb..650540ea8 100644
--- a/web/backend/main.go
+++ b/web/backend/main.go
@@ -25,6 +25,7 @@ import (
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
+ "github.com/sipeed/picoclaw/web/backend/utils"
)
func main() {
@@ -51,7 +52,7 @@ func main() {
flag.Parse()
// Resolve config path
- configPath := getDefaultConfigPath()
+ configPath := utils.GetDefaultConfigPath()
if flag.NArg() > 0 {
configPath = flag.Arg(0)
}
@@ -60,6 +61,10 @@ func main() {
if err != nil {
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 explicitPublic bool
@@ -109,7 +114,7 @@ func main() {
// API Routes (e.g. /api/status)
apiHandler := api.NewHandler(absPath)
- apiHandler.SetServerOptions(portNum, effectivePublic, launcherCfg.AllowedCIDRs)
+ apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
apiHandler.RegisterRoutes(mux)
// Frontend Embedded Assets
@@ -128,13 +133,13 @@ func main() {
)
// Print startup banner
- fmt.Print(banner)
+ fmt.Print(utils.Banner)
fmt.Println()
fmt.Println(" Open the following URL in your browser:")
fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
if effectivePublic {
- if ip := getLocalIP(); ip != "" {
+ if ip := utils.GetLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
}
}
@@ -145,7 +150,7 @@ func main() {
go func() {
time.Sleep(500 * time.Millisecond)
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)
}
}()
diff --git a/web/backend/utils.go b/web/backend/utils/banner.go
similarity index 54%
rename from web/backend/utils.go
rename to web/backend/utils/banner.go
index 6fa734aeb..a64ea6390 100644
--- a/web/backend/utils.go
+++ b/web/backend/utils/banner.go
@@ -1,19 +1,10 @@
-package main
-
-import (
- "fmt"
- "net"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
-)
+package utils
const (
colorBlue = "\x1b[38;2;62;93;185m"
colorRed = "\x1b[38;2;213;70;70m"
colorReset = "\x1b[0m"
- banner = "\r\n" +
+ Banner = "\r\n" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
@@ -22,40 +13,3 @@ const (
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" +
colorReset
)
-
-// getDefaultConfigPath returns the default path to the picoclaw config file.
-func getDefaultConfigPath() string {
- home, err := os.UserHomeDir()
- if err != nil {
- return "config.json"
- }
- return filepath.Join(home, ".picoclaw", "config.json")
-}
-
-// getLocalIP returns the local IP address of the machine.
-func getLocalIP() string {
- addrs, err := net.InterfaceAddrs()
- if err != nil {
- return ""
- }
- for _, a := range addrs {
- if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
- return ipnet.IP.String()
- }
- }
- return ""
-}
-
-// openBrowser automatically opens the given URL in the default browser.
-func openBrowser(url string) error {
- switch runtime.GOOS {
- case "linux":
- return exec.Command("xdg-open", url).Start()
- case "windows":
- return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
- case "darwin":
- return exec.Command("open", url).Start()
- default:
- return fmt.Errorf("unsupported platform")
- }
-}
diff --git a/web/backend/utils/onboard.go b/web/backend/utils/onboard.go
new file mode 100644
index 000000000..fbe34f220
--- /dev/null
+++ b/web/backend/utils/onboard.go
@@ -0,0 +1,42 @@
+package utils
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+var execCommand = exec.Command
+
+func EnsureOnboarded(configPath string) error {
+ _, err := os.Stat(configPath)
+ if err == nil {
+ return nil
+ }
+ if !os.IsNotExist(err) {
+ return fmt.Errorf("stat config: %w", err)
+ }
+
+ cmd := execCommand(FindPicoclawBinary(), "onboard")
+ cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+configPath)
+ cmd.Stdin = strings.NewReader("n\n")
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ trimmed := strings.TrimSpace(string(output))
+ if trimmed == "" {
+ return fmt.Errorf("run onboard: %w", err)
+ }
+ return fmt.Errorf("run onboard: %w: %s", err, trimmed)
+ }
+
+ if _, err := os.Stat(configPath); err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("onboard completed but did not create config %s", configPath)
+ }
+ return fmt.Errorf("verify config after onboard: %w", err)
+ }
+
+ return nil
+}
diff --git a/web/backend/utils/onboard_test.go b/web/backend/utils/onboard_test.go
new file mode 100644
index 000000000..06f967e76
--- /dev/null
+++ b/web/backend/utils/onboard_test.go
@@ -0,0 +1,101 @@
+package utils
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestEnsureOnboardedSkipsWhenConfigExists(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ origExecCommand := execCommand
+ defer func() { execCommand = origExecCommand }()
+
+ called := false
+ execCommand = func(name string, args ...string) *exec.Cmd {
+ called = true
+ return exec.Command("sh", "-c", "exit 1")
+ }
+
+ if err := EnsureOnboarded(configPath); err != nil {
+ t.Fatalf("EnsureOnboarded() error = %v", err)
+ }
+ if called {
+ t.Fatal("expected onboard command not to run when config already exists")
+ }
+}
+
+func TestEnsureOnboardedRunsOnboardWhenConfigMissing(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ t.Setenv("EXPECTED_CONFIG_PATH", configPath)
+
+ origExecCommand := execCommand
+ defer func() { execCommand = origExecCommand }()
+
+ var gotName string
+ var gotArgs []string
+ execCommand = func(name string, args ...string) *exec.Cmd {
+ gotName = name
+ gotArgs = append([]string(nil), args...)
+ return exec.Command(
+ "sh",
+ "-c",
+ `test "$PICOCLAW_CONFIG" = "$EXPECTED_CONFIG_PATH" &&
+mkdir -p "$(dirname "$PICOCLAW_CONFIG")" &&
+printf '{}' > "$PICOCLAW_CONFIG"`,
+ )
+ }
+
+ if err := EnsureOnboarded(configPath); err != nil {
+ t.Fatalf("EnsureOnboarded() error = %v", err)
+ }
+ if gotName == "" {
+ t.Fatal("expected onboard command to run")
+ }
+ if len(gotArgs) != 1 || gotArgs[0] != "onboard" {
+ t.Fatalf("command args = %#v, want []string{\"onboard\"}", gotArgs)
+ }
+ if _, err := os.Stat(configPath); err != nil {
+ t.Fatalf("expected config to be created: %v", err)
+ }
+}
+
+func TestEnsureOnboardedFailsWhenOnboardDoesNotCreateConfig(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+
+ origExecCommand := execCommand
+ defer func() { execCommand = origExecCommand }()
+
+ execCommand = func(name string, args ...string) *exec.Cmd {
+ return exec.Command("sh", "-c", "exit 0")
+ }
+
+ if err := EnsureOnboarded(configPath); err == nil {
+ t.Fatal("EnsureOnboarded() error = nil, want failure when onboard does not create config")
+ }
+}
+
+func TestEnsureOnboardedIncludesOnboardOutputOnFailure(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+
+ origExecCommand := execCommand
+ defer func() { execCommand = origExecCommand }()
+
+ execCommand = func(name string, args ...string) *exec.Cmd {
+ return exec.Command("sh", "-c", "echo onboarding failed >&2; exit 2")
+ }
+
+ err := EnsureOnboarded(configPath)
+ if err == nil {
+ t.Fatal("EnsureOnboarded() error = nil, want failure")
+ }
+ if !strings.Contains(err.Error(), "onboarding failed") {
+ t.Fatalf("error = %q, want onboard output included", err)
+ }
+}
diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go
new file mode 100644
index 000000000..4e6c32c56
--- /dev/null
+++ b/web/backend/utils/runtime.go
@@ -0,0 +1,80 @@
+package utils
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+)
+
+// GetDefaultConfigPath returns the default path to the picoclaw config file.
+func GetDefaultConfigPath() string {
+ if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
+ return configPath
+ }
+ if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" {
+ return filepath.Join(picoclawHome, "config.json")
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "config.json"
+ }
+ return filepath.Join(home, ".picoclaw", "config.json")
+}
+
+// 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"
+ }
+
+ if p := os.Getenv("PICOCLAW_BINARY"); p != "" {
+ if info, _ := os.Stat(p); info != nil && !info.IsDir() {
+ return p
+ }
+ }
+
+ 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
+ }
+ }
+
+ return "picoclaw"
+}
+
+// GetLocalIP returns the local IP address of the machine.
+func GetLocalIP() string {
+ addrs, err := net.InterfaceAddrs()
+ if err != nil {
+ return ""
+ }
+ for _, a := range addrs {
+ if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
+ return ipnet.IP.String()
+ }
+ }
+ return ""
+}
+
+// OpenBrowser automatically opens the given URL in the default browser.
+func OpenBrowser(url string) error {
+ switch runtime.GOOS {
+ case "linux":
+ return exec.Command("xdg-open", url).Start()
+ case "windows":
+ return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
+ case "darwin":
+ return exec.Command("open", url).Start()
+ default:
+ return fmt.Errorf("unsupported platform")
+ }
+}
diff --git a/web/frontend/package.json b/web/frontend/package.json
index ee46cdcda..687fd5771 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -32,7 +32,7 @@
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
- "shadcn": "^3.8.5",
+ "shadcn": "^4.0.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 8e89cbbe5..9de3354a1 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -66,8 +66,8 @@ importers:
specifier: ^4.0.1
version: 4.0.1
shadcn:
- specifier: ^3.8.5
- version: 3.8.5(@types/node@24.11.0)(typescript@5.9.3)
+ specifier: ^4.0.5
+ version: 4.0.5(@types/node@24.11.0)(typescript@5.9.3)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -512,8 +512,8 @@ packages:
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
- '@hono/node-server@1.19.9':
- resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
+ '@hono/node-server@1.19.11':
+ resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -1359,79 +1359,66 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1516,28 +1503,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -2296,8 +2279,8 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
- express-rate-limit@8.2.1:
- resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==}
+ express-rate-limit@8.3.1:
+ resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
@@ -2496,8 +2479,8 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
- hono@4.12.3:
- resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==}
+ hono@4.12.7:
+ resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
engines: {node: '>=16.9.0'}
html-parse-stringify@3.0.1:
@@ -2559,8 +2542,8 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
- ip-address@10.0.1:
- resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
+ ip-address@10.1.0:
+ resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
@@ -2785,28 +2768,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -3501,8 +3480,8 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
- shadcn@3.8.5:
- resolution: {integrity: sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA==}
+ shadcn@4.0.5:
+ resolution: {integrity: sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg==}
hasBin: true
shebang-command@2.0.0:
@@ -4332,9 +4311,9 @@ snapshots:
'@fontsource-variable/inter@5.2.8': {}
- '@hono/node-server@1.19.9(hono@4.12.3)':
+ '@hono/node-server@1.19.11(hono@4.12.7)':
dependencies:
- hono: 4.12.3
+ hono: 4.12.7
'@humanfs/core@0.19.1': {}
@@ -4396,7 +4375,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
dependencies:
- '@hono/node-server': 1.19.9(hono@4.12.3)
+ '@hono/node-server': 1.19.11(hono@4.12.7)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
@@ -4405,8 +4384,8 @@ snapshots:
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
- express-rate-limit: 8.2.1(express@5.2.1)
- hono: 4.12.3
+ express-rate-limit: 8.3.1(express@5.2.1)
+ hono: 4.12.7
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -6146,10 +6125,10 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
- express-rate-limit@8.2.1(express@5.2.1):
+ express-rate-limit@8.3.1(express@5.2.1):
dependencies:
express: 5.2.1
- ip-address: 10.0.1
+ ip-address: 10.1.0
express@5.2.1:
dependencies:
@@ -6374,7 +6353,7 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
- hono@4.12.3: {}
+ hono@4.12.7: {}
html-parse-stringify@3.0.1:
dependencies:
@@ -6430,7 +6409,7 @@ snapshots:
inline-style-parser@0.2.7: {}
- ip-address@10.0.1: {}
+ ip-address@10.1.0: {}
ipaddr.js@1.9.1: {}
@@ -7534,7 +7513,7 @@ snapshots:
setprototypeof@1.2.0: {}
- shadcn@3.8.5(@types/node@24.11.0)(typescript@5.9.3):
+ shadcn@4.0.5(@types/node@24.11.0)(typescript@5.9.3):
dependencies:
'@antfu/ni': 25.0.0
'@babel/core': 7.29.0
diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts
index 5a58d48f0..020e92e3a 100644
--- a/web/frontend/src/api/gateway.ts
+++ b/web/frontend/src/api/gateway.ts
@@ -14,6 +14,8 @@ interface GatewayStatusResponse {
interface GatewayActionResponse {
status: string
pid?: number
+ log_total?: number
+ log_run_id?: number
}
const BASE_URL = ""
@@ -59,4 +61,10 @@ export async function restartGateway(): Promise {
})
}
+export async function clearGatewayLogs(): Promise {
+ return request("/api/gateway/logs/clear", {
+ method: "POST",
+ })
+}
+
export type { GatewayStatusResponse, GatewayActionResponse }
diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts
index 56ef148db..10b0d28fd 100644
--- a/web/frontend/src/api/sessions.ts
+++ b/web/frontend/src/api/sessions.ts
@@ -2,6 +2,7 @@
export interface SessionSummary {
id: string
+ title: string
preview: string
message_count: number
created: string
diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts
new file mode 100644
index 000000000..307cbd788
--- /dev/null
+++ b/web/frontend/src/api/skills.ts
@@ -0,0 +1,79 @@
+export interface SkillSupportItem {
+ name: string
+ path: string
+ source: "workspace" | "global" | "builtin" | string
+ description: string
+}
+
+export interface SkillDetailResponse extends SkillSupportItem {
+ content: string
+}
+
+interface SkillsResponse {
+ skills: SkillSupportItem[]
+}
+
+interface SkillActionResponse {
+ status?: string
+ name?: string
+ path?: string
+ source?: string
+ description?: string
+}
+
+async function request(path: string, options?: RequestInit): Promise {
+ const res = await fetch(path, options)
+ if (!res.ok) {
+ throw new Error(await extractErrorMessage(res))
+ }
+ return res.json() as Promise
+}
+
+export async function getSkills(): Promise {
+ return request("/api/skills")
+}
+
+export async function getSkill(name: string): Promise {
+ return request(`/api/skills/${encodeURIComponent(name)}`)
+}
+
+export async function importSkill(file: File): Promise {
+ const formData = new FormData()
+ formData.set("file", file)
+
+ const res = await fetch("/api/skills/import", {
+ method: "POST",
+ body: formData,
+ })
+ if (!res.ok) {
+ throw new Error(await extractErrorMessage(res))
+ }
+ return res.json() as Promise
+}
+
+export async function deleteSkill(name: string): Promise {
+ return request(
+ `/api/skills/${encodeURIComponent(name)}`,
+ {
+ method: "DELETE",
+ },
+ )
+}
+
+async function extractErrorMessage(res: Response): Promise {
+ try {
+ const body = (await res.json()) as {
+ error?: string
+ errors?: string[]
+ }
+ if (Array.isArray(body.errors) && body.errors.length > 0) {
+ return body.errors.join("; ")
+ }
+ if (typeof body.error === "string" && body.error.trim() !== "") {
+ return body.error
+ }
+ } catch {
+ // ignore invalid body
+ }
+ return `API error: ${res.status} ${res.statusText}`
+}
diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts
new file mode 100644
index 000000000..9f09efbfd
--- /dev/null
+++ b/web/frontend/src/api/tools.ts
@@ -0,0 +1,56 @@
+export interface ToolSupportItem {
+ name: string
+ description: string
+ category: string
+ config_key: string
+ status: "enabled" | "disabled" | "blocked"
+ reason_code?: string
+}
+
+interface ToolsResponse {
+ tools: ToolSupportItem[]
+}
+
+interface ToolActionResponse {
+ status: string
+}
+
+async function request(path: string, options?: RequestInit): Promise {
+ const res = await fetch(path, options)
+ if (!res.ok) {
+ let message = `API error: ${res.status} ${res.statusText}`
+ try {
+ const body = (await res.json()) as {
+ error?: string
+ errors?: string[]
+ }
+ if (Array.isArray(body.errors) && body.errors.length > 0) {
+ message = body.errors.join("; ")
+ } else if (typeof body.error === "string" && body.error.trim() !== "") {
+ message = body.error
+ }
+ } catch {
+ // ignore invalid body
+ }
+ throw new Error(message)
+ }
+ return res.json() as Promise
+}
+
+export async function getTools(): Promise {
+ return request("/api/tools")
+}
+
+export async function setToolEnabled(
+ name: string,
+ enabled: boolean,
+): Promise {
+ return request(
+ `/api/tools/${encodeURIComponent(name)}/state`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled }),
+ },
+ )
+}
diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx
index dc24f8781..702212857 100644
--- a/web/frontend/src/components/app-sidebar.tsx
+++ b/web/frontend/src/components/app-sidebar.tsx
@@ -7,6 +7,8 @@ import {
IconListDetails,
IconMessageCircle,
IconSettings,
+ IconSparkles,
+ IconTools,
} from "@tabler/icons-react"
import { Link, useRouterState } from "@tanstack/react-router"
import * as React from "react"
@@ -53,6 +55,10 @@ const baseNavGroups: Omit[] = [
label: "navigation.model_group",
defaultOpen: true,
},
+ {
+ label: "navigation.agent_group",
+ defaultOpen: true,
+ },
{
label: "navigation.services",
defaultOpen: true,
@@ -113,6 +119,23 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
},
{
...baseNavGroups[2],
+ items: [
+ {
+ title: "navigation.skills",
+ url: "/agent/skills",
+ icon: IconSparkles,
+ translateTitle: true,
+ },
+ {
+ title: "navigation.tools",
+ url: "/agent/tools",
+ icon: IconTools,
+ translateTitle: true,
+ },
+ ],
+ },
+ {
+ ...baseNavGroups[3],
items: [
{
title: "navigation.config",
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx
index 0fd23a6a5..a3ab843b4 100644
--- a/web/frontend/src/components/chat/chat-page.tsx
+++ b/web/frontend/src/components/chat/chat-page.tsx
@@ -43,11 +43,18 @@ export function ChatPage() {
handleSetDefault,
} = useChatModels({ isConnected })
- const { sessions, hasMore, observerRef, loadSessions, handleDeleteSession } =
- useSessionHistory({
- activeSessionId,
- onDeletedActiveSession: newChat,
- })
+ const {
+ sessions,
+ hasMore,
+ loadError,
+ loadErrorMessage,
+ observerRef,
+ loadSessions,
+ handleDeleteSession,
+ } = useSessionHistory({
+ activeSessionId,
+ onDeletedActiveSession: newChat,
+ })
const handleScroll = (e: React.UIEvent) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
@@ -96,6 +103,8 @@ export function ChatPage() {
sessions={sessions}
activeSessionId={activeSessionId}
hasMore={hasMore}
+ loadError={loadError}
+ loadErrorMessage={loadErrorMessage}
observerRef={observerRef}
onOpenChange={(open) => {
if (open) {
diff --git a/web/frontend/src/components/chat/session-history-menu.tsx b/web/frontend/src/components/chat/session-history-menu.tsx
index f2e93295c..3f293e353 100644
--- a/web/frontend/src/components/chat/session-history-menu.tsx
+++ b/web/frontend/src/components/chat/session-history-menu.tsx
@@ -17,6 +17,8 @@ interface SessionHistoryMenuProps {
sessions: SessionSummary[]
activeSessionId: string
hasMore: boolean
+ loadError: boolean
+ loadErrorMessage: string
observerRef: RefObject
onOpenChange: (open: boolean) => void
onSwitchSession: (sessionId: string) => void
@@ -27,6 +29,8 @@ export function SessionHistoryMenu({
sessions,
activeSessionId,
hasMore,
+ loadError,
+ loadErrorMessage,
observerRef,
onOpenChange,
onSwitchSession,
@@ -44,7 +48,14 @@ export function SessionHistoryMenu({
- {sessions.length === 0 ? (
+ {loadError && (
+
+
+ {loadErrorMessage}
+
+
+ )}
+ {sessions.length === 0 && !loadError ? (
{t("chat.noHistory")}
@@ -60,7 +71,7 @@ export function SessionHistoryMenu({
onClick={() => onSwitchSession(session.id)}
>
- {session.preview}
+ {session.title || session.preview}
{t("chat.messagesCount", {
diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx
index c2d502079..d7e1aa1b5 100644
--- a/web/frontend/src/components/config/config-page.tsx
+++ b/web/frontend/src/components/config/config-page.tsx
@@ -189,6 +189,11 @@ export function ConfigPage() {
session: {
dm_scope: dmScope,
},
+ tools: {
+ exec: {
+ allow_remote: form.allowRemote,
+ },
+ },
heartbeat: {
enabled: form.heartbeatEnabled,
interval: heartbeatInterval,
diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx
index 340ece333..90813be2a 100644
--- a/web/frontend/src/components/config/config-sections.tsx
+++ b/web/frontend/src/components/config/config-sections.tsx
@@ -63,6 +63,13 @@ export function AgentDefaultsSection({
}
/>
+ onFieldChange("allowRemote", checked)}
+ />
+
export interface CoreConfigForm {
workspace: string
restrictToWorkspace: boolean
+ allowRemote: boolean
maxTokens: string
maxToolIterations: string
summarizeMessageThreshold: string
@@ -54,6 +55,7 @@ export const DM_SCOPE_OPTIONS = [
export const EMPTY_FORM: CoreConfigForm = {
workspace: "",
restrictToWorkspace: true,
+ allowRemote: true,
maxTokens: "32768",
maxToolIterations: "50",
summarizeMessageThreshold: "20",
@@ -103,6 +105,8 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
const session = asRecord(root.session)
const heartbeat = asRecord(root.heartbeat)
const devices = asRecord(root.devices)
+ const tools = asRecord(root.tools)
+ const exec = asRecord(tools.exec)
return {
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
@@ -110,6 +114,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
defaults.restrict_to_workspace === undefined
? EMPTY_FORM.restrictToWorkspace
: asBool(defaults.restrict_to_workspace),
+ allowRemote:
+ exec.allow_remote === undefined
+ ? EMPTY_FORM.allowRemote
+ : asBool(exec.allow_remote),
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
maxToolIterations: asNumberString(
defaults.max_tool_iterations,
diff --git a/web/frontend/src/components/config/raw-json-panel.tsx b/web/frontend/src/components/config/raw-json-panel.tsx
index f67bd89f5..b8a059506 100644
--- a/web/frontend/src/components/config/raw-json-panel.tsx
+++ b/web/frontend/src/components/config/raw-json-panel.tsx
@@ -22,7 +22,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
-import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
export function RawJsonPanel() {
@@ -142,17 +141,16 @@ export function RawJsonPanel() {
)}
-
-
+