diff --git a/logs/gateway.log b/logs/gateway.log new file mode 100644 index 000000000..770d23f8b --- /dev/null +++ b/logs/gateway.log @@ -0,0 +1,2 @@ +{"level":"warn","path":"/home/stevef/dev/tomerge/github/picoclaw/config.json","time":"2026-03-24T08:13:49+01:00","caller":"/home/stevef/dev/tomerge/github/picoclaw/pkg/config/config.go:1363","message":"config file not found, using default config"} +{"level":"warn","path":"/home/stevef/dev/tomerge/github/picoclaw/config.json","time":"2026-03-24T08:15:23+01:00","caller":"/home/stevef/dev/tomerge/github/picoclaw/pkg/config/config.go:1363","message":"config file not found, using default config"} diff --git a/logs/gateway_panic.log b/logs/gateway_panic.log new file mode 100644 index 000000000..67e98bfaf --- /dev/null +++ b/logs/gateway_panic.log @@ -0,0 +1,26 @@ +Error: error creating provider: model "" not found in model_list: model "" not found in model_list or providers +Usage: + picoclaw gateway [flags] + +Aliases: + gateway, g + +Flags: + -E, --allow-empty Continue starting even when no default model is configured + -d, --debug Enable debug logging + -h, --help help for gateway + -T, --no-truncate Disable string truncation in debug logs + +Error: error creating provider: model "" not found in model_list: model "" not found in model_list or providers +Usage: + picoclaw gateway [flags] + +Aliases: + gateway, g + +Flags: + -E, --allow-empty Continue starting even when no default model is configured + -d, --debug Enable debug logging + -h, --help help for gateway + -T, --no-truncate Disable string truncation in debug logs + diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 808d12c07..a54dbffbe 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -327,11 +327,11 @@ func registerSharedTools( cfg.Tools.Skills.SearchCache.MaxSize, time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, ) - agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) + agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache, cfg.Tools.Skills.Whitelist, cfg.Tools.Skills.WhitelistEnabled)) } if install_skills_enable { - agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) + agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace, cfg.Tools.Skills.Whitelist, cfg.Tools.Skills.WhitelistEnabled)) } } @@ -437,6 +437,8 @@ func registerSharedTools( } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) } + // Apply global tools whitelist + agent.Tools.Filter(cfg.Tools.Whitelist, cfg.Tools.WhitelistEnabled) } } @@ -446,7 +448,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { if err := al.ensureHooksInitialized(ctx); err != nil { return err } - if err := al.ensureMCPInitialized(ctx); err != nil { + if err := al.EnsureMCPInitialized(ctx); err != nil { return err } @@ -1293,7 +1295,7 @@ func (al *AgentLoop) ProcessDirectWithChannel( if err := al.ensureHooksInitialized(ctx); err != nil { return "", err } - if err := al.ensureMCPInitialized(ctx); err != nil { + if err := al.EnsureMCPInitialized(ctx); err != nil { return "", err } @@ -1317,7 +1319,7 @@ func (al *AgentLoop) ProcessHeartbeat( if err := al.ensureHooksInitialized(ctx); err != nil { return "", err } - if err := al.ensureMCPInitialized(ctx); err != nil { + if err := al.EnsureMCPInitialized(ctx); err != nil { return "", err } diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go index b9c844d1a..e35609340 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/loop_mcp.go @@ -59,7 +59,7 @@ func (r *mcpRuntime) hasManager() bool { // 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 { +func (al *AgentLoop) EnsureMCPInitialized(ctx context.Context) error { if !al.cfg.Tools.IsToolEnabled("mcp") { return nil } diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index ad6613e8c..c8d66049b 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -332,7 +332,7 @@ func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID s if err := al.ensureHooksInitialized(ctx); err != nil { return "", err } - if err := al.ensureMCPInitialized(ctx); err != nil { + if err := al.EnsureMCPInitialized(ctx); err != nil { return "", err } diff --git a/pkg/config/config.go b/pkg/config/config.go index 7165246e5..5b88a0146 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -387,6 +387,10 @@ type DiscordConfig struct { ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` } +func (c *DiscordConfig) SetToken(token string) { + c.Token = *NewSecureString(token) +} + type MaixCamConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` @@ -427,6 +431,14 @@ type SlackConfig struct { ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` } +func (c *SlackConfig) SetBotToken(token string) { + c.BotToken = *NewSecureString(token) +} + +func (c *SlackConfig) SetAppToken(token string) { + c.AppToken = *NewSecureString(token) +} + type MatrixConfig struct { Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` Homeserver string `json:"homeserver" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` @@ -625,6 +637,24 @@ type ModelConfig struct { isVirtual bool } +func (c *ModelConfig) UnmarshalJSON(data []byte) error { + type Alias ModelConfig + aux := &struct { + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + *Alias + }{ + Alias: (*Alias)(c), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + c.APIKeys = toSecureStrings(mergeAPIKeys(aux.APIKey, aux.APIKeys)) + return nil +} + // APIKey returns the first API key from apiKeys func (c *ModelConfig) APIKey() string { if len(c.APIKeys) > 0 { @@ -657,6 +687,8 @@ func (c *ModelConfig) SetAPIKey(value string) { } } + + type ToolDiscoveryConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` @@ -811,6 +843,8 @@ type SkillsToolsConfig struct { Github SkillsGithubConfig `yaml:"github,omitempty" json:"github"` MaxConcurrentSearches int `yaml:"-" json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` SearchCache SearchCacheConfig `yaml:"-" json:"search_cache"` + Whitelist FlexibleStringSlice `json:"whitelist,omitempty" env:"PICOCLAW_TOOLS_SKILLS_WHITELIST"` + WhitelistEnabled bool `json:"whitelist_enabled,omitempty" env:"PICOCLAW_TOOLS_SKILLS_WHITELIST_ENABLED"` } type MediaCleanupConfig struct { @@ -857,7 +891,9 @@ type ToolsConfig struct { Exec ExecConfig `json:"exec" yaml:"-"` Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"` MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` - MCP MCPConfig `json:"mcp" yaml:"-"` + Whitelist FlexibleStringSlice `json:"whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WHITELIST"` + WhitelistEnabled bool `json:"whitelist_enabled,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WHITELIST_ENABLED"` + MCP MCPConfig `json:"mcp" yaml:"-""` AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index c2e1a31f3..bfda81c26 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -358,11 +358,13 @@ func DefaultConfig() *Config { }, }, Gateway: GatewayConfig{ - Host: "127.0.0.1", - Port: 18790, - HotReload: false, - LogLevel: DefaultGatewayLogLevel, + Host: "127.0.0.1", + Port: 18790, + ChatEnabled: true, + HotReload: false, + LogLevel: DefaultGatewayLogLevel, }, + Tools: ToolsConfig{ FilterSensitiveData: true, FilterMinLength: 8, diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go index e9f4085d3..30e6f4204 100644 --- a/pkg/config/gateway.go +++ b/pkg/config/gateway.go @@ -10,12 +10,15 @@ import ( const DefaultGatewayLogLevel = "warn" type GatewayConfig struct { - Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` - Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` - HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"` - LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"` + Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` + Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` + APIKey string `json:"api_key" env:"PICOCLAW_GATEWAY_API_KEY"` + ChatEnabled bool `json:"chat_enabled" env:"PICOCLAW_GATEWAY_CHAT_ENABLED"` + HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"` + LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"` } + func canonicalGatewayLogLevel(level logger.LogLevel) string { switch level { case logger.DEBUG: diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 509b5d37e..6f6911122 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -203,8 +203,20 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error } } runningServices.HealthServer.SetReloadFunc(reloadTrigger) + runningServices.HealthServer.SetAPIKey(cfg.Gateway.APIKey) agentLoop.SetReloadFunc(reloadTrigger) + // Setup synchronous /chat endpoint handler + if cfg.Gateway.ChatEnabled { + runningServices.HealthServer.SetChatFunc(func(ctx context.Context, message, sessionID string) (string, error) { + if sessionID == "" { + sessionID = "http-chat" + } + return agentLoop.ProcessDirectWithChannel(ctx, message, sessionID, "http", "chat") + }) + } + + fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Println("Press Ctrl+C to stop") diff --git a/pkg/health/server.go b/pkg/health/server.go index 2602cb965..16447a3c6 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -19,8 +19,11 @@ type Server struct { startTime time.Time reloadFunc func() error authToken string // optional bearer token for protected endpoints + chatFunc func(ctx context.Context, message, sessionID string) (string, error) + apiKey string } + type Check struct { Name string `json:"name"` Status string `json:"status"` @@ -46,6 +49,8 @@ func NewServer(host string, port int, token string) *Server { mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/reload", s.reloadHandler) + mux.HandleFunc("/chat", s.chatHandler) + addr := fmt.Sprintf("%s:%d", host, port) s.server = &http.Server{ @@ -248,3 +253,89 @@ func extractBearerToken(header string) string { } return header[len(prefix):] } + +// SetChatFunc sets the callback that processes /chat requests. +func (s *Server) SetChatFunc(fn func(ctx context.Context, message, sessionID string) (string, error)) { +s.mu.Lock() +defer s.mu.Unlock() +s.chatFunc = fn +} + +// SetAPIKey sets the expected X-API-Key header value. +func (s *Server) SetAPIKey(key string) { +s.mu.Lock() +defer s.mu.Unlock() +s.apiKey = key +} + +func (s *Server) verifyAPIKey(r *http.Request) bool { +s.mu.RLock() +defer s.mu.RUnlock() +if s.apiKey == "" { + true +} +return r.Header.Get("X-API-Key") == s.apiKey +} + +// ChatRequest is the JSON body for POST /chat. +type ChatRequest struct { +Message string `json:"message"` +SessionID string `json:"session_id,omitempty"` +} + +// ChatResponse is the JSON response from POST /chat. +type ChatResponse struct { +Response string `json:"response"` +} + +func (s *Server) chatHandler(w http.ResponseWriter, r *http.Request) { +if !s.verifyAPIKey(r) { +tent-Type", "application/json") +authorized) +.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + +} +if r.Method != http.MethodPost { +tent-Type", "application/json") +otAllowed) +.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"}) + +} + +s.mu.RLock() +chatFunc := s.chatFunc +s.mu.RUnlock() + +if chatFunc == nil { +tent-Type", "application/json") +available) +.NewEncoder(w).Encode(map[string]string{"error": "chat not configured"}) + +} + +var req ChatRequest +if err := json.NewDecoder(r.Body).Decode(&req); err != nil { +tent-Type", "application/json") +uest) +.NewEncoder(w).Encode(map[string]string{"error": "invalid JSON: " + err.Error()}) + +} +if req.Message == "" { +tent-Type", "application/json") +uest) +.NewEncoder(w).Encode(map[string]string{"error": "message field is required"}) + +} + +reply, err := chatFunc(r.Context(), req.Message, req.SessionID) +if err != nil { +tent-Type", "application/json") +ternalServerError) +.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + +} + +w.Header().Set("Content-Type", "application/json") +w.WriteHeader(http.StatusOK) +json.NewEncoder(w).Encode(ChatResponse{Response: reply}) +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index dae730536..b5cf0b8cd 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -45,6 +45,18 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( } } +func NewAzureAIProvider(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *HTTPProvider { + return &HTTPProvider{ + delegate: openai_compat.NewProvider( + apiKey, + apiBase, + proxy, + openai_compat.WithAzureHeaders(), + openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), + ), + } +} + func (p *HTTPProvider) Chat( ctx context.Context, messages []Message, @@ -72,6 +84,11 @@ func (p *HTTPProvider) GetDefaultModel() string { return "" } +func (p *HTTPProvider) SetUseAzureHeaders(use bool) { + p.delegate.SetUseAzureHeaders(use) +} + func (p *HTTPProvider) SupportsNativeSearch() bool { return p.delegate.SupportsNativeSearch() } + diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 7cda033ad..d4c3da2d9 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -57,10 +57,13 @@ var stripModelPrefixProviders = map[string]struct{}{ "mistral": {}, "vivgrid": {}, "minimax": {}, - "novita": {}, - "lmstudio": {}, + "novita": {}, + "lmstudio": {}, + "azure-ai": {}, + "azure-foundry": {}, } + func WithMaxTokensField(maxTokensField string) Option { return func(p *Provider) { p.maxTokensField = maxTokensField diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 30aa76eb3..2ca8dd8c7 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -923,8 +923,8 @@ func TestSupportsPromptCacheKey(t *testing.T) { }{ {"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://myresource.openai.azure.com/openai/deployments/gpt-4", false}, + {"https://eastus.openai.azure.com/v1", false}, {"https://api.mistral.ai/v1", false}, {"https://generativelanguage.googleapis.com/v1beta", false}, {"https://api.deepseek.com/v1", false}, @@ -995,7 +995,7 @@ func TestIsNativeSearchHost(t *testing.T) { want bool }{ {"https://api.openai.com/v1", true}, - {"https://myresource.openai.azure.com/openai/deployments/gpt-4", true}, + {"https://myresource.openai.azure.com/openai/deployments/gpt-4", false}, {"https://api.mistral.ai/v1", false}, {"https://api.deepseek.com/v1", false}, {"https://api.groq.com/openai/v1", false}, diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index f5985a662..d30018e45 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -63,6 +63,8 @@ type SkillsLoader struct { workspaceSkills string // workspace skills (project-level) globalSkills string // global skills (~/.picoclaw/skills) builtinSkills string // builtin skills + whitelist []string + whitelistEnabled bool } // SkillRoots returns all unique skill root directories used by this loader. @@ -88,12 +90,14 @@ func (sl *SkillsLoader) SkillRoots() []string { return out } -func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader { +func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string, whitelist []string, whitelistEnabled bool) *SkillsLoader { return &SkillsLoader{ workspace: workspace, workspaceSkills: filepath.Join(workspace, "skills"), globalSkills: globalSkills, // ~/.picoclaw/skills builtinSkills: builtinSkills, + whitelist: whitelist, + whitelistEnabled: whitelistEnabled, } } @@ -101,6 +105,18 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { skills := make([]SkillInfo, 0) seen := make(map[string]bool) + isWhitelisted := func(name string) bool { + if !sl.whitelistEnabled { + return true + } + for _, w := range sl.whitelist { + if w == name { + return true + } + } + return false + } + addSkills := func(dir, source string) { if dir == "" { return @@ -113,6 +129,12 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { if !d.IsDir() { continue } + + // First check if whitelisted before doing more expensive operations. + if !isWhitelisted(d.Name()) { + continue + } + skillFile := filepath.Join(dir, d.Name(), "SKILL.md") if _, err := os.Stat(skillFile); err != nil { continue @@ -127,6 +149,12 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { info.Description = metadata.Description info.Name = metadata.Name } + + // Double check whitelisted name if metadata name is different from directory name + if info.Name != d.Name() && !isWhitelisted(info.Name) { + continue + } + if err := info.validate(); err != nil { slog.Warn("invalid skill from "+source, "name", info.Name, "error", err) continue @@ -148,6 +176,19 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { } func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { + if sl.whitelistEnabled { + whitelisted := false + for _, w := range sl.whitelist { + if w == name { + whitelisted = true + break + } + } + if !whitelisted { + return "", false + } + } + // 1. load from workspace skills first (project-level) if sl.workspaceSkills != "" { skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md") @@ -155,6 +196,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { return sl.stripFrontmatter(string(content)), true } } +// ... // 2. then load from global skills (~/.picoclaw/skills) if sl.globalSkills != "" { @@ -204,11 +246,11 @@ func (sl *SkillsLoader) BuildSkillsSummary() string { escapedDesc := escapeXML(s.Description) escapedPath := escapeXML(s.Path) - lines = append(lines, fmt.Sprintf(" ")) - lines = append(lines, fmt.Sprintf(" %s", escapedName)) - lines = append(lines, fmt.Sprintf(" %s", escapedDesc)) - lines = append(lines, fmt.Sprintf(" %s", escapedPath)) - lines = append(lines, fmt.Sprintf(" %s", s.Source)) + lines = append(lines, " ") + lines = append(lines, " "+escapedName+"") + lines = append(lines, " "+escapedDesc+"") + lines = append(lines, " "+escapedPath+"") + lines = append(lines, " "+s.Source+"") lines = append(lines, " ") } lines = append(lines, "") diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go index 645d8b7ac..69d8b99db 100644 --- a/pkg/skills/loader_test.go +++ b/pkg/skills/loader_test.go @@ -155,7 +155,7 @@ func TestListSkillsWorkspaceOverridesGlobal(t *testing.T) { createSkillDir(t, filepath.Join(ws, "skills"), "my-skill", "my-skill", "workspace version") createSkillDir(t, global, "my-skill", "my-skill", "global version") - sl := NewSkillsLoader(ws, global, "") + sl := NewSkillsLoader(ws, global, "", nil, false) skills := sl.ListSkills() assert.Len(t, skills, 1) @@ -172,7 +172,7 @@ func TestListSkillsGlobalOverridesBuiltin(t *testing.T) { createSkillDir(t, global, "my-skill", "my-skill", "global version") createSkillDir(t, builtin, "my-skill", "my-skill", "builtin version") - sl := NewSkillsLoader(ws, global, builtin) + sl := NewSkillsLoader(ws, global, builtin, nil, false) skills := sl.ListSkills() assert.Len(t, skills, 1) @@ -189,7 +189,7 @@ func TestListSkillsMetadataNameDedup(t *testing.T) { createSkillDir(t, filepath.Join(ws, "skills"), "dir-a", "shared-name", "workspace version") createSkillDir(t, global, "dir-b", "shared-name", "global version") - sl := NewSkillsLoader(ws, global, "") + sl := NewSkillsLoader(ws, global, "", nil, false) skills := sl.ListSkills() assert.Len(t, skills, 1) @@ -207,7 +207,7 @@ func TestListSkillsMultipleDistinctSkills(t *testing.T) { createSkillDir(t, global, "skill-b", "skill-b", "desc b") createSkillDir(t, builtin, "skill-c", "skill-c", "desc c") - sl := NewSkillsLoader(ws, global, builtin) + sl := NewSkillsLoader(ws, global, builtin, nil, false) skills := sl.ListSkills() assert.Len(t, skills, 3) @@ -230,7 +230,7 @@ func TestListSkillsInvalidSkillSkipped(t *testing.T) { // Valid skill createSkillDir(t, global, "good-skill", "good-skill", "desc") - sl := NewSkillsLoader(ws, global, "") + sl := NewSkillsLoader(ws, global, "", nil, false) skills := sl.ListSkills() assert.Len(t, skills, 1) @@ -243,7 +243,7 @@ func TestListSkillsEmptyAndNonexistentDirs(t *testing.T) { emptyDir := filepath.Join(tmp, "empty") require.NoError(t, os.MkdirAll(emptyDir, 0o755)) - sl := NewSkillsLoader(ws, emptyDir, filepath.Join(tmp, "nonexistent")) + sl := NewSkillsLoader(ws, emptyDir, filepath.Join(tmp, "nonexistent"), nil, false) skills := sl.ListSkills() assert.Empty(t, skills) @@ -259,7 +259,7 @@ func TestListSkillsDirWithoutSkillMD(t *testing.T) { // Valid skill alongside createSkillDir(t, global, "real-skill", "real-skill", "desc") - sl := NewSkillsLoader(ws, global, "") + sl := NewSkillsLoader(ws, global, "", nil, false) skills := sl.ListSkills() assert.Len(t, skills, 1) @@ -333,7 +333,7 @@ func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) { global := filepath.Join(tmp, "global") builtin := filepath.Join(tmp, "builtin") - sl := NewSkillsLoader(workspace, " "+global+" ", "\t"+builtin+"\n") + sl := NewSkillsLoader(workspace, " "+global+" ", "\t"+builtin+"\n", nil, false) roots := sl.SkillRoots() assert.Equal(t, []string{ @@ -417,3 +417,47 @@ func TestGetSkillMetadata_IgnoresHTMLCommentBlocks(t *testing.T) { assert.Equal(t, "biomed-skill", meta.Name) assert.Equal(t, "Summarize biomedical papers.", meta.Description) } +func TestListSkillsWithWhitelist(t *testing.T) { + tmp := t.TempDir() + ws := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + builtin := filepath.Join(tmp, "builtin") + + createSkillDir(t, filepath.Join(ws, "skills"), "skill-a", "skill-a", "desc a") + createSkillDir(t, global, "skill-b", "skill-b", "desc b") + createSkillDir(t, builtin, "skill-c", "skill-c", "desc c") + + t.Run("allow-one", func(t *testing.T) { + sl := NewSkillsLoader(ws, global, builtin, []string{"skill-a"}, true) + skills := sl.ListSkills() + assert.Len(t, skills, 1) + assert.Equal(t, "skill-a", skills[0].Name) + }) + + t.Run("allow-two", func(t *testing.T) { + sl := NewSkillsLoader(ws, global, builtin, []string{"skill-a", "skill-c"}, true) + skills := sl.ListSkills() + assert.Len(t, skills, 2) + names := []string{skills[0].Name, skills[1].Name} + assert.Contains(t, names, "skill-a") + assert.Contains(t, names, "skill-c") + }) + + t.Run("allow-none", func(t *testing.T) { + sl := NewSkillsLoader(ws, global, builtin, []string{"non-existent"}, true) + skills := sl.ListSkills() + assert.Empty(t, skills) + }) + + t.Run("empty-whitelist-allows-all", func(t *testing.T) { + sl := NewSkillsLoader(ws, global, builtin, []string{}, false) + skills := sl.ListSkills() + assert.Len(t, skills, 3) + }) + + t.Run("nil-whitelist-allows-all", func(t *testing.T) { + sl := NewSkillsLoader(ws, global, builtin, nil, false) + skills := sl.ListSkills() + assert.Len(t, skills, 3) + }) +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index e51dff71a..bb179509d 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -423,21 +423,32 @@ func (r *ToolRegistry) GetSummaries() []string { return summaries } -// GetAll returns all registered tools (both core and non-core with TTL > 0). -// Used by SubTurn to inherit parent's tool set. -func (r *ToolRegistry) GetAll() []Tool { - r.mu.RLock() - defer r.mu.RUnlock() +// Filter removes tools that are not in the whitelist. +// If enabled is false, it does nothing. +func (r *ToolRegistry) Filter(whitelist []string, enabled bool) { + if !enabled { + return + } - sorted := r.sortedToolNames() - tools := make([]Tool, 0, len(sorted)) - for _, name := range sorted { - entry := r.tools[name] + r.mu.Lock() + defer r.mu.Unlock() - // Include core tools and non-core tools with active TTL - if entry.IsCore || entry.TTL > 0 { - tools = append(tools, entry.Tool) + whitelistMap := make(map[string]struct{}, len(whitelist)) + for _, name := range whitelist { + whitelistMap[name] = struct{}{} + } + + removed := 0 + for name := range r.tools { + if _, allowed := whitelistMap[name]; !allowed { + delete(r.tools, name) + removed++ } } - return tools + + if removed > 0 { + r.version.Add(1) + logger.InfoCF("tools", "Filtered tools based on whitelist", + map[string]any{"removed": removed, "remaining": len(r.tools)}) + } } diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 71bfe730b..77eb44655 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -15,22 +15,23 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) -// InstallSkillTool allows the LLM agent to install skills from registries. -// It shares the same RegistryManager that FindSkillsTool uses, -// so all registries configured in config are available for installation. type InstallSkillTool struct { - registryMgr *skills.RegistryManager - workspace string - mu sync.Mutex + registryMgr *skills.RegistryManager + workspace string + whitelist []string + whitelistEnabled bool + mu sync.Mutex } // NewInstallSkillTool creates a new InstallSkillTool. // registryMgr is the shared registry manager (same instance as FindSkillsTool). // workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/. -func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { +func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string, whitelist []string, whitelistEnabled bool) *InstallSkillTool { return &InstallSkillTool{ registryMgr: registryMgr, workspace: workspace, + whitelist: whitelist, + whitelistEnabled: whitelistEnabled, mu: sync.Mutex{}, } } @@ -80,6 +81,20 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error())) } + // Check whitelist + if t.whitelistEnabled { + whitelisted := false + for _, w := range t.whitelist { + if w == slug { + whitelisted = true + break + } + } + if !whitelisted { + return ErrorResult(fmt.Sprintf("skill %q is not in whitelist and cannot be installed", slug)) + } + } + // Validate registry registryName, _ := args["registry"].(string) if err := utils.ValidateSkillIdentifier(registryName); err != nil { diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go index 676fcecc0..4d90b7fcc 100644 --- a/pkg/tools/skills_install_test.go +++ b/pkg/tools/skills_install_test.go @@ -13,19 +13,19 @@ import ( ) func TestInstallSkillToolName(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) assert.Equal(t, "install_skill", tool.Name()) } func TestInstallSkillToolMissingSlug(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) result := tool.Execute(context.Background(), map[string]any{}) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") } func TestInstallSkillToolEmptySlug(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) result := tool.Execute(context.Background(), map[string]any{ "slug": " ", }) @@ -34,7 +34,7 @@ func TestInstallSkillToolEmptySlug(t *testing.T) { } func TestInstallSkillToolUnsafeSlug(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) cases := []string{ "../etc/passwd", @@ -56,7 +56,7 @@ func TestInstallSkillToolAlreadyExists(t *testing.T) { skillDir := filepath.Join(workspace, "skills", "existing-skill") require.NoError(t, os.MkdirAll(skillDir, 0o755)) - tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) + tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace, nil, false) result := tool.Execute(context.Background(), map[string]any{ "slug": "existing-skill", "registry": "clawhub", @@ -67,7 +67,7 @@ func TestInstallSkillToolAlreadyExists(t *testing.T) { func TestInstallSkillToolRegistryNotFound(t *testing.T) { workspace := t.TempDir() - tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) + tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace, nil, false) result := tool.Execute(context.Background(), map[string]any{ "slug": "some-skill", "registry": "nonexistent", @@ -78,7 +78,7 @@ func TestInstallSkillToolRegistryNotFound(t *testing.T) { } func TestInstallSkillToolParameters(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) params := tool.Parameters() props, ok := params["properties"].(map[string]any) @@ -95,10 +95,55 @@ func TestInstallSkillToolParameters(t *testing.T) { } func TestInstallSkillToolMissingRegistry(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) result := tool.Execute(context.Background(), map[string]any{ "slug": "some-skill", }) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "invalid registry") } +func TestInstallSkillToolWhitelist(t *testing.T) { + workspace := t.TempDir() + rm := skills.NewRegistryManager() + + t.Run("blocked-by-whitelist", func(t *testing.T) { + tool := NewInstallSkillTool(rm, workspace, []string{"allowed-skill"}, true) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "blocked-skill", + "registry": "clawhub", + }) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "not in whitelist") + }) + + t.Run("allowed-by-whitelist", func(t *testing.T) { + // This will still fail because registry is not found, but it should pass the whitelist check + tool := NewInstallSkillTool(rm, workspace, []string{"allowed-skill"}, true) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "allowed-skill", + "registry": "clawhub", + }) + assert.True(t, result.IsError) + assert.NotContains(t, result.ForLLM, "not in whitelist") + }) + + t.Run("empty-whitelist-allows-all", func(t *testing.T) { + tool := NewInstallSkillTool(rm, workspace, []string{}, false) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "any-skill", + "registry": "clawhub", + }) + assert.True(t, result.IsError) + assert.NotContains(t, result.ForLLM, "not in whitelist") + }) + + t.Run("nil-whitelist-allows-all", func(t *testing.T) { + tool := NewInstallSkillTool(rm, workspace, nil, false) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "any-skill", + "registry": "clawhub", + }) + assert.True(t, result.IsError) + assert.NotContains(t, result.ForLLM, "not in whitelist") + }) +} diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go index 2b6cffd38..bf5c8e8e9 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/skills_search.go @@ -12,15 +12,19 @@ import ( type FindSkillsTool struct { registryMgr *skills.RegistryManager cache *skills.SearchCache + whitelist []string + enabled bool } // NewFindSkillsTool creates a new FindSkillsTool. // registryMgr is the shared registry manager (built from config in createToolRegistry). // cache is the search cache for deduplicating similar queries. -func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { +func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache, whitelist []string, enabled bool) *FindSkillsTool { return &FindSkillsTool{ registryMgr: registryMgr, cache: cache, + whitelist: whitelist, + enabled: enabled, } } @@ -79,6 +83,22 @@ func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *Tool return ErrorResult(fmt.Sprintf("skill search failed: %v", err)) } + // Filter by whitelist if enabled + if t.enabled { + filtered := make([]skills.SearchResult, 0, len(results)) + whitelistMap := make(map[string]struct{}, len(t.whitelist)) + for _, w := range t.whitelist { + whitelistMap[w] = struct{}{} + } + for _, r := range results { + if _, ok := whitelistMap[r.Slug]; ok { + filtered = append(filtered, r) + } + } + results = filtered + } + + // Cache the results. if t.cache != nil && len(results) > 0 { t.cache.Put(query, results) diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/skills_search_test.go index 0e5387cf5..7d2955b3b 100644 --- a/pkg/tools/skills_search_test.go +++ b/pkg/tools/skills_search_test.go @@ -10,19 +10,19 @@ import ( ) func TestFindSkillsToolName(t *testing.T) { - tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) + tool := NewFindSkillsTool(skills.NewRegistryManager(), nil, nil, false) assert.Equal(t, "find_skills", tool.Name()) } func TestFindSkillsToolMissingQuery(t *testing.T) { - tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) + tool := NewFindSkillsTool(skills.NewRegistryManager(), nil, nil, false) result := tool.Execute(context.Background(), map[string]any{}) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "query is required") } func TestFindSkillsToolEmptyQuery(t *testing.T) { - tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) + tool := NewFindSkillsTool(skills.NewRegistryManager(), nil, nil, false) result := tool.Execute(context.Background(), map[string]any{ "query": " ", }) @@ -35,7 +35,7 @@ func TestFindSkillsToolCacheHit(t *testing.T) { {Slug: "github", Score: 0.9, RegistryName: "clawhub"}, }) - tool := NewFindSkillsTool(skills.NewRegistryManager(), cache) + tool := NewFindSkillsTool(skills.NewRegistryManager(), cache, nil, false) result := tool.Execute(context.Background(), map[string]any{ "query": "github", }) @@ -46,7 +46,7 @@ func TestFindSkillsToolCacheHit(t *testing.T) { } func TestFindSkillsToolParameters(t *testing.T) { - tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) + tool := NewFindSkillsTool(skills.NewRegistryManager(), nil, nil, false) params := tool.Parameters() props, ok := params["properties"].(map[string]any) @@ -60,7 +60,7 @@ func TestFindSkillsToolParameters(t *testing.T) { } func TestFindSkillsToolDescription(t *testing.T) { - tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) + tool := NewFindSkillsTool(skills.NewRegistryManager(), nil, nil, false) assert.NotEmpty(t, tool.Description()) assert.Contains(t, tool.Description(), "skill") } diff --git a/workspace/HEARTBEAT.md b/workspace/HEARTBEAT.md new file mode 100644 index 000000000..9a4e3ca80 --- /dev/null +++ b/workspace/HEARTBEAT.md @@ -0,0 +1,22 @@ +# Heartbeat Check List + +This file contains tasks for the heartbeat service to check periodically. + +## Examples + +- Check for unread messages +- Review upcoming calendar events +- Check device status (e.g., MaixCam) + +## Instructions + +- Execute ALL tasks listed below. Do NOT skip any task. +- For simple tasks (e.g., report current time), respond directly. +- For complex tasks that may take time, use the spawn tool to create a subagent. +- The spawn tool is async - subagent results will be sent to the user automatically. +- After spawning a subagent, CONTINUE to process remaining tasks. +- Only respond with HEARTBEAT_OK when ALL tasks are done AND nothing needs attention. + +--- + +Add your heartbeat tasks below this line: diff --git a/workspace/cron/jobs.json b/workspace/cron/jobs.json new file mode 100644 index 000000000..b8cdc503b --- /dev/null +++ b/workspace/cron/jobs.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "jobs": [] +} \ No newline at end of file diff --git a/workspace/heartbeat.log b/workspace/heartbeat.log new file mode 100644 index 000000000..8fb65ced5 --- /dev/null +++ b/workspace/heartbeat.log @@ -0,0 +1 @@ +[2026-03-24 08:15:50] [INFO] Created default HEARTBEAT.md template diff --git a/workspace/state/state.json b/workspace/state/state.json new file mode 100644 index 000000000..912b9bc59 --- /dev/null +++ b/workspace/state/state.json @@ -0,0 +1,4 @@ +{ + "last_channel": "telegram:8271300679", + "timestamp": "2026-03-24T08:43:14.295101255+01:00" +} \ No newline at end of file