diff --git a/README.fr.md b/README.fr.md
index 08a1926b6..c1544cd4f 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -980,6 +980,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..5ac939220 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -921,6 +921,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..3ce9ba930 100644
--- a/README.md
+++ b/README.md
@@ -1034,6 +1034,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 +1505,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..52caf5317 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -976,6 +976,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..7dc569c94 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -945,6 +945,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..410862267 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -517,6 +517,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 +880,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/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 098843666..3762015e9 100644
--- a/go.mod
+++ b/go.mod
@@ -21,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
@@ -50,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/pkg/agent/loop.go b/pkg/agent/loop.go
index ed483d371..0a5a78857 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
@@ -259,119 +259,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() {
@@ -451,6 +340,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()
}
@@ -639,6 +539,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/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 1487358ef..2b4dc10e0 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
@@ -674,6 +703,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/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 bc452559c..2296245ae 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
}
}
@@ -335,8 +336,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 {
@@ -376,21 +377,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 {
- // JSON is malformed (likely truncated due to max_tokens or LLM stopping early).
- // Try to repair it by appending closing characters.
- if repairedArgs, repairErr := repairJSON(tc.Function.Arguments); repairErr == nil {
- arguments = repairedArgs
- log.Printf("openai_compat: recovered tool call arguments for %q (auto-repaired)", name)
- } else {
- // JSON is too malformed to repair. Log and signal truncation.
- log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
- truncated = true
- continue // Skip this malformed tool call entirely
- }
- }
- }
+ arguments = decodeToolCallArguments(tc.Function.Arguments, name)
}
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
@@ -429,6 +416,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.
@@ -545,24 +565,15 @@ func asFloat(v any) (float64, bool) {
}
}
-// repairJSON attempts to fix commonly truncated JSON objects by appending closing characters.
-func repairJSON(s string) (map[string]any, error) {
- var result map[string]any
-
- // Fast path: try closing suffixes for flat JSON objects
- suffixes := []string{
- "}",
- "\"}",
- "\"}}",
- "\"}]}",
- "]}",
+// 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
}
-
- for _, suffix := range suffixes {
- if err := json.Unmarshal([]byte(s+suffix), &result); err == nil {
- return result, nil
- }
- }
-
- return nil, fmt.Errorf("failed to repair json")
+ 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 df2732b38..b056788e0 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/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 e248ea966..003cd860c 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"
@@ -818,6 +819,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
@@ -826,10 +831,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 {
@@ -888,6 +903,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 {
@@ -901,7 +923,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))
@@ -992,3 +1013,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 188fb8adb..0737d2087 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"
@@ -18,6 +19,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)
@@ -55,6 +58,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, "", " ")
@@ -163,6 +168,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) {
@@ -205,6 +212,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")
@@ -290,6 +299,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)
@@ -404,6 +415,205 @@ func TestWebFetchTool_extractText(t *testing.T) {
}
}
+func withPrivateWebFetchHostsAllowed(t *testing.T) {
+ t.Helper()
+ previous := allowPrivateWebFetchHosts.Load()
+ allowPrivateWebFetchHosts.Store(true)
+ t.Cleanup(func() {
+ allowPrivateWebFetchHosts.Store(previous)
+ })
+}
+
+func TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) {
+ tool, err := NewWebFetchTool(50000, testFetchLimit)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": "http://127.0.0.1:0",
+ })
+
+ if !result.IsError {
+ t.Errorf("expected error for private host URL, got success")
+ }
+ if !strings.Contains(result.ForLLM, "private or local network") &&
+ !strings.Contains(result.ForUser, "private or local network") {
+ t.Errorf("expected private host block message, got %q", result.ForLLM)
+ }
+}
+
+func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
+ withPrivateWebFetchHostsAllowed(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("ok"))
+ }))
+ defer server.Close()
+
+ tool, err := NewWebFetchTool(50000, testFetchLimit)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": server.URL,
+ })
+
+ if result.IsError {
+ t.Errorf("expected success when private host access is allowed in tests, got %q", result.ForLLM)
+ }
+}
+
+// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked
+func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {
+ tool, err := NewWebFetchTool(50000, testFetchLimit)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": "http://[::ffff:127.0.0.1]:0",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for IPv4-mapped IPv6 loopback URL, got success")
+ }
+}
+
+// TestWebFetch_BlocksMetadataIP verifies 169.254.169.254 is blocked
+func TestWebFetch_BlocksMetadataIP(t *testing.T) {
+ tool, err := NewWebFetchTool(50000, testFetchLimit)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": "http://169.254.169.254/latest/meta-data",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for cloud metadata IP, got success")
+ }
+}
+
+// TestWebFetch_BlocksIPv6UniqueLocal verifies fc00::/7 addresses are blocked
+func TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) {
+ tool, err := NewWebFetchTool(50000, testFetchLimit)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": "http://[fd00::1]:0",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for IPv6 unique local address, got success")
+ }
+}
+
+// TestWebFetch_Blocks6to4WithPrivateEmbed verifies 6to4 with private embedded IPv4 is blocked
+func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) {
+ tool, err := NewWebFetchTool(50000, testFetchLimit)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+ // 2002:7f00:0001::1 embeds 127.0.0.1
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": "http://[2002:7f00:0001::1]:0",
+ })
+
+ if !result.IsError {
+ t.Error("expected error for 6to4 with private embedded IPv4, got success")
+ }
+}
+
+// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked
+func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) {
+ tool, err := NewWebFetchTool(50000, testFetchLimit)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+ // 2002:0801:0101::1 embeds 8.1.1.1 (public) — pre-flight should pass,
+ // connection will fail (no listener) but that's after the SSRF check.
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": "http://[2002:0801:0101::1]:0",
+ })
+
+ // Should NOT be blocked by SSRF check — error should be connection failure, not "private"
+ if result.IsError && strings.Contains(result.ForLLM, "private") {
+ t.Error("6to4 with public embedded IPv4 should not be blocked as private")
+ }
+}
+
+// TestWebFetch_RedirectToPrivateBlocked verifies redirects to private IPs are blocked
+func TestWebFetch_RedirectToPrivateBlocked(t *testing.T) {
+ withPrivateWebFetchHostsAllowed(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Redirect to a private IP
+ http.Redirect(w, r, "http://10.0.0.1/secret", http.StatusFound)
+ }))
+ defer server.Close()
+
+ // Temporarily disable private host allowance for the redirect check
+ allowPrivateWebFetchHosts.Store(false)
+ defer allowPrivateWebFetchHosts.Store(true)
+
+ tool, err := NewWebFetchTool(50000, testFetchLimit)
+ if err != nil {
+ t.Fatalf("Failed to create web fetch tool: %v", err)
+ }
+ result := tool.Execute(context.Background(), map[string]any{
+ "url": server.URL,
+ })
+
+ if !result.IsError {
+ t.Error("expected error when redirecting to private IP, got success")
+ }
+}
+
+// TestIsPrivateOrRestrictedIP_Table tests IP classification logic
+func TestIsPrivateOrRestrictedIP_Table(t *testing.T) {
+ tests := []struct {
+ ip string
+ blocked bool
+ desc string
+ }{
+ {"127.0.0.1", true, "IPv4 loopback"},
+ {"10.0.0.1", true, "IPv4 private class A"},
+ {"172.16.0.1", true, "IPv4 private class B"},
+ {"192.168.1.1", true, "IPv4 private class C"},
+ {"169.254.169.254", true, "link-local / cloud metadata"},
+ {"100.64.0.1", true, "carrier-grade NAT"},
+ {"0.0.0.0", true, "unspecified"},
+ {"8.8.8.8", false, "public DNS"},
+ {"1.1.1.1", false, "public DNS"},
+ {"::1", true, "IPv6 loopback"},
+ {"::ffff:127.0.0.1", true, "IPv4-mapped IPv6 loopback"},
+ {"::ffff:10.0.0.1", true, "IPv4-mapped IPv6 private"},
+ {"fc00::1", true, "IPv6 unique local"},
+ {"fd00::1", true, "IPv6 unique local"},
+ {"2002:7f00:0001::1", true, "6to4 with embedded 127.x (private)"},
+ {"2002:0a00:0001::1", true, "6to4 with embedded 10.0.0.1 (private)"},
+ {"2002:0801:0101::1", false, "6to4 with embedded 8.1.1.1 (public)"},
+ {"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true, "Teredo with client 10.0.0.1 (private)"},
+ {"2001:0000:4136:e378:8000:63bf:f7f6:fefe", false, "Teredo with client 8.9.1.1 (public)"},
+ {"2607:f8b0:4004:800::200e", false, "public IPv6 (Google)"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ ip := net.ParseIP(tt.ip)
+ if ip == nil {
+ t.Fatalf("failed to parse IP: %s", tt.ip)
+ }
+ got := isPrivateOrRestrictedIP(ip)
+ if got != tt.blocked {
+ t.Errorf("isPrivateOrRestrictedIP(%s) = %v, want %v", tt.ip, got, tt.blocked)
+ }
+ })
+ }
+}
+
// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain
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/skills/skills-page.tsx b/web/frontend/src/components/skills/skills-page.tsx
new file mode 100644
index 000000000..3b5c5acb4
--- /dev/null
+++ b/web/frontend/src/components/skills/skills-page.tsx
@@ -0,0 +1,314 @@
+import {
+ IconFileInfo,
+ IconLoader2,
+ IconPlus,
+ IconTrash,
+} from "@tabler/icons-react"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { type ChangeEvent, useRef, useState } from "react"
+import { useTranslation } from "react-i18next"
+import ReactMarkdown from "react-markdown"
+import remarkGfm from "remark-gfm"
+import { toast } from "sonner"
+
+import {
+ type SkillSupportItem,
+ deleteSkill,
+ getSkill,
+ getSkills,
+ importSkill,
+} from "@/api/skills"
+import { PageHeader } from "@/components/page-header"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+export function SkillsPage() {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const importInputRef = useRef(null)
+ const [selectedSkill, setSelectedSkill] = useState(
+ null,
+ )
+ const [skillPendingDelete, setSkillPendingDelete] =
+ useState(null)
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["skills"],
+ queryFn: getSkills,
+ })
+ const {
+ data: selectedSkillDetail,
+ isLoading: isSkillDetailLoading,
+ error: skillDetailError,
+ } = useQuery({
+ queryKey: ["skills", selectedSkill?.name],
+ queryFn: () => getSkill(selectedSkill!.name),
+ enabled: selectedSkill !== null,
+ })
+
+ const importMutation = useMutation({
+ mutationFn: async (file: File) => importSkill(file),
+ onSuccess: () => {
+ toast.success(t("pages.agent.skills.import_success"))
+ void queryClient.invalidateQueries({ queryKey: ["skills"] })
+ },
+ onError: (err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("pages.agent.skills.import_error"),
+ )
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: async (name: string) => deleteSkill(name),
+ onSuccess: (_, deletedName) => {
+ toast.success(t("pages.agent.skills.delete_success"))
+ setSkillPendingDelete(null)
+ if (
+ selectedSkill?.name === deletedName &&
+ selectedSkill.source === "workspace"
+ ) {
+ setSelectedSkill(null)
+ }
+ void queryClient.invalidateQueries({ queryKey: ["skills"] })
+ },
+ onError: (err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("pages.agent.skills.delete_error"),
+ )
+ },
+ })
+
+ const handleImportClick = () => {
+ importInputRef.current?.click()
+ }
+
+ const handleImportFileChange = (event: ChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+ importMutation.mutate(file)
+ event.target.value = ""
+ }
+
+ return (
+
+
+
+
+ >
+ }
+ />
+
+
+
+ {isLoading ? (
+
+ {t("labels.loading")}
+
+ ) : error ? (
+
+ {t("pages.agent.load_error")}
+
+ ) : (
+
+
+ {t("pages.agent.skills.description")}
+
+
+ {data?.skills.length ? (
+
+ {data.skills.map((skill) => (
+
+
+
+
+
+ {skill.name}
+
+
+ {skill.description ||
+ t("pages.agent.skills.no_description")}
+
+
+
+
+ {skill.source === "workspace" ? (
+
+ ) : null}
+
+
+
+
+
+ {t("pages.agent.skills.path")}
+
+
+ {skill.path}
+
+
+
+ ))}
+
+ ) : (
+
+
+ {t("pages.agent.skills.empty")}
+
+
+ )}
+
+ )}
+
+
+
+ {
+ if (!open) setSelectedSkill(null)
+ }}
+ >
+
+
+
+ {selectedSkill?.name || t("pages.agent.skills.viewer_title")}
+
+
+ {selectedSkill?.description ||
+ t("pages.agent.skills.viewer_description")}
+
+
+
+
+ {isSkillDetailLoading ? (
+
+ {t("pages.agent.skills.loading_detail")}
+
+ ) : skillDetailError ? (
+
+ {t("pages.agent.skills.load_detail_error")}
+
+ ) : selectedSkillDetail ? (
+
+
+
+ {selectedSkillDetail.content}
+
+
+
+ ) : null}
+
+
+
+
+ {
+ if (!open) setSkillPendingDelete(null)
+ }}
+ >
+
+
+
+ {t("pages.agent.skills.delete_title")}
+
+
+ {t("pages.agent.skills.delete_description", {
+ name: skillPendingDelete?.name,
+ })}
+
+
+
+
+ {t("common.cancel")}
+
+ {
+ if (skillPendingDelete)
+ deleteMutation.mutate(skillPendingDelete.name)
+ }}
+ >
+ {deleteMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {t("pages.agent.skills.delete_confirm")}
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/tools/tools-page.tsx b/web/frontend/src/components/tools/tools-page.tsx
new file mode 100644
index 000000000..05aa42122
--- /dev/null
+++ b/web/frontend/src/components/tools/tools-page.tsx
@@ -0,0 +1,190 @@
+import { IconLoader2 } from "@tabler/icons-react"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { useTranslation } from "react-i18next"
+import { toast } from "sonner"
+
+import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools"
+import { PageHeader } from "@/components/page-header"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { cn } from "@/lib/utils"
+
+export function ToolsPage() {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["tools"],
+ queryFn: getTools,
+ })
+
+ const toggleMutation = useMutation({
+ mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
+ setToolEnabled(name, enabled),
+ onSuccess: (_, variables) => {
+ toast.success(
+ variables.enabled
+ ? t("pages.agent.tools.enable_success")
+ : t("pages.agent.tools.disable_success"),
+ )
+ void queryClient.invalidateQueries({ queryKey: ["tools"] })
+ },
+ onError: (err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("pages.agent.tools.toggle_error"),
+ )
+ },
+ })
+
+ const groupedTools = (() => {
+ if (!data) return [] as Array<[string, ToolSupportItem[]]>
+ const buckets = new Map()
+ for (const item of data.tools) {
+ const list = buckets.get(item.category) ?? []
+ list.push(item)
+ buckets.set(item.category, list)
+ }
+ return Array.from(buckets.entries())
+ })()
+
+ return (
+
+
+
+
+
+ {isLoading ? (
+
+ {t("labels.loading")}
+
+ ) : error ? (
+
+ {t("pages.agent.load_error")}
+
+ ) : (
+
+
+ {t("pages.agent.tools.description")}
+
+
+ {data?.tools.length ? (
+ groupedTools.map(([category, items]) => (
+
+
+ {t(`pages.agent.tools.categories.${category}`)}
+
+
+ {items.map((tool) => {
+ const reasonText = tool.reason_code
+ ? t(`pages.agent.tools.reasons.${tool.reason_code}`)
+ : ""
+ const isPending =
+ toggleMutation.isPending &&
+ toggleMutation.variables?.name === tool.name
+ const nextEnabled = tool.status !== "enabled"
+
+ return (
+
+
+
+
+
+ {tool.name}
+
+
+ {tool.description}
+
+
+
+
+
+
+
+
+
+
+ {t("pages.agent.tools.config_key", {
+ key: tool.config_key,
+ })}
+
+ {reasonText ? (
+
+ {reasonText}
+
+ ) : null}
+
+
+ )
+ })}
+
+
+ ))
+ ) : (
+
+
+ {t("pages.agent.tools.empty")}
+
+
+ )}
+
+ )}
+
+
+
+ )
+}
+
+function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
+ const { t } = useTranslation()
+
+ return (
+
+ {t(`pages.agent.tools.status.${status}`)}
+
+ )
+}
diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts
index 7735ad928..4ce615dcf 100644
--- a/web/frontend/src/hooks/use-pico-chat.ts
+++ b/web/frontend/src/hooks/use-pico-chat.ts
@@ -1,6 +1,8 @@
import dayjs from "dayjs"
import { useAtomValue } from "jotai"
import { useCallback, useEffect, useRef, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { toast } from "sonner"
import { getPicoToken } from "@/api/pico"
import { getSessionHistory } from "@/api/sessions"
@@ -100,6 +102,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
}
export function usePicoChat() {
+ const { t } = useTranslation()
const { status: gatewayState } = useAtomValue(gatewayAtom)
const [messages, setMessages] = useState([])
const [connectionState, setConnectionState] =
@@ -317,43 +320,38 @@ export function usePicoChat() {
// Switch to a historical session
const switchSession = useCallback(
async (sessionId: string) => {
- // Disconnect current WebSocket
- disconnect()
-
- // Set new session ID
- setActiveSessionId(sessionId)
- setIsTyping(false)
-
- // Load history from backend
- try {
- const detail = await getSessionHistory(sessionId)
- // Set all history messages timestamp from the session updated time as fallback,
- // since currently the backend doesn't return per-message timestamp in the history API.
- // We'll use the session's updated time for now.
- const fallbackTime = detail.updated
-
- setMessages(
- detail.messages.map((m, i) => ({
- id: `hist-${i}-${Date.now()}`,
- role: m.role as "user" | "assistant",
- content: m.content,
- timestamp: fallbackTime,
- })),
- )
- } catch (err) {
- console.error("Failed to load session history:", err)
- setMessages([])
+ if (sessionId === activeSessionIdRef.current) {
+ return
+ }
+
+ try {
+ const detail = await getSessionHistory(sessionId)
+ const fallbackTime = detail.updated
+ const historyMessages = detail.messages.map((m, i) => ({
+ id: `hist-${i}-${Date.now()}`,
+ role: m.role as "user" | "assistant",
+ content: m.content,
+ timestamp: fallbackTime,
+ }))
+
+ // Only switch the active websocket session after history has loaded successfully.
+ disconnect()
+ setActiveSessionId(sessionId)
+ setIsTyping(false)
+ setMessages(historyMessages)
+ } catch (err) {
+ console.error("Failed to load session history:", err)
+ toast.error(t("chat.historyOpenFailed"))
+ return
}
- // Reconnect with new session ID (will use the updated ref)
- // Small delay to ensure state has settled
setTimeout(() => {
if (gatewayState === "running") {
connect()
}
}, 100)
},
- [disconnect, connect, gatewayState],
+ [connect, disconnect, gatewayState, t],
)
// Start a new empty chat
diff --git a/web/frontend/src/hooks/use-session-history.ts b/web/frontend/src/hooks/use-session-history.ts
index 1a6d5c956..790339dba 100644
--- a/web/frontend/src/hooks/use-session-history.ts
+++ b/web/frontend/src/hooks/use-session-history.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"
+import { useTranslation } from "react-i18next"
import { type SessionSummary, deleteSession, getSessions } from "@/api/sessions"
@@ -13,22 +14,26 @@ export function useSessionHistory({
activeSessionId,
onDeletedActiveSession,
}: UseSessionHistoryOptions) {
+ const { t } = useTranslation()
const observerRef = useRef(null)
const [sessions, setSessions] = useState([])
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
+ const [loadError, setLoadError] = useState(false)
const loadSessions = useCallback(
async (reset = true) => {
try {
const currentOffset = reset ? 0 : offset
if (reset) {
+ setLoadError(false)
setHasMore(true)
setOffset(0)
}
const data = await getSessions(currentOffset, LIMIT)
+ setLoadError(false)
if (data.length < LIMIT) {
setHasMore(false)
@@ -45,8 +50,12 @@ export function useSessionHistory({
}
setOffset(currentOffset + data.length)
- } catch {
- // silently fail
+ } catch (err) {
+ console.error("Failed to fetch session history:", err)
+ setLoadError(true)
+ if (!reset) {
+ setHasMore(false)
+ }
} finally {
setIsLoadingMore(false)
}
@@ -55,11 +64,16 @@ export function useSessionHistory({
)
useEffect(() => {
- if (!observerRef.current || !hasMore || isLoadingMore) return
+ if (!observerRef.current || !hasMore || isLoadingMore || loadError) return
const observer = new IntersectionObserver(
(entries) => {
- if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
+ if (
+ entries[0].isIntersecting &&
+ hasMore &&
+ !isLoadingMore &&
+ !loadError
+ ) {
setIsLoadingMore(true)
void loadSessions(false)
}
@@ -69,7 +83,7 @@ export function useSessionHistory({
observer.observe(observerRef.current)
return () => observer.disconnect()
- }, [hasMore, isLoadingMore, loadSessions])
+ }, [hasMore, isLoadingMore, loadError, loadSessions])
const handleDeleteSession = useCallback(
async (id: string) => {
@@ -89,6 +103,8 @@ export function useSessionHistory({
return {
sessions,
hasMore,
+ loadError,
+ loadErrorMessage: t("chat.historyLoadFailed"),
observerRef,
loadSessions,
handleDeleteSession,
diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts
index 0848af468..5579a955b 100644
--- a/web/frontend/src/hooks/use-sidebar-channels.ts
+++ b/web/frontend/src/hooks/use-sidebar-channels.ts
@@ -27,7 +27,7 @@ import {
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
import { gatewayAtom } from "@/store/gateway"
-const DEFAULT_VISIBLE_CHANNELS = 5
+const DEFAULT_VISIBLE_CHANNELS = 4
const CHANNEL_IMPORTANCE_ORDER = [
"discord",
"feishu",
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index f1ed0ac16..b88b5c924 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -4,6 +4,9 @@
"model_group": "Models",
"models": "Models",
"credentials": "Credentials",
+ "agent_group": "Agent",
+ "skills": "Skills",
+ "tools": "Tools",
"services": "Services",
"channels_group": "Channels",
"show_more_channels": "More",
@@ -25,6 +28,8 @@
},
"history": "History",
"noHistory": "No chat history yet",
+ "historyLoadFailed": "Failed to load chat history",
+ "historyOpenFailed": "Failed to open this chat history",
"loadingMore": "Loading more...",
"deleteSession": "Delete session",
"messagesCount": "{{count}} messages",
@@ -324,12 +329,108 @@
}
},
"pages": {
+ "agent": {
+ "load_error": "Failed to load agent support information.",
+ "stats": {
+ "workspace": "Workspace",
+ "workspace_hint": "The default agent workspace used for runtime files and workspace skills.",
+ "skills": "Available Skills",
+ "skills_hint": "Skills discovered from workspace, global, and builtin roots.",
+ "tools": "Enabled Tools",
+ "tools_hint": "{{blocked}} blocked by missing dependencies."
+ },
+ "skills": {
+ "title": "Skills",
+ "description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
+ "hero_title": "Skill Library",
+ "hero_description": "Browse every capability package the agent can load, then drill straight into the effective SKILL.md without leaving the page.",
+ "stats": {
+ "total": "Total Skills",
+ "workspace": "Workspace",
+ "shared": "Shared"
+ },
+ "empty": "No skills are currently available.",
+ "import": "Import Skill",
+ "import_title": "Import Skill",
+ "import_description": "Create a workspace skill by uploading a markdown file as the new SKILL.md.",
+ "import_name": "Skill Name",
+ "import_name_placeholder": "e.g. my-workflow",
+ "import_file": "Markdown File",
+ "import_file_hint": "Upload a .md file. The backend stores it as workspace/skills//SKILL.md.",
+ "import_confirm": "Import Skill",
+ "import_success": "Skill imported.",
+ "import_error": "Failed to import skill.",
+ "view": "View",
+ "delete": "Delete",
+ "delete_title": "Delete Skill?",
+ "delete_description": "\"{{name}}\" will be removed from workspace skills.",
+ "delete_confirm": "Delete",
+ "delete_success": "Skill deleted.",
+ "delete_error": "Failed to delete skill.",
+ "viewer_title": "Skill Content",
+ "viewer_description": "Read the current effective SKILL.md content here.",
+ "loading_detail": "Loading skill content...",
+ "load_detail_error": "Failed to load skill content.",
+ "source": "Source",
+ "path": "Skill Path",
+ "no_description": "No description provided.",
+ "sources": {
+ "workspace": "Workspace",
+ "global": "Global",
+ "builtin": "Builtin"
+ },
+ "errors": {
+ "file_required": "Please choose a markdown file to import."
+ }
+ },
+ "tools": {
+ "title": "Tools",
+ "description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
+ "hero_title": "Tool Surface",
+ "hero_description": "Inspect what the agent can actually call right now, which capabilities are blocked, and where each tool is controlled in config.",
+ "stats": {
+ "enabled": "Enabled",
+ "blocked": "Blocked",
+ "categories": "Categories"
+ },
+ "empty": "No tools are available.",
+ "enable": "Enable",
+ "disable": "Disable",
+ "enable_success": "Tool enabled.",
+ "disable_success": "Tool disabled.",
+ "toggle_error": "Failed to update tool state.",
+ "config_key": "Controlled by tools.{{key}}",
+ "status": {
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "blocked": "Blocked"
+ },
+ "categories": {
+ "automation": "Automation",
+ "filesystem": "Filesystem",
+ "web": "Web",
+ "communication": "Communication",
+ "skills": "Skills",
+ "agents": "Agents",
+ "hardware": "Hardware",
+ "discovery": "Discovery"
+ },
+ "reasons": {
+ "requires_linux": "This tool only works on Linux hosts with the required device files exposed.",
+ "requires_skills": "Enable `tools.skills` before this skill-registry tool can be used.",
+ "requires_subagent": "Enable `tools.subagent` before the spawn tool can delegate work.",
+ "requires_mcp_discovery": "Enable `tools.mcp.discovery` before MCP discovery tools become available."
+ }
+ }
+ },
"config": {
"load_error": "Failed to load configuration. Please refresh and try again.",
"workspace": "Workspace Directory",
"workspace_hint": "Base directory for agent file operations.",
"restrict_workspace": "Restrict to Workspace",
"restrict_workspace_hint": "Only allow file operations inside workspace.",
+ "allow_remote": "Allow Remote Shell Execution",
+ "allow_remote_hint": "When enabled, shell commands can also run for remote sessions or non-local contexts. When disabled, shell execution stays limited to local safe contexts.",
"max_tokens": "Max Tokens",
"max_tokens_hint": "Upper token limit per model response.",
"max_tool_iterations": "Max Tool Iterations",
@@ -387,7 +488,9 @@
"unsaved_changes": "You have unsaved changes."
},
"logs": {
- "description": "System logs and monitoring."
+ "description": "System logs and monitoring.",
+ "clear": "Clear logs",
+ "empty": "Waiting for logs..."
}
}
}
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index b66f0f03d..12833cbf5 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -4,6 +4,9 @@
"model_group": "模型",
"models": "模型",
"credentials": "凭据",
+ "agent_group": "智能体",
+ "skills": "技能",
+ "tools": "工具",
"services": "服务",
"channels_group": "频道",
"show_more_channels": "更多",
@@ -25,6 +28,8 @@
},
"history": "历史记录",
"noHistory": "暂无对话历史",
+ "historyLoadFailed": "加载历史记录失败",
+ "historyOpenFailed": "打开该历史会话失败",
"loadingMore": "加载更多...",
"deleteSession": "删除会话",
"messagesCount": "{{count}} 条消息",
@@ -324,12 +329,108 @@
}
},
"pages": {
+ "agent": {
+ "load_error": "加载 Agent 支持信息失败。",
+ "stats": {
+ "workspace": "工作目录",
+ "workspace_hint": "默认 Agent 运行时使用的工作目录,也用于加载工作区技能。",
+ "skills": "可用技能数",
+ "skills_hint": "从工作区、全局目录和内置目录发现的技能。",
+ "tools": "已启用工具",
+ "tools_hint": "其中 {{blocked}} 个因依赖未满足而不可用。"
+ },
+ "skills": {
+ "title": "技能",
+ "description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
+ "hero_title": "技能库",
+ "hero_description": "在这里查看 Agent 当前可加载的能力包,并且不离开页面就能直接阅读生效后的 SKILL.md。",
+ "stats": {
+ "total": "技能总数",
+ "workspace": "工作区技能",
+ "shared": "共享技能"
+ },
+ "empty": "当前没有可用技能。",
+ "import": "导入技能",
+ "import_title": "导入技能",
+ "import_description": "通过上传 Markdown 文件创建工作区技能,文件会保存为新的 SKILL.md。",
+ "import_name": "技能名称",
+ "import_name_placeholder": "例如 my-workflow",
+ "import_file": "Markdown 文件",
+ "import_file_hint": "上传一个 .md 文件。后端会保存到 workspace/skills//SKILL.md。",
+ "import_confirm": "导入技能",
+ "import_success": "技能导入成功。",
+ "import_error": "导入技能失败。",
+ "view": "查看",
+ "delete": "删除",
+ "delete_title": "删除技能?",
+ "delete_description": "将从工作区技能中移除「{{name}}」。",
+ "delete_confirm": "删除",
+ "delete_success": "技能已删除。",
+ "delete_error": "删除技能失败。",
+ "viewer_title": "技能内容",
+ "viewer_description": "这里展示当前生效的 SKILL.md 内容。",
+ "loading_detail": "正在加载技能内容...",
+ "load_detail_error": "加载技能内容失败。",
+ "source": "来源",
+ "path": "技能路径",
+ "no_description": "未提供描述。",
+ "sources": {
+ "workspace": "工作区",
+ "global": "全局",
+ "builtin": "内置"
+ },
+ "errors": {
+ "file_required": "请先选择要导入的 Markdown 文件。"
+ }
+ },
+ "tools": {
+ "title": "工具",
+ "description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
+ "hero_title": "工具面板",
+ "hero_description": "集中查看 Agent 现在真正可调用的工具、被阻塞的能力,以及它们分别受哪项配置控制。",
+ "stats": {
+ "enabled": "已启用",
+ "blocked": "被阻塞",
+ "categories": "分类数"
+ },
+ "empty": "当前没有可用工具。",
+ "enable": "启用",
+ "disable": "禁用",
+ "enable_success": "工具已启用。",
+ "disable_success": "工具已禁用。",
+ "toggle_error": "更新工具状态失败。",
+ "config_key": "由 tools.{{key}} 控制",
+ "status": {
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "blocked": "被阻塞"
+ },
+ "categories": {
+ "automation": "自动化",
+ "filesystem": "文件系统",
+ "web": "网页",
+ "communication": "通信",
+ "skills": "技能",
+ "agents": "Agent",
+ "hardware": "硬件",
+ "discovery": "发现"
+ },
+ "reasons": {
+ "requires_linux": "该工具仅在 Linux 主机上可用,并且需要暴露对应的设备文件。",
+ "requires_skills": "需要先启用 `tools.skills`,该技能注册表工具才能使用。",
+ "requires_subagent": "需要先启用 `tools.subagent`,`spawn` 才能委派任务。",
+ "requires_mcp_discovery": "需要先启用 `tools.mcp.discovery`,MCP 发现工具才会可用。"
+ }
+ }
+ },
"config": {
"load_error": "加载配置失败,请刷新后重试。",
"workspace": "工作目录",
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
"restrict_workspace": "限制工作目录访问",
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
+ "allow_remote": "允许远程执行 Shell 命令",
+ "allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行 shell 命令;关闭后,仅允许本地安全上下文执行。",
"max_tokens": "最大 Token 数",
"max_tokens_hint": "单次模型响应允许的最大 Token 数。",
"max_tool_iterations": "最大工具迭代次数",
@@ -387,7 +488,9 @@
"unsaved_changes": "您有未保存的更改。"
},
"logs": {
- "description": "系统日志和监控。"
+ "description": "系统日志和监控。",
+ "clear": "清空日志",
+ "empty": "等待日志中..."
}
}
}
diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts
index 336504075..60f19ab53 100644
--- a/web/frontend/src/routeTree.gen.ts
+++ b/web/frontend/src/routeTree.gen.ts
@@ -13,10 +13,13 @@ import { Route as ModelsRouteImport } from './routes/models'
import { Route as LogsRouteImport } from './routes/logs'
import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config'
+import { Route as AgentRouteImport } from './routes/agent'
import { Route as ChannelsRouteRouteImport } from './routes/channels/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ConfigRawRouteImport } from './routes/config.raw'
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
+import { Route as AgentToolsRouteImport } from './routes/agent/tools'
+import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
const ModelsRoute = ModelsRouteImport.update({
id: '/models',
@@ -38,6 +41,11 @@ const ConfigRoute = ConfigRouteImport.update({
path: '/config',
getParentRoute: () => rootRouteImport,
} as any)
+const AgentRoute = AgentRouteImport.update({
+ id: '/agent',
+ path: '/agent',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ChannelsRouteRoute = ChannelsRouteRouteImport.update({
id: '/channels',
path: '/channels',
@@ -58,24 +66,40 @@ const ChannelsNameRoute = ChannelsNameRouteImport.update({
path: '/$name',
getParentRoute: () => ChannelsRouteRoute,
} as any)
+const AgentToolsRoute = AgentToolsRouteImport.update({
+ id: '/tools',
+ path: '/tools',
+ getParentRoute: () => AgentRoute,
+} as any)
+const AgentSkillsRoute = AgentSkillsRouteImport.update({
+ id: '/skills',
+ path: '/skills',
+ getParentRoute: () => AgentRoute,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/channels': typeof ChannelsRouteRouteWithChildren
+ '/agent': typeof AgentRouteWithChildren
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
+ '/agent/skills': typeof AgentSkillsRoute
+ '/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
'/config/raw': typeof ConfigRawRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/channels': typeof ChannelsRouteRouteWithChildren
+ '/agent': typeof AgentRouteWithChildren
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
+ '/agent/skills': typeof AgentSkillsRoute
+ '/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
'/config/raw': typeof ConfigRawRoute
}
@@ -83,10 +107,13 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/channels': typeof ChannelsRouteRouteWithChildren
+ '/agent': typeof AgentRouteWithChildren
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
+ '/agent/skills': typeof AgentSkillsRoute
+ '/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
'/config/raw': typeof ConfigRawRoute
}
@@ -95,30 +122,39 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/channels'
+ | '/agent'
| '/config'
| '/credentials'
| '/logs'
| '/models'
+ | '/agent/skills'
+ | '/agent/tools'
| '/channels/$name'
| '/config/raw'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/channels'
+ | '/agent'
| '/config'
| '/credentials'
| '/logs'
| '/models'
+ | '/agent/skills'
+ | '/agent/tools'
| '/channels/$name'
| '/config/raw'
id:
| '__root__'
| '/'
| '/channels'
+ | '/agent'
| '/config'
| '/credentials'
| '/logs'
| '/models'
+ | '/agent/skills'
+ | '/agent/tools'
| '/channels/$name'
| '/config/raw'
fileRoutesById: FileRoutesById
@@ -126,6 +162,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
+ AgentRoute: typeof AgentRouteWithChildren
ConfigRoute: typeof ConfigRouteWithChildren
CredentialsRoute: typeof CredentialsRoute
LogsRoute: typeof LogsRoute
@@ -162,6 +199,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ConfigRouteImport
parentRoute: typeof rootRouteImport
}
+ '/agent': {
+ id: '/agent'
+ path: '/agent'
+ fullPath: '/agent'
+ preLoaderRoute: typeof AgentRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/channels': {
id: '/channels'
path: '/channels'
@@ -190,6 +234,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChannelsNameRouteImport
parentRoute: typeof ChannelsRouteRoute
}
+ '/agent/tools': {
+ id: '/agent/tools'
+ path: '/tools'
+ fullPath: '/agent/tools'
+ preLoaderRoute: typeof AgentToolsRouteImport
+ parentRoute: typeof AgentRoute
+ }
+ '/agent/skills': {
+ id: '/agent/skills'
+ path: '/skills'
+ fullPath: '/agent/skills'
+ preLoaderRoute: typeof AgentSkillsRouteImport
+ parentRoute: typeof AgentRoute
+ }
}
}
@@ -205,6 +263,18 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
ChannelsRouteRouteChildren,
)
+interface AgentRouteChildren {
+ AgentSkillsRoute: typeof AgentSkillsRoute
+ AgentToolsRoute: typeof AgentToolsRoute
+}
+
+const AgentRouteChildren: AgentRouteChildren = {
+ AgentSkillsRoute: AgentSkillsRoute,
+ AgentToolsRoute: AgentToolsRoute,
+}
+
+const AgentRouteWithChildren = AgentRoute._addFileChildren(AgentRouteChildren)
+
interface ConfigRouteChildren {
ConfigRawRoute: typeof ConfigRawRoute
}
@@ -219,6 +289,7 @@ const ConfigRouteWithChildren =
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
+ AgentRoute: AgentRouteWithChildren,
ConfigRoute: ConfigRouteWithChildren,
CredentialsRoute: CredentialsRoute,
LogsRoute: LogsRoute,
diff --git a/web/frontend/src/routes/agent.tsx b/web/frontend/src/routes/agent.tsx
new file mode 100644
index 000000000..78104de5b
--- /dev/null
+++ b/web/frontend/src/routes/agent.tsx
@@ -0,0 +1,22 @@
+import {
+ Navigate,
+ Outlet,
+ createFileRoute,
+ useRouterState,
+} from "@tanstack/react-router"
+
+export const Route = createFileRoute("/agent")({
+ component: AgentLayout,
+})
+
+function AgentLayout() {
+ const pathname = useRouterState({
+ select: (state) => state.location.pathname,
+ })
+
+ if (pathname === "/agent") {
+ return
+ }
+
+ return
+}
diff --git a/web/frontend/src/routes/agent/skills.tsx b/web/frontend/src/routes/agent/skills.tsx
new file mode 100644
index 000000000..bbe396bdb
--- /dev/null
+++ b/web/frontend/src/routes/agent/skills.tsx
@@ -0,0 +1,11 @@
+import { createFileRoute } from "@tanstack/react-router"
+
+import { SkillsPage } from "@/components/skills/skills-page"
+
+export const Route = createFileRoute("/agent/skills")({
+ component: AgentSkillsRoute,
+})
+
+function AgentSkillsRoute() {
+ return
+}
diff --git a/web/frontend/src/routes/agent/tools.tsx b/web/frontend/src/routes/agent/tools.tsx
new file mode 100644
index 000000000..ac8738a8f
--- /dev/null
+++ b/web/frontend/src/routes/agent/tools.tsx
@@ -0,0 +1,11 @@
+import { createFileRoute } from "@tanstack/react-router"
+
+import { ToolsPage } from "@/components/tools/tools-page"
+
+export const Route = createFileRoute("/agent/tools")({
+ component: AgentToolsRoute,
+})
+
+function AgentToolsRoute() {
+ return
+}
diff --git a/web/frontend/src/routes/logs.tsx b/web/frontend/src/routes/logs.tsx
index 39688bd84..ef39e0bdf 100644
--- a/web/frontend/src/routes/logs.tsx
+++ b/web/frontend/src/routes/logs.tsx
@@ -1,10 +1,12 @@
+import { IconTrash } from "@tabler/icons-react"
import { createFileRoute } from "@tanstack/react-router"
import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
-import { getGatewayStatus } from "@/api/gateway"
+import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
import { PageHeader } from "@/components/page-header"
+import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { gatewayAtom } from "@/store/gateway"
@@ -15,12 +17,31 @@ export const Route = createFileRoute("/logs")({
function LogsPage() {
const { t } = useTranslation()
const [logs, setLogs] = useState([])
+ const [clearing, setClearing] = useState(false)
const logOffsetRef = useRef(0)
const logRunIdRef = useRef(-1)
+ const syncTokenRef = useRef(0)
const scrollRef = useRef(null)
const gateway = useAtomValue(gatewayAtom)
+ const handleClearLogs = async () => {
+ setClearing(true)
+ try {
+ const data = await clearGatewayLogs()
+ syncTokenRef.current += 1
+ setLogs([])
+ logOffsetRef.current = data.log_total ?? 0
+ if (data.log_run_id !== undefined) {
+ logRunIdRef.current = data.log_run_id
+ }
+ } catch {
+ // Ignore clear failures silently to avoid noisy transient errors.
+ } finally {
+ setClearing(false)
+ }
+ }
+
useEffect(() => {
let mounted = true
let timeout: ReturnType
@@ -40,17 +61,17 @@ function LogsPage() {
}
try {
+ const requestToken = syncTokenRef.current
+ const requestOffset = logOffsetRef.current
+ const requestRunId = logRunIdRef.current
const data = await getGatewayStatus({
- log_offset: logOffsetRef.current,
- log_run_id: logRunIdRef.current,
+ log_offset: requestOffset,
+ log_run_id: requestRunId,
})
- if (!mounted) return
+ if (!mounted || requestToken !== syncTokenRef.current) return
- if (
- data.log_run_id !== undefined &&
- data.log_run_id !== logRunIdRef.current
- ) {
+ if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) {
logRunIdRef.current = data.log_run_id
logOffsetRef.current = 0
if (data.logs) {
@@ -90,13 +111,25 @@ function LogsPage() {
-
-
- {t("navigation.logs")}
-
-
- {t("pages.logs.description")}
-
+
+
+
+ {t("navigation.logs")}
+
+
+ {t("pages.logs.description")}
+
+
+
+
@@ -104,7 +137,7 @@ function LogsPage() {
{logs.length === 0 ? (
- Waiting for logs...
+ {t("pages.logs.empty")}
) : (
logs.map((log, i) => (