azure skills whitelisting: fix skills loader, security config, and tests

This commit is contained in:
stevef 2026-03-24 11:27:04 +01:00
parent 968e77225a
commit 3c6639517d
19 changed files with 252 additions and 177 deletions

View file

@ -273,7 +273,7 @@ test: generate
## fmt: Format Go code ## fmt: Format Go code
fmt: fmt:
@$(GOLANGCI_LINT) fmt @gofmt -s -w $$(find . -name "*.go" -not -path "./web/*" -not -path "./vendor/*")
## lint: Run linters ## lint: Run linters
lint: lint:

View file

@ -43,7 +43,7 @@ func NewSkillsCommand() *cobra.Command {
globalDir := filepath.Dir(internal.GetConfigPath()) globalDir := filepath.Dir(internal.GetConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills") globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
d.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir) d.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir, nil, false)
return nil return nil
}, },

View file

@ -73,7 +73,7 @@ func NewContextBuilder(workspace string) *ContextBuilder {
return &ContextBuilder{ return &ContextBuilder{
workspace: workspace, workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir, nil, false),
memory: NewMemoryStore(workspace), memory: NewMemoryStore(workspace),
} }
} }

View file

@ -327,11 +327,25 @@ func registerSharedTools(
cfg.Tools.Skills.SearchCache.MaxSize, cfg.Tools.Skills.SearchCache.MaxSize,
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
) )
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache, cfg.Tools.Skills.Whitelist, cfg.Tools.Skills.WhitelistEnabled)) agent.Tools.Register(
tools.NewFindSkillsTool(
registryMgr,
searchCache,
cfg.Tools.Skills.Whitelist,
cfg.Tools.Skills.WhitelistEnabled,
),
)
} }
if install_skills_enable { if install_skills_enable {
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace, cfg.Tools.Skills.Whitelist, cfg.Tools.Skills.WhitelistEnabled)) agent.Tools.Register(
tools.NewInstallSkillTool(
registryMgr,
agent.Workspace,
cfg.Tools.Skills.Whitelist,
cfg.Tools.Skills.WhitelistEnabled,
),
)
} }
} }

View file

@ -893,7 +893,7 @@ type ToolsConfig struct {
MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"`
Whitelist FlexibleStringSlice `json:"whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WHITELIST"` Whitelist FlexibleStringSlice `json:"whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WHITELIST"`
WhitelistEnabled bool `json:"whitelist_enabled,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WHITELIST_ENABLED"` WhitelistEnabled bool `json:"whitelist_enabled,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WHITELIST_ENABLED"`
MCP MCPConfig `json:"mcp" yaml:"-""` MCP MCPConfig `json:"mcp" yaml:"-"`
AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`

View file

@ -34,8 +34,9 @@ func TestJSONUnmarshalPrivateFields(t *testing.T) {
if s.PublicField != "pub" { if s.PublicField != "pub" {
t.Errorf("PublicField = %q, want 'pub'", s.PublicField) t.Errorf("PublicField = %q, want 'pub'", s.PublicField)
} }
// Private fields cannot be unmarshaled from JSON
if s.privateField != "" { if s.privateField != "" {
t.Errorf("privateField = %q, want empty because unexported fields are ignored", s.privateField) t.Errorf("privateField = %q, want empty string (private fields are not unmarshaled)", s.privateField)
} }
} }

View file

@ -216,7 +216,6 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
}) })
} }
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop") fmt.Println("Press Ctrl+C to stop")

View file

@ -2,15 +2,28 @@ package health
import ( import (
"context" "context"
"crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps" "maps"
"net/http" "net/http"
"os"
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/logger"
) )
// 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"`
}
type Server struct { type Server struct {
server *http.Server server *http.Server
mu sync.RWMutex mu sync.RWMutex
@ -23,7 +36,6 @@ type Server struct {
apiKey string apiKey string
} }
type Check struct { type Check struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
@ -35,6 +47,7 @@ type StatusResponse struct {
Status string `json:"status"` Status string `json:"status"`
Uptime string `json:"uptime"` Uptime string `json:"uptime"`
Checks map[string]Check `json:"checks,omitempty"` Checks map[string]Check `json:"checks,omitempty"`
Pid int `json:"pid"`
} }
func NewServer(host string, port int, token string) *Server { func NewServer(host string, port int, token string) *Server {
@ -51,13 +64,13 @@ func NewServer(host string, port int, token string) *Server {
mux.HandleFunc("/reload", s.reloadHandler) mux.HandleFunc("/reload", s.reloadHandler)
mux.HandleFunc("/chat", s.chatHandler) mux.HandleFunc("/chat", s.chatHandler)
addr := fmt.Sprintf("%s:%d", host, port) addr := fmt.Sprintf("%s:%d", host, port)
s.server = &http.Server{ s.server = &http.Server{
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,
ReadTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 5 * time.Second, // WriteTimeout must be long enough for LLM inference; 5 min is generous.
WriteTimeout: 5 * time.Minute,
} }
return s return s
@ -121,7 +134,39 @@ func (s *Server) SetReloadFunc(fn func() error) {
s.reloadFunc = fn s.reloadFunc = fn
} }
// SetChatFunc sets the callback that processes /chat requests.
// fn receives the user message and an optional session ID and must return the
// agent's reply (or an error). It is called synchronously inside the HTTP
// handler, so the write timeout on the server governs the maximum duration.
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 == "" {
return true
}
return r.Header.Get("X-API-Key") == s.apiKey
}
func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
if !s.verifyAPIKey(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
@ -129,21 +174,6 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Token check
s.mu.RLock()
requiredToken := s.authToken
s.mu.RUnlock()
if requiredToken != "" {
given := extractBearerToken(r.Header.Get("Authorization"))
if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
}
s.mu.Lock() s.mu.Lock()
reloadFunc := s.reloadFunc reloadFunc := s.reloadFunc
s.mu.Unlock() s.mu.Unlock()
@ -175,6 +205,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
resp := StatusResponse{ resp := StatusResponse{
Status: "ok", Status: "ok",
Uptime: uptime.String(), Uptime: uptime.String(),
Pid: os.Getpid(),
} }
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
@ -218,20 +249,72 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
}) })
} }
// HandlerMux is the interface for registering HTTP handlers, used by // RegisterOnMux registers /health, /ready, /reload and /chat handlers onto the
// RegisterOnMux so that callers can pass any mux implementation // given mux. This allows the health endpoints to be served by a shared HTTP server.
// (e.g. *http.ServeMux or a custom dynamic mux). func (s *Server) RegisterOnMux(mux *http.ServeMux) {
type HandlerMux interface {
Handle(pattern string, handler http.Handler)
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
}
// RegisterOnMux registers /health, /ready and /reload handlers onto the given mux.
// This allows the health endpoints to be served by a shared HTTP server.
func (s *Server) RegisterOnMux(mux HandlerMux) {
mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler) mux.HandleFunc("/reload", s.reloadHandler)
mux.HandleFunc("/chat", s.chatHandler)
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
logger.Error("GATEWAY IS HITTING ITSELF FOR LLM CALLS!")
http.Error(w, "GATEWAY LOOP DETECTION", http.StatusLoopDetected)
})
}
// chatHandler handles POST /chat — a synchronous HTTP chat API.
// Request body: {"message": "...", "session_id": "..." (optional)}
// Response body: {"response": "..."}
func (s *Server) chatHandler(w http.ResponseWriter, r *http.Request) {
if !s.verifyAPIKey(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
if r.Method != http.MethodPost {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"})
return
}
s.mu.RLock()
chatFunc := s.chatFunc
s.mu.RUnlock()
if chatFunc == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "chat not configured"})
return
}
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
if req.Message == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "message field is required"})
return
}
reply, err := chatFunc(r.Context(), req.Message, req.SessionID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(ChatResponse{Response: reply})
} }
func statusString(ok bool) string { func statusString(ok bool) string {
@ -240,102 +323,3 @@ func statusString(ok bool) string {
} }
return "fail" return "fail"
} }
// extractBearerToken returns the token from an "Authorization: Bearer <t>" header,
// or the empty string if the header is missing or malformed.
func extractBearerToken(header string) string {
const prefix = "Bearer "
if len(header) < len(prefix) {
return ""
}
if header[:len(prefix)] != prefix {
return ""
}
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

@ -240,6 +240,36 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.ExtraBody, cfg.ExtraBody,
), modelID, nil ), modelID, nil
case "nvidia":
apiBase := cfg.APIBase
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
p := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
cfg.APIKey(),
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
cfg.ExtraBody,
)
// NVIDIA sometimes prefers api-key header or has issues with Bearer in some environments
p.SetUseAzureHeaders(false) // NVIDIA main gateway prefers standard Bearer headers; api-key causes 404s
return p, "nvidia/" + modelID, nil
case "azure-ai", "azure-foundry":
// Azure AI Foundry / Studio compatible with OpenAI API format,
// but using api-key header instead of Authorization: Bearer.
if cfg.APIKey() == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for protocol %q", protocol)
}
return NewAzureAIProvider(
cfg.APIKey(),
cfg.APIBase,
cfg.Proxy,
cfg.RequestTimeout,
), modelID, nil
case "minimax": case "minimax":
// Minimax requires reasoning_split: true in the request body // Minimax requires reasoning_split: true in the request body
if cfg.APIKey() == "" && cfg.APIBase == "" { if cfg.APIKey() == "" && cfg.APIBase == "" {

View file

@ -91,4 +91,3 @@ func (p *HTTPProvider) SetUseAzureHeaders(use bool) {
func (p *HTTPProvider) SupportsNativeSearch() bool { func (p *HTTPProvider) SupportsNativeSearch() bool {
return p.delegate.SupportsNativeSearch() return p.delegate.SupportsNativeSearch()
} }

View file

@ -11,8 +11,10 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/common"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
@ -31,14 +33,18 @@ type (
) )
type Provider struct { type Provider struct {
apiKey string apiKey string
apiBase string apiBase string
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
httpClient *http.Client httpClient *http.Client
extraBody map[string]any // Additional fields to inject into request body extraBody map[string]any // Additional fields to inject into request body
userAgent string userAgent string
useAzureHeaders bool // Use api-key header instead of Authorization: Bearer
mu sync.RWMutex // Protect useAzureHeaders
} }
type Option func(*Provider) type Option func(*Provider)
const defaultRequestTimeout = common.DefaultRequestTimeout const defaultRequestTimeout = common.DefaultRequestTimeout
@ -90,6 +96,19 @@ func WithExtraBody(extraBody map[string]any) Option {
} }
} }
func WithAzureHeaders(use bool) Option {
return func(p *Provider) {
p.useAzureHeaders = use
}
}
func (p *Provider) SetUseAzureHeaders(use bool) {
p.mu.Lock()
defer p.mu.Unlock()
p.useAzureHeaders = use
}
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
p := &Provider{ p := &Provider{
apiKey: apiKey, apiKey: apiKey,
@ -459,7 +478,7 @@ func isNativeSearchHost(apiBase string) bool {
return false return false
} }
host := u.Hostname() host := u.Hostname()
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") return host == "api.openai.com"
} }
// supportsPromptCacheKey reports whether the given API base is known to // supportsPromptCacheKey reports whether the given API base is known to
@ -472,5 +491,5 @@ func supportsPromptCacheKey(apiBase string) bool {
return false return false
} }
host := u.Hostname() host := u.Hostname()
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") return host == "api.openai.com"
} }

View file

@ -59,10 +59,10 @@ func (info SkillInfo) validate() error {
} }
type SkillsLoader struct { type SkillsLoader struct {
workspace string workspace string
workspaceSkills string // workspace skills (project-level) workspaceSkills string // workspace skills (project-level)
globalSkills string // global skills (~/.picoclaw/skills) globalSkills string // global skills (~/.picoclaw/skills)
builtinSkills string // builtin skills builtinSkills string // builtin skills
whitelist []string whitelist []string
whitelistEnabled bool whitelistEnabled bool
} }
@ -90,13 +90,19 @@ func (sl *SkillsLoader) SkillRoots() []string {
return out return out
} }
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string, whitelist []string, whitelistEnabled bool) *SkillsLoader { func NewSkillsLoader(
workspace string,
globalSkills string,
builtinSkills string,
whitelist []string,
whitelistEnabled bool,
) *SkillsLoader {
return &SkillsLoader{ return &SkillsLoader{
workspace: workspace, workspace: workspace,
workspaceSkills: filepath.Join(workspace, "skills"), workspaceSkills: filepath.Join(workspace, "skills"),
globalSkills: globalSkills, // ~/.picoclaw/skills globalSkills: globalSkills, // ~/.picoclaw/skills
builtinSkills: builtinSkills, builtinSkills: builtinSkills,
whitelist: whitelist, whitelist: whitelist,
whitelistEnabled: whitelistEnabled, whitelistEnabled: whitelistEnabled,
} }
} }
@ -196,7 +202,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
return sl.stripFrontmatter(string(content)), true return sl.stripFrontmatter(string(content)), true
} }
} }
// ... // ...
// 2. then load from global skills (~/.picoclaw/skills) // 2. then load from global skills (~/.picoclaw/skills)
if sl.globalSkills != "" { if sl.globalSkills != "" {

View file

@ -417,6 +417,7 @@ func TestGetSkillMetadata_IgnoresHTMLCommentBlocks(t *testing.T) {
assert.Equal(t, "biomed-skill", meta.Name) assert.Equal(t, "biomed-skill", meta.Name)
assert.Equal(t, "Summarize biomedical papers.", meta.Description) assert.Equal(t, "Summarize biomedical papers.", meta.Description)
} }
func TestListSkillsWithWhitelist(t *testing.T) { func TestListSkillsWithWhitelist(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
ws := filepath.Join(tmp, "workspace") ws := filepath.Join(tmp, "workspace")

View file

@ -26,13 +26,18 @@ type InstallSkillTool struct {
// NewInstallSkillTool creates a new InstallSkillTool. // NewInstallSkillTool creates a new InstallSkillTool.
// registryMgr is the shared registry manager (same instance as FindSkillsTool). // registryMgr is the shared registry manager (same instance as FindSkillsTool).
// workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/. // workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/.
func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string, whitelist []string, whitelistEnabled bool) *InstallSkillTool { func NewInstallSkillTool(
registryMgr *skills.RegistryManager,
workspace string,
whitelist []string,
whitelistEnabled bool,
) *InstallSkillTool {
return &InstallSkillTool{ return &InstallSkillTool{
registryMgr: registryMgr, registryMgr: registryMgr,
workspace: workspace, workspace: workspace,
whitelist: whitelist, whitelist: whitelist,
whitelistEnabled: whitelistEnabled, whitelistEnabled: whitelistEnabled,
mu: sync.Mutex{}, mu: sync.Mutex{},
} }
} }

View file

@ -102,6 +102,7 @@ func TestInstallSkillToolMissingRegistry(t *testing.T) {
assert.True(t, result.IsError) assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "invalid registry") assert.Contains(t, result.ForLLM, "invalid registry")
} }
func TestInstallSkillToolWhitelist(t *testing.T) { func TestInstallSkillToolWhitelist(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
rm := skills.NewRegistryManager() rm := skills.NewRegistryManager()

View file

@ -19,7 +19,12 @@ type FindSkillsTool struct {
// NewFindSkillsTool creates a new FindSkillsTool. // NewFindSkillsTool creates a new FindSkillsTool.
// registryMgr is the shared registry manager (built from config in createToolRegistry). // registryMgr is the shared registry manager (built from config in createToolRegistry).
// cache is the search cache for deduplicating similar queries. // cache is the search cache for deduplicating similar queries.
func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache, whitelist []string, enabled bool) *FindSkillsTool { func NewFindSkillsTool(
registryMgr *skills.RegistryManager,
cache *skills.SearchCache,
whitelist []string,
enabled bool,
) *FindSkillsTool {
return &FindSkillsTool{ return &FindSkillsTool{
registryMgr: registryMgr, registryMgr: registryMgr,
cache: cache, cache: cache,
@ -98,7 +103,6 @@ func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *Tool
results = filtered results = filtered
} }
// Cache the results. // Cache the results.
if t.cache != nil && len(results) > 0 { if t.cache != nil && len(results) > 0 {
t.cache.Put(query, results) t.cache.Put(query, results)

View file

@ -106,10 +106,14 @@ build-dev-picoclaw:
@mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")" @mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
# Run all tests
test: test:
cd $(BACKEND_DIR) && ${WEB_GO} test ./... cd $(BACKEND_DIR) && ${WEB_GO} test ./...
cd $(FRONTEND_DIR) && pnpm lint @if command -v pnpm >/dev/null 2>&1; then \
cd $(FRONTEND_DIR) && pnpm lint; \
else \
echo "pnpm not found, skipping frontend linting"; \
fi
# Lint and format # Lint and format
lint: lint:

View file

@ -130,8 +130,12 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
return return
} }
if mc.APIKey != "" { apiKey := mc.APIKey
mc.ModelConfig.SetAPIKey(mc.APIKey) if apiKey == "" {
apiKey = mc.ModelConfig.APIKey()
}
if apiKey != "" {
mc.ModelConfig.SetAPIKey(apiKey)
} }
cfg, err := config.LoadConfig(h.configPath) cfg, err := config.LoadConfig(h.configPath)
@ -201,13 +205,15 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
return return
} }
// Preserve the existing API key when the caller omits it (empty string). apiKey := mc.APIKey
// This lets the UI update api_base / proxy without clearing the stored secret. if apiKey == "" {
if mc.APIKey == "" { apiKey = mc.ModelConfig.APIKey()
mc.ModelConfig.SetAPIKey(cfg.ModelList[idx].APIKey())
} else {
mc.ModelConfig.SetAPIKey(mc.APIKey)
} }
if apiKey == "" {
apiKey = cfg.ModelList[idx].APIKey()
}
mc.ModelConfig.SetAPIKey(apiKey)
// Preserve existing ExtraBody when omitted (nil), but clear it when // Preserve existing ExtraBody when omitted (nil), but clear it when
// the frontend sends an empty object {} to indicate the field should // the frontend sends an empty object {} to indicate the field should
// be removed. // be removed.

View file

@ -507,6 +507,8 @@ func newSkillsLoader(workspace string) *skills.SkillsLoader {
workspace, workspace,
filepath.Join(globalConfigDir(), "skills"), filepath.Join(globalConfigDir(), "skills"),
builtinSkillsDir(), builtinSkillsDir(),
nil,
false,
) )
} }