From 97bf4ff3fddd99c5a6a1d9a74a4e7637f34d7063 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Sun, 15 Feb 2026 23:56:13 +0900 Subject: [PATCH 01/13] Fix Japanese translation --- README.ja.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.ja.md b/README.ja.md index e33b312f9..706af2c75 100644 --- a/README.ja.md +++ b/README.ja.md @@ -3,7 +3,7 @@

PicoClaw: Go で書かれた超効率 AI アシスタント

-

$10 ハードウェア · 10MB RAM · 1秒起動 · 皮皮虾,我们走!

+

$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!

@@ -39,7 +39,7 @@ ## 📢 ニュース -2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 皮皮虾,我们走! +2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ! ## ✨ 特徴 @@ -729,7 +729,7 @@ Discord: https://discord.gg/V4sAZ9XWpN ## 🐛 トラブルシューティング -### Web 検索で「API 配置问题」と表示される +### Web 検索で「API 設定の問題」と表示される 検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。 From 7ce5b75178356d4c81044faa6d2ea06cd69ec507 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Mon, 16 Feb 2026 00:47:17 +0900 Subject: [PATCH 02/13] Fix shadowing field runnnig --- pkg/channels/maixcam.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go index 5fc19adbe..01e570b25 100644 --- a/pkg/channels/maixcam.go +++ b/pkg/channels/maixcam.go @@ -18,7 +18,6 @@ type MaixCamChannel struct { listener net.Listener clients map[net.Conn]bool clientsMux sync.RWMutex - running bool } type MaixCamMessage struct { @@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC BaseChannel: base, config: cfg, clients: make(map[net.Conn]bool), - running: false, }, nil } From ff3c875b3fad1116a7ea7e22a10034a741b38f18 Mon Sep 17 00:00:00 2001 From: Humaid Koreshi Date: Tue, 17 Feb 2026 02:15:59 +0600 Subject: [PATCH 03/13] docs: add missing Chinese language link to Japanese README --- README.ja.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.ja.md b/README.ja.md index e33b312f9..c6babf510 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ License

-**日本語** | [English](README.md) +[中文](README.zh.md) | **日本語** | [English](README.md) From ad747e8e8925cb9cb48cfc232f40156b0905b613 Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Tue, 17 Feb 2026 14:27:03 +0100 Subject: [PATCH 04/13] fix(Makefile): update LDFLAGS and GOFLAGS for optimized build size Signed-off-by: Boris Bliznioukov --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 9786b30bb..c3f889f8f 100644 --- a/Makefile +++ b/Makefile @@ -11,11 +11,11 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') -LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)" +LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w" # Go variables GO?=go -GOFLAGS?=-v +GOFLAGS?=-v -tags stdjson # Installation INSTALL_PREFIX?=$(HOME)/.local From 2d758d714faf8d4cc7fe48d7886bb8f3a2971a8b Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Tue, 17 Feb 2026 14:55:37 +0100 Subject: [PATCH 05/13] feat(goreleaser): add 'stdjson' tag to picoclaw build configuration Signed-off-by: Boris Bliznioukov --- .goreleaser.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 368a0f06b..0354928f3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,6 +11,8 @@ builds: - id: picoclaw env: - CGO_ENABLED=0 + tags: + - stdjson goos: - linux - windows From 2d876eaa9809d3a958ffc2e73c8eed8b8d760531 Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Tue, 17 Feb 2026 15:00:06 +0100 Subject: [PATCH 06/13] feat(goreleaser): enhance build flags with versioning and commit info Signed-off-by: Boris Bliznioukov --- .github/workflows/release.yml | 2 ++ .goreleaser.yaml | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fe3a684e..4e9399128 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,7 @@ jobs: ref: ${{ inputs.tag }} - name: Setup Go from go.mod + id: setup-go uses: actions/setup-go@v5 with: go-version-file: go.mod @@ -89,6 +90,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} + GOVERSION: ${{ steps.setup-go.outputs.go-version }} - name: Apply release flags shell: bash diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0354928f3..2c47f7d86 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -13,6 +13,12 @@ builds: - CGO_ENABLED=0 tags: - stdjson + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.gitCommit={{ .ShortCommit }} + - -X main.buildTime={{ .Date }} + - -X main.goVersion={{ .Env.GOVERSION }} goos: - linux - windows From 4cd3f99dd6f2ddfd3378b269d6f295d4a5ecc763 Mon Sep 17 00:00:00 2001 From: "zenix.huang" Date: Mon, 16 Feb 2026 12:49:11 +0900 Subject: [PATCH 07/13] fix: remove max_tokens --- pkg/providers/codex_provider.go | 4 ---- pkg/providers/codex_provider_test.go | 11 +++++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 6dff3a52e..9e36217ae 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -260,10 +260,6 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, params.Instructions = openai.Opt(defaultCodexInstructions) } - if maxTokens, ok := options["max_tokens"].(int); ok { - params.MaxOutputTokens = openai.Opt(int64(maxTokens)) - } - if len(tools) > 0 { params.Tools = translateToolsForCodex(tools) } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 317b1a5de..c34593e7b 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -29,6 +29,9 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) { if params.Instructions.Or("") != defaultCodexInstructions { t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions) } + if params.MaxOutputTokens.Valid() { + t.Fatalf("MaxOutputTokens should not be set for Codex backend") + } } func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { @@ -214,6 +217,10 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { http.Error(w, "stream must be true", http.StatusBadRequest) return } + if _, ok := reqBody["max_output_tokens"]; ok { + http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) + return + } resp := map[string]interface{}{ "id": "resp_test", @@ -293,6 +300,10 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) http.Error(w, "temperature is not supported", http.StatusBadRequest) return } + if _, ok := reqBody["max_output_tokens"]; ok { + http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) + return + } if reqBody["stream"] != true { http.Error(w, "stream must be true", http.StatusBadRequest) return From 0d16525fab81f1010bf6ddafd5ea68d975613a88 Mon Sep 17 00:00:00 2001 From: "zenix.huang" Date: Mon, 16 Feb 2026 13:08:37 +0900 Subject: [PATCH 08/13] fix: codex tool call --- pkg/providers/codex_provider.go | 36 ++++++++++++++++++++++--- pkg/providers/codex_provider_test.go | 39 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 9e36217ae..7617bf716 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -217,12 +217,18 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, }) } for _, tc := range msg.ToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) + name, args, ok := resolveCodexToolCall(tc) + if !ok { + logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{ + "call_id": tc.ID, + }) + continue + } inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfFunctionCall: &responses.ResponseFunctionToolCallParam{ CallID: tc.ID, - Name: tc.Name, - Arguments: string(argsJSON), + Name: name, + Arguments: args, }, }) } @@ -267,6 +273,30 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, return params } +func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) { + name = tc.Name + if name == "" && tc.Function != nil { + name = tc.Function.Name + } + if name == "" { + return "", "", false + } + + if len(tc.Arguments) > 0 { + argsJSON, err := json.Marshal(tc.Arguments) + if err != nil { + return "", "", false + } + return name, string(argsJSON), true + } + + if tc.Function != nil && tc.Function.Arguments != "" { + return name, tc.Function.Arguments, true + } + + return name, "{}", true +} + func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { result := make([]responses.ToolUnionParam, 0, len(tools)) for _, t := range tools { diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index c34593e7b..8406760c4 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -68,6 +68,45 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) { } } +func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "Read a file"}, + { + Role: "assistant", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }, + }, + }, + {Role: "tool", Content: "ok", ToolCallID: "call_1"}, + } + + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + if params.Input.OfInputItemList == nil { + t.Fatal("Input.OfInputItemList should not be nil") + } + if len(params.Input.OfInputItemList) != 3 { + t.Fatalf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList)) + } + + fc := params.Input.OfInputItemList[1].OfFunctionCall + if fc == nil { + t.Fatal("assistant tool call should be converted to function_call input item") + } + if fc.Name != "read_file" { + t.Errorf("Function call name = %q, want %q", fc.Name, "read_file") + } + if fc.Arguments != `{"path":"README.md"}` { + t.Errorf("Function call arguments = %q, want %q", fc.Arguments, `{"path":"README.md"}`) + } +} + func TestBuildCodexParams_WithTools(t *testing.T) { tools := []ToolDefinition{ { From f820da42d7a63b05bef448839fd7fc5e13527d3b Mon Sep 17 00:00:00 2001 From: Leandro Barbosa Date: Tue, 17 Feb 2026 17:52:28 -0300 Subject: [PATCH 09/13] docs: add Brazilian Portuguese README (README.pt-br.md) Add complete pt-BR translation of the README and update language navigation links across all existing READMEs (English, Chinese, Japanese) to include the Portuguese option. --- README.ja.md | 2 +- README.md | 2 +- README.pt-br.md | 881 ++++++++++++++++++++++++++++++++++++++++++++++++ README.zh.md | 2 +- 4 files changed, 884 insertions(+), 3 deletions(-) create mode 100644 README.pt-br.md diff --git a/README.ja.md b/README.ja.md index b86d636ac..0da84571a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ License

-[中文](README.zh.md) | **日本語** | [English](README.md) +[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [English](README.md) diff --git a/README.md b/README.md index e80e2213c..59b9bea7c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Twitter

- [中文](README.zh.md) | [日本語](README.ja.md) | **English** + [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **English** --- diff --git a/README.pt-br.md b/README.pt-br.md new file mode 100644 index 000000000..d250cc956 --- /dev/null +++ b/README.pt-br.md @@ -0,0 +1,881 @@ +
+PicoClaw + +

PicoClaw: Assistente de IA Ultra-Eficiente em Go

+ +

Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+ Website + Twitter +

+ + [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Português** +
+ +--- + +🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [nanobot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código. + +⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 DECLARACAO DE SEGURANCA & CANAIS OFICIAIS** +> +> * **SEM CRIPTOMOEDAS:** O PicoClaw **NAO** possui nenhum token/moeda oficial. Todas as alegacoes no `pump.fun` ou outras plataformas de negociacao sao **GOLPES**. +> * **DOMINIO OFICIAL:** O **UNICO** site oficial e **[picoclaw.io](https://picoclaw.io)**, e o site da empresa e **[sipeed.com](https://sipeed.com)**. +> * **Aviso:** Muitos dominios `.ai/.org/.com/.net/...` foram registrados por terceiros, nao sao nossos. +> * **Aviso:** O PicoClaw esta em fase inicial de desenvolvimento e pode ter problemas de seguranca de rede nao resolvidos. Nao implante em ambientes de producao antes da versao v1.0. +> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memoria (10-20MB) nas versoes mais recentes. Planejamos priorizar a otimizacao de recursos assim que o conjunto de funcionalidades estiver estavel. + + +## 📢 Novidades + +2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw esta crescendo mais rapido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papeis de voluntarios e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter voce a bordo! + +2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado a comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw. +🚀 **Chamada para Acao:** Envie suas solicitacoes de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na proxima reuniao semanal. + +2026-02-09 🎉 PicoClaw lancado oficialmente! Construido em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu! + +## ✨ Funcionalidades + +🪶 **Ultra-Leve**: Consumo de memoria <10MB — 99% menor que o Clawdbot para funcionalidades essenciais. + +💰 **Custo Minimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. + +⚡️ **Inicializacao Relampago**: Tempo de inicializacao 400X mais rapido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. + +🌍 **Portabilidade Real**: Um unico binario auto-contido para RISC-V, ARM e x86. Um clique e ja era! + +🤖 **Auto-Construido por IA**: Implementacao nativa em Go de forma autonoma — 95% do nucleo gerado pelo Agente com refinamento humano no loop. + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Linguagem** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **Inicializacao**
(CPU 0.8GHz) | >500s | >30s | **<1s** | +| **Custo** | Mac Mini $599 | Maioria dos SBC Linux
~$50 | **Qualquer placa Linux**
**A partir de $10** | + +PicoClaw + +## 🦾 Demonstracao + +### 🛠️ Fluxos de Trabalho Padrao do Assistente + + + + + + + + + + + + + + + + + +

🧩 Engenharia Full-Stack

🗂️ Gerenciamento de Logs & Planejamento

🔎 Busca Web & Aprendizado

Desenvolver • Implantar • EscalarAgendar • Automatizar • MemorizarDescobrir • Analisar • Tendencias
+ +### 📱 Rode em celulares Android antigos + +De uma segunda vida ao seu celular de dez anos atras! Transforme-o em um assistente de IA inteligente com o PicoClaw. Inicio rapido: + +1. **Instale o Termux** (Disponivel no F-Droid ou Google Play). +2. **Execute os comandos** + +```bash +# Nota: Substitua v0.1.1 pela versao mais recente da pagina de Releases +wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 +chmod +x picoclaw-linux-arm64 +pkg install proot +termux-chroot ./picoclaw-linux-arm64 onboard +``` + +Depois siga as instrucoes na secao "Inicio Rapido" para completar a configuracao! + +PicoClaw + +### 🐜 Implantacao Inovadora com Baixo Consumo + +O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versao E (Ethernet) ou W (WiFi6), para Assistente Domestico Minimalista +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutencao Automatizada de Servidores +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente + +https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 + +🌟 Mais cenarios de implantacao aguardam voce! + +## 📦 Instalacao + +### Instalar com binario pre-compilado + +Baixe o binario para sua plataforma na pagina de [releases](https://github.com/sipeed/picoclaw/releases). + +### Instalar a partir do codigo-fonte (funcionalidades mais recentes, recomendado para desenvolvimento) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Build, sem necessidade de instalar +make build + +# Build para multiplas plataformas +make build-all + +# Build e Instalar +make install +``` + +## 🐳 Docker Compose + +Voce tambem pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente. + +```bash +# 1. Clone este repositorio +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Configure suas API keys +cp config/config.example.json config/config.json +vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. + +# 3. Build & Iniciar +docker compose --profile gateway up -d + +# 4. Ver logs +docker compose logs -f picoclaw-gateway + +# 5. Parar +docker compose --profile gateway down +``` + +### Modo Agente (Execucao unica) + +```bash +# Fazer uma pergunta +docker compose run --rm picoclaw-agent -m "Quanto e 2+2?" + +# Modo interativo +docker compose run --rm picoclaw-agent +``` + +### Rebuild + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d +``` + +### 🚀 Inicio Rapido + +> [!TIP] +> Configure sua API key em `~/.picoclaw/config.json`. +> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas gratis/mes) ou use o fallback automatico integrado. + +**1. Inicializar** + +```bash +picoclaw onboard +``` + +**2. Configurar** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "openrouter": { + "api_key": "xxx", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +**3. Obter API Keys** + +* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponivel (2000 consultas/mes) + +> **Nota**: Veja `config.example.json` para um modelo de configuracao completo. + +**4. Conversar** + +```bash +picoclaw agent -m "Quanto e 2+2?" +``` + +Pronto! Voce tem um assistente de IA funcionando em 2 minutos. + +--- + +## 💬 Integracao com Apps de Chat + +Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE. + +| Canal | Nivel de Configuracao | +| --- | --- | +| **Telegram** | Facil (apenas um token) | +| **Discord** | Facil (bot token + intents) | +| **QQ** | Facil (AppID + AppSecret) | +| **DingTalk** | Medio (credenciais do app) | +| **LINE** | Medio (credenciais + webhook URL) | + +
+Telegram (Recomendado) + +**1. Criar o bot** + +* Abra o Telegram, busque `@BotFather` +* Envie `/newbot`, siga as instrucoes +* Copie o token + +**2. Configurar** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +> Obtenha seu User ID pelo `@userinfobot` no Telegram. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+Discord + +**1. Criar o bot** + +* Acesse +* Crie um aplicativo → Bot → Add Bot +* Copie o token do bot + +**2. Habilitar Intents** + +* Nas configuracoes do Bot, habilite **MESSAGE CONTENT INTENT** +* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissoes baseada em dados dos membros + +**3. Obter seu User ID** + +* Configuracoes do Discord → Avancado → habilite **Modo Desenvolvedor** +* Clique com botao direito no seu avatar → **Copiar ID do Usuario** + +**4. Configurar** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Convidar o bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Abra a URL de convite gerada e adicione o bot ao seu servidor + +**6. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+QQ + +**1. Criar o bot** + +- Acesse a [QQ Open Platform](https://q.qq.com/#) +- Crie um aplicativo → Obtenha **AppID** e **AppSecret** + +**2. Configurar** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique numeros QQ para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Criar o bot** + +* Acesse a [Open Platform](https://open.dingtalk.com/) +* Crie um app interno +* Copie o Client ID e Client Secret + +**2. Configurar** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique IDs para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. Criar uma Conta Oficial LINE** + +- Acesse o [LINE Developers Console](https://developers.line.biz/) +- Crie um provider → Crie um canal Messaging API +- Copie o **Channel Secret** e o **Channel Access Token** + +**2. Configurar** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +**3. Configurar URL do Webhook** + +O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel: + +```bash +# Exemplo com ngrok +ngrok http 18791 +``` + +Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**. + +**4. Executar** + +```bash +picoclaw gateway +``` + +> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original. + +> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao servico `picoclaw-gateway` para expor a porta do webhook. + +
+ +## ClawdChat Junte-se a Rede Social de Agentes + +Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma unica mensagem via CLI ou qualquer App de Chat integrado. + +**Leia `https://clawdchat.ai/skill.md` e siga as instrucoes para entrar no [ClawdChat.ai](https://clawdchat.ai)** + +## ⚙️ Configuracao Detalhada + +Arquivo de configuracao: `~/.picoclaw/config.json` + +### Estrutura do Workspace + +O PicoClaw armazena dados no workspace configurado (padrao: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sessoes de conversa e historico +├── memory/ # Memoria de longo prazo (MEMORY.md) +├── state/ # Estado persistente (ultimo canal, etc.) +├── cron/ # Banco de dados de tarefas agendadas +├── skills/ # Skills personalizadas +├── AGENTS.md # Guia de comportamento do Agente +├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) +├── IDENTITY.md # Identidade do Agente +├── SOUL.md # Alma do Agente +├── TOOLS.md # Descricao das ferramentas +└── USER.md # Preferencias do usuario +``` + +### 🔒 Sandbox de Seguranca + +O PicoClaw roda em um ambiente sandbox por padrao. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado. + +#### Configuracao Padrao + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Opcao | Padrao | Descricao | +|-------|--------|-----------| +| `workspace` | `~/.picoclaw/workspace` | Diretorio de trabalho do agente | +| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace | + +#### Ferramentas Protegidas + +Quando `restrict_to_workspace: true`, as seguintes ferramentas sao restritas ao sandbox: + +| Ferramenta | Funcao | Restricao | +|------------|--------|-----------| +| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace | +| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace | +| `list_dir` | Listar diretorios | Apenas diretorios dentro do workspace | +| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace | +| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace | +| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace | + +#### Protecao Adicional do Exec + +Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos: + +* `rm -rf`, `del /f`, `rmdir /s` — Exclusao em massa +* `format`, `mkfs`, `diskpart` — Formatacao de disco +* `dd if=` — Criacao de imagem de disco +* Escrita em `/dev/sd[a-z]` — Escrita direta no disco +* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema +* Fork bomb `:(){ :|:& };:` + +#### Exemplos de Erro + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Desabilitar Restricoes (Risco de Seguranca) + +Se voce precisa que o agente acesse caminhos fora do workspace: + +**Metodo 1: Arquivo de configuracao** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Metodo 2: Variavel de ambiente** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Aviso**: Desabilitar esta restricao permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados. + +#### Consistencia do Limite de Seguranca + +A configuracao `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execucao: + +| Caminho de Execucao | Limite de Seguranca | +|----------------------|---------------------| +| Agente Principal | `restrict_to_workspace` ✅ | +| Subagente / Spawn | Herda a mesma restricao ✅ | +| Tarefas Heartbeat | Herda a mesma restricao ✅ | + +Todos os caminhos compartilham a mesma restricao de workspace — nao ha como contornar o limite de seguranca por meio de subagentes ou tarefas agendadas. + +### Heartbeat (Tarefas Periodicas) + +O PicoClaw pode executar tarefas periodicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: + +```markdown +# Tarefas Periodicas + +- Verificar meu email para mensagens importantes +- Revisar minha agenda para proximos eventos +- Verificar a previsao do tempo +``` + +O agente lera este arquivo a cada 30 minutos (configuravel) e executara as tarefas usando as ferramentas disponiveis. + +#### Tarefas Assincronas com Spawn + +Para tarefas de longa duracao (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: + +```markdown +# Tarefas Periodicas + +## Tarefas Rapidas (resposta direta) +- Informar hora atual + +## Tarefas Longas (usar spawn para async) +- Buscar noticias de IA na web e resumir +- Verificar email e reportar mensagens importantes +``` + +**Comportamentos principais:** + +| Funcionalidade | Descricao | +|----------------|-----------| +| **spawn** | Cria subagente assincrono, nao bloqueia o heartbeat | +| **Contexto independente** | Subagente tem seu proprio contexto, sem historico de sessao | +| **Ferramenta message** | Subagente se comunica diretamente com o usuario via ferramenta message | +| **Nao-bloqueante** | Apos o spawn, o heartbeat continua para a proxima tarefa | + +#### Como Funciona a Comunicacao do Subagente + +``` +Heartbeat dispara + ↓ +Agente le HEARTBEAT.md + ↓ +Para tarefa longa: spawn subagente + ↓ ↓ +Continua proxima tarefa Subagente trabalha independentemente + ↓ ↓ +Todas tarefas concluidas Subagente usa ferramenta "message" + ↓ ↓ +Responde HEARTBEAT_OK Usuario recebe resultado diretamente +``` + +O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comunicar com o usuario independentemente sem passar pelo agente principal. + +**Configuracao:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Opcao | Padrao | Descricao | +|-------|--------|-----------| +| `enabled` | `true` | Habilitar/desabilitar heartbeat | +| `interval` | `30` | Intervalo de verificacao em minutos (min: 5) | + +**Variaveis de ambiente:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar +* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo + +### Provedores + +> [!NOTE] +> O Groq fornece transcricao de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serao automaticamente transcritas. + +| Provedor | Finalidade | Obter API Key | +| --- | --- | --- | +| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) | +| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **Transcricao de voz** (Whisper) | [console.groq.com](https://console.groq.com) | + +
+Configuracao Zhipu + +**1. Obter API key** + +* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configurar** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Sua API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw agent -m "Ola, como vai?" +``` + +
+ +
+Exemplo de configuracao completa + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +## Referencia CLI + +| Comando | Descricao | +| --- | --- | +| `picoclaw onboard` | Inicializar configuracao & workspace | +| `picoclaw agent -m "..."` | Conversar com o agente | +| `picoclaw agent` | Modo de chat interativo | +| `picoclaw gateway` | Iniciar o gateway (para bots de chat) | +| `picoclaw status` | Mostrar status | +| `picoclaw cron list` | Listar todas as tarefas agendadas | +| `picoclaw cron add ...` | Adicionar uma tarefa agendada | + +### Tarefas Agendadas / Lembretes + +O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`: + +* **Lembretes unicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez apos 10min +* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas +* **Expressoes Cron**: "Remind me at 9am daily" (Me lembre as 9h todos os dias) → usa expressao cron + +As tarefas sao armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente. + +## 🤝 Contribuir & Roadmap + +PRs sao bem-vindos! O codigo-fonte e intencionalmente pequeno e legivel. 🤗 + +Roadmap em breve... + +Grupo de desenvolvedores em formacao. Requisito de entrada: Pelo menos 1 PR com merge. + +Grupos de usuarios: + +Discord: + +PicoClaw + +## 🐛 Solucao de Problemas + +### Busca web mostra "API 配置问题" + +Isso e normal se voce ainda nao configurou uma API key de busca. O PicoClaw fornecera links uteis para busca manual. + +Para habilitar a busca web: + +1. **Opcao 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas gratis/mes) para os melhores resultados. +2. **Opcao 2 (Sem Cartao de Credito)**: Se voce nao tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key). + +Adicione a key em `~/.picoclaw/config.json` se usar o Brave: + +```json +{ + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +### Erros de filtragem de conteudo + +Alguns provedores (como Zhipu) possuem filtragem de conteudo. Tente reformular sua pergunta ou use um modelo diferente. + +### Bot do Telegram diz "Conflict: terminated by other getUpdates" + +Isso acontece quando outra instancia do bot esta rodando. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez. + +--- + +## 📝 Comparacao de API Keys + +| Servico | Plano Gratuito | Caso de Uso | +| --- | --- | --- | +| **OpenRouter** | 200K tokens/mes | Multiplos modelos (Claude, GPT-4, etc.) | +| **Zhipu** | 200K tokens/mes | Melhor para usuarios chineses | +| **Brave Search** | 2000 consultas/mes | Funcionalidade de busca web | +| **Groq** | Plano gratuito disponivel | Inferencia ultra-rapida (Llama, Mixtral) | diff --git a/README.zh.md b/README.zh.md index e7dc8d769..6c87ba785 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,7 +14,7 @@ Twitter

- **中文** | [日本語](README.ja.md) | [English](README.md) + **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [English](README.md) --- From 01d694b9985a66c3d7119fc9f74ce8ed4f0f21b5 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:33:34 +0800 Subject: [PATCH 10/13] fix: Add comprehensive command injection and system abuse prevention patterns (#401) * Add comprehensive command injection and system abuse prevention patterns * fix: Container running as root --- Dockerfile | 9 ++++++++- docker-compose.yml | 8 ++++---- pkg/tools/shell.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index dd98ec0bd..0360cfda6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,14 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw -# Create picoclaw home directory +# Create non-root user and group +RUN addgroup -g 1000 picoclaw && \ + adduser -D -u 1000 -G picoclaw picoclaw + +# Switch to non-root user +USER picoclaw + +# Run onboard to create initial directories and config RUN /usr/local/bin/picoclaw onboard ENTRYPOINT ["picoclaw"] diff --git a/docker-compose.yml b/docker-compose.yml index 48769627c..32e8ee339 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,8 @@ services: profiles: - agent volumes: - - ./config/config.json:/root/.picoclaw/config.json:ro - - picoclaw-workspace:/root/.picoclaw/workspace + - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro + - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace entrypoint: ["picoclaw", "agent"] stdin_open: true tty: true @@ -31,9 +31,9 @@ services: - gateway volumes: # Configuration file - - ./config/config.json:/root/.picoclaw/config.json:ro + - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro # Persistent workspace (sessions, memory, logs) - - picoclaw-workspace:/root/.picoclaw/workspace + - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace command: ["gateway"] volumes: diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 713850f97..9c82b2748 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -31,6 +31,40 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool { 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*:`), + regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`\$\{[^}]+\}`), + regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\|\s*sh\b`), + regexp.MustCompile(`\|\s*bash\b`), + regexp.MustCompile(`;\s*rm\s+-[rf]`), + regexp.MustCompile(`&&\s*rm\s+-[rf]`), + regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), + regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), + regexp.MustCompile(`<<\s*EOF`), + regexp.MustCompile(`\$\(\s*cat\s+`), + regexp.MustCompile(`\$\(\s*curl\s+`), + regexp.MustCompile(`\$\(\s*wget\s+`), + regexp.MustCompile(`\$\(\s*which\s+`), + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), + regexp.MustCompile(`\bchown\b`), + regexp.MustCompile(`\bpkill\b`), + regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bkill\s+-[9]\b`), + regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bnpm\s+install\s+-g\b`), + regexp.MustCompile(`\bpip\s+install\s+--user\b`), + regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), + regexp.MustCompile(`\byum\s+(install|remove)\b`), + regexp.MustCompile(`\bdnf\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bssh\b.*@`), + regexp.MustCompile(`\beval\b`), + regexp.MustCompile(`\bsource\s+.*\.sh\b`), } return &ExecTool{ From 193fbcab11fe3c448f982f43e7837585e843acea Mon Sep 17 00:00:00 2001 From: lxowalle Date: Wed, 18 Feb 2026 16:01:41 +0800 Subject: [PATCH 11/13] docs: update PR template --- .github/pull_request_template.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7910cb1e2..c96b7da12 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,7 @@ ## 📝 Description + + + ## 🗣️ Type of Change - [ ] 🐞 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) @@ -11,25 +14,28 @@ - [ ] 👨‍💻 Mostly Human-written (Human lead, AI assisted or none) -## 🔗 Linked Issue +## 🔗 Related Issue + + + ## 📚 Technical Context (Skip for Docs) -* **Reference:** [URL] -* **Reasoning:** ... +- **Reference URL:** +- **Reasoning:** + +## 🧪 Test Environment +- **Hardware:** +- **OS:** +- **Model/Provider:** +- **Channels:** -## 🧪 Test Environment & Hardware -- **Hardware:** [e.g. Raspberry Pi 5, Orange Pi, PC] -- **OS:** [e.g. Debian 12, Ubuntu 22.04] -- **Model/Provider:** [e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3] -- **Channels:** [e.g. Discord, Telegram, Feishu, ...] - - -## 📸 Proof of Work (Optional for Docs) +## 📸 Evidence (Optional)
Click to view Logs/Screenshots -
+ + ## ☑️ Checklist - [ ] My code/docs follow the style of this project. From 3390576eeacb97bdd3da95b156e226ab72ee0929 Mon Sep 17 00:00:00 2001 From: Zenix Date: Wed, 18 Feb 2026 17:30:30 +0900 Subject: [PATCH 12/13] Feature/websearch OpenAI (#118) * feature: add web search for codex models * fix: use more elegant way to solve the issue. --- config/config.example.json | 5 +- pkg/config/config.go | 33 ++++--- pkg/config/config_test.go | 39 ++++++++ pkg/migrate/config.go | 12 ++- pkg/providers/codex_provider.go | 37 +++++--- pkg/providers/codex_provider_test.go | 129 +++++++++++++++++++++++++-- pkg/providers/http_provider.go | 14 +-- 7 files changed, 230 insertions(+), 39 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 7cd0ab8c6..37c2bcd81 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -79,7 +79,8 @@ }, "openai": { "api_key": "", - "api_base": "" + "api_base": "", + "web_search": true }, "openrouter": { "api_key": "sk-or-v1-xxx", @@ -144,4 +145,4 @@ "host": "0.0.0.0", "port": 18790 } -} \ No newline at end of file +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 1d34f56f3..92a4a5862 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -167,19 +167,19 @@ type DevicesConfig struct { } type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI ProviderConfig `json:"openai"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` - Nvidia ProviderConfig `json:"nvidia"` - Ollama ProviderConfig `json:"ollama"` - Moonshot ProviderConfig `json:"moonshot"` - ShengSuanYun ProviderConfig `json:"shengsuanyun"` - DeepSeek ProviderConfig `json:"deepseek"` - GitHubCopilot ProviderConfig `json:"github_copilot"` + Anthropic ProviderConfig `json:"anthropic"` + OpenAI OpenAIProviderConfig `json:"openai"` + OpenRouter ProviderConfig `json:"openrouter"` + Groq ProviderConfig `json:"groq"` + Zhipu ProviderConfig `json:"zhipu"` + VLLM ProviderConfig `json:"vllm"` + Gemini ProviderConfig `json:"gemini"` + Nvidia ProviderConfig `json:"nvidia"` + Ollama ProviderConfig `json:"ollama"` + Moonshot ProviderConfig `json:"moonshot"` + ShengSuanYun ProviderConfig `json:"shengsuanyun"` + DeepSeek ProviderConfig `json:"deepseek"` + GitHubCopilot ProviderConfig `json:"github_copilot"` } type ProviderConfig struct { @@ -190,6 +190,11 @@ type ProviderConfig struct { ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` } +type OpenAIProviderConfig struct { + ProviderConfig + WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` +} + type GatewayConfig struct { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` @@ -308,7 +313,7 @@ func DefaultConfig() *Config { }, Providers: ProvidersConfig{ Anthropic: ProviderConfig{}, - OpenAI: ProviderConfig{}, + OpenAI: OpenAIProviderConfig{WebSearch: true}, OpenRouter: ProviderConfig{}, Groq: ProviderConfig{}, Zhipu: ProviderConfig{}, diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index febfd0456..a1f73f0b3 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -204,3 +204,42 @@ func TestConfig_Complete(t *testing.T) { t.Error("Heartbeat should be enabled by default") } } + +func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Providers.OpenAI.WebSearch { + t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true") + } +} + +func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Providers.OpenAI.WebSearch { + t.Fatal("OpenAI codex web search should remain true when unset in config file") + } +} + +func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Providers.OpenAI.WebSearch { + t.Fatal("OpenAI codex web search should be false when disabled in config file") + } +} diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 9c1e36359..57032e566 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -108,7 +108,10 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error case "anthropic": cfg.Providers.Anthropic = pc case "openai": - cfg.Providers.OpenAI = pc + cfg.Providers.OpenAI = config.OpenAIProviderConfig{ + ProviderConfig: pc, + WebSearch: getBoolOrDefault(pMap, "web_search", true), + } case "openrouter": cfg.Providers.OpenRouter = pc case "groq": @@ -363,6 +366,13 @@ func getBool(data map[string]interface{}, key string) (bool, bool) { return b, ok } +func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool { + if v, ok := getBool(data, key); ok { + return v + } + return defaultVal +} + func getStringSlice(data map[string]interface{}, key string) []string { v, ok := data[key] if !ok { diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 7617bf716..e3526cfb5 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -18,9 +18,10 @@ const codexDefaultModel = "gpt-5.2" const codexDefaultInstructions = "You are Codex, a coding assistant." type CodexProvider struct { - client *openai.Client - accountID string - tokenSource func() (string, string, error) + client *openai.Client + accountID string + tokenSource func() (string, string, error) + enableWebSearch bool } const defaultCodexInstructions = "You are Codex, a coding assistant." @@ -37,8 +38,9 @@ func NewCodexProvider(token, accountID string) *CodexProvider { } client := openai.NewClient(opts...) return &CodexProvider{ - client: &client, - accountID: accountID, + client: &client, + accountID: accountID, + enableWebSearch: true, } } @@ -78,7 +80,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To }) } - params := buildCodexParams(messages, tools, resolvedModel, options) + params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch) stream := p.client.Responses.NewStreaming(ctx, params, opts...) defer stream.Close() @@ -182,7 +184,7 @@ func resolveCodexModel(model string) (string, string) { return codexDefaultModel, "unsupported model family" } -func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) responses.ResponseNewParams { +func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, enableWebSearch bool) responses.ResponseNewParams { var inputItems responses.ResponseInputParam var instructions string @@ -266,8 +268,8 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, params.Instructions = openai.Opt(defaultCodexInstructions) } - if len(tools) > 0 { - params.Tools = translateToolsForCodex(tools) + if len(tools) > 0 || enableWebSearch { + params.Tools = translateToolsForCodex(tools, enableWebSearch) } return params @@ -297,9 +299,19 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) return name, "{}", true } -func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { - result := make([]responses.ToolUnionParam, 0, len(tools)) +func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { + capHint := len(tools) + if enableWebSearch { + capHint++ + } + result := make([]responses.ToolUnionParam, 0, capHint) for _, t := range tools { + if t.Type != "function" { + continue + } + if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { + continue + } ft := responses.FunctionToolParam{ Name: t.Function.Name, Parameters: t.Function.Parameters, @@ -310,6 +322,9 @@ func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { } result = append(result, responses.ToolUnionParam{OfFunction: &ft}) } + if enableWebSearch { + result = append(result, responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch)) + } return result } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 8406760c4..92e276165 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -19,7 +19,7 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) { params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{ "max_tokens": 2048, "temperature": 0.7, - }) + }, true) if params.Model != "gpt-4o" { t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") } @@ -39,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, true) if !params.Instructions.Valid() { t.Fatal("Instructions should be set") } @@ -59,7 +59,7 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) { }, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } @@ -87,7 +87,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { {Role: "tool", Content: "ok", ToolCallID: "call_1"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } @@ -123,7 +123,7 @@ func TestBuildCodexParams_WithTools(t *testing.T) { }, }, } - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, false) if len(params.Tools) != 1 { t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) } @@ -136,12 +136,61 @@ func TestBuildCodexParams_WithTools(t *testing.T) { } func TestBuildCodexParams_StoreIsFalse(t *testing.T) { - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, false) if !params.Store.Valid() || params.Store.Or(true) != false { t.Error("Store should be explicitly set to false") } } +func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) { + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, true) + if len(params.Tools) != 1 { + t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) + } + if params.Tools[0].OfWebSearch == nil { + t.Fatal("Tool should include built-in web_search") + } + if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch { + t.Errorf("Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch) + } +} + +func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "web_search", + Description: "local web search", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "read_file", + Description: "read file", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + } + + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, true) + if len(params.Tools) != 2 { + t.Fatalf("len(Tools) = %d, want 2", len(params.Tools)) + } + if params.Tools[0].OfFunction == nil || params.Tools[0].OfFunction.Name != "read_file" { + t.Fatalf("first tool should be function read_file, got %#v", params.Tools[0]) + } + if params.Tools[1].OfWebSearch == nil { + t.Fatalf("second tool should be built-in web_search, got %#v", params.Tools[1]) + } +} + func TestParseCodexResponse_TextOutput(t *testing.T) { respJSON := `{ "id": "resp_test", @@ -260,6 +309,16 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) return } + toolsAny, ok := reqBody["tools"].([]interface{}) + if !ok || len(toolsAny) != 1 { + http.Error(w, "missing default web search tool", http.StatusBadRequest) + return + } + toolObj, ok := toolsAny[0].(map[string]interface{}) + if !ok || toolObj["type"] != "web_search" { + http.Error(w, "expected web_search tool", http.StatusBadRequest) + return + } resp := map[string]interface{}{ "id": "resp_test", @@ -307,6 +366,64 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { } } +func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + return + } + + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if _, ok := reqBody["tools"]; ok { + http.Error(w, "tools should be absent when web search disabled", http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []map[string]interface{}{ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": []map[string]interface{}{ + {"type": "output_text", "text": "Hi from Codex!"}, + }, + }, + }, + "usage": map[string]interface{}{ + "input_tokens": 4, + "output_tokens": 3, + "total_tokens": 7, + "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, + "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + }, + } + writeCompletedSSE(w, resp) + })) + defer server.Close() + + provider := NewCodexProvider("test-token", "acc-123") + provider.enableWebSearch = false + provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") + + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hi from Codex!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") + } +} + func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 4cf2c6db2..946aa29d2 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -208,7 +208,7 @@ func createClaudeAuthProvider() (LLMProvider, error) { return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil } -func createCodexAuthProvider() (LLMProvider, error) { +func createCodexAuthProvider(enableWebSearch bool) (LLMProvider, error) { cred, err := auth.GetCredential("openai") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) @@ -216,7 +216,9 @@ func createCodexAuthProvider() (LLMProvider, error) { if cred == nil { return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") } - return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil + p := NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()) + p.enableWebSearch = enableWebSearch + return p, nil } func CreateProvider(cfg *config.Config) (LLMProvider, error) { @@ -241,10 +243,12 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { case "openai", "gpt": if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { - return NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()), nil + c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()) + c.enableWebSearch = cfg.Providers.OpenAI.WebSearch + return c, nil } if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() + return createCodexAuthProvider(cfg.Providers.OpenAI.WebSearch) } apiKey = cfg.Providers.OpenAI.APIKey apiBase = cfg.Providers.OpenAI.APIBase @@ -369,7 +373,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { 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() + return createCodexAuthProvider(cfg.Providers.OpenAI.WebSearch) } apiKey = cfg.Providers.OpenAI.APIKey apiBase = cfg.Providers.OpenAI.APIBase From eda6e373323861fea99630013946d7aeea94ade0 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:31:15 +0800 Subject: [PATCH 13/13] feat: Support modifying the command filtering list of the exec tool (#410) --- cmd/picoclaw/main.go | 6 +- docs/tools_configuration.md | 122 ++++++++++++++++++++++++++++++++++++ pkg/agent/loop.go | 2 +- pkg/config/config.go | 9 +++ pkg/tools/cron.go | 7 ++- pkg/tools/shell.go | 119 ++++++++++++++++++++++------------- 6 files changed, 215 insertions(+), 50 deletions(-) create mode 100644 docs/tools_configuration.md diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index fd7ec484a..128f8c421 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -563,7 +563,7 @@ func gatewayCmd() { // Setup cron tool and service execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout) + cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg) heartbeatService := heartbeat.NewHeartbeatService( cfg.WorkspacePath(), @@ -988,14 +988,14 @@ func getConfigPath() string { return filepath.Join(home, ".picoclaw", "config.json") } -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *cron.CronService { +func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *cron.CronService { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") // Create cron service cronService := cron.NewCronService(cronStorePath, nil) // Create and register CronTool - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout) + cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, config) agentLoop.RegisterTool(cronTool) // Set the onJob handler diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md new file mode 100644 index 000000000..8777ddbd6 --- /dev/null +++ b/docs/tools_configuration.md @@ -0,0 +1,122 @@ +# Tools Configuration + +PicoClaw's tools configuration is located in the `tools` field of `config.json`. + +## Directory Structure + +```json +{ + "tools": { + "web": { ... }, + "exec": { ... }, + "approval": { ... }, + "cron": { ... } + } +} +``` + +## Web Tools + +Web tools are used for web search and fetching. + +### Brave + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | false | Enable Brave search | +| `api_key` | string | - | Brave Search API key | +| `max_results` | int | 5 | Maximum number of results | + +### DuckDuckGo + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | true | Enable DuckDuckGo search | +| `max_results` | int | 5 | Maximum number of results | + +### Perplexity + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | false | Enable Perplexity search | +| `api_key` | string | - | Perplexity API key | +| `max_results` | int | 5 | Maximum number of results | + +## Exec Tool + +The exec tool is used to execute shell commands. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | +| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | + +### Functionality + +- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns +- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked + +### Default Blocked Command Patterns + +By default, PicoClaw blocks the following dangerous commands: + +- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s` +- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*` +- System operations: `shutdown`, `reboot`, `poweroff` +- Command substitution: `$()`, `${}`, backticks +- Pipe to shell: `| sh`, `| bash` +- Privilege escalation: `sudo`, `chmod`, `chown` +- Process control: `pkill`, `killall`, `kill -9` +- Remote operations: `curl | sh`, `wget | sh`, `ssh` +- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Containers: `docker run`, `docker exec` +- Git: `git push`, `git force` +- Other: `eval`, `source *.sh` + +### Configuration Example + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ], + } + } +} +``` + +## Approval Tool + +The approval tool controls permissions for dangerous operations. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | true | Enable approval functionality | +| `write_file` | bool | true | Require approval for file writes | +| `edit_file` | bool | true | Require approval for file edits | +| `append_file` | bool | true | Require approval for file appends | +| `exec` | bool | true | Require approval for command execution | +| `timeout_minutes` | int | 5 | Approval timeout in minutes | + +## Cron Tool + +The cron tool is used for scheduling periodic tasks. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | + +## Environment Variables + +All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: + +For example: +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` + +Note: Array-type environment variables are not currently supported and must be set via the config file. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d3afa298e..8c6c58c96 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -71,7 +71,7 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg registry.Register(tools.NewAppendFileTool(workspace, restrict)) // Shell execution - registry.Register(tools.NewExecTool(workspace, restrict)) + registry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg)) if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{ BraveAPIKey: cfg.Tools.Web.Brave.APIKey, diff --git a/pkg/config/config.go b/pkg/config/config.go index 92a4a5862..a1cc978b6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -227,9 +227,15 @@ type CronToolsConfig struct { ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout } +type ExecConfig struct { + EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` + CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` +} + type ToolsConfig struct { Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` + Exec ExecConfig `json:"exec"` } func DefaultConfig() *Config { @@ -347,6 +353,9 @@ func DefaultConfig() *Config { Cron: CronToolsConfig{ ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations }, + Exec: ExecConfig{ + EnableDenyPatterns: true, + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 21bee42ef..e2764d8ac 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -29,9 +30,9 @@ type CronTool struct { // NewCronTool creates a new CronTool // execTimeout: 0 means no timeout, >0 sets the timeout duration -func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *CronTool { - execTool := NewExecTool(workspace, restrict) - execTool.SetTimeout(execTimeout) // 0 means no timeout +func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *CronTool { + execTool := NewExecToolWithConfig(workspace, restrict, config) + execTool.SetTimeout(execTimeout) return &CronTool{ cronService: cronService, executor: executor, diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 9c82b2748..bd612d9ae 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "github.com/sipeed/picoclaw/pkg/config" "os" "os/exec" "path/filepath" @@ -21,50 +22,82 @@ type ExecTool struct { restrictToWorkspace bool } +var defaultDenyPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), + regexp.MustCompile(`\bdel\s+/[fq]\b`), + 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(`\b(shutdown|reboot|poweroff)\b`), + regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), + regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`\$\{[^}]+\}`), + regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\|\s*sh\b`), + regexp.MustCompile(`\|\s*bash\b`), + regexp.MustCompile(`;\s*rm\s+-[rf]`), + regexp.MustCompile(`&&\s*rm\s+-[rf]`), + regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), + regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), + regexp.MustCompile(`<<\s*EOF`), + regexp.MustCompile(`\$\(\s*cat\s+`), + regexp.MustCompile(`\$\(\s*curl\s+`), + regexp.MustCompile(`\$\(\s*wget\s+`), + regexp.MustCompile(`\$\(\s*which\s+`), + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), + regexp.MustCompile(`\bchown\b`), + regexp.MustCompile(`\bpkill\b`), + regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bkill\s+-[9]\b`), + regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bnpm\s+install\s+-g\b`), + regexp.MustCompile(`\bpip\s+install\s+--user\b`), + regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), + regexp.MustCompile(`\byum\s+(install|remove)\b`), + regexp.MustCompile(`\bdnf\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bssh\b.*@`), + regexp.MustCompile(`\beval\b`), + regexp.MustCompile(`\bsource\s+.*\.sh\b`), +} + func NewExecTool(workingDir string, restrict bool) *ExecTool { - denyPatterns := []*regexp.Regexp{ - regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), - regexp.MustCompile(`\bdel\s+/[fq]\b`), - 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(`\b(shutdown|reboot|poweroff)\b`), - regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), - regexp.MustCompile(`\$\([^)]+\)`), - regexp.MustCompile(`\$\{[^}]+\}`), - regexp.MustCompile("`[^`]+`"), - regexp.MustCompile(`\|\s*sh\b`), - regexp.MustCompile(`\|\s*bash\b`), - regexp.MustCompile(`;\s*rm\s+-[rf]`), - regexp.MustCompile(`&&\s*rm\s+-[rf]`), - regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), - regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), - regexp.MustCompile(`<<\s*EOF`), - regexp.MustCompile(`\$\(\s*cat\s+`), - regexp.MustCompile(`\$\(\s*curl\s+`), - regexp.MustCompile(`\$\(\s*wget\s+`), - regexp.MustCompile(`\$\(\s*which\s+`), - regexp.MustCompile(`\bsudo\b`), - regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), - regexp.MustCompile(`\bchown\b`), - regexp.MustCompile(`\bpkill\b`), - regexp.MustCompile(`\bkillall\b`), - regexp.MustCompile(`\bkill\s+-[9]\b`), - regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bnpm\s+install\s+-g\b`), - regexp.MustCompile(`\bpip\s+install\s+--user\b`), - regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), - regexp.MustCompile(`\byum\s+(install|remove)\b`), - regexp.MustCompile(`\bdnf\s+(install|remove)\b`), - regexp.MustCompile(`\bdocker\s+run\b`), - regexp.MustCompile(`\bdocker\s+exec\b`), - regexp.MustCompile(`\bgit\s+push\b`), - regexp.MustCompile(`\bgit\s+force\b`), - regexp.MustCompile(`\bssh\b.*@`), - regexp.MustCompile(`\beval\b`), - regexp.MustCompile(`\bsource\s+.*\.sh\b`), + return NewExecToolWithConfig(workingDir, restrict, nil) +} + +func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool { + denyPatterns := make([]*regexp.Regexp, 0) + + enableDenyPatterns := true + if config != nil { + execConfig := config.Tools.Exec + enableDenyPatterns = execConfig.EnableDenyPatterns + if enableDenyPatterns { + if len(execConfig.CustomDenyPatterns) > 0 { + fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) + for _, pattern := range execConfig.CustomDenyPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err) + continue + } + denyPatterns = append(denyPatterns, re) + } + } else { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) + } + } 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.") + } + } else { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) } return &ExecTool{