added two new providers: NVIDIA and Azure plus Security enhancments to lock down skills if desired and added a configurable chat API

This commit is contained in:
stevef 2026-03-24 09:05:24 +01:00
parent d8c5183d9a
commit 8d95449084
24 changed files with 472 additions and 70 deletions

2
logs/gateway.log Normal file
View file

@ -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"}

26
logs/gateway_panic.log Normal file
View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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_"`

View file

@ -360,9 +360,11 @@ func DefaultConfig() *Config {
Gateway: GatewayConfig{
Host: "127.0.0.1",
Port: 18790,
ChatEnabled: true,
HotReload: false,
LogLevel: DefaultGatewayLogLevel,
},
Tools: ToolsConfig{
FilterSensitiveData: true,
FilterMinLength: 8,

View file

@ -12,10 +12,13 @@ const DefaultGatewayLogLevel = "warn"
type GatewayConfig struct {
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:

View file

@ -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")

View file

@ -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})
}

View file

@ -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()
}

View file

@ -59,8 +59,11 @@ var stripModelPrefixProviders = map[string]struct{}{
"minimax": {},
"novita": {},
"lmstudio": {},
"azure-ai": {},
"azure-foundry": {},
}
func WithMaxTokensField(maxTokensField string) Option {
return func(p *Provider) {
p.maxTokensField = maxTokensField

View file

@ -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},

View file

@ -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(" <skill>"))
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc))
lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath))
lines = append(lines, fmt.Sprintf(" <source>%s</source>", s.Source))
lines = append(lines, " <skill>")
lines = append(lines, " <name>"+escapedName+"</name>")
lines = append(lines, " <description>"+escapedDesc+"</description>")
lines = append(lines, " <location>"+escapedPath+"</location>")
lines = append(lines, " <source>"+s.Source+"</source>")
lines = append(lines, " </skill>")
}
lines = append(lines, "</skills>")

View file

@ -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)
})
}

View file

@ -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)})
}
}

View file

@ -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
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 {

View file

@ -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")
})
}

View file

@ -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)

View file

@ -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")
}

22
workspace/HEARTBEAT.md Normal file
View file

@ -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:

4
workspace/cron/jobs.json Normal file
View file

@ -0,0 +1,4 @@
{
"version": 1,
"jobs": []
}

1
workspace/heartbeat.log Normal file
View file

@ -0,0 +1 @@
[2026-03-24 08:15:50] [INFO] Created default HEARTBEAT.md template

View file

@ -0,0 +1,4 @@
{
"last_channel": "telegram:8271300679",
"timestamp": "2026-03-24T08:43:14.295101255+01:00"
}