Merge branch 'master'
This commit is contained in:
commit
0b5285aab3
10 changed files with 368 additions and 26 deletions
|
|
@ -622,7 +622,7 @@ data:
|
||||||
"api_key": "picoclaw-secret-123",
|
"api_key": "picoclaw-secret-123",
|
||||||
"chat_enabled": true,
|
"chat_enabled": true,
|
||||||
"hot_reload": true,
|
"hot_reload": true,
|
||||||
"log_level": "info"
|
"log_level": "debug"
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
@ -669,6 +669,8 @@ data:
|
||||||
"weather": true,
|
"weather": true,
|
||||||
"summarize": true,
|
"summarize": true,
|
||||||
"github": true,
|
"github": true,
|
||||||
|
"monday": true,
|
||||||
|
"harvest": true,
|
||||||
"freeride": true,
|
"freeride": true,
|
||||||
"hdn-server": true
|
"hdn-server": true
|
||||||
}
|
}
|
||||||
|
|
@ -786,6 +788,8 @@ data:
|
||||||
"weather",
|
"weather",
|
||||||
"summarize",
|
"summarize",
|
||||||
"github",
|
"github",
|
||||||
|
"monday",
|
||||||
|
"harvest",
|
||||||
"freeride",
|
"freeride",
|
||||||
"hdn-server"
|
"hdn-server"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -102,11 +102,13 @@ func NewAgentInstance(
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
}
|
}
|
||||||
if cfg.Tools.IsToolEnabled("exec") {
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths)
|
execTool, err := tools.NewExecToolWithDenyPaths(workspace, restrict, [][]*regexp.Regexp{allowReadPaths}, denyWritePaths, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec",
|
logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec",
|
||||||
map[string]any{"error": err.Error()})
|
map[string]any{"error": err.Error()})
|
||||||
} else {
|
execTool = nil
|
||||||
|
}
|
||||||
|
if execTool != nil {
|
||||||
toolsRegistry.Register(execTool)
|
toolsRegistry.Register(execTool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,20 @@ func NewAgentLoop(
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
) *AgentLoop {
|
) *AgentLoop {
|
||||||
|
logger.Debug("Initializing AgentRegistry...")
|
||||||
registry := NewAgentRegistry(cfg, provider)
|
registry := NewAgentRegistry(cfg, provider)
|
||||||
|
|
||||||
// Set up shared fallback chain with rate limiting.
|
// Set up shared fallback chain with rate limiting.
|
||||||
cooldownPath := filepath.Join(filepath.Dir(filepath.Clean(registry.GetDefaultAgent().Workspace)), "cooldowns.json")
|
// Harden: ensure we have a valid workspace for the cooldown file even if the default agent isn't ready.
|
||||||
|
baseDir := filepath.Dir(configPath)
|
||||||
|
if defaultAgent := registry.GetDefaultAgent(); defaultAgent != nil {
|
||||||
|
baseDir = filepath.Dir(filepath.Clean(defaultAgent.Workspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
cooldownPath := filepath.Join(baseDir, "cooldowns.json")
|
||||||
|
logger.Debugf("Initializing CooldownTracker at %s", cooldownPath)
|
||||||
cooldown := providers.NewCooldownTracker(cooldownPath)
|
cooldown := providers.NewCooldownTracker(cooldownPath)
|
||||||
|
|
||||||
rl := providers.NewRateLimiterRegistry()
|
rl := providers.NewRateLimiterRegistry()
|
||||||
// Register rate limiters for all agents' candidates so that RPM limits
|
// Register rate limiters for all agents' candidates so that RPM limits
|
||||||
// configured in ModelConfig are enforced before each LLM call.
|
// configured in ModelConfig are enforced before each LLM call.
|
||||||
|
|
@ -46,6 +55,7 @@ func NewAgentLoop(
|
||||||
defaultAgent := registry.GetDefaultAgent()
|
defaultAgent := registry.GetDefaultAgent()
|
||||||
var stateManager *state.Manager
|
var stateManager *state.Manager
|
||||||
if defaultAgent != nil {
|
if defaultAgent != nil {
|
||||||
|
logger.Debugf("Initializing State Manager for agent %s", defaultAgent.ID)
|
||||||
stateManager = state.NewManager(defaultAgent.Workspace)
|
stateManager = state.NewManager(defaultAgent.Workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,24 +114,46 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the gateway runtime using the configuration loaded from configPath.
|
// Run starts the gateway runtime using the configuration loaded from configPath.
|
||||||
func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runErr error) {
|
func Run(
|
||||||
panicPath := filepath.Join(homePath, logPath, panicFile)
|
debug bool,
|
||||||
panicFunc, err := logger.InitPanic(panicPath)
|
homePath string,
|
||||||
if err != nil {
|
configPath string,
|
||||||
return fmt.Errorf("error initializing panic log: %w", err)
|
allowEmptyStartup bool,
|
||||||
}
|
) (runErr error) {
|
||||||
defer panicFunc()
|
fmt.Printf("📂 Home Path: %s\n", homePath)
|
||||||
|
fmt.Printf("📄 Config Path: %s\n", configPath)
|
||||||
|
|
||||||
if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil {
|
// Ensure home directory exists early
|
||||||
|
if err := os.MkdirAll(homePath, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize panic logging
|
||||||
|
panicPath := filepath.Join(homePath, logPath, panicFile)
|
||||||
|
fmt.Printf("🔧 Initializing panic log: %s\n", panicPath)
|
||||||
|
panicCleanup, err := logger.InitPanic(panicPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize panic log: %w", err)
|
||||||
|
}
|
||||||
|
defer panicCleanup()
|
||||||
|
fmt.Println("✓ Panic log initialized")
|
||||||
|
|
||||||
|
// Enable main file logging
|
||||||
|
mainLogPath := filepath.Join(homePath, logPath, logFile)
|
||||||
|
fmt.Printf("🔧 Enabling file logging: %s\n", mainLogPath)
|
||||||
|
if err = logger.EnableFileLogging(mainLogPath); err != nil {
|
||||||
logger.Fatal(fmt.Sprintf("error enabling file logging: %v", err))
|
logger.Fatal(fmt.Sprintf("error enabling file logging: %v", err))
|
||||||
}
|
}
|
||||||
defer logger.DisableFileLogging()
|
defer logger.DisableFileLogging()
|
||||||
|
fmt.Println("✓ File logging enabled")
|
||||||
|
|
||||||
|
// Set initial log level from config if possible, otherwise default to INFO
|
||||||
if debug {
|
if debug {
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
} else {
|
} else {
|
||||||
logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
|
logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
logger.ErrorCF("gateway", "Gateway startup failed", map[string]any{
|
logger.ErrorCF("gateway", "Gateway startup failed", map[string]any{
|
||||||
|
|
@ -144,10 +166,12 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
fmt.Println("🔍 Loading configuration...")
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error loading config: %w", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
}
|
}
|
||||||
|
logger.Info("✓ Configuration loaded")
|
||||||
|
|
||||||
if err = preCheckConfig(cfg); err != nil {
|
if err = preCheckConfig(cfg); err != nil {
|
||||||
return fmt.Errorf("config pre-check failed: %w", err)
|
return fmt.Errorf("config pre-check failed: %w", err)
|
||||||
|
|
@ -156,6 +180,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
// Debug mode permanently overrides the config log level to DEBUG.
|
// Debug mode permanently overrides the config log level to DEBUG.
|
||||||
if debug {
|
if debug {
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
|
logger.SetLevel(logger.DEBUG)
|
||||||
} else {
|
} else {
|
||||||
effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
|
effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
|
||||||
logger.SetLevelFromString(effectiveLogLevel)
|
logger.SetLevelFromString(effectiveLogLevel)
|
||||||
|
|
@ -167,7 +192,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
return fmt.Errorf("error opening gateway listeners: %w", err)
|
return fmt.Errorf("error opening gateway listeners: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce singleton: write PID file with generated token.
|
// Enforce singleton and generate auth token
|
||||||
pidData, err := pid.WritePidFile(homePath, bindPlan.ProbeHost, cfg.Gateway.Port)
|
pidData, err := pid.WritePidFile(homePath, bindPlan.ProbeHost, cfg.Gateway.Port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("write pid file failed: %v", err)
|
logger.Warnf("write pid file failed: %v", err)
|
||||||
|
|
@ -177,6 +202,8 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
return fmt.Errorf("singleton check failed: %w", err)
|
return fmt.Errorf("singleton check failed: %w", err)
|
||||||
}
|
}
|
||||||
defer pid.RemovePidFile(homePath)
|
defer pid.RemovePidFile(homePath)
|
||||||
|
|
||||||
|
logger.Info("✓ PID file and auth token initialized")
|
||||||
closeListeners := true
|
closeListeners := true
|
||||||
defer func() {
|
defer func() {
|
||||||
if !closeListeners {
|
if !closeListeners {
|
||||||
|
|
@ -187,17 +214,26 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating provider: %w", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
}
|
}
|
||||||
|
logger.Infof("✓ LLM Provider initialized (Model: %s)", modelID)
|
||||||
|
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
cfg.Agents.Defaults.ModelName = modelID
|
cfg.Agents.Defaults.ModelName = modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
|
||||||
agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider)
|
||||||
|
logger.Info("✓ Agent loop initialized")
|
||||||
|
|
||||||
|
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token, listenResult)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error setting up services: %w", err)
|
||||||
|
}
|
||||||
|
logger.Info("✓ Core services started")
|
||||||
|
|
||||||
fmt.Println("\n📦 Agent Status:")
|
fmt.Println("\n📦 Agent Status:")
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
|
|
@ -212,11 +248,6 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
"skills_total": skillsInfo["total"],
|
"skills_total": skillsInfo["total"],
|
||||||
"skills_available": skillsInfo["available"],
|
"skills_available": skillsInfo["available"],
|
||||||
})
|
})
|
||||||
|
|
||||||
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token, listenResult)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
closeListeners = false
|
closeListeners = false
|
||||||
|
|
||||||
// Setup manual reload channel for /reload endpoint
|
// Setup manual reload channel for /reload endpoint
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,29 @@ var protocolMetaByName = map[string]protocolMeta{
|
||||||
"longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
|
"longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
|
||||||
"modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
|
"modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
|
||||||
"mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"},
|
"mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"},
|
||||||
|
|
||||||
|
// Specialty and Custom Protocols
|
||||||
|
"anthropic": {defaultAPIBase: "https://api.anthropic.com"},
|
||||||
|
"google": {defaultAPIBase: "https://openrouter.ai/api/v1"}, // Alias for OpenRouter/OpenAI-compatible
|
||||||
|
"elevenlabs": {},
|
||||||
|
"claude-cli": {},
|
||||||
|
"codex-cli": {},
|
||||||
|
"antigravity": {},
|
||||||
|
"cli": {},
|
||||||
|
"fs": {},
|
||||||
|
"memory": {},
|
||||||
|
"dummy": {},
|
||||||
|
"openai-tts": {},
|
||||||
|
"gpt-4-v": {defaultAPIBase: "https://api.openai.com/v1"},
|
||||||
|
"gpt-4o": {defaultAPIBase: "https://api.openai.com/v1"},
|
||||||
|
"gpt-4-turbo": {defaultAPIBase: "https://api.openai.com/v1"},
|
||||||
|
"azure": {},
|
||||||
|
"azure-openai": {},
|
||||||
|
"bedrock": {},
|
||||||
|
"github-copilot": {},
|
||||||
|
"github-copilot-chat": {},
|
||||||
|
"copilot": {},
|
||||||
|
"claude": {defaultAPIBase: "https://api.anthropic.com"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||||
|
|
@ -88,15 +111,15 @@ func createCodexAuthProvider() (LLMProvider, error) {
|
||||||
// If no prefix is specified, it defaults to "openai".
|
// If no prefix is specified, it defaults to "openai".
|
||||||
// Examples:
|
// Examples:
|
||||||
// - "openai/gpt-4o" -> ("openai", "gpt-4o")
|
// - "openai/gpt-4o" -> ("openai", "gpt-4o")
|
||||||
// - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6")
|
// - "anthropic/claude-3-opus" -> ("anthropic", "claude-3-opus")
|
||||||
// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol
|
// - "gpt-4o" -> ("openai", "gpt-4o")
|
||||||
func ExtractProtocol(model string) (protocol, modelID string) {
|
func ExtractProtocol(model string) (protocol, modelID string) {
|
||||||
model = strings.TrimSpace(model)
|
model = strings.TrimSpace(model)
|
||||||
protocol, modelID, found := strings.Cut(model, "/")
|
p, m, found := strings.Cut(model, "/")
|
||||||
if !found {
|
if !found {
|
||||||
return "openai", model
|
return "openai", model
|
||||||
}
|
}
|
||||||
return protocol, modelID
|
return p, m
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveAPIBase returns the configured API base, or the protocol default when
|
// ResolveAPIBase returns the configured API base, or the protocol default when
|
||||||
|
|
@ -133,8 +156,14 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
protocol, modelID := ExtractProtocol(cfg.Model)
|
protocol, modelID := ExtractProtocol(cfg.Model)
|
||||||
if cfg.Protocol != "" {
|
if cfg.Protocol != "" {
|
||||||
protocol = cfg.Protocol
|
protocol = cfg.Protocol
|
||||||
|
// If protocol was explicitly set, modelID should be the full model string
|
||||||
|
// unless it was already prefixed with the SAME protocol.
|
||||||
|
if p, m, found := strings.Cut(cfg.Model, "/"); found && strings.EqualFold(p, protocol) {
|
||||||
|
modelID = m
|
||||||
|
} else {
|
||||||
modelID = cfg.Model
|
modelID = cfg.Model
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
userAgent := cfg.UserAgent
|
userAgent := cfg.UserAgent
|
||||||
if userAgent == "" {
|
if userAgent == "" {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@ func (p *HTTPProvider) GetDefaultModel() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *HTTPProvider) SupportsThinking() bool {
|
||||||
|
return p.delegate.SupportsThinking()
|
||||||
|
}
|
||||||
|
|
||||||
func (p *HTTPProvider) SupportsNativeSearch() bool {
|
func (p *HTTPProvider) SupportsNativeSearch() bool {
|
||||||
return p.delegate.SupportsNativeSearch()
|
return p.delegate.SupportsNativeSearch()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,32 @@ func (p *Provider) buildRequestBody(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thinking support for OpenRouter
|
||||||
|
if level, ok := options["thinking_level"].(string); ok && level != "" && level != "off" {
|
||||||
|
if u, err := url.Parse(p.apiBase); err == nil && u.Hostname() == "openrouter.ai" {
|
||||||
|
// Map level to budget tokens (using same scale as Anthropic)
|
||||||
|
budget := 0
|
||||||
|
switch level {
|
||||||
|
case "low":
|
||||||
|
budget = 4096
|
||||||
|
case "medium":
|
||||||
|
budget = 16384
|
||||||
|
case "high":
|
||||||
|
budget = 32000
|
||||||
|
case "xhigh":
|
||||||
|
budget = 64000
|
||||||
|
}
|
||||||
|
if budget > 0 {
|
||||||
|
requestBody["thinking"] = map[string]any{
|
||||||
|
"type": "enabled",
|
||||||
|
"budget_tokens": budget,
|
||||||
|
}
|
||||||
|
// Remove temperature when thinking is enabled (strict requirement for some models)
|
||||||
|
delete(requestBody, "temperature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Merge extra body fields configured per-provider/model.
|
// Merge extra body fields configured per-provider/model.
|
||||||
// These are injected last so they take precedence over defaults.
|
// These are injected last so they take precedence over defaults.
|
||||||
maps.Copy(requestBody, p.extraBody)
|
maps.Copy(requestBody, p.extraBody)
|
||||||
|
|
@ -466,6 +492,16 @@ func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Provider) SupportsThinking() bool {
|
||||||
|
// Enable thinking support for OpenRouter.
|
||||||
|
// OpenRouter acts as a passthrough for Anthropic and DeepSeek-R1 models that support thinking.
|
||||||
|
u, err := url.Parse(p.apiBase)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return u.Hostname() == "openrouter.ai"
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Provider) SupportsNativeSearch() bool {
|
func (p *Provider) SupportsNativeSearch() bool {
|
||||||
return isNativeSearchHost(p.apiBase)
|
return isNativeSearchHost(p.apiBase)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ type ExecTool struct {
|
||||||
allowPatterns []*regexp.Regexp
|
allowPatterns []*regexp.Regexp
|
||||||
customAllowPatterns []*regexp.Regexp
|
customAllowPatterns []*regexp.Regexp
|
||||||
allowedPathPatterns []*regexp.Regexp
|
allowedPathPatterns []*regexp.Regexp
|
||||||
|
denyWritePaths []*regexp.Regexp
|
||||||
restrictToWorkspace bool
|
restrictToWorkspace bool
|
||||||
allowRemote bool
|
allowRemote bool
|
||||||
sessionManager *SessionManager
|
sessionManager *SessionManager
|
||||||
|
|
@ -115,7 +116,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) {
|
func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) {
|
||||||
return NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...)
|
return NewExecToolWithDenyPaths(workingDir, restrict, allowPaths, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExecToolWithConfig(
|
func NewExecToolWithConfig(
|
||||||
|
|
@ -123,6 +124,16 @@ func NewExecToolWithConfig(
|
||||||
restrict bool,
|
restrict bool,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
allowPaths ...[]*regexp.Regexp,
|
allowPaths ...[]*regexp.Regexp,
|
||||||
|
) (*ExecTool, error) {
|
||||||
|
return NewExecToolWithDenyPaths(workingDir, restrict, allowPaths, nil, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecToolWithDenyPaths(
|
||||||
|
workingDir string,
|
||||||
|
restrict bool,
|
||||||
|
allowPaths [][]*regexp.Regexp,
|
||||||
|
denyWritePaths []*regexp.Regexp,
|
||||||
|
cfg *config.Config,
|
||||||
) (*ExecTool, error) {
|
) (*ExecTool, error) {
|
||||||
denyPatterns := make([]*regexp.Regexp, 0)
|
denyPatterns := make([]*regexp.Regexp, 0)
|
||||||
customAllowPatterns := make([]*regexp.Regexp, 0)
|
customAllowPatterns := make([]*regexp.Regexp, 0)
|
||||||
|
|
@ -149,7 +160,6 @@ func NewExecToolWithConfig(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
|
|
||||||
fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
|
fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
|
||||||
}
|
}
|
||||||
for _, pattern := range execConfig.CustomAllowPatterns {
|
for _, pattern := range execConfig.CustomAllowPatterns {
|
||||||
|
|
@ -175,6 +185,7 @@ func NewExecToolWithConfig(
|
||||||
allowPatterns: nil,
|
allowPatterns: nil,
|
||||||
customAllowPatterns: customAllowPatterns,
|
customAllowPatterns: customAllowPatterns,
|
||||||
allowedPathPatterns: allowedPathPatterns,
|
allowedPathPatterns: allowedPathPatterns,
|
||||||
|
denyWritePaths: denyWritePaths,
|
||||||
restrictToWorkspace: restrict,
|
restrictToWorkspace: restrict,
|
||||||
allowRemote: allowRemote,
|
allowRemote: allowRemote,
|
||||||
sessionManager: getSessionManager(),
|
sessionManager: getSessionManager(),
|
||||||
|
|
@ -1038,6 +1049,45 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
return "Command blocked by safety guard (dangerous pattern detected)"
|
return "Command blocked by safety guard (dangerous pattern detected)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check deny write paths - block commands that write to protected directories
|
||||||
|
if len(t.denyWritePaths) > 0 {
|
||||||
|
words := strings.Fields(cmd)
|
||||||
|
for i, word := range words {
|
||||||
|
// Skip flags but check their argument (next word)
|
||||||
|
if word == "-p" || word == "-rf" || word == "-r" || word == "-f" || word == "-d" {
|
||||||
|
// Check the next word as the actual path
|
||||||
|
if i+1 < len(words) {
|
||||||
|
nextWord := words[i+1]
|
||||||
|
for _, pattern := range t.denyWritePaths {
|
||||||
|
if pattern.MatchString(nextWord) {
|
||||||
|
return fmt.Sprintf("Command blocked: cannot write to %s (access denied)", nextWord)
|
||||||
|
}
|
||||||
|
// Also check path components
|
||||||
|
pathParts := strings.Split(nextWord, "/")
|
||||||
|
for _, part := range pathParts {
|
||||||
|
if pattern.MatchString(part) {
|
||||||
|
return fmt.Sprintf("Command blocked: cannot write to %s (access denied)", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, pattern := range t.denyWritePaths {
|
||||||
|
if pattern.MatchString(word) {
|
||||||
|
return fmt.Sprintf("Command blocked: cannot write to %s (access denied)", word)
|
||||||
|
}
|
||||||
|
// Also check path components like "skills" in "mkdir -p skills/my_skill"
|
||||||
|
pathParts := strings.Split(word, "/")
|
||||||
|
for _, part := range pathParts {
|
||||||
|
if pattern.MatchString(part) {
|
||||||
|
return fmt.Sprintf("Command blocked: cannot write to %s (access denied)", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.allowPatterns) > 0 {
|
if len(t.allowPatterns) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -1613,3 +1614,54 @@ func TestEncodeKeyTokenWithPtyKeyMode(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShellTool_DenyWritePaths(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
command string
|
||||||
|
denyPaths []*regexp.Regexp
|
||||||
|
expectBlock bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "mkdir blocked",
|
||||||
|
command: "mkdir -p skills",
|
||||||
|
denyPaths: []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)},
|
||||||
|
expectBlock: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mkdir -p blocked",
|
||||||
|
command: "mkdir -p skills/my_skill",
|
||||||
|
denyPaths: []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)},
|
||||||
|
expectBlock: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mkdir allowed",
|
||||||
|
command: "mkdir -p workspace/data",
|
||||||
|
denyPaths: []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)},
|
||||||
|
expectBlock: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "touch skills file blocked",
|
||||||
|
command: "touch skills/test.txt",
|
||||||
|
denyPaths: []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)},
|
||||||
|
expectBlock: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tool, err := NewExecToolWithDenyPaths("", false, nil, tt.denyPaths, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"action": "run",
|
||||||
|
"command": tt.command,
|
||||||
|
})
|
||||||
|
if tt.expectBlock {
|
||||||
|
require.True(t, result.IsError, "expected block for command: %s", tt.command)
|
||||||
|
require.Contains(t, result.ForLLM, "access denied")
|
||||||
|
} else {
|
||||||
|
require.False(t, result.IsError, "expected allow for command: %s, got: %s", tt.command, result.ForLLM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
124
test_results.txt
Normal file
124
test_results.txt
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# v3.466
|
||||||
|
no required module provides package v3.466; to add it:
|
||||||
|
go get v3.466
|
||||||
|
FAIL v3.466 [setup failed]
|
||||||
|
# Balancing
|
||||||
|
package Balancing is not in std (/usr/local/go/src/Balancing)
|
||||||
|
FAIL Balancing [setup failed]
|
||||||
|
# Makefile
|
||||||
|
package Makefile is not in std (/usr/local/go/src/Makefile)
|
||||||
|
FAIL Makefile [setup failed]
|
||||||
|
# across
|
||||||
|
package across is not in std (/usr/local/go/src/across)
|
||||||
|
FAIL across [setup failed]
|
||||||
|
# components.
|
||||||
|
malformed import path "components.": trailing dot in path element
|
||||||
|
FAIL components. [setup failed]
|
||||||
|
? github.com/sipeed/picoclaw/cmd/freeride-diag [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/membench (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron (cached)
|
||||||
|
? github.com/sipeed/picoclaw/cmd/picoclaw/internal/freeride [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/model (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/status (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/version (cached)
|
||||||
|
? github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui [no test files]
|
||||||
|
? github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config [no test files]
|
||||||
|
? github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui [no test files]
|
||||||
|
? github.com/sipeed/picoclaw/examples/pico-echo-server [no test files]
|
||||||
|
? github.com/sipeed/picoclaw/pkg [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/agent (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/audio (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/audio/asr (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/audio/tts (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/auth (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/bus (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/dingtalk (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/discord (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/feishu (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/irc (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/line (cached)
|
||||||
|
? github.com/sipeed/picoclaw/pkg/channels/maixcam [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/matrix (cached)
|
||||||
|
? github.com/sipeed/picoclaw/pkg/channels/onebot [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/pico (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/qq (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/slack (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/teams_webhook (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/telegram (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/vk (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/wecom (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/weixin (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/channels/whatsapp (cached)
|
||||||
|
? github.com/sipeed/picoclaw/pkg/channels/whatsapp_native [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/commands (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/config (cached)
|
||||||
|
? github.com/sipeed/picoclaw/pkg/constants [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/credential (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/cron (cached)
|
||||||
|
? github.com/sipeed/picoclaw/pkg/devices [no test files]
|
||||||
|
? github.com/sipeed/picoclaw/pkg/devices/events [no test files]
|
||||||
|
? github.com/sipeed/picoclaw/pkg/devices/sources [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/fileutil (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/gateway (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/health (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/heartbeat (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/identity (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/isolation (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/logger (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/mcp (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/media (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/memory (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/migrate (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/migrate/internal (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/migrate/sources/openclaw (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/netbind (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/pid (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/anthropic (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/anthropic_messages (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/azure (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/bedrock (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/cli (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/common (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/httpapi (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/oauth (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/openai_compat (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/providers/openai_responses_common (cached)
|
||||||
|
? github.com/sipeed/picoclaw/pkg/providers/protocoltypes [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/routing (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/seahorse (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/security (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/security/behavior (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/security/canary (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/security/ipia (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/security/pii (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/security/policy (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/session (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/skills (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/state (cached)
|
||||||
|
? github.com/sipeed/picoclaw/pkg/tokenizer [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/tools (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/tools/fs (cached)
|
||||||
|
? github.com/sipeed/picoclaw/pkg/tools/hardware [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/tools/integration (cached)
|
||||||
|
? github.com/sipeed/picoclaw/pkg/tools/shared [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/updater (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/pkg/utils (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/web/backend (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/web/backend/api (cached)
|
||||||
|
? github.com/sipeed/picoclaw/web/backend/dashboardauth [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/web/backend/launcherconfig (cached)
|
||||||
|
ok github.com/sipeed/picoclaw/web/backend/middleware (cached)
|
||||||
|
? github.com/sipeed/picoclaw/web/backend/model [no test files]
|
||||||
|
ok github.com/sipeed/picoclaw/web/backend/utils (cached)
|
||||||
|
FAIL
|
||||||
Loading…
Add table
Reference in a new issue