From 792639d8134c2fb37f23ca896219e11c2ae13ddf Mon Sep 17 00:00:00 2001 From: RinZ27 <222222878+RinZ27@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:18:13 +0700 Subject: [PATCH 1/7] Enforce workspace boundaries with configurable restriction option Implemented a unified path validation helper to ensure filesystem operations stay within the designated workspace. This now supports a 'restrict_to_workspace' option in config.json (enabled by default) to allow flexibility for specific environments while maintaining a secure default posture. I've updated read_file, write_file, list_dir, append_file, edit_file, and exec tools to respect this setting and included tests for both restricted and unrestricted modes. --- config.example.json | 1 + pkg/agent/loop.go | 13 +++-- pkg/config/config.go | 2 + pkg/tools/edit.go | 47 +++++++----------- pkg/tools/filesystem.go | 79 ++++++++++++++++++++++++++++--- pkg/tools/filesystem_test.go | 92 ++++++++++++++++++++++++++++++++++++ pkg/tools/shell.go | 4 +- 7 files changed, 195 insertions(+), 43 deletions(-) create mode 100644 pkg/tools/filesystem_test.go diff --git a/config.example.json b/config.example.json index bc5c2bb..01dd726 100644 --- a/config.example.json +++ b/config.example.json @@ -2,6 +2,7 @@ "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true, "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d38848b..8cc317a 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -38,11 +38,13 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers workspace := cfg.WorkspacePath() os.MkdirAll(workspace, 0755) + restrict := cfg.Agents.Defaults.RestrictToWorkspace + toolsRegistry := tools.NewToolRegistry() - toolsRegistry.Register(&tools.ReadFileTool{}) - toolsRegistry.Register(&tools.WriteFileTool{}) - toolsRegistry.Register(&tools.ListDirTool{}) - toolsRegistry.Register(tools.NewExecTool(workspace)) + toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict)) + toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict)) + toolsRegistry.Register(tools.NewListDirTool(workspace, restrict)) + toolsRegistry.Register(tools.NewExecTool(workspace, restrict)) braveAPIKey := cfg.Tools.Web.Search.APIKey toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults)) @@ -66,8 +68,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers toolsRegistry.Register(spawnTool) // Register edit file tool - editFileTool := tools.NewEditFileTool(workspace) + editFileTool := tools.NewEditFileTool(workspace, restrict) toolsRegistry.Register(editFileTool) + toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) sessionsManager := session.NewSessionManager(filepath.Join(filepath.Dir(cfg.WorkspacePath()), "sessions")) diff --git a/pkg/config/config.go b/pkg/config/config.go index 5b9c2b5..ed31fbe 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,6 +24,7 @@ type AgentsConfig struct { type AgentDefaults struct { Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` @@ -126,6 +127,7 @@ func DefaultConfig() *Config { Agents: AgentsConfig{ Defaults: AgentDefaults{ Workspace: "~/.picoclaw/workspace", + RestrictToWorkspace: true, Model: "glm-4.7", MaxTokens: 8192, Temperature: 0.7, diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index 339148e..f3632ad 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -4,20 +4,21 @@ import ( "context" "fmt" "os" - "path/filepath" "strings" ) // EditFileTool edits a file by replacing old_text with new_text. // The old_text must exist exactly in the file. type EditFileTool struct { - allowedDir string // Optional directory restriction for security + allowedDir string + restrict bool } // NewEditFileTool creates a new EditFileTool with optional directory restriction. -func NewEditFileTool(allowedDir string) *EditFileTool { +func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool { return &EditFileTool{ allowedDir: allowedDir, + restrict: restrict, } } @@ -66,27 +67,9 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) return "", fmt.Errorf("new_text is required") } - // Resolve path and enforce directory restriction if configured - resolvedPath := path - if filepath.IsAbs(path) { - resolvedPath = filepath.Clean(path) - } else { - abs, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("failed to resolve path: %w", err) - } - resolvedPath = abs - } - - // Check directory restriction - if t.allowedDir != "" { - allowedAbs, err := filepath.Abs(t.allowedDir) - if err != nil { - return "", fmt.Errorf("failed to resolve allowed directory: %w", err) - } - if !strings.HasPrefix(resolvedPath, allowedAbs) { - return "", fmt.Errorf("path %s is outside allowed directory %s", path, t.allowedDir) - } + resolvedPath, err := validatePath(path, t.allowedDir, t.restrict) + if err != nil { + return "", err } if _, err := os.Stat(resolvedPath); os.IsNotExist(err) { @@ -118,10 +101,13 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) return fmt.Sprintf("Successfully edited %s", path), nil } -type AppendFileTool struct{} +type AppendFileTool struct { + workspace string + restrict bool +} -func NewAppendFileTool() *AppendFileTool { - return &AppendFileTool{} +func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool { + return &AppendFileTool{workspace: workspace, restrict: restrict} } func (t *AppendFileTool) Name() string { @@ -160,9 +146,12 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{ return "", fmt.Errorf("content is required") } - filePath := filepath.Clean(path) + resolvedPath, err := validatePath(path, t.workspace, t.restrict) + if err != nil { + return "", err + } - f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return "", fmt.Errorf("failed to open file: %w", err) } diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 721eb7f..8cfa6f5 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -5,9 +5,45 @@ import ( "fmt" "os" "path/filepath" + "strings" ) -type ReadFileTool struct{} +// validatePath ensures the given path is within the workspace if restrict is true. +func validatePath(path, workspace string, restrict bool) (string, error) { + if workspace == "" { + return path, nil + } + + absWorkspace, err := filepath.Abs(workspace) + if err != nil { + return "", fmt.Errorf("failed to resolve workspace path: %w", err) + } + + var absPath string + if filepath.IsAbs(path) { + absPath = filepath.Clean(path) + } else { + absPath, err = filepath.Abs(filepath.Join(absWorkspace, path)) + if err != nil { + return "", fmt.Errorf("failed to resolve file path: %w", err) + } + } + + if restrict && !strings.HasPrefix(absPath, absWorkspace) { + return "", fmt.Errorf("access denied: path is outside the workspace") + } + + return absPath, nil +} + +type ReadFileTool struct { + workspace string + restrict bool +} + +func NewReadFileTool(workspace string, restrict bool) *ReadFileTool { + return &ReadFileTool{workspace: workspace, restrict: restrict} +} func (t *ReadFileTool) Name() string { return "read_file" @@ -36,7 +72,12 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) return "", fmt.Errorf("path is required") } - content, err := os.ReadFile(path) + resolvedPath, err := validatePath(path, t.workspace, t.restrict) + if err != nil { + return "", err + } + + content, err := os.ReadFile(resolvedPath) if err != nil { return "", fmt.Errorf("failed to read file: %w", err) } @@ -44,7 +85,14 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) return string(content), nil } -type WriteFileTool struct{} +type WriteFileTool struct { + workspace string + restrict bool +} + +func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool { + return &WriteFileTool{workspace: workspace, restrict: restrict} +} func (t *WriteFileTool) Name() string { return "write_file" @@ -82,19 +130,31 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{} return "", fmt.Errorf("content is required") } - dir := filepath.Dir(path) + resolvedPath, err := validatePath(path, t.workspace, t.restrict) + if err != nil { + return "", err + } + + dir := filepath.Dir(resolvedPath) if err := os.MkdirAll(dir, 0755); err != nil { return "", fmt.Errorf("failed to create directory: %w", err) } - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + if err := os.WriteFile(resolvedPath, []byte(content), 0644); err != nil { return "", fmt.Errorf("failed to write file: %w", err) } return "File written successfully", nil } -type ListDirTool struct{} +type ListDirTool struct { + workspace string + restrict bool +} + +func NewListDirTool(workspace string, restrict bool) *ListDirTool { + return &ListDirTool{workspace: workspace, restrict: restrict} +} func (t *ListDirTool) Name() string { return "list_dir" @@ -123,7 +183,12 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) path = "." } - entries, err := os.ReadDir(path) + resolvedPath, err := validatePath(path, t.workspace, t.restrict) + if err != nil { + return "", err + } + + entries, err := os.ReadDir(resolvedPath) if err != nil { return "", fmt.Errorf("failed to read directory: %w", err) } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go new file mode 100644 index 0000000..a4eacc1 --- /dev/null +++ b/pkg/tools/filesystem_test.go @@ -0,0 +1,92 @@ +package tools + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidatePath(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "picoclaw-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + workspace := filepath.Join(tmpDir, "workspace") + os.MkdirAll(workspace, 0755) + + tests := []struct { + name string + path string + workspace string + restrict bool + wantErr bool + }{ + { + name: "Valid relative path", + path: "test.txt", + workspace: workspace, + restrict: true, + wantErr: false, + }, + { + name: "Valid nested path", + path: "dir/test.txt", + workspace: workspace, + restrict: true, + wantErr: false, + }, + { + name: "Path traversal attempt (restricted)", + path: "../test.txt", + workspace: workspace, + restrict: true, + wantErr: true, + }, + { + name: "Path traversal attempt (unrestricted)", + path: "../test.txt", + workspace: workspace, + restrict: false, + wantErr: false, + }, + { + name: "Absolute path inside workspace", + path: filepath.Join(workspace, "test.txt"), + workspace: workspace, + restrict: true, + wantErr: false, + }, + { + name: "Absolute path outside workspace (restricted)", + path: "/etc/passwd", + workspace: workspace, + restrict: true, + wantErr: true, + }, + { + name: "Absolute path outside workspace (unrestricted)", + path: "/etc/passwd", + workspace: workspace, + restrict: false, + wantErr: false, + }, + { + name: "Empty workspace (no restriction)", + path: "/etc/passwd", + workspace: "", + restrict: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validatePath(tt.path, tt.workspace, tt.restrict) + if (err != nil) != tt.wantErr { + t.Errorf("validatePath() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index d8aea40..cddbcdb 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -20,7 +20,7 @@ type ExecTool struct { restrictToWorkspace bool } -func NewExecTool(workingDir string) *ExecTool { +func NewExecTool(workingDir string, restrict bool) *ExecTool { denyPatterns := []*regexp.Regexp{ regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), regexp.MustCompile(`\bdel\s+/[fq]\b`), @@ -37,7 +37,7 @@ func NewExecTool(workingDir string) *ExecTool { timeout: 60 * time.Second, denyPatterns: denyPatterns, allowPatterns: nil, - restrictToWorkspace: false, + restrictToWorkspace: restrict, } } From 481eee672e3f8e1fdb06f4e7081981d97d56e03b Mon Sep 17 00:00:00 2001 From: Diegox-17 Date: Thu, 12 Feb 2026 00:42:40 -0600 Subject: [PATCH 2/7] Fix LLM error by cleaning up CONSCIOUSLY message history Added logic to remove orphaned tool messages from history to prevent LLM errors. --- pkg/agent/context.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index e737fbd..e32e456 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -189,6 +189,17 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary } + //This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM + // --- INICIO DEL FIX --- + //Diegox-17 + for len(history) > 0 && (history[0].Role == "tool") { + logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error", + map[string]interface{}{"role": history[0].Role}) + history = history[1:] + } + //Diegox-17 + // --- FIN DEL FIX --- + messages = append(messages, providers.Message{ Role: "system", Content: systemPrompt, From 8661d544066b217ca5f6aca9e0e852aa658a4413 Mon Sep 17 00:00:00 2001 From: lxowalle Date: Thu, 12 Feb 2026 21:58:40 +0800 Subject: [PATCH 3/7] * Delete unused file --- pkg/tools/filesystem_test.go | 92 ------------------------------------ 1 file changed, 92 deletions(-) delete mode 100644 pkg/tools/filesystem_test.go diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go deleted file mode 100644 index a4eacc1..0000000 --- a/pkg/tools/filesystem_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package tools - -import ( - "os" - "path/filepath" - "testing" -) - -func TestValidatePath(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "picoclaw-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - workspace := filepath.Join(tmpDir, "workspace") - os.MkdirAll(workspace, 0755) - - tests := []struct { - name string - path string - workspace string - restrict bool - wantErr bool - }{ - { - name: "Valid relative path", - path: "test.txt", - workspace: workspace, - restrict: true, - wantErr: false, - }, - { - name: "Valid nested path", - path: "dir/test.txt", - workspace: workspace, - restrict: true, - wantErr: false, - }, - { - name: "Path traversal attempt (restricted)", - path: "../test.txt", - workspace: workspace, - restrict: true, - wantErr: true, - }, - { - name: "Path traversal attempt (unrestricted)", - path: "../test.txt", - workspace: workspace, - restrict: false, - wantErr: false, - }, - { - name: "Absolute path inside workspace", - path: filepath.Join(workspace, "test.txt"), - workspace: workspace, - restrict: true, - wantErr: false, - }, - { - name: "Absolute path outside workspace (restricted)", - path: "/etc/passwd", - workspace: workspace, - restrict: true, - wantErr: true, - }, - { - name: "Absolute path outside workspace (unrestricted)", - path: "/etc/passwd", - workspace: workspace, - restrict: false, - wantErr: false, - }, - { - name: "Empty workspace (no restriction)", - path: "/etc/passwd", - workspace: "", - restrict: true, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := validatePath(tt.path, tt.workspace, tt.restrict) - if (err != nil) != tt.wantErr { - t.Errorf("validatePath() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} From 0661d0554172181c75961a88d7e8b63b1c43efff Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:28:27 +0800 Subject: [PATCH 4/7] ci: add build check workflow (#71) Signed-off-by: Guoguo --- .github/workflows/build.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..aad0f32 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,21 @@ +name: build + +on: + push: + branches: ["main"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + run: make build-all From 1d143fa10aef1e0a6ddf2112023fa7989d605d5c Mon Sep 17 00:00:00 2001 From: mxrain Date: Thu, 12 Feb 2026 13:45:45 +0800 Subject: [PATCH 5/7] feat: add Telegram proxy support and flexible allow_from matching - Add proxy config field for Telegram channel to support HTTP/SOCKS proxies - Use telego.WithHTTPClient to route all Telegram API requests through proxy - Add FlexibleStringSlice type so allow_from accepts both strings and numbers - Improve IsAllowed to match numeric ID, username, and @username formats - Update config.example.json with proxy field --- config.example.json | 1 + pkg/channels/base.go | 13 ++++- pkg/channels/telegram.go | 18 ++++++- pkg/config/config.go | 103 ++++++++++++++++++++++++++------------- 4 files changed, 99 insertions(+), 36 deletions(-) diff --git a/config.example.json b/config.example.json index 99348e9..aaaf296 100644 --- a/config.example.json +++ b/config.example.json @@ -13,6 +13,7 @@ "telegram": { "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", + "proxy": "", "allow_from": ["YOUR_USER_ID"] }, "discord": { diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 3ade400..fabec1a 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -3,6 +3,7 @@ package channels import ( "context" "fmt" + "strings" "github.com/sipeed/picoclaw/pkg/bus" ) @@ -47,8 +48,18 @@ func (c *BaseChannel) IsAllowed(senderID string) bool { return true } + // Extract parts from compound senderID like "123456|username" + idPart := senderID + userPart := "" + if idx := strings.Index(senderID, "|"); idx > 0 { + idPart = senderID[:idx] + userPart = senderID[idx+1:] + } + for _, allowed := range c.allowList { - if senderID == allowed { + // Strip leading "@" from allowed value for username matching + trimmed := strings.TrimPrefix(allowed, "@") + if senderID == allowed || idPart == allowed || senderID == trimmed || idPart == trimmed || (userPart != "" && (userPart == allowed || userPart == trimmed)) { return true } } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 73a4290..3ad4818 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -3,6 +3,8 @@ package channels import ( "context" "fmt" + "net/http" + "net/url" "os" "regexp" "strings" @@ -40,7 +42,21 @@ func (c *thinkingCancel) Cancel() { } func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) { - bot, err := telego.NewBot(cfg.Token) + var opts []telego.BotOption + + if cfg.Proxy != "" { + proxyURL, parseErr := url.Parse(cfg.Proxy) + if parseErr != nil { + return nil, fmt.Errorf("invalid proxy URL %q: %w", cfg.Proxy, parseErr) + } + opts = append(opts, telego.WithHTTPClient(&http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + }, + })) + } + + bot, err := telego.NewBot(cfg.Token, opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) } diff --git a/pkg/config/config.go b/pkg/config/config.go index bc1451f..7f06999 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "fmt" "os" "path/filepath" "sync" @@ -9,6 +10,39 @@ import ( "github.com/caarlos0/env/v11" ) +// FlexibleStringSlice is a []string that also accepts JSON numbers, +// so allow_from can contain both "123" and 123. +type FlexibleStringSlice []string + +func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { + // Try []string first + var ss []string + if err := json.Unmarshal(data, &ss); err == nil { + *f = ss + return nil + } + + // Try []interface{} to handle mixed types + var raw []interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + result := make([]string, 0, len(raw)) + for _, v := range raw { + switch val := v.(type) { + case string: + result = append(result, val) + case float64: + result = append(result, fmt.Sprintf("%.0f", val)) + default: + result = append(result, fmt.Sprintf("%v", val)) + } + } + *f = result + return nil +} + type Config struct { Agents AgentsConfig `json:"agents"` Channels ChannelsConfig `json:"channels"` @@ -44,51 +78,52 @@ type ChannelsConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` - VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` } type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` + Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` - ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` } type SlackConfig struct { @@ -149,12 +184,12 @@ func DefaultConfig() *Config { WhatsApp: WhatsAppConfig{ Enabled: false, BridgeURL: "ws://localhost:3001", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, Telegram: TelegramConfig{ Enabled: false, Token: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, Feishu: FeishuConfig{ Enabled: false, @@ -162,30 +197,30 @@ func DefaultConfig() *Config { AppSecret: "", EncryptKey: "", VerificationToken: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, Discord: DiscordConfig{ Enabled: false, Token: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, MaixCam: MaixCamConfig{ Enabled: false, Host: "0.0.0.0", Port: 18790, - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, QQ: QQConfig{ Enabled: false, AppID: "", AppSecret: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, DingTalk: DingTalkConfig{ Enabled: false, ClientID: "", ClientSecret: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, Slack: SlackConfig{ Enabled: false, From 53c69ae41e4d118f279e029fb134dc2ace3a23e1 Mon Sep 17 00:00:00 2001 From: mxrain Date: Thu, 12 Feb 2026 14:21:04 +0800 Subject: [PATCH 6/7] fix: use cmd /c on Windows for shell command execution The exec tool was hardcoded to use 'sh -c' which doesn't exist on Windows, causing all tool calls to fail silently in gateway mode. --- pkg/tools/shell.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index cddbcdb..9e5d03c 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strings" "time" ) @@ -27,7 +28,7 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool { regexp.MustCompile(`\brmdir\s+/s\b`), regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args) regexp.MustCompile(`\bdd\s+if=`), - regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) + regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), } @@ -91,7 +92,12 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) defer cancel() - cmd := exec.CommandContext(cmdCtx, "sh", "-c", command) + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(cmdCtx, "cmd", "/c", command) + } else { + cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) + } if cwd != "" { cmd.Dir = cwd } From a9a7a89c079126625e08e6f4890d2c34a58560a2 Mon Sep 17 00:00:00 2001 From: mxrain Date: Thu, 12 Feb 2026 18:27:49 +0800 Subject: [PATCH 7/7] feat: add Moonshot/Kimi and NVIDIA provider support with proxy --- config.example.json | 9 ++ pkg/config/config.go | 11 +- pkg/providers/http_provider.go | 183 +++++++++++++++++++++------------ 3 files changed, 134 insertions(+), 69 deletions(-) diff --git a/config.example.json b/config.example.json index aaaf296..ed5cb70 100644 --- a/config.example.json +++ b/config.example.json @@ -81,6 +81,15 @@ "vllm": { "api_key": "", "api_base": "" + }, + "nvidia": { + "api_key": "nvapi-xxx", + "api_base": "", + "proxy": "http://127.0.0.1:7890" + }, + "moonshot": { + "api_key": "sk-xxx", + "api_base": "" } }, "tools": { diff --git a/pkg/config/config.go b/pkg/config/config.go index 7f06999..56f1e19 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -127,9 +127,9 @@ type DingTalkConfig struct { } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` } @@ -141,11 +141,14 @@ type ProvidersConfig struct { Zhipu ProviderConfig `json:"zhipu"` VLLM ProviderConfig `json:"vllm"` Gemini ProviderConfig `json:"gemini"` + Nvidia ProviderConfig `json:"nvidia"` + Moonshot ProviderConfig `json:"moonshot"` } type ProviderConfig struct { APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` } @@ -237,6 +240,8 @@ func DefaultConfig() *Config { Zhipu: ProviderConfig{}, VLLM: ProviderConfig{}, Gemini: ProviderConfig{}, + Nvidia: ProviderConfig{}, + Moonshot: ProviderConfig{}, }, Gateway: GatewayConfig{ Host: "0.0.0.0", diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index e982e09..b2539a1 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "github.com/sipeed/picoclaw/pkg/auth" @@ -25,13 +26,24 @@ type HTTPProvider struct { httpClient *http.Client } -func NewHTTPProvider(apiKey, apiBase string) *HTTPProvider { +func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { + client := &http.Client{ + Timeout: 0, + } + + if proxy != "" { + proxyURL, err := url.Parse(proxy) + if err == nil { + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + } + } + return &HTTPProvider{ - apiKey: apiKey, - apiBase: apiBase, - httpClient: &http.Client{ - Timeout: 0, - }, + apiKey: apiKey, + apiBase: apiBase, + httpClient: client, } } @@ -40,6 +52,14 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too return nil, fmt.Errorf("API base not configured") } + // Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5) + if idx := strings.Index(model, "/"); idx != -1 { + prefix := model[:idx] + if prefix == "moonshot" || prefix == "nvidia" { + model = model[idx+1:] + } + } + requestBody := map[string]interface{}{ "model": model, "messages": messages, @@ -60,7 +80,13 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too } if temperature, ok := options["temperature"].(float64); ok { - requestBody["temperature"] = temperature + lowerModel := strings.ToLower(model) + // Kimi k2 models only support temperature=1 + if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { + requestBody["temperature"] = 1.0 + } else { + requestBody["temperature"] = temperature + } } jsonData, err := json.Marshal(requestBody) @@ -196,7 +222,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { model := cfg.Agents.Defaults.Model providerName := strings.ToLower(cfg.Agents.Defaults.Provider) - var apiKey, apiBase string + var apiKey, apiBase, proxy string lowerModel := strings.ToLower(model) @@ -268,72 +294,97 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { // Fallback: detect provider from model name if apiKey == "" && apiBase == "" { - switch { case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"): - apiKey = cfg.Providers.OpenRouter.APIKey - if cfg.Providers.OpenRouter.APIBase != "" { - apiBase = cfg.Providers.OpenRouter.APIBase - } else { - apiBase = "https://openrouter.ai/api/v1" - } + switch { + case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": + apiKey = cfg.Providers.Moonshot.APIKey + apiBase = cfg.Providers.Moonshot.APIBase + proxy = cfg.Providers.Moonshot.Proxy + if apiBase == "" { + apiBase = "https://api.moonshot.cn/v1" + } - case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - return createClaudeAuthProvider() - } - apiKey = cfg.Providers.Anthropic.APIKey - apiBase = cfg.Providers.Anthropic.APIBase - if apiBase == "" { - apiBase = "https://api.anthropic.com/v1" - } - - case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() - } - apiKey = cfg.Providers.OpenAI.APIKey - apiBase = cfg.Providers.OpenAI.APIBase - if apiBase == "" { - apiBase = "https://api.openai.com/v1" - } - - case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": - apiKey = cfg.Providers.Gemini.APIKey - apiBase = cfg.Providers.Gemini.APIBase - if apiBase == "" { - apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - - case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": - apiKey = cfg.Providers.Zhipu.APIKey - apiBase = cfg.Providers.Zhipu.APIBase - if apiBase == "" { - apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - - case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": - apiKey = cfg.Providers.Groq.APIKey - apiBase = cfg.Providers.Groq.APIBase - if apiBase == "" { - apiBase = "https://api.groq.com/openai/v1" - } - - case cfg.Providers.VLLM.APIBase != "": - apiKey = cfg.Providers.VLLM.APIKey - apiBase = cfg.Providers.VLLM.APIBase - - default: - if cfg.Providers.OpenRouter.APIKey != "" { + case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"): apiKey = cfg.Providers.OpenRouter.APIKey + proxy = cfg.Providers.OpenRouter.Proxy if cfg.Providers.OpenRouter.APIBase != "" { apiBase = cfg.Providers.OpenRouter.APIBase } else { apiBase = "https://openrouter.ai/api/v1" } - } else { - return nil, fmt.Errorf("no API key configured for model: %s", model) + + case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + return createClaudeAuthProvider() + } + apiKey = cfg.Providers.Anthropic.APIKey + apiBase = cfg.Providers.Anthropic.APIBase + proxy = cfg.Providers.Anthropic.Proxy + if apiBase == "" { + apiBase = "https://api.anthropic.com/v1" + } + + case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): + if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { + return createCodexAuthProvider() + } + apiKey = cfg.Providers.OpenAI.APIKey + apiBase = cfg.Providers.OpenAI.APIBase + proxy = cfg.Providers.OpenAI.Proxy + if apiBase == "" { + apiBase = "https://api.openai.com/v1" + } + + case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": + apiKey = cfg.Providers.Gemini.APIKey + apiBase = cfg.Providers.Gemini.APIBase + proxy = cfg.Providers.Gemini.Proxy + if apiBase == "" { + apiBase = "https://generativelanguage.googleapis.com/v1beta" + } + + case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": + apiKey = cfg.Providers.Zhipu.APIKey + apiBase = cfg.Providers.Zhipu.APIBase + proxy = cfg.Providers.Zhipu.Proxy + if apiBase == "" { + apiBase = "https://open.bigmodel.cn/api/paas/v4" + } + + case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": + apiKey = cfg.Providers.Groq.APIKey + apiBase = cfg.Providers.Groq.APIBase + proxy = cfg.Providers.Groq.Proxy + if apiBase == "" { + apiBase = "https://api.groq.com/openai/v1" + } + + case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "": + apiKey = cfg.Providers.Nvidia.APIKey + apiBase = cfg.Providers.Nvidia.APIBase + proxy = cfg.Providers.Nvidia.Proxy + if apiBase == "" { + apiBase = "https://integrate.api.nvidia.com/v1" + } + + case cfg.Providers.VLLM.APIBase != "": + apiKey = cfg.Providers.VLLM.APIKey + apiBase = cfg.Providers.VLLM.APIBase + proxy = cfg.Providers.VLLM.Proxy + + default: + if cfg.Providers.OpenRouter.APIKey != "" { + apiKey = cfg.Providers.OpenRouter.APIKey + proxy = cfg.Providers.OpenRouter.Proxy + if cfg.Providers.OpenRouter.APIBase != "" { + apiBase = cfg.Providers.OpenRouter.APIBase + } else { + apiBase = "https://openrouter.ai/api/v1" + } + } else { + return nil, fmt.Errorf("no API key configured for model: %s", model) + } } } - } if apiKey == "" && !strings.HasPrefix(model, "bedrock/") { return nil, fmt.Errorf("no API key configured for provider (model: %s)", model) @@ -343,5 +394,5 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { return nil, fmt.Errorf("no API base configured for provider (model: %s)", model) } - return NewHTTPProvider(apiKey, apiBase), nil + return NewHTTPProvider(apiKey, apiBase, proxy), nil }