diff --git a/.gitignore b/.gitignore index 72f3b1761..b869ecc33 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ build/ # Secrets & Config (keep templates, ignore actual secrets) .env config/config.json +.security.yml +onboard + # Test coverage.txt diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ea93d0377..9c26de34f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,6 +2,11 @@ # vim: set ts=2 sw=2 tw=0 fo=cnqoj version: 2 +git: + ignore_tags: + - nightly + - ".*-nightly.*" + before: hooks: - go mod tidy diff --git a/Makefile b/Makefile index be8a42278..c54be1555 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,13 @@ define PATCH_MIPS_FLAGS fi endef +# Patch creack/pty for loong64 support (upstream doesn't have ztypes_loong64.go) +PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \ + if [ -d "$$pty_dir" ] && [ ! -f "$$pty_dir/ztypes_loong64.go" ]; then \ + chmod +w "$$pty_dir" 2>/dev/null || true; \ + printf '//go:build linux && loong64\npackage pty\ntype (_C_int int32; _C_uint uint32)\n' > "$$pty_dir/ztypes_loong64.go"; \ + fi + # Golangci-lint GOLANGCI_LINT?=golangci-lint @@ -131,6 +138,14 @@ build-launcher: @ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher" +## build-launcher-tui: Build the picoclaw-launcher TUI binary +build-launcher-tui: + @echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..." + @mkdir -p $(BUILD_DIR) + @$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui + @ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui + @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui" + ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary build-whatsapp-native: generate ## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." @@ -182,6 +197,7 @@ build-all: generate GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + @$(PTY_PATCH_LOONG64) GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) @@ -245,11 +261,11 @@ fmt: ## lint: Run linters lint: - @CGO_ENABLED=0 $(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS) + @$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS) ## fix: Fix linting issues fix: - @CGO_ENABLED=0 $(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS) + @$(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS) ## deps: Download dependencies deps: diff --git a/README.md b/README.md index d3572ce2f..48e9266ca 100644 --- a/README.md +++ b/README.md @@ -322,14 +322,17 @@ This creates `~/.picoclaw/config.json` and the workspace directory. "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-your-api-key" + "model": "openai/gpt-5.4" + // api_key is now loaded from .security.yml } ] } ``` > See `config/config.example.json` in the repo for a complete configuration template with all available options. +> +> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details. + **3. Chat** diff --git a/assets/wechat.png b/assets/wechat.png index effb4dab9..ecce856af 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw-launcher-tui/README.md b/cmd/picoclaw-launcher-tui/README.md new file mode 100644 index 000000000..a942045a5 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/README.md @@ -0,0 +1,69 @@ +# Picoclaw Launcher TUI + +This directory contains the terminal-based TUI launcher for `picoclaw`. +It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment. + +## Architecture + +The TUI launcher is implemented purely in Go with no external runtime dependencies: +* **`main.go`**: Application entry point, handles initialization and main event loop +* **`ui/`**: TUI interface components built on tview + tcell framework: + - `home.go`: Main dashboard with navigation menu + - `schemes.go`: AI model scheme management + - `users.go`: User and API key management for model providers + - `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor + - `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status) + - `app.go`: Core TUI application framework and navigation logic + - `models.go`: Data structures and state management +* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system + +## Getting Started + +### Prerequisites + +* Go 1.25+ +* Terminal with 256-color support (most modern terminals are compatible) + +### Development + +Run the TUI launcher directly in development mode: + +```bash +# From project root +go run ./cmd/picoclaw-launcher-tui + +# Or from this directory +go run . +``` + +### Build + +Build the standalone TUI launcher binary: + +```bash +# From project root (recommended) +make build-launcher-tui + +# Output will be at: +# build/picoclaw-launcher-tui-- +# with symlink build/picoclaw-launcher-tui + +# Or build directly from this directory +go build -o picoclaw-launcher-tui . +``` + +### Key Features + +* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments +* ⚙️ AI model scheme and API key management +* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.) +* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring) +* 💬 One-click launch of interactive AI chat session +* 🎯 Keyboard-first design with intuitive shortcuts + +### Other Commands + +```bash +# Run with custom config file path +go run . /path/to/custom/config.json +``` diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index 0af743bb5..23227d56a 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -28,6 +28,8 @@ func agentCmd(message, sessionKey, model string, debug bool) error { return fmt.Errorf("error loading config: %w", err) } + logger.ConfigureFromEnv() + if debug { logger.SetLevel(logger.DEBUG) fmt.Println("🔍 Debug mode enabled") diff --git a/config/config.example.json b/config/config.example.json index 8dc7bdc59..9ea8f738d 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -10,6 +10,7 @@ "max_tool_iterations": 20, "summarize_message_threshold": 20, "summarize_token_percent": 75, + "split_on_marker": false, "tool_feedback": { "enabled": false, "max_args_length": 300 @@ -129,6 +130,10 @@ "encrypt_key": "", "verification_token": "", "allow_from": [], + "placeholder": { + "enabled": true, + "text": ["Thinking...", "Processing...", "Typing..."] + }, "reasoning_channel_id": "", "random_reaction_emoji": [], "is_lark": false @@ -160,7 +165,7 @@ }, "placeholder": { "enabled": true, - "text": "Thinking... 💭" + "text": ["Thinking...", "Processing...", "Typing..."] }, "reasoning_channel_id": "", "crypto_database_path": "", @@ -223,13 +228,8 @@ "nickserv_password": "", "sasl_user": "", "sasl_password": "", - "channels": [ - "#mychannel" - ], - "request_caps": [ - "server-time", - "message-tags" - ], + "channels": ["#mychannel"], + "request_caps": ["server-time", "message-tags"], "allow_from": [], "group_trigger": { "mention_only": true @@ -240,79 +240,6 @@ "reasoning_channel_id": "" } }, - "providers": { - "_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version", - "anthropic": { - "api_key": "", - "api_base": "" - }, - "openai": { - "api_key": "", - "api_base": "", - "web_search": true - }, - "openrouter": { - "api_key": "sk-or-v1-xxx", - "api_base": "" - }, - "groq": { - "api_key": "gsk_xxx", - "api_base": "" - }, - "zhipu": { - "api_key": "YOUR_ZHIPU_API_KEY", - "api_base": "" - }, - "gemini": { - "api_key": "", - "api_base": "" - }, - "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": "" - }, - "qwen": { - "api_key": "sk-xxx", - "api_base": "" - }, - "ollama": { - "api_key": "", - "api_base": "http://localhost:11434/v1" - }, - "cerebras": { - "api_key": "", - "api_base": "" - }, - "volcengine": { - "api_key": "", - "api_base": "" - }, - "mistral": { - "api_key": "", - "api_base": "https://api.mistral.ai/v1" - }, - "avian": { - "api_key": "", - "api_base": "https://api.avian.io/v1" - }, - "longcat": { - "api_key": "", - "api_base": "https://api.longcat.chat/openai" - }, - "modelscope": { - "api_key": "", - "api_base": "https://api-inference.modelscope.cn/v1" - } - }, "tools": { "allow_read_paths": null, "allow_write_paths": null, @@ -324,9 +251,7 @@ "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", - "api_keys": [ - "YOUR_BRAVE_API_KEY" - ], + "api_keys": ["YOUR_BRAVE_API_KEY"], "max_results": 5 }, "tavily": { @@ -342,9 +267,7 @@ "perplexity": { "enabled": false, "api_key": "pplx-xxx", - "api_keys": [ - "pplx-xxx" - ], + "api_keys": ["pplx-xxx"], "max_results": 5 }, "searxng": { @@ -359,6 +282,12 @@ "search_engine": "search_std", "max_results": 5 }, + "baidu_search": { + "enabled": false, + "api_key": "", + "base_url": "https://qianfan.baidubce.com/v2/ai_search/web_search", + "max_results": 10 + }, "fetch_limit_bytes": 10485760, "private_host_whitelist": [] }, @@ -387,19 +316,12 @@ "filesystem": { "enabled": false, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - "/tmp" - ] + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] }, "github": { "enabled": false, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-github" - ], + "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" } @@ -407,10 +329,7 @@ "brave-search": { "enabled": false, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-brave-search" - ], + "args": ["-y", "@modelcontextprotocol/server-brave-search"], "env": { "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY" } @@ -427,10 +346,7 @@ "slack": { "enabled": false, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-slack" - ], + "args": ["-y", "@modelcontextprotocol/server-slack"], "env": { "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md index dd4b45eba..baded984e 100644 --- a/docs/channels/matrix/README.md +++ b/docs/channels/matrix/README.md @@ -22,7 +22,7 @@ Add this to `config.json`: }, "placeholder": { "enabled": true, - "text": "Thinking..." + "text": ["Thinking...", "Processing...", "Typing..."] }, "reasoning_channel_id": "", "message_format": "richtext", @@ -45,12 +45,19 @@ Add this to `config.json`: | join_on_invite | bool | No | Auto-join invited rooms | | allow_from | []string | No | User whitelist (Matrix user IDs) | | group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) | -| placeholder | object | No | Placeholder message config | +| placeholder | object | No | Placeholder message config (see below) | | reasoning_channel_id | string | No | Target channel for reasoning output | | message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only | | crypto_database_path | string | No | Path to store the crypto database (uses workspace path `~/.picoclaw/workspace` if empty) | | crypto_passphrase | string | No | Serialization key for encrypting session keys in the database; must remain unchanged once set | +### Placeholder Config + +| Field | Type | Required | Description | +|---------|----------------|----------|-------------| +| enabled | bool | No | Enable placeholder messages (default: false) | +| text | string/[]string | No | Placeholder text(s). Can be a single string or array of strings. If multiple texts are provided, one is randomly selected at runtime. Default: "Thinking..." | + ## 3. Currently Supported - Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.) diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md index cd68a057e..81afa550b 100644 --- a/docs/channels/matrix/README.zh.md +++ b/docs/channels/matrix/README.zh.md @@ -22,7 +22,7 @@ }, "placeholder": { "enabled": true, - "text": "Thinking... 💭" + "text": ["Thinking...", "Processing...", "Typing..."] }, "reasoning_channel_id": "", "message_format": "richtext", @@ -51,6 +51,13 @@ | crypto_database_path | string | 否 | 加密数据库存储路径(为空时使用工作空间路径 `~/.picoclaw/workspace`) | | crypto_passphrase | string | 否 | 加密数据库中 session key 的序列化密钥;设置后不能更改 | +### 占位消息配置 (Placeholder) + +| 字段 | 类型 | 必填 | 说明 | +|---------|-----------------|------|------| +| enabled | bool | 否 | 是否启用占位消息(默认:false) | +| text | string/[]string | 否 | 占位文本。可以是单个字符串或字符串数组。如果提供多个文本,运行时会随机选择一个。默认:"Thinking..." | + ## 3. 当前支持 - 文本消息收发 diff --git a/docs/configuration.md b/docs/configuration.md index 4e77300cf..9360d3897 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` +### Gateway Log Level + +`gateway.log_level` controls Gateway log verbosity and is configurable in `config.json`. + +```json +{ + "gateway": { + "log_level": "fatal" + } +} +``` + +When omitted, the default is `fatal`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`. + +You can also override this with the environment variable `PICOCLAW_LOG_LEVEL`. + ### Workspace Layout PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`): @@ -454,6 +470,70 @@ This design also enables **multi-agent support** with flexible provider selectio - **Load balancing**: Distribute requests across multiple endpoints - **Centralized configuration**: Manage all providers in one place +#### 🔒 Security Configuration (Recommended) + +PicoClaw supports separating sensitive data (API keys, tokens, secrets) from your main configuration by storing them in a `.security.yml` file. + +**Key Benefits:** +- **Security**: Sensitive data is never in your main config file +- **Easy sharing**: Share config.json without exposing API keys +- **Version control**: Add `.security.yml` to `.gitignore` +- **Flexible deployment**: Different environments can use different security files + +**Quick Setup:** + +1. Create `~/.picoclaw/.security.yml` with your API keys: +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-proj-your-actual-openai-key" + claude-sonnet-4.6: + api_keys: + - "sk-ant-your-actual-anthropic-key" +channels: + telegram: + token: "your-telegram-bot-token" +web: + brave: + api_keys: + - "BSAyour-brave-api-key" + glm_search: + api_key: "your-glm-search-api-key" +``` + +2. Set proper permissions: +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +3. Remove sensitive fields from `config.json` (recommended): +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4" + // api_key loaded from .security.yml + } + ], + "channels": { + "telegram": { + "enabled": true" + // token loaded from .security.yml + } + } +} +``` + +**How it works:** +- Values from `.security.yml` are automatically mapped to config fields +- No special syntax needed — just omit sensitive fields from config.json +- If a field exists in both files, `.security.yml` value takes precedence +- You can mix direct values in config.json with security values + +For complete documentation, see [`security_configuration.md`](security_configuration.md). + #### All Supported Vendors | Vendor | `model` Prefix | Default API Base | Protocol | API Key | @@ -515,16 +595,20 @@ This design also enables **multi-agent support** with flexible provider selectio } ``` +> **Security Note**: You can remove `api_key` fields from your config and store them in `.security.yml` instead. See [Security Configuration](#-security-configuration-recommended) above for details. + #### Vendor-Specific Examples +> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended). +
OpenAI ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-..." + "model": "openai/gpt-5.4" + // api_key: set in .security.yml } ``` @@ -536,8 +620,8 @@ This design also enables **multi-agent support** with flexible provider selectio ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "model": "volcengine/ark-code-latest" + // api_key: set in .security.yml } ``` @@ -549,8 +633,8 @@ This design also enables **multi-agent support** with flexible provider selectio ```json { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" + "model": "zhipu/glm-4.7" + // api_key: set in .security.yml } ``` @@ -562,8 +646,8 @@ This design also enables **multi-agent support** with flexible provider selectio ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "model": "deepseek/deepseek-chat" + // api_key: set in .security.yml } ``` @@ -575,8 +659,8 @@ This design also enables **multi-agent support** with flexible provider selectio ```json { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "model": "anthropic/claude-sonnet-4.6" + // api_key: set in .security.yml } ``` @@ -616,8 +700,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' { "model_name": "my-custom-model", "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-..." + "api_base": "https://my-proxy.com/v1" + // api_key: set in .security.yml } ``` @@ -629,6 +713,33 @@ PicoClaw strips only the outer `litellm/` prefix before sending the request, so Configure multiple endpoints for the same model name — PicoClaw will automatically round-robin between them: +**Option 1: Multiple API Keys in .security.yml (Recommended)** + +```yaml +# .security.yml +model_list: + gpt-5.4: + api_keys: + - "sk-proj-key-1" + - "sk-proj-key-2" +``` + +```json +// config.json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1" + // api_keys loaded from .security.yml + } + ] +} +``` + +**Option 2: Multiple Model Entries** + ```json { "model_list": [ @@ -685,6 +796,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m } ``` +> **Note**: The `providers` format is deprecated. Use the new `model_list` format with `.security.yml` for better security. +
@@ -701,18 +814,10 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m "dm_scope": "per-channel-peer", "backlog_limit": 20 }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, "channels": { "telegram": { - "enabled": true, - "token": "123456:ABC...", + "enabled": true" + // token: set in .security.yml "allow_from": ["123456789"] } }, @@ -731,6 +836,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m } ``` +> **Note**: Sensitive fields (`api_key`, `token`, etc.) can be omitted and stored in `.security.yml` for better security. +
### Scheduled Tasks / Reminders @@ -754,6 +861,7 @@ Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace | Topic | Description | | ----- | ----------- | +| [Security Configuration](security_configuration.md) | Store API keys and secrets in separate `.security.yml` file | | [Sensitive Data Filtering](sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM | | [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks | | [Steering](steering.md) | Inject messages into a running agent loop between tool calls | diff --git a/docs/credential_encryption.md b/docs/credential_encryption.md index dde8c782c..de3b70e09 100644 --- a/docs/credential_encryption.md +++ b/docs/credential_encryption.md @@ -31,7 +31,7 @@ enc://AAAA...base64... { "model_name": "gpt-4o", "model": "openai/gpt-4o", - "api_key": "enc://AAAA...base64...", + // "api_key": "enc://AAAA...base64..." move to .security.yml "api_base": "https://api.openai.com/v1" } ] diff --git a/docs/docker.md b/docs/docker.md index f868d4a42..a00dfbe9f 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -26,6 +26,9 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d > [!TIP] > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. +> [!NOTE] +> The `gateway` profile only serves the webhook handlers (including Pico when enabled) and health endpoints on the gateway port, so it does not expose generic REST chat endpoints such as `/chat` or `/a2a`. Launcher mode adds the browser UI plus `/api/pico/token` and a `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled. + ```bash # 5. Check logs docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway diff --git a/docs/providers.md b/docs/providers.md index 3a740d3b8..42d46189a 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -27,6 +27,7 @@ | `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | | `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | +| `mimo` | LLM (Xiaomi MiMo direct) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | ### Model Configuration (model_list) @@ -63,6 +64,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | | **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | +| **Xiaomi MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) | | **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | diff --git a/docs/security_configuration.md b/docs/security_configuration.md new file mode 100644 index 000000000..f4fe0e304 --- /dev/null +++ b/docs/security_configuration.md @@ -0,0 +1,644 @@ +# Security Configuration + +## Overview + +PicoClaw supports separating sensitive data (API keys, tokens, secrets, passwords) from the main configuration by storing them in a `.security.yml` file. This improves security by: + +1. **Separation of concerns**: Configuration settings and secrets are in separate files +2. **Easier sharing**: The main config can be shared without exposing sensitive data +3. **Better version control**: `.security.yml` should be added to `.gitignore` +4. **Flexible deployment**: Different environments can use different security files + +## File Structure + +``` +~/.picoclaw/ +├── config.json # Main configuration (safe to share) +└── .security.yml # Security data (never share) +``` + +## How It Works + +The security configuration works through **direct field mapping**, NOT through `ref:` string references. The system automatically loads values from `.security.yml` and applies them to the corresponding fields in `config.json`. + +### Key Points: + +- Values in `.security.yml` are automatically mapped to corresponding fields in the config +- The mapping is based on field names and structure, not on reference strings +- If a value exists in `.security.yml`, it **overrides** the value in `config.json` +- You can omit sensitive fields from `config.json` entirely (recommended) + +## Security Configuration Structure + +### Complete Example: .security.yml + +```yaml +# Model API Keys +# All models MUST use `api_keys` (plural) array format +# Even a single key must be provided as an array with one element +model_list: + gpt-5.4: + api_keys: + - "sk-proj-your-actual-openai-key-1" + - "sk-proj-your-actual-openai-key-2" # Optional: Multiple keys for failover + claude-sonnet-4.6: + api_keys: + - "sk-ant-your-actual-anthropic-key" # Single key in array format + +# Channel Tokens +channels: + telegram: + token: "your-telegram-bot-token" + feishu: + app_secret: "your-feishu-app-secret" + encrypt_key: "your-feishu-encrypt-key" + verification_token: "your-feishu-verification-token" + discord: + token: "your-discord-bot-token" + weixin: + token: "your-weixin-token" + qq: + app_secret: "your-qq-app-secret" + dingtalk: + client_secret: "your-dingtalk-client-secret" + slack: + bot_token: "your-slack-bot-token" + app_token: "your-slack-app-token" + matrix: + access_token: "your-matrix-access-token" + line: + channel_secret: "your-line-channel-secret" + channel_access_token: "your-line-channel-access-token" + onebot: + access_token: "your-onebot-access-token" + wecom: + token: "your-wecom-token" + encoding_aes_key: "your-wecom-encoding-aes-key" + wecom_app: + corp_secret: "your-wecom-app-corp-secret" + token: "your-wecom-app-token" + encoding_aes_key: "your-wecom-app-encoding-aes-key" + wecom_aibot: + secret: "your-wecom-aibot-secret" + token: "your-wecom-aibot-token" + encoding_aes_key: "your-wecom-aibot-encoding-aes-key" + pico: + token: "your-pico-token" + irc: + password: "your-irc-password" + nickserv_password: "your-irc-nickserv-password" + sasl_password: "your-irc-sasl-password" + +# Web Tool API Keys +web: + brave: + api_keys: + - "BSAyour-brave-api-key-1" + - "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover + tavily: + api_keys: + - "tvly-your-tavily-api-key" # Single key in array format + perplexity: + api_keys: + - "pplx-your-perplexity-api-key" # Single key in array format + glm_search: + api_key: "your-glm-search-api-key" # GLMSearch uses single key format (not array) + baidu_search: + api_key: "your-baidu-search-api-key" + +# Skills Registry Tokens +skills: + github: + token: "your-github-token" + clawhub: + auth_token: "your-clawhub-auth-token" +``` + +## Usage + +### Step 1: Create .security.yml + +Create or copy the security file: +```bash +cp security.example.yml ~/.picoclaw/.security.yml +``` + +### Step 2: Fill in your actual values + +Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual API keys and tokens. + +### Step 3: Set proper permissions + +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +### Step 4: Simplify config.json (Recommended) + +You can now remove sensitive fields from `config.json` since they're loaded from `.security.yml`: + +**Before:** +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1", + "api_key": "sk-your-actual-api-key-here" + } + ], + "channels": { + "telegram": { + "enabled": true, + "token": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" + } + } +} +``` + +**After:** +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1" + // api_key is now loaded from .security.yml + } + ], + "channels": { + "telegram": { + "enabled": true" + // token is now loaded from .security.yml + } + } +} +``` + +### Step 5: Verify + +Restart PicoClaw and verify it loads correctly: +```bash +picoclaw --version +``` + +## Field Mapping Rules + +### Models + +**In .security.yml:** +```yaml +model_list: + : + api_keys: + - "key-1" + - "key-2" +``` + +**Mapping:** +- Field `api_keys` (array) maps to the model's API keys +- The `` must match the `model_name` field in `config.json` +- Supports indexed names (e.g., "gpt-5.4:0") - the system will also try the base name ("gpt-5.4") + +### Channels + +Each channel maps its fields directly: + +**In .security.yml:** +```yaml +channels: + telegram: + token: "value" + feishu: + app_secret: "value" + encrypt_key: "value" + verification_token: "value" + discord: + token: "value" +``` + +**Mapping:** +- `channels.telegram.token` → `config.channels.telegram.token` +- `channels.feishu.app_secret` → `config.channels.feishu.app_secret` +- etc. + +### Web Tools + +**Brave, Tavily, Perplexity:** +```yaml +web: + brave: + api_keys: + - "key-1" + - "key-2" +``` +- Use `api_keys` (plural) array format + +**GLMSearch:** +```yaml +web: + glm_search: + api_key: "single-key-here" +``` +- Use `api_key` (singular) single string format + +**BaiduSearch:** +```yaml +web: + baidu_search: + api_key: "your-key" +``` +- Use `api_key` (singular) single string format + +### Skills + +**In .security.yml:** +```yaml +skills: + github: + token: "value" + clawhub: + auth_token: "value" +``` + +## API Key Formats + +### Models - Single key + +Use array format with one element: +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-your-key" +``` + +### Models - Multiple keys (Load Balancing & Failover) + +Use array format with multiple elements: +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-your-key-1" + - "sk-your-key-2" + - "sk-your-key-3" +``` + +**Benefits:** +- **Load balancing**: Requests are distributed across multiple keys +- **Failover**: Automatic switching to another key if one fails +- **Rate limit management**: Distribute usage across multiple keys +- **High availability**: Reduce downtime during API provider issues + +### Web Tools (Brave/Tavily/Perplexity) - Single key + +```yaml +web: + brave: + api_keys: + - "BSA-your-key" +``` + +### Web Tools (Brave/Tavily/Perplexity) - Multiple keys + +```yaml +web: + brave: + api_keys: + - "BSA-key-1" + - "BSA-key-2" +``` + +### Web Tool (GLMSearch/BaiduSearch) - Single key only + +```yaml +web: + glm_search: + api_key: "your-glm-key" # Single string (NOT array) + baidu_search: + api_key: "your-baidu-key" # Single string (NOT array) +``` + +## Model Name Matching + +The system supports intelligent model name matching in `.security.yml`: + +### Example 1: Exact Match + +**config.json:** +```json +{ + "model_name": "gpt-5.4:0" +} +``` + +**.security.yml (exact match with index):** +```yaml +model_list: + gpt-5.4:0: + api_keys: ["key-1"] +``` + +### Example 2: Base Name Match + +**config.json:** +```json +{ + "model_name": "gpt-5.4:0" +} +``` + +**.security.yml (base name without index):** +```yaml +model_list: + gpt-5.4: + api_keys: ["key-1", "key-2"] +``` + +Both methods work. The base name match allows you to use simpler keys in `.security.yml` even when your config uses indexed model names for load balancing. + +## Backward Compatibility + +The system maintains full backward compatibility: + +1. **Direct values**: You can still use direct values in `config.json` (not recommended for production) +2. **Mixed usage**: You can have some fields in `.security.yml` and others in `config.json` +3. **Optional security file**: If `.security.yml` doesn't exist, the system will only use values from `config.json` +4. **Override behavior**: If a field exists in both files, `.security.yml` value takes precedence + +## Environment Variables + +You can override any security value using environment variables: + +**For models:** +```bash +export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env" +``` + +**For channels:** +```bash +export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env" +export PICOCLAW_CHANNELS_FEISHU_APP_SECRET="secret-from-env" +``` + +**For web tools:** +```bash +export PICOCLAW_TOOLS_WEB_BRAVE_API_KEY="key-from-env" +export PICOCLAW_TOOLS_WEB_BAIDU_API_KEY="baidu-key-from-env" +``` + +Environment variables have the highest priority and will override both `config.json` and `.security.yml` values. + +The pattern is: `PICOCLAW_
__` with underscores separating path segments and converted to uppercase. + +## Security Best Practices + +1. **Never commit `.security.yml`** to version control +2. **Add to .gitignore**: Ensure `.security.yml` is in your `.gitignore` file +3. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml` +4. **Use different keys** for different environments (dev, staging, production) +5. **Rotate keys regularly** and update `.security.yml` +6. **Backup securely**: Encrypt backups containing `.security.yml` +7. **Review access**: Ensure only authorized users have read access to the file + +## API + +### loadSecurityConfig + +```go +func loadSecurityConfig(securityPath string) (*SecurityConfig, error) +``` + +Loads the security configuration from `.security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist. + +### saveSecurityConfig + +```go +func saveSecurityConfig(securityPath string, sec *SecurityConfig) error +``` + +Saves the security configuration to `.security.yml` with `0o600` permissions. + +### applySecurityConfig + +```go +func applySecurityConfig(cfg *Config, sec *SecurityConfig) error +``` + +Applies security configuration to the main config by copying values from `.security.yml` to the corresponding fields in the config. + +### securityPath + +```go +func securityPath(configPath string) string +``` + +Returns the path to `.security.yml` relative to the config file. + +## Example: Complete Configuration + +### config.json + +```json +{ + "version": 1, + "agents": { + "defaults": { + "workspace": "~/picoclaw-workspace", + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_base": "https://api.anthropic.com/v1" + } + ], + "channels": { + "telegram": { + "enabled": true + } + }, + "tools": { + "web": { + "brave": { + "enabled": true + } + } + } +} +``` + +### .security.yml + +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-proj-actual-openai-key-1" + - "sk-proj-actual-openai-key-2" + claude-sonnet-4.6: + api_keys: + - "sk-ant-actual-anthropic-key" + +channels: + telegram: + token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" + +web: + brave: + api_keys: + - "BSAactualbravekey-1" + - "BSAactualbravekey-2" + tavily: + api_keys: + - "tvly-your-tavily-key" + glm_search: + api_key: "your-glm-key" + baidu_search: + api_key: "your-baidu-key" +``` + +## Testing + +Run the security configuration tests: + +```bash +go test ./pkg/config -run TestSecurityConfig +``` + +## Troubleshooting + +### Error: "failed to load security config" + +- Verify `.security.yml` exists in the same directory as `config.json` +- Check the YAML syntax is valid (use a YAML validator) +- Ensure file permissions allow reading + +### Error: "model security entry not found" + +- Ensure the model name in `config.json` matches exactly in `.security.yml` +- Check that the `model_list` section exists in `.security.yml` +- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index +- Verify the YAML structure is correct (proper indentation) + +### Multiple API Keys Not Working + +- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch/BaiduSearch) +- Check that the array format is correct in YAML (proper indentation with dashes) +- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format) +- GLMSearch and BaiduSearch MUST use `api_key` (single string format) + +### Load Balancing/Failover Issues + +- Verify all API keys in the `api_keys` array are valid +- Check that all keys have the same rate limits and permissions +- Monitor logs to see which keys are being used and failing +- Ensure the `api_keys` array is properly formatted in YAML + +### Keys Not Being Applied + +- Check that `.security.yml` is in the same directory as `config.json` +- Verify the file permissions allow reading (`chmod 600 ~/.picoclaw/.security.yml`) +- Ensure the YAML structure matches the expected format +- Check for typos in field names (case-sensitive) +- Verify the model/channel names match exactly (case-sensitive) + +## Migration Guide + +### Step 1: Backup your config + +```bash +cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup +``` + +### Step 2: Create .security.yml + +```bash +cp security.example.yml ~/.picoclaw/.security.yml +``` + +### Step 3: Fill in your API keys + +Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual keys. + +### Step 4: Remove sensitive fields from config.json + +Remove or comment out sensitive fields from `config.json`: +- `api_key` fields from `model_list` entries +- `token` fields from `channels` +- `api_key` fields from `tools.web` +- `token`/`auth_token` fields from `tools.skills` + +### Step 5: Set proper permissions + +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +### Step 6: Test + +```bash +picoclaw --version +``` + +### Step 7: Verify functionality + +Test your models and channels to ensure everything works correctly. + +### Step 8: Clean up (optional) + +If everything works, you can delete the backup: +```bash +rm ~/.picoclaw/config.json.backup +``` + +## Advanced: Encrypted API Keys + +PicoClaw supports encrypting API keys in the security file for additional protection. + +### Setup + +1. Set a passphrase via environment variable: +```bash +export PICOCLAW_CREDENTIAL_PASSPHRASE="your-secure-passphrase" +``` + +2. When saving config, API keys will be encrypted automatically: +```go +SaveConfig(path, config) +``` + +### Encrypted Format + +Encrypted keys are stored as: +```yaml +model_list: + gpt-5.4: + api_keys: + - "enc://encrypted-base64-string" +``` + +The system automatically decrypts keys at runtime when loading the configuration. + +### Benefits + +- Additional layer of security +- Keys are encrypted at rest +- Passphrase can be managed separately from the config file + +### Important Notes + +- Always backup your passphrase securely +- If you lose the passphrase, you'll lose access to encrypted keys +- Use a strong, unique passphrase +- Never commit the passphrase to version control diff --git a/docs/zh/providers.md b/docs/zh/providers.md index e7b323ebf..057e7d3d5 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -26,6 +26,7 @@ | `mistral` | LLM (Mistral 直连) | [console.mistral.ai](https://console.mistral.ai) | | `longcat` | LLM (Longcat 直连) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) | +| `mimo` | LLM (小米 MiMo 直连) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | ### 模型配置 (model_list) @@ -62,6 +63,7 @@ | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) | | **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) | | **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) | +| **小米 MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) | | **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | diff --git a/go.mod b/go.mod index e9ef37e98..54c275102 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 + github.com/creack/pty v1.1.24 github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 github.com/gdamore/tcell/v2 v2.13.8 @@ -20,7 +21,6 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 - github.com/mattn/go-sqlite3 v1.14.34 github.com/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/mymmrac/telego v1.7.0 @@ -41,6 +41,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.26.4 modernc.org/sqlite v1.46.1 + rsc.io/qr v0.2.0 ) require ( @@ -69,6 +70,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -84,7 +86,6 @@ require ( modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - rsc.io/qr v0.2.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 87117bc98..ae12473f3 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 12e3cdd4d..c3fcc9fff 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -26,6 +26,7 @@ type ContextBuilder struct { memory *MemoryStore toolDiscoveryBM25 bool toolDiscoveryRegex bool + splitOnMarker bool // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. @@ -52,6 +53,11 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil return cb } +func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { + cb.splitOnMarker = enabled + return cb +} + func getGlobalConfigDir() string { if home := os.Getenv(config.EnvHome); home != "" { return home @@ -157,6 +163,14 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md parts = append(parts, "# Memory\n\n"+memoryContext) } + // Multi-Message Sending (if enabled) + if cb.splitOnMarker { + parts = append(parts, `# MULTI-MESSAGE OUTPUT +You MUST frequently use <|[SPLIT]|> to break your responses into multiple short messages. NEVER output a single long wall of text. Actively split distinct concepts or parts. Example: Message part 1<|[SPLIT]|>Message part 2<|[SPLIT]|>Message part 3 + +Each part separated by the marker will be sent as an independent message.`) + } + // Join with "---" separator return strings.Join(parts, "\n\n---\n\n") } diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 90e729da5..c22318c5e 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -103,10 +103,12 @@ func NewAgentInstance( sessions := initSessionStore(sessionsDir) mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled - contextBuilder := NewContextBuilder(workspace).WithToolDiscovery( - mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25, - mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex, - ) + contextBuilder := NewContextBuilder(workspace). + WithToolDiscovery( + mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25, + mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex, + ). + WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker) agentID := routing.DefaultAgentID agentName := "" diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index e073cb929..e296a18cb 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -236,8 +236,9 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { t.Fatal("exec tool not registered") } execResult := execTool.Execute(context.Background(), map[string]any{ - "command": "cat " + filepath.Base(mediaPath), - "working_dir": mediaDir, + "action": "run", + "command": "cat " + filepath.Base(mediaPath), + "cwd": mediaDir, }) if execResult.IsError { t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 4ca02cd90..b806c9fb7 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -85,6 +85,7 @@ type processOptions struct { DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization SendResponse bool // Whether to send response via bus + SuppressToolFeedback bool // Whether to suppress inline tool feedback messages NoHistory bool // If true, don't load session history (for heartbeat) SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue) } @@ -1247,14 +1248,15 @@ func (al *AgentLoop) ProcessHeartbeat( return "", fmt.Errorf("no default agent for heartbeat") } return al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: "heartbeat", - Channel: channel, - ChatID: chatID, - UserMessage: content, - DefaultResponse: defaultResponse, - EnableSummary: false, - SendResponse: false, - NoHistory: true, // Don't load session history for heartbeat + SessionKey: "heartbeat", + Channel: channel, + ChatID: chatID, + UserMessage: content, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + SuppressToolFeedback: true, + NoHistory: true, // Don't load session history for heartbeat }) } @@ -1952,6 +1954,7 @@ turnLoop: isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || strings.Contains(errMsg, "context window") || + strings.Contains(errMsg, "context_window") || strings.Contains(errMsg, "maximum context length") || strings.Contains(errMsg, "token limit") || strings.Contains(errMsg, "too many tokens") || @@ -2310,7 +2313,9 @@ turnLoop: ) // Send tool feedback to chat channel if enabled (from HEAD) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && ts.channel != "" { + if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && + ts.channel != "" && + !ts.opts.SuppressToolFeedback { feedbackPreview := utils.Truncate( string(argsJSON), al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index e0a5dffb3..2366b1277 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -1018,6 +1019,40 @@ func (m *artifactThenSendProvider) GetDefaultModel() string { return "artifact-then-send-model" } +type toolFeedbackProvider struct { + filePath string + calls int +} + +func (m *toolFeedbackProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_heartbeat_read_file", + Type: "function", + Name: "read_file", + Arguments: map[string]any{"path": m.filePath}, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "HEARTBEAT_OK", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *toolFeedbackProvider) GetDefaultModel() string { + return "heartbeat-tool-feedback-model" +} + type toolLimitOnlyProvider struct{} func (m *toolLimitOnlyProvider) Chat( @@ -2313,6 +2348,112 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T } } +func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt") + if err := os.WriteFile(heartbeatFile, []byte("heartbeat task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1") + if err != nil { + t.Fatalf("ProcessHeartbeat() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("ProcessHeartbeat() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no outbound tool feedback during heartbeat, got %+v", outbound) + case <-time.After(200 * time.Millisecond): + } +} + +func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback.txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user-1", + ChatID: "chat-1", + Content: "check tool feedback", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.Channel != "telegram" { + t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram") + } + if outbound.ChatID != "chat-1" { + t.Fatalf("tool feedback chatID = %q, want %q", outbound.ChatID, "chat-1") + } + if !strings.Contains(outbound.Content, "`read_file`") { + t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback for regular messages") + } +} + func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() @@ -2677,3 +2818,111 @@ func TestFilterClientWebSearch_EmptyInput(t *testing.T) { t.Fatalf("len(result) = %d, want 0", len(result)) } } + +type overflowProvider struct { + calls int + lastMessages []providers.Message + chatFunc func(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any) (*providers.LLMResponse, error) +} + +func (p *overflowProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.calls++ + p.lastMessages = append([]providers.Message(nil), messages...) + + if p.chatFunc != nil { + return p.chatFunc(ctx, messages, tools, model, opts) + } + + if p.calls == 1 { + return nil, errors.New("context_window_exceeded") + } + + return &providers.LLMResponse{ + Content: "Recovered from overflow", + }, nil +} + +func (p *overflowProvider) GetDefaultModel() string { + return "test-model" +} + +func TestProcessMessage_ContextOverflowRecovery(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + _ = cfg + + provider := &overflowProvider{} + al.registry = NewAgentRegistry(al.cfg, provider) + + sessionKey := "agent:main:test-session" + agent := al.GetRegistry().GetDefaultAgent() + + for i := 0; i < 5; i++ { + agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "heavy message"}) + agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"}) + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "test", + ChatID: "chat1", + SenderID: "user1", + SessionKey: "test-session", + Content: "trigger recovery", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Recovered from overflow" { + t.Fatalf("response = %q, want %q", response, "Recovered from overflow") + } + + if provider.calls != 2 { + t.Fatalf("expected 2 calls, got %d", provider.calls) + } +} + +func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + _ = cfg + + provider := &overflowProvider{} + al.registry = NewAgentRegistry(al.cfg, provider) + + recoveryMsg := "error: status 400: context_window_exceeded" + + provider.chatFunc = func( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, + ) (*providers.LLMResponse, error) { + if provider.calls == 1 { + return nil, errors.New(recoveryMsg) + } + return &providers.LLMResponse{Content: "Anthropic recovery success"}, nil + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "test", + ChatID: "chat1", + SenderID: "user1", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if !strings.Contains(response, "Anthropic recovery success") { + t.Fatalf("response = %q, want success message", response) + } + if provider.calls != 2 { + t.Fatalf("expected 2 calls for retry, got %d", provider.calls) + } +} diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 3b5b4f8bb..2385544a6 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -254,10 +254,7 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return "", nil } - text := c.config.Placeholder.Text - if text == "" { - text = "Thinking... 💭" - } + text := c.config.Placeholder.GetRandomText() msg, err := c.session.ChannelMessageSend(chatID, text) if err != nil { diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 0ab70649f..76df988ad 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -211,10 +211,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", nil } - text := c.config.Placeholder.Text - if text == "" { - text = "Thinking..." - } + text := c.config.Placeholder.GetRandomText() cardContent, err := buildMarkdownCard(text) if err != nil { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index c8269dc77..7bcb933ce 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -608,8 +608,10 @@ func newChannelWorker(name string, ch Channel) *channelWorker { } } -// runWorker processes outbound messages for a single channel, splitting -// messages that exceed the channel's maximum message length. +// runWorker processes outbound messages for a single channel. +// Message processing follows this order: +// 1. SplitByMarker (if enabled in config) - LLM semantic marker-based splitting +// 2. SplitMessage - channel-specific length-based splitting (MaxMessageLength) func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) { defer close(w.done) for { @@ -622,15 +624,29 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } - if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - chunks := SplitMessage(msg.Content, maxLen) - for _, chunk := range chunks { - chunkMsg := msg - chunkMsg.Content = chunk - m.sendWithRetry(ctx, name, w, chunkMsg) + + // Collect all message chunks to send + var chunks []string + + // Step 1: Try marker-based splitting if enabled + if m.config != nil && m.config.Agents.Defaults.SplitOnMarker { + if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 { + for _, chunk := range markerChunks { + chunks = append(chunks, splitByLength(chunk, maxLen)...) + } } - } else { - m.sendWithRetry(ctx, name, w, msg) + } + + // Step 2: Fallback to length-based splitting if no chunks from marker + if len(chunks) == 0 { + chunks = splitByLength(msg.Content, maxLen) + } + + // Step 3: Send all chunks + for _, chunk := range chunks { + chunkMsg := msg + chunkMsg.Content = chunk + m.sendWithRetry(ctx, name, w, chunkMsg) } case <-ctx.Done(): return @@ -638,6 +654,14 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) } } +// splitByLength splits content by maxLen if needed, otherwise returns single chunk. +func splitByLength(content string, maxLen int) []string { + if maxLen > 0 && len([]rune(content)) > maxLen { + return SplitMessage(content, maxLen) + } + return []string{content} +} + // sendWithRetry sends a message through the channel with rate limiting and // retry logic. It classifies errors to determine the retry strategy: // - ErrNotRunning / ErrSendFailed: permanent, no retry diff --git a/pkg/channels/marker.go b/pkg/channels/marker.go new file mode 100644 index 000000000..4801e3d27 --- /dev/null +++ b/pkg/channels/marker.go @@ -0,0 +1,37 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package channels + +import ( + "strings" +) + +// MessageSplitMarker is the delimiter used to split a message into multiple outbound messages. +// When SplitOnMarker is enabled in config, the Manager will split messages on this marker +// and send each part as a separate message. +const MessageSplitMarker = "<|[SPLIT]|>" + +// SplitByMarker splits a message by the MessageSplitMarker and returns the parts. +// Empty parts (including from consecutive markers) are filtered out. +// If no marker is found, returns a single-element slice containing the original content. +func SplitByMarker(content string) []string { + if content == "" { + return nil + } + parts := strings.Split(content, MessageSplitMarker) + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + if len(result) == 0 { + return []string{content} + } + return result +} diff --git a/pkg/channels/marker_test.go b/pkg/channels/marker_test.go new file mode 100644 index 000000000..b7b4ca99e --- /dev/null +++ b/pkg/channels/marker_test.go @@ -0,0 +1,141 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package channels + +import ( + "testing" +) + +func TestSplitByMarker_Basic(t *testing.T) { + content := "Hello <|[SPLIT]|>World" + chunks := SplitByMarker(content) + + if len(chunks) != 2 { + t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Hello" { + t.Errorf("Expected first chunk 'Hello', got %q", chunks[0]) + } + if chunks[1] != "World" { + t.Errorf("Expected second chunk 'World', got %q", chunks[1]) + } +} + +func TestSplitByMarker_NoMarker(t *testing.T) { + content := "Hello World" + chunks := SplitByMarker(content) + + if len(chunks) != 1 { + t.Fatalf("Expected 1 chunk, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Hello World" { + t.Errorf("Expected chunk 'Hello World', got %q", chunks[0]) + } +} + +func TestSplitByMarker_MultipleMarkers(t *testing.T) { + content := "Part1 <|[SPLIT]|> Part2 <|[SPLIT]|> Part3" + chunks := SplitByMarker(content) + + if len(chunks) != 3 { + t.Fatalf("Expected 3 chunks, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Part1" || chunks[1] != "Part2" || chunks[2] != "Part3" { + t.Errorf("Unexpected chunks: %q", chunks) + } +} + +func TestSplitByMarker_EmptyParts(t *testing.T) { + // Test consecutive markers and leading/trailing markers + content := "<|[SPLIT]|>Hello <|[SPLIT]|><|[SPLIT]|>World<|[SPLIT]|>" + chunks := SplitByMarker(content) + + if len(chunks) != 2 { + t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Hello" || chunks[1] != "World" { + t.Errorf("Unexpected chunks: %q", chunks) + } +} + +func TestSplitByMarker_WhitespaceTrimmed(t *testing.T) { + content := " Hello <|[SPLIT]|> World " + chunks := SplitByMarker(content) + + if len(chunks) != 2 { + t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Hello" || chunks[1] != "World" { + t.Errorf("Whitespace should be trimmed: %q", chunks) + } +} + +func TestSplitByMarker_EmptyInput(t *testing.T) { + chunks := SplitByMarker("") + if len(chunks) != 0 { + t.Errorf("Expected empty slice for empty input, got %d chunks", len(chunks)) + } +} + +// TestMarkerAndLengthSplitIntegration tests that SplitByMarker and SplitMessage work together correctly. +// Marker splitting happens first (per-agent config), then length splitting happens (per-channel config). +func TestMarkerAndLengthSplitIntegration(t *testing.T) { + maxLen := 10 + + // Original content: "Short <|[SPLIT]|> ThisIsAVeryLongString" + content := "Short <|[SPLIT]|> ThisIsAVeryLongString" + markerChunks := SplitByMarker(content) + + // Step 1: Marker split should give us 2 chunks + if len(markerChunks) != 2 { + t.Fatalf("Expected 2 marker chunks, got %d: %q", len(markerChunks), markerChunks) + } + + // Step 2: Length split should be applied to each marker chunk + var finalChunks []string + for _, chunk := range markerChunks { + if len([]rune(chunk)) > maxLen { + lengthChunks := SplitMessage(chunk, maxLen) + finalChunks = append(finalChunks, lengthChunks...) + } else { + finalChunks = append(finalChunks, chunk) + } + } + + // "Short" is 6 chars, within limit + // "ThisIsAVeryLongString" is 22 chars, should be split into multiple chunks + // SplitMessage with maxLen=10 splits: "ThisIsAVeryLongString" -> ["ThisI", "sAVer", "yLong", "String"] (5 chunks) + if len(finalChunks) != 5 { + t.Errorf("Expected 5 final chunks, got %d: %q", len(finalChunks), finalChunks) + } + + // Verify first chunk is unchanged + if finalChunks[0] != "Short" { + t.Errorf("First chunk should be 'Short', got %q", finalChunks[0]) + } + + // Verify all length-split chunks are within limit + for i, chunk := range finalChunks[1:] { + if len([]rune(chunk)) > maxLen { + t.Errorf("Chunk %d exceeds maxLen: %q (%d chars)", i+1, chunk, len([]rune(chunk))) + } + } +} + +// TestMarkerSplitPreservesCodeBlockIntegrity tests that marker split preserves code block boundaries +func TestMarkerSplitPreservesCodeBlockIntegrity(t *testing.T) { + content := "Hello <|[SPLIT]|>```go\npackage main\n```<|[SPLIT]|>World" + chunks := SplitByMarker(content) + + if len(chunks) != 3 { + t.Fatalf("Expected 3 chunks, got %d: %q", len(chunks), chunks) + } + + // Verify code block is intact in middle chunk + if chunks[1] != "```go\npackage main\n```" { + t.Errorf("Code block not preserved correctly: %q", chunks[1]) + } +} diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index 50b86158d..f6370fa20 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -573,10 +573,7 @@ func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", fmt.Errorf("matrix room ID is empty") } - text := strings.TrimSpace(c.config.Placeholder.Text) - if text == "" { - text = "Thinking... 💭" - } + text := c.config.Placeholder.GetRandomText() resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ MsgType: event.MsgNotice, diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index f3ba55a92..1aa1941cf 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -275,10 +275,7 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin return "", nil } - text := c.config.Placeholder.Text - if text == "" { - text = "Thinking... 💭" - } + text := c.config.Placeholder.GetRandomText() msgID := uuid.New().String() outMsg := newMessage(TypeMessageCreate, map[string]any{ diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index e7da1d615..5adb40a7e 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -402,10 +402,7 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s return "", nil } - text := phCfg.Text - if text == "" { - text = "Thinking... 💭" - } + text := phCfg.GetRandomText() cid, threadID, err := parseTelegramChatID(chatID) if err != nil { diff --git a/pkg/config/SECURITY_CONFIG.md b/pkg/config/SECURITY_CONFIG.md deleted file mode 100644 index 4f783aaa5..000000000 --- a/pkg/config/SECURITY_CONFIG.md +++ /dev/null @@ -1,545 +0,0 @@ -# Security Configuration Refactoring - -## Overview - -This refactoring introduces a `.security.yml` file to store all sensitive data (API keys, tokens, secrets, passwords) separately from the main configuration. This improves security by: - -1. **Separation of concerns**: Configuration settings and secrets are in separate files -2. **Easier sharing**: The main config can be shared without exposing sensitive data -3. **Better version control**: `.security.yml` can be added to `.gitignore` -4. **Flexible deployment**: Different environments can use different security files - -## File Structure - -``` -~/.picoclaw/ -├── config.json # Main configuration (safe to share) -└── .security.yml # Security data (never share) -``` - -## Usage - -### Basic Configuration - -In your `config.json`, use `ref:` references to point to values in `.security.yml`: - -```json -{ - "version": 1, - "model_list": [ - { - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_base": "https://api.openai.com/v1", - "api_key": "ref:model_list.gpt-5.4.api_key" - } - ], - "channels": { - "telegram": { - "enabled": true, - "token": "ref:channels.telegram.token" - } - } -} -``` - -### Security Configuration - -In your `.security.yml`, store the actual values: - -```yaml -model_list: - gpt-5.4: - api_keys: - - "sk-your-actual-api-key-1" - - "sk-your-actual-api-key-2" # Optional: Multiple keys for failover - claude-sonnet-4.6: - api_keys: - - "sk-your-actual-anthropic-key" # Single key in array format - -channels: - telegram: - token: "your-telegram-bot-token" - -web: - brave: - api_keys: - - "BSAyour-brave-api-key-1" - - "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover - tavily: - api_keys: - - "tvly-your-tavily-api-key" # Single key in array format - glm_search: - api_key: "your-glm-search-api-key" # GLMSearch uses single key format -``` - -## Reference Format - -### Model API Keys - -Format: `ref:model_list..api_key` - -Example: `ref:model_list.gpt-5.4.api_key` - -### Channel Tokens/Secrets - -Format: `ref:channels..` - -Examples: -- `ref:channels.telegram.token` -- `ref:channels.feishu.app_secret` -- `ref:channels.feishu.encrypt_key` -- `ref:channels.feishu.verification_token` -- `ref:channels.discord.token` -- `ref:channels.qq.app_secret` -- `ref:channels.dingtalk.client_secret` -- `ref:channels.slack.bot_token` -- `ref:channels.slack.app_token` -- `ref:channels.matrix.access_token` -- `ref:channels.line.channel_secret` -- `ref:channels.line.channel_access_token` -- `ref:channels.onebot.access_token` -- `ref:channels.wecom.secret` -- `ref:channels.pico.token` -- `ref:channels.irc.password` -- `ref:channels.irc.nickserv_password` -- `ref:channels.irc.sasl_password` - -### Web Tool API Keys - -Format: `ref:web..` - -Examples: -- `ref:web.brave.api_key` -- `ref:web.tavily.api_key` -- `ref:web.perplexity.api_key` -- `ref:web.glm_search.api_key` - -### Skills Registry Tokens - -Format: `ref:skills..` - -Examples: -- `ref:skills.github.token` -- `ref:skills.clawhub.auth_token` - -## Backward Compatibility - -The refactoring maintains full backward compatibility: - -1. **Direct values**: You can still use direct values in `config.json` (not recommended for production) -2. **Mixed usage**: You can mix `ref:` references and direct values -3. **Optional security file**: If `.security.yml` doesn't exist, all references will fail (but direct values still work) - -### API Key Formats in .security.yml - -**Models (gpt-5.4, claude-sonnet-4.6, etc.):** -- Must use `api_keys` (array) format -- Both single and multiple keys use array format - -**Web Tools (Brave, Tavily, Perplexity):** -- Must use `api_keys` (array) format -- Both single and multiple keys use array format - -**Web Tools (GLMSearch):** -- Must use `api_key` (single string) format -- Does NOT support array format - -**Channels (Telegram, Discord, etc.):** -- Use single field names (e.g., `token`, `app_secret`) -- Each channel uses its specific field names - -### Single Key (Models) - -Use array format with one element: -```yaml -model_list: - gpt-5.4: - api_keys: - - "sk-your-key" -``` - -In `config.json`: -```json -{ - "api_key": "ref:model_list.gpt-5.4.api_key" -} -``` - -### Single Key (GLMSearch) - -Use single string format: -```yaml -web: - glm_search: - api_key: "your-glm-key" -``` - -In `config.json`: -```json -{ - "api_key": "ref:web.glm_search.api_key" -} -``` - -## Migration Guide - -### Step 1: Create .security.yml - -Copy the example template: -```bash -cp security.example.yml ~/.picoclaw/.security.yml -``` - -### Step 2: Fill in your actual values - -Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual API keys and tokens. - -### Step 3: Update config.json - -Replace sensitive values in `~/.picoclaw/config.json` with `ref:` references: - -**Before:** -```json -{ - "model_list": [ - { - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "sk-your-actual-api-key-here" - } - ] -} -``` - -**After:** -```json -{ - "model_list": [ - { - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "ref:model_list.gpt-5.4.api_key" - } - ] -} -``` - -### Step 4: Verify - -Restart PicoClaw and verify it loads correctly: -```bash -picoclaw --version -``` - -## Security Best Practices - -1. **Never commit `.security.yml`** to version control -2. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml` -3. **Use different keys** for different environments (dev, staging, production) -4. **Rotate keys regularly** and update `.security.yml` -5. **Backup securely**: Encrypt backups containing `.security.yml` - -## API - -### LoadSecurityConfig - -```go -func LoadSecurityConfig(securityPath string) (*SecurityConfig, error) -``` - -Loads the security configuration from `.security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist. - -### SaveSecurityConfig - -```go -func SaveSecurityConfig(securityPath string, sec *SecurityConfig) error -``` - -Saves the security configuration to `.security.yml` with `0o600` permissions. - -### ResolveReference - -```go -func (sec *SecurityConfig) ResolveReference(ref string) (string, error) -``` - -Resolves a reference string (e.g., `"ref:model_list.test.api_key"`) and returns the actual value. - -### SecurityPath - -```go -func SecurityPath(configPath string) string -``` - -Returns the path to `.security.yml` relative to the config file. - -## Example: Complete Configuration - -### config.json -```json -{ - "version": 1, - "agents": { - "defaults": { - "workspace": "~/picoclaw-workspace", - "model_name": "gpt-5.4" - } - }, - "model_list": [ - { - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_base": "https://api.openai.com/v1", - "api_key": "ref:model_list.gpt-5.4.api_key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_base": "https://api.anthropic.com/v1", - "api_key": "ref:model_list.claude-sonnet-4.6.api_key" - } - ], - "channels": { - "telegram": { - "enabled": true, - "token": "ref:channels.telegram.token" - } - }, - "tools": { - "web": { - "brave": { - "enabled": true, - "api_key": "ref:web.brave.api_key" - } - } - } -} -``` - -### .security.yml -```yaml -model_list: - gpt-5.4: - api_keys: - - "sk-proj-actual-openai-key-1" - - "sk-proj-actual-openai-key-2" - claude-sonnet-4.6: - api_keys: - - "sk-ant-actual-anthropic-key" # Single key in array format - -channels: - telegram: - token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" - -web: - brave: - api_keys: - - "BSAactualbravekey-1" - - "BSAactualbravekey-2" - tavily: - api_keys: - - "tvly-your-tavily-key" # Single key in array format - glm_search: - api_key: "your-glm-key" # GLMSearch uses single key format -``` - -## Testing - -The refactoring includes comprehensive tests: - -```bash -go test ./pkg/config -run TestSecurityConfig -``` - -## Troubleshooting - -### Error: "model security entry not found" - -- Ensure the model name in your reference matches exactly in `.security.yml` -- Check that the `model_list` section exists in `.security.yml` -- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index - -### Error: "failed to load security config" - -- Verify `.security.yml` exists in the same directory as `config.json` -- Check the YAML syntax is valid (use a YAML validator) -- Ensure file permissions allow reading - -### Error: "unknown reference path" - -- Verify the reference format is correct -- Check the path structure matches the examples above -- Ensure all required sections exist in `.security.yml` - -## Advanced Features - -### Multiple API Keys (Load Balancing & Failover) - -Both models and web tools support multiple API keys for improved reliability: - -**Benefits:** -- **Load balancing**: Requests are distributed across multiple keys -- **Failover**: Automatic switching to another key if one fails -- **Rate limit management**: Distribute usage across multiple keys -- **High availability**: Reduce downtime during API provider issues - -#### Example: Model with Multiple Keys - -**.security.yml:** -```yaml -model_list: - gpt-5.4: - api_keys: - - "sk-proj-key-1" - - "sk-proj-key-2" - - "sk-proj-key-3" -``` - -**config.json:** -```json -{ - "model_list": [ - { - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_key": "ref:model_list.gpt-5.4.api_key" - } - ] -} -``` - -#### Example: Web Tool with Multiple Keys - -**.security.yml:** -```yaml -web: - brave: - api_keys: - - "BSA-key-1" - - "BSA-key-2" - tavily: - api_keys: - - "tvly-your-key" # Single key in array format - glm_search: - api_key: "your-glm-key" # GLMSearch uses single key format -``` - -**config.json:** -```json -{ - "tools": { - "web": { - "brave": { - "enabled": true, - "api_key": "ref:web.brave.api_key" - }, - "tavily": { - "enabled": true, - "api_key": "ref:web.tavily.api_key" - } - } - } -} -``` - -#### Supported Formats - -**Models - Single key:** -```yaml -model_list: - gpt-5.4: - api_keys: - - "sk-your-key" # Array with one element -``` - -**Models - Multiple keys:** -```yaml -model_list: - gpt-5.4: - api_keys: - - "sk-your-key-1" - - "sk-your-key-2" - - "sk-your-key-3" -``` - -**Web Tools (Brave/Tavily/Perplexity) - Single key:** -```yaml -web: - brave: - api_keys: - - "BSA-your-key" # Array with one element -``` - -**Web Tools (Brave/Tavily/Perplexity) - Multiple keys:** -```yaml -web: - brave: - api_keys: - - "BSA-key-1" - - "BSA-key-2" -``` - -**Web Tool (GLMSearch) - Single key only:** -```yaml -web: - glm_search: - api_key: "your-glm-key" # Single string (NOT array) -``` - -All formats work identically in `config.json` - you always use the same reference format: -```json -{ - "api_key": "ref:model_list.gpt-5.4.api_key" -} -``` - -### Model Indexing for Load Balancing - -When you have multiple models with the same base name but different API keys, you can use indexed names: - -**.security.yml:** -```yaml -model_list: - gpt-5.4: - api_keys: - - "sk-proj-key-1" - - "sk-proj-key-2" -``` - -The system will automatically expand this into multiple model entries with fallback support. - -### Environment Variables - -You can override any security value using environment variables: - -**For models:** -```bash -export PICOCLAW_MODEL_LIST_GPT-5.4_API_KEY="sk-from-env" -``` - -**For channels:** -```bash -export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env" -``` - -**For web tools:** -```bash -export PICOCLAW_WEB_BRAVE_API_KEY="key-from-env" -``` - -Environment variables follow this pattern: `PICOCLAW_
___` with dots replaced by underscores and converted to uppercase. - -### Multiple API Keys Not Working - -- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch) -- Check that the array format is correct in YAML (proper indentation) -- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format) -- GLMSearch MUST use `api_key` (single string format) -- The reference in `config.json` is the same regardless of single or multiple keys - -### Load Balancing/Failover Issues - -- Verify all API keys in the `api_keys` array are valid -- Check that all keys have the same rate limits and permissions -- Monitor logs to see which keys are being used and failing diff --git a/pkg/config/config.go b/pkg/config/config.go index fe32b3df8..5dd0236db 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "fmt" + "math/rand" "os" "path/filepath" "strings" @@ -324,6 +325,7 @@ type AgentDefaults struct { SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` + SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB @@ -386,8 +388,20 @@ type TypingConfig struct { // PlaceholderConfig controls placeholder message behavior (Phase 10). type PlaceholderConfig struct { - Enabled bool `json:"enabled"` - Text string `json:"text,omitempty"` + Enabled bool `json:"enabled"` + Text FlexibleStringSlice `json:"text,omitempty"` +} + +// GetRandomText returns a random placeholder text, or default if none set. +func (p *PlaceholderConfig) GetRandomText() string { + if len(p.Text) == 0 { + return "Thinking..." + } + if len(p.Text) == 1 { + return p.Text[0] + } + idx := rand.Intn(len(p.Text)) + return p.Text[idx] } type StreamingConfig struct { @@ -1325,17 +1339,29 @@ func LoadConfig(path string) (*Config, error) { if err != nil { return nil, err } - // Load security configuration - securityPath := securityPath(path) - sec, err := loadSecurityConfig(securityPath) + + // Legacy config (no version field) + tmpCfg, e := loadConfigV0(data) + if e != nil { + return nil, e + } + + tmpCfgMigrated, e := tmpCfg.Migrate() + if e != nil { + logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) + return nil, e + } + + // Load security configuration from .security.yml + secPath := securityPath(path) + sec, err := loadSecurityConfig(secPath) if err != nil { return nil, fmt.Errorf("failed to load security config: %w", err) } - // Apply security references from .security.yml BEFORE resolveAPIKeys - // This resolves ref: references to actual values - if err := applySecurityConfig(cfg, sec); err != nil { - return nil, fmt.Errorf("failed to apply security config: %w", err) + // Merge security configs: config.json takes precedence over .security.yml + if err := applySecurityConfigWithPrecedence(cfg, tmpCfgMigrated, sec); err != nil { + return nil, fmt.Errorf("failed to merge security config: %w", err) } default: return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) @@ -1568,6 +1594,28 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error { return nil } +// applySecurityConfigWithPrecedence merges security config from tmpCfg (migrated from configV0) and sec (SecurityConfig), +// with tmpCfg taking precedence. It then applies the merged security config to cfg. +func applySecurityConfigWithPrecedence(cfg *Config, tmpCfg *Config, sec *SecurityConfig) error { + // Get security config from tmpCfg (already extracted during migration) + var tmpSec *SecurityConfig + if tmpCfg != nil { + tmpSec = tmpCfg.security + } + + // If tmpCfg has no security config, just apply sec directly + if tmpSec == nil { + return applySecurityConfig(cfg, sec) + } + + // Merge sec and tmpSec, with tmpSec (from config.json) taking precedence + // mergeSecurityConfig(existing, newer) - newer takes precedence + mergedSec := mergeSecurityConfig(sec, tmpSec) + + // Apply the merged security config to cfg + return applySecurityConfig(cfg, mergedSec) +} + func toNameIndex(list []*ModelConfig) []string { nameList := make([]string, 0, len(list)) countMap := make(map[string]int) diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go index 214b5d47e..fcfbad517 100644 --- a/pkg/config/config_old.go +++ b/pkg/config/config_old.go @@ -834,6 +834,7 @@ type webToolsConfigV0 struct { Perplexity perplexityConfigV0 ` json:"perplexity"` SearXNG SearXNGConfig ` json:"searxng"` GLMSearch glmSearchConfigV0 ` json:"glm_search"` + BaiduSearch baiduSearchConfigV0 ` json:"baidu_search"` PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` @@ -925,11 +926,34 @@ func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, *GLMSearchSecu }, sec } +type baiduSearchConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"` +} + +func (v *baiduSearchConfigV0) ToBaiduSearchConfig() (BaiduSearchConfig, *BaiduSearchSecurity) { + var sec *BaiduSearchSecurity + if v.APIKey != "" { + sec = &BaiduSearchSecurity{ + APIKey: v.APIKey, + } + } + return BaiduSearchConfig{ + Enabled: v.Enabled, + apiKey: v.APIKey, + BaseURL: v.BaseURL, + MaxResults: v.MaxResults, + }, sec +} + func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) { brave, braveSecurity := v.Brave.ToBraveConfig() tavily, tavilySecurity := v.Tavily.ToTavilyConfig() perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig() glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig() + baiduSearch, baiduSearchSecurity := v.BaiduSearch.ToBaiduSearchConfig() return WebToolsConfig{ ToolConfig: v.ToolConfig, @@ -939,16 +963,18 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) Perplexity: perplexity, SearXNG: v.SearXNG, GLMSearch: glmSearch, + BaiduSearch: baiduSearch, PreferNative: v.PreferNative, Proxy: v.Proxy, FetchLimitBytes: v.FetchLimitBytes, Format: v.Format, PrivateHostWhitelist: v.PrivateHostWhitelist, }, WebToolsSecurity{ - Brave: braveSecurity, - Tavily: tavilySecurity, - Perplexity: perplexitySecurity, - GLMSearch: glmSearchSecurity, + Brave: braveSecurity, + Tavily: tavilySecurity, + Perplexity: perplexitySecurity, + GLMSearch: glmSearchSecurity, + BaiduSearch: baiduSearchSecurity, } } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index bedd46f6e..6718de91e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -487,6 +487,33 @@ func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) { } } +func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) { + cfg := DefaultConfig() + if cfg.Agents.Defaults.ToolFeedback.Enabled { + t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false") + } +} + +func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile( + configPath, + []byte(`{"version":1,"agents":{"defaults":{"workspace":"./workspace"}}}`), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Agents.Defaults.ToolFeedback.Enabled { + t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file") + } +} + func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index f3f82a8c1..ba9709314 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -39,9 +39,10 @@ func DefaultConfig() *Config { SummarizeTokenPercent: 75, SteeringMode: "one-at-a-time", ToolFeedback: ToolFeedbackConfig{ - Enabled: true, + Enabled: false, MaxArgsLength: 300, }, + SplitOnMarker: false, }, }, Bindings: []AgentBinding{}, @@ -62,7 +63,7 @@ func DefaultConfig() *Config { Typing: TypingConfig{Enabled: true}, Placeholder: PlaceholderConfig{ Enabled: true, - Text: "Thinking... 💭", + Text: FlexibleStringSlice{"Thinking... 💭"}, }, Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200}, UseMarkdownV2: false, @@ -111,7 +112,7 @@ func DefaultConfig() *Config { }, Placeholder: PlaceholderConfig{ Enabled: true, - Text: "Thinking... 💭", + Text: FlexibleStringSlice{"Thinking... 💭"}, }, CryptoDatabasePath: "", CryptoPassphrase: "", diff --git a/pkg/config/example_security_usage.go b/pkg/config/example_security_usage.go index 09aee9aa3..42a1831b0 100644 --- a/pkg/config/example_security_usage.go +++ b/pkg/config/example_security_usage.go @@ -11,20 +11,33 @@ Package config # Example: Using Security Configuration -## 1. Create security.yml +## Overview -File: ~/.picoclaw/security.yml +The security configuration feature allows you to separate sensitive data (API keys, +tokens, secrets, passwords) from your main configuration. The system automatically +loads values from `.security.yml` and applies them to the corresponding fields in +your config. + +**Key Points:** +- Values from `.security.yml` are automatically mapped to config fields +- No `ref:` syntax is needed - just omit sensitive fields from config.json +- If a field exists in both files, `.security.yml` value takes precedence +- You can mix direct values in config.json with security values + +## 1. Create .security.yml + +File: ~/.picoclaw/.security.yml ```yaml # Model API Keys -# Note: Use 'api_keys' array for multiple keys (load balancing/failover) -# Single key should be provided as an array with one element +# All models MUST use 'api_keys' (plural) array format +# Even a single key must be provided as an array with one element model_list: gpt-5.4: api_keys: - "sk-proj-your-actual-openai-key-1" - - "sk-proj-your-actual-openai-key-2" # Failover key + - "sk-proj-your-actual-openai-key-2" # Optional: Multiple keys for failover claude-sonnet-4.6: api_keys: - "sk-ant-your-actual-anthropic-key" # Single key in array format @@ -38,80 +51,95 @@ channels: token: "your-discord-bot-token" # Web Tool Keys -# Note: Use 'api_keys' array for multiple keys (load balancing/failover) -# For GLMSearch, use 'api_key' (single string) +# Brave, Tavily, Perplexity: Use 'api_keys' array +# GLMSearch, BaiduSearch: Use 'api_key' single string web: brave: api_keys: - "BSAyour-brave-api-key-1" - - "BSAyour-brave-api-key-2" # Failover key + - "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover tavily: api_keys: - "tvly-your-tavily-api-key" # Single key in array format + perplexity: + api_keys: + - "pplx-your-perplexity-api-key" # Single key in array format glm_search: api_key: "your-glm-search-api-key" # Single key (not array) + baidu_search: + api_key: "your-baidu-search-api-key" # Single key (not array) ``` -## 2. Update config.json to use references +## 2. Simplify config.json File: ~/.picoclaw/config.json +Note: Sensitive fields are omitted because they're loaded from .security.yml + ```json - { - "version": 1, - "agents": { - "defaults": { - "workspace": "~/picoclaw-workspace", - "model_name": "gpt-5.4" - } - }, - "model_list": [ - { - "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", - "api_base": "https://api.openai.com/v1", - "api_key": "ref:model_list.gpt-5.4.api_key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_base": "https://api.anthropic.com/v1", - "api_key": "ref:model_list.claude-sonnet-4.6.api_key" - } - ], - "channels": { - "telegram": { - "enabled": true, - "token": "ref:channels.telegram.token" - }, - "discord": { - "enabled": true, - "token": "ref:channels.discord.token" - } - }, + { + "version": 1, + "agents": { + "defaults": { + "workspace": "~/picoclaw-workspace", + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1" + // api_key is automatically loaded from .security.yml + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_base": "https://api.anthropic.com/v1" + // api_key is automatically loaded from .security.yml + } + ], + "channels": { + "telegram": { + "enabled": true + // token is automatically loaded from .security.yml + }, + "discord": { + "enabled": true + // token is automatically loaded from .security.yml + } + }, "tools": { "web": { "brave": { - "enabled": true, - "api_key": "ref:web.brave.api_key" + "enabled": true + // api_key is automatically loaded from .security.yml }, "tavily": { - "enabled": true, - "api_key": "ref:web.tavily.api_key" + "enabled": true + // api_key is automatically loaded from .security.yml + }, + "glm_search": { + "enabled": true + // api_key is automatically loaded from .security.yml + }, + "baidu_search": { + "enabled": true + // api_key is automatically loaded from .security.yml } } } - } + } ``` ## 3. Set proper permissions ```bash -chmod 600 ~/.picoclaw/security.yml +chmod 600 ~/.picoclaw/.security.yml ``` ## 4. Add to .gitignore @@ -127,51 +155,131 @@ chmod 600 ~/.picoclaw/security.yml picoclaw --version ``` -# Available Reference Paths +# Supported Fields in .security.yml ## Model API Keys -- ref:model_list..api_key + +All models MUST use the `api_keys` (plural) array format in .security.yml. + +```yaml +model_list: + + : + api_keys: + - "key-1" + - "key-2" # Optional: Multiple keys for failover + +``` Examples: -- ref:model_list.gpt-5.4.api_key -- ref:model_list.claude-sonnet-4.6.api_key +```yaml +model_list: -**Note:** In .security.yml, use `api_keys` (array) format for models. -Both single and multiple keys should use the array format. + gpt-5.4: + api_keys: + - "sk-proj-key-1" + - "sk-proj-key-2" + claude-sonnet-4.6: + api_keys: + - "sk-ant-key" + +``` + +**Important:** +- Always use `api_keys` (plural) for models +- Even a single key must be in an array format +- The model_name in .security.yml must match the model_name in config.json ## Channel Tokens/Secrets -- ref:channels.telegram.token -- ref:channels.feishu.app_secret -- ref:channels.feishu.encrypt_key -- ref:channels.feishu.verification_token -- ref:channels.discord.token -- ref:channels.qq.app_secret -- ref:channels.dingtalk.client_secret -- ref:channels.slack.bot_token -- ref:channels.slack.app_token -- ref:channels.matrix.access_token -- ref:channels.line.channel_secret -- ref:channels.line.channel_access_token -- ref:channels.onebot.access_token -- ref:channels.wecom.secret -- ref:channels.pico.token -- ref:channels.irc.password -- ref:channels.irc.nickserv_password -- ref:channels.irc.sasl_password + +```yaml +channels: + + telegram: + token: "value" + feishu: + app_secret: "value" + encrypt_key: "value" + verification_token: "value" + discord: + token: "value" + weixin: + token: "value" + qq: + app_secret: "value" + dingtalk: + client_secret: "value" + slack: + bot_token: "value" + app_token: "value" + matrix: + access_token: "value" + line: + channel_secret: "value" + channel_access_token: "value" + onebot: + access_token: "value" + wecom: + token: "value" + encoding_aes_key: "value" + wecom_app: + corp_secret: "value" + token: "value" + encoding_aes_key: "value" + wecom_aibot: + secret: "value" + token: "value" + encoding_aes_key: "value" + pico: + token: "value" + irc: + password: "value" + nickserv_password: "value" + sasl_password: "value" ## Web Tool API Keys -- ref:web.brave.api_key -- ref:web.tavily.api_key -- ref:web.perplexity.api_key -- ref:web.glm_search.api_key -**Note:** -- Brave, Tavily, Perplexity: Use `api_keys` (array) format in .security.yml -- GLMSearch: Use `api_key` (single string) format in .security.yml +**Brave, Tavily, Perplexity:** +```yaml +web: + + brave: + api_keys: + - "BSA-key-1" + - "BSA-key-2" + tavily: + api_keys: + - "tvly-key" + perplexity: + api_keys: + - "pplx-key" + +``` +Use `api_keys` (plural) array format. + +**GLMSearch, BaiduSearch:** +```yaml +web: + + glm_search: + api_key: "your-glm-key" + baidu_search: + api_key: "your-baidu-key" + +``` +Use `api_key` (singular) single string format. ## Skills Registry Tokens -- ref:skills.github.token -- ref:skills.clawhub.auth_token + +```yaml +skills: + + github: + token: "value" + clawhub: + auth_token: "value" + +``` # Backward Compatibility @@ -185,14 +293,14 @@ You can still use direct values in config.json if needed: "model_name": "local-model", "model": "ollama/llama3", "api_base": "http://localhost:11434/v1", - "api_key": "ollama" // Direct value (no reference) + "api_key": "ollama" // Direct value (works fine) } ] } ``` -You can also mix references and direct values: +You can also mix security values and direct values: ```json @@ -200,10 +308,12 @@ You can also mix references and direct values: "model_list": [ { "model_name": "cloud-model", - "api_key": "ref:model_list.cloud-model.api_key" // From .security.yml + // api_key loaded from .security.yml }, { "model_name": "local-model", + "model": "ollama/llama3", + "api_base": "http://localhost:11434/v1", "api_key": "ollama" // Direct value } ] @@ -211,6 +321,11 @@ You can also mix references and direct values: ``` +**Priority Order:** +1. Environment variables (highest priority) +2. .security.yml values +3. config.json direct values (lowest priority) + # Migration from Old Config ## Step 1: Backup your config @@ -218,7 +333,7 @@ You can also mix references and direct values: cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup ``` -## Step 2: Copy the example security file +## Step 2: Create .security.yml ```bash cp security.example.yml ~/.picoclaw/.security.yml ``` @@ -226,10 +341,19 @@ cp security.example.yml ~/.picoclaw/.security.yml ## Step 3: Fill in your API keys Edit ~/.picoclaw/.security.yml and replace placeholders with your actual keys. -## Step 4: Update config.json references -Replace sensitive values in ~/.picoclaw/config.json with ref: references. +## Step 4: Simplify config.json (Recommended) +Remove sensitive fields from ~/.picoclaw/config.json: +- `api_key` fields from model_list entries +- `token` fields from channels +- `api_key` fields from tools.web +- `token`/`auth_token` fields from tools.skills -## Step 5: Test +## Step 5: Set permissions +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +## Step 6: Test ```bash picoclaw --version ``` @@ -243,9 +367,11 @@ rm ~/.picoclaw/config.json.backup ## Multiple API Keys (Load Balancing & Failover) -You can configure multiple API keys for both models and web tools to enable: +You can configure multiple API keys for models and web tools to enable: - **Load balancing**: Requests are distributed across multiple keys - **Failover**: If a key fails, the system automatically switches to another key +- **Rate limit management**: Distribute usage across multiple keys +- **High availability**: Reduce downtime during API provider issues ### Example: Model with Multiple Keys @@ -269,7 +395,7 @@ model_list: { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "ref:model_list.gpt-5.4.api_key" + "api_base": "https://api.openai.com/v1" } ] } @@ -301,8 +427,13 @@ web: "tools": { "web": { "brave": { - "enabled": true, - "api_key": "ref:web.brave.api_key" + "enabled": true + }, + "tavily": { + "enabled": true + }, + "glm_search": { + "enabled": true } } } @@ -310,9 +441,9 @@ web: ``` -### Single Key +## Single Key Format -Use array format with one element: +**Models, Brave, Tavily, Perplexity:** ```yaml model_list: @@ -322,36 +453,32 @@ model_list: ``` -### Multiple Keys (Load Balancing & Failover) - -Use array format with multiple elements: +**GLMSearch, BaiduSearch:** ```yaml -model_list: +web: - gpt-5.4: - api_keys: - - "sk-proj-key-1" - - "sk-proj-key-2" - - "sk-proj-key-3" + glm_search: + api_key: "your-glm-key" # Single key (not array) ``` -**Important:** All model keys in .security.yml must use the `api_keys` (plural) array format. -The single `api_key` (singular) format is NOT supported for models. - -### Model Index Matching +## Model Name Matching The system supports intelligent model name matching in .security.yml: -**Example 1: Exact Match** -```yaml -# config.json +### Example 1: Exact Match + +**config.json:** +```json { "model_name": "gpt-5.4:0" } -# .security.yml (exact match with index) +``` + +**.security.yml (exact match with index):** +```yaml model_list: gpt-5.4:0: @@ -359,26 +486,30 @@ model_list: ``` -**Example 2: Base Name Match** -```yaml -# config.json +### Example 2: Base Name Match + +**config.json:** +```json { "model_name": "gpt-5.4:0" } -# .security.yml (base name without index) +``` + +**.security.yml (base name without index):** +```yaml model_list: gpt-5.4: - api_keys: ["key-1"] + api_keys: ["key-1", "key-2"] ``` Both methods work. The base name match allows you to use simpler keys in .security.yml even when your config uses indexed model names for load balancing. -### Security File Permissions +## Security File Permissions The security file should have restricted permissions: @@ -391,26 +522,64 @@ This ensures only the owner can read and write the file. # Security Best Practices 1. Never commit .security.yml to version control -2. Set file permissions: chmod 600 ~/.picoclaw/.security.yml -3. Use different keys for different environments -4. Rotate keys regularly and update .security.yml -5. Encrypt backups containing .security.yml +2. Add .security.yml to your .gitignore file +3. Set file permissions: chmod 600 ~/.picoclaw/.security.yml +4. Use different keys for different environments (dev, staging, production) +5. Rotate keys regularly and update .security.yml +6. Encrypt backups containing .security.yml +7. Review access regularly + +# Environment Variables + +You can override any security value using environment variables: + +```bash +# Channels +export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env" +export PICOCLAW_CHANNELS_DISCORD_TOKEN="discord-token-from-env" + +# Web Tools +export PICOCLAW_TOOLS_WEB_BRAVE_API_KEY="brave-key-from-env" +export PICOCLAW_TOOLS_WEB_BAIDU_API_KEY="baidu-key-from-env" + +# Skills +export PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN="github-token-from-env" +``` + +Environment variables have the highest priority and will override both config.json +and .security.yml values. # Troubleshooting +## Error: "failed to load security config" +- Ensure .security.yml exists in the same directory as config.json +- Check YAML syntax is valid (use a YAML validator) +- Verify file permissions allow reading + ## Error: "model security entry not found" - Check that the model name in config.json matches exactly in .security.yml - Verify the model_list section exists in .security.yml +- For indexed names (e.g., "gpt-5.4:0"), check both exact match and base name match +- Ensure the YAML structure is correct (proper indentation) -## Error: "failed to load security config" -- Ensure .security.yml exists in the same directory as config.json -- Check YAML syntax is valid -- Verify file permissions allow reading +## Multiple API Keys Not Working +- Ensure you're using `api_keys` (plural) in .security.yml for models and web tools (except GLMSearch/BaiduSearch) +- Check that the array format is correct in YAML (proper indentation with dashes) +- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format) +- GLMSearch and BaiduSearch MUST use `api_key` (single string format) -## Error: "unknown reference path" -- Verify the reference format is correct -- Check the path structure matches the examples above -- Ensure all required sections exist in .security.yml +## Keys Not Being Applied +- Check that .security.yml is in the same directory as config.json +- Verify the file permissions allow reading (chmod 600 ~/.picoclaw/.security.yml) +- Ensure the YAML structure matches the expected format +- Check for typos in field names (case-sensitive) +- Verify the model/channel names match exactly (case-sensitive) + +## Load Balancing/Failover Issues +- Verify all API keys in the api_keys array are valid +- Check that all keys have the same rate limits and permissions +- Monitor logs to see which keys are being used and failing +- Ensure the api_keys array is properly formatted in YAML */ package config diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 03990ce5b..002988f2f 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -43,7 +43,8 @@ func TestSecurityConfigIntegration(t *testing.T) { t.Run("Full workflow with security references", func(t *testing.T) { tmpDir := t.TempDir() - // Create config.json with references + // Create config.json with direct security values (not ref: references) + // These values should take precedence over .security.yml configPath := filepath.Join(tmpDir, "config.json") configContent := `{ "version": 1, @@ -52,25 +53,25 @@ func TestSecurityConfigIntegration(t *testing.T) { "model_name": "test-model", "model": "openai/test-model", "api_base": "https://api.openai.com/v1", - "api_key": "ref:model_list.test-model.api_key" + "api_key": "sk-from-config-json-direct" } ], "channels": { "telegram": { "enabled": true, - "token": "ref:channels.telegram.token" + "token": "token-from-config-json-direct" } }, "tools": { "web": { "brave": { "enabled": true, - "api_key": "ref:web.brave.api_key" + "api_key": "BSA-from-config-json-direct" } }, "skills": { "github": { - "token": "ref:skills.github.token" + "token": "ghp-from-config-json-direct" } } } @@ -78,46 +79,47 @@ func TestSecurityConfigIntegration(t *testing.T) { err := os.WriteFile(configPath, []byte(configContent), 0o644) require.NoError(t, err) - // Create .security.yml with actual values + // Create .security.yml with different values + // These should be overridden by config.json values securityPath := filepath.Join(tmpDir, SecurityConfigFile) securityContent := `model_list: test-model: api_keys: - - "sk-test-api-key-12345" + - "sk-from-security-yml" channels: telegram: - token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" + token: "token-from-security-yml" web: brave: api_keys: - - "BSAbrave-api-key-67890" + - "BSA-from-security-yml" skills: github: - token: "ghp_github-token-abc123"` + token: "ghp-from-security-yml"` err = os.WriteFile(securityPath, []byte(securityContent), 0o600) require.NoError(t, err) - // Load config and verify references are resolved + // Load config and verify config.json values take precedence cfg, err := LoadConfig(configPath) require.NoError(t, err) require.NotNil(t, cfg) - // Verify model API key is resolved + // Verify model API key from config.json takes precedence assert.Equal(t, 1, len(cfg.ModelList)) assert.Equal(t, "test-model", cfg.ModelList[0].ModelName) - assert.Equal(t, "sk-test-api-key-12345", cfg.ModelList[0].apiKeys[0]) + assert.Equal(t, "sk-from-config-json-direct", cfg.ModelList[0].apiKeys[0]) - // Verify channel token is resolved - assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.token) + // Verify channel token from config.json takes precedence + assert.Equal(t, "token-from-config-json-direct", cfg.Channels.Telegram.token) - // Verify web tool API key is resolved - assert.Equal(t, "BSAbrave-api-key-67890", cfg.Tools.Web.Brave.APIKey()) + // Verify web tool API key from config.json takes precedence + assert.Equal(t, "BSA-from-config-json-direct", cfg.Tools.Web.Brave.APIKey()) - // Verify skills token is resolved - assert.Equal(t, "ghp_github-token-abc123", cfg.Tools.Skills.Github.token) + // Verify skills token from config.json takes precedence + assert.Equal(t, "ghp-from-config-json-direct", cfg.Tools.Skills.Github.token) }) } diff --git a/pkg/gateway/channel_matrix.go b/pkg/gateway/channel_matrix.go index f753c60e2..a46addae1 100644 --- a/pkg/gateway/channel_matrix.go +++ b/pkg/gateway/channel_matrix.go @@ -1,4 +1,4 @@ -//go:build !mipsle && !netbsd +//go:build !mipsle && !netbsd && !(freebsd && arm) package gateway @@ -12,6 +12,9 @@ import ( // - netbsd/*: modernc.org/sqlite v1.46.1 fails to compile due to broken // generated mutex code on NetBSD (for example sqlite_netbsd_amd64.go calls // mu.enter/mu.leave, but the generated mutex type does not define them). + // - freebsd/arm: modernc.org/libc v1.67.6 fails to compile due to broken + // generated 32-bit FreeBSD code (size_t/uint64 and int32/int64 mismatches + // in libc_freebsd.go). // // This means Matrix is currently unavailable on those targets. The proper // long-term fix is to split Matrix basic support from its E2EE/sqlite-backed diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index eeb1436de..1bcc1cec9 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -2,6 +2,7 @@ package logger import ( "fmt" + "io" "os" "path/filepath" "runtime" @@ -100,6 +101,12 @@ func SetConsoleLevel(level LogLevel) { logger = logger.Level(level) } +func DisableConsole() { + mu.Lock() + defer mu.Unlock() + logger = zerolog.New(io.Discard).With().Timestamp().Caller().Logger() +} + func GetLevel() LogLevel { mu.RLock() defer mu.RUnlock() @@ -170,6 +177,22 @@ func DisableFileLogging() { fileLogger = zerolog.Logger{} } +func ConfigureFromEnv() { + if logFile := os.Getenv("PICOCLAW_LOG_FILE"); logFile != "" { + if strings.HasPrefix(logFile, "~/") { + if home := os.Getenv("HOME"); home != "" { + logFile = filepath.Join(home, logFile[2:]) + } + } + + if err := EnableFileLogging(logFile); err != nil { + fmt.Fprintf(os.Stderr, "failed to enable file logging: %v\n", err) + } else { + DisableConsole() + } + } +} + func getCallerSkip() int { for i := 2; i < 15; i++ { pc, file, _, ok := runtime.Caller(i) diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 6ad3a8dd6..1eca72607 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -4,7 +4,11 @@ import ( "bytes" "encoding/json" "errors" + "fmt" + "os" + "path/filepath" "testing" + "time" "github.com/rs/zerolog" ) @@ -365,3 +369,40 @@ func TestAppendFields_ErrorUsesErrorString(t *testing.T) { t.Fatalf("error field = %#v, want %q", got["error"], "transcription request failed") } } + +func TestDisableConsole(t *testing.T) { + DisableConsole() + Info("this should go to nowhere") +} + +func TestConfigureFromEnv(t *testing.T) { + home := os.Getenv("HOME") + if home == "" { + t.Skip("HOME not set") + } + + tmpFile := "/tmp/picoclaw_test_log_" + fmt.Sprintf("%d", time.Now().UnixNano()) + defer os.Remove(tmpFile) + + os.Setenv("PICOCLAW_LOG_FILE", tmpFile) + defer os.Unsetenv("PICOCLAW_LOG_FILE") + + ConfigureFromEnv() + + if logFile == nil { + t.Error("expected log file to be set") + } + + Info("test message") + + os.Setenv("PICOCLAW_LOG_FILE", "~/test_log") + ConfigureFromEnv() + + expanded := filepath.Join(home, "test_log") + defer os.Remove(expanded) +} + +func TestConfigureFromEnvNoEnv(t *testing.T) { + os.Unsetenv("PICOCLAW_LOG_FILE") + ConfigureFromEnv() +} diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index fd9bf1e81..e7691aa93 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -84,6 +84,15 @@ var ( substr("messages.1.content.1.tool_use.id"), substr("invalid request format"), } + contextOverflowPatterns = []errorPattern{ + rxp(`context[_ ]?length[_ ]?exceeded`), + rxp(`context[_ ]?window[_ ]?exceeded`), + substr("maximum context length"), + substr("token limit"), + substr("too many tokens"), + substr("prompt is too long"), + substr("request too large"), + } imageDimensionPatterns = []errorPattern{ rxp(`image dimensions exceed max`), @@ -201,6 +210,9 @@ func classifyByMessage(msg string) FailoverReason { if matchesAny(msg, formatPatterns) { return FailoverFormat } + if matchesAny(msg, contextOverflowPatterns) { + return FailoverContextOverflow + } return "" } diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go index 67d9af62b..46b180835 100644 --- a/pkg/providers/error_classifier_test.go +++ b/pkg/providers/error_classifier_test.go @@ -221,6 +221,30 @@ func TestClassifyError_ImageDimensionError(t *testing.T) { } } +func TestClassifyError_ContextOverflowPatterns(t *testing.T) { + patterns := []string{ + "context_length_exceeded", + "context_window_exceeded", + "maximum context length", + "token limit", + "too many tokens", + "prompt is too long", + "request too large", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverContextOverflow { + t.Errorf("pattern %q: reason = %q, want context_overflow", msg, result.Reason) + } + } +} + func TestClassifyError_ImageSizeError(t *testing.T) { err := errors.New("image exceeds 20 mb limit") result := ClassifyError(err, "openai", "gpt-4o") @@ -265,6 +289,7 @@ func TestFailoverError_IsRetriable(t *testing.T) { {FailoverTimeout, true}, {FailoverOverloaded, true}, {FailoverFormat, false}, + {FailoverContextOverflow, false}, {FailoverUnknown, true}, } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index d0edb0d01..10f1e5a89 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -178,7 +178,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding": + "coding-plan", "alibaba-coding", "qwen-coding", "mimo": // All other OpenAI-compatible HTTP providers if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -372,6 +372,8 @@ func getDefaultAPIBase(protocol string) string { return "https://api.longcat.chat/openai" case "modelscope": return "https://api-inference.modelscope.cn/v1" + case "mimo": + return "https://api.xiaomimimo.com/v1" default: return "" } diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 2fed18c35..f1fe02cc2 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -123,6 +123,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"ollama", "ollama"}, {"longcat", "longcat"}, {"modelscope", "modelscope"}, + {"mimo", "mimo"}, } for _, tt := range tests { @@ -252,6 +253,35 @@ func TestGetDefaultAPIBase_Novita(t *testing.T) { } } +func TestCreateProviderFromConfig_Mimo(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-mimo", + Model: "mimo/mimo-v2-pro", + APIBase: "https://api.xiaomimimo.com/v1", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "mimo-v2-pro" { + t.Errorf("modelID = %q, want %q", modelID, "mimo-v2-pro") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestGetDefaultAPIBase_Mimo(t *testing.T) { + if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1") + } +} + func TestCreateProviderFromConfig_Anthropic(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-anthropic", diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 9a4d126a7..f98ae9243 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -71,13 +71,14 @@ type NativeSearchCapable interface { type FailoverReason string const ( - FailoverAuth FailoverReason = "auth" - FailoverRateLimit FailoverReason = "rate_limit" - FailoverBilling FailoverReason = "billing" - FailoverTimeout FailoverReason = "timeout" - FailoverFormat FailoverReason = "format" - FailoverOverloaded FailoverReason = "overloaded" - FailoverUnknown FailoverReason = "unknown" + FailoverAuth FailoverReason = "auth" + FailoverRateLimit FailoverReason = "rate_limit" + FailoverBilling FailoverReason = "billing" + FailoverTimeout FailoverReason = "timeout" + FailoverFormat FailoverReason = "format" + FailoverContextOverflow FailoverReason = "context_overflow" + FailoverOverloaded FailoverReason = "overloaded" + FailoverUnknown FailoverReason = "unknown" ) // FailoverError wraps an LLM provider error with classification metadata. @@ -101,7 +102,7 @@ func (e *FailoverError) Unwrap() error { // IsRetriable returns true if this error should trigger fallback to next candidate. // Non-retriable: Format errors (bad request structure, image dimension/size). func (e *FailoverError) IsRetriable() bool { - return e.Reason != FailoverFormat + return e.Reason != FailoverFormat && e.Reason != FailoverContextOverflow } // ModelConfig holds primary model and fallback list. diff --git a/pkg/tools/session.go b/pkg/tools/session.go new file mode 100644 index 000000000..141dd4b5e --- /dev/null +++ b/pkg/tools/session.go @@ -0,0 +1,252 @@ +package tools + +import ( + "bytes" + "errors" + "io" + "os" + "sync" + "time" + + "github.com/google/uuid" +) + +const maxOutputBufferSize = 1 * 1024 * 1024 // 1MB + +const outputTruncateMarker = "\n... [output truncated, exceeded 1MB]\n" + +// PtyKeyMode represents arrow key encoding mode for PTY sessions. +// Programs send smkx/rmkx sequences to switch between CSI and SS3 modes. +type PtyKeyMode uint8 + +const ( + PtyKeyModeCSI PtyKeyMode = iota // triggered by rmkx (\x1b[?1l) + PtyKeyModeSS3 // triggered by smkx (\x1b[?1h) +) + +const PtyKeyModeNotFound PtyKeyMode = 255 + +var ( + ErrSessionNotFound = errors.New("session not found") + ErrSessionDone = errors.New("session already completed") + ErrPTYNotSupported = errors.New("PTY is not supported on this platform") + ErrNoStdin = errors.New("no stdin available") +) + +type ProcessSession struct { + mu sync.Mutex + ID string + PID int + Command string + PTY bool + Background bool + StartTime int64 + ExitCode int + Status string + stdinWriter io.Writer + stdoutPipe io.Reader + outputBuffer *bytes.Buffer + outputTruncated bool + ptyMaster *os.File + + // ptyKeyMode tracks arrow key encoding mode (CSI vs SS3) + ptyKeyMode PtyKeyMode +} + +func (s *ProcessSession) IsDone() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.Status == "done" || s.Status == "exited" +} + +func (s *ProcessSession) GetPtyKeyMode() PtyKeyMode { + s.mu.Lock() + defer s.mu.Unlock() + return s.ptyKeyMode +} + +func (s *ProcessSession) SetPtyKeyMode(mode PtyKeyMode) { + s.mu.Lock() + defer s.mu.Unlock() + s.ptyKeyMode = mode +} + +func (s *ProcessSession) GetStatus() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.Status +} + +func (s *ProcessSession) SetStatus(status string) { + s.mu.Lock() + defer s.mu.Unlock() + s.Status = status +} + +func (s *ProcessSession) GetExitCode() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.ExitCode +} + +func (s *ProcessSession) SetExitCode(code int) { + s.mu.Lock() + defer s.mu.Unlock() + s.ExitCode = code +} + +func (s *ProcessSession) killProcess() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.Status != "running" { + return ErrSessionDone + } + + pid := s.PID + if pid <= 0 { + return ErrSessionNotFound + } + + if err := killProcessGroup(pid); err != nil { + return err + } + + s.Status = "done" + s.ExitCode = -1 + return nil +} + +func (s *ProcessSession) Kill() error { + return s.killProcess() +} + +func (s *ProcessSession) Write(data string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.Status != "running" { + return ErrSessionDone + } + + var writer io.Writer + if s.PTY && s.ptyMaster != nil { + writer = s.ptyMaster + } else if s.stdinWriter != nil { + writer = s.stdinWriter + } else { + return ErrNoStdin + } + + _, err := writer.Write([]byte(data)) + return err +} + +func (s *ProcessSession) Read() string { + s.mu.Lock() + defer s.mu.Unlock() + + if s.outputBuffer.Len() == 0 { + return "" + } + + data := s.outputBuffer.String() + s.outputBuffer.Reset() + return data +} + +func (s *ProcessSession) ToSessionInfo() SessionInfo { + s.mu.Lock() + defer s.mu.Unlock() + + return SessionInfo{ + ID: s.ID, + Command: s.Command, + Status: s.Status, + PID: s.PID, + StartedAt: s.StartTime, + } +} + +type SessionManager struct { + mu sync.RWMutex + sessions map[string]*ProcessSession +} + +func NewSessionManager() *SessionManager { + sm := &SessionManager{ + sessions: make(map[string]*ProcessSession), + } + + // Start cleaner goroutine - runs every 5 minutes, cleans up sessions done for >30 minutes + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + sm.cleanupOldSessions() + } + }() + + return sm +} + +// cleanupOldSessions removes sessions that are done and older than 30 minutes +func (sm *SessionManager) cleanupOldSessions() { + sm.mu.Lock() + defer sm.mu.Unlock() + + cutoff := time.Now().Add(-30 * time.Minute) + for id, session := range sm.sessions { + if session.IsDone() && session.StartTime < cutoff.Unix() { + delete(sm.sessions, id) + } + } +} + +func (sm *SessionManager) Add(session *ProcessSession) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.sessions[session.ID] = session +} + +func (sm *SessionManager) Get(sessionID string) (*ProcessSession, error) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + session, ok := sm.sessions[sessionID] + if !ok { + return nil, ErrSessionNotFound + } + + return session, nil +} + +func (sm *SessionManager) Remove(sessionID string) { + sm.mu.Lock() + defer sm.mu.Unlock() + delete(sm.sessions, sessionID) +} + +func (sm *SessionManager) List() []SessionInfo { + sm.mu.RLock() + defer sm.mu.RUnlock() + + result := make([]SessionInfo, 0, len(sm.sessions)) + for _, session := range sm.sessions { + result = append(result, session.ToSessionInfo()) + } + + return result +} + +func generateSessionID() string { + return uuid.New().String()[:8] +} + +type SessionInfo struct { + ID string `json:"id"` + Command string `json:"command"` + Status string `json:"status"` + PID int `json:"pid"` + StartedAt int64 `json:"startedAt"` +} diff --git a/pkg/tools/session_process_unix.go b/pkg/tools/session_process_unix.go new file mode 100644 index 000000000..2fe30166e --- /dev/null +++ b/pkg/tools/session_process_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package tools + +import ( + "syscall" +) + +func killProcessGroup(pid int) error { + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + return nil +} diff --git a/pkg/tools/session_process_windows.go b/pkg/tools/session_process_windows.go new file mode 100644 index 000000000..7cf558954 --- /dev/null +++ b/pkg/tools/session_process_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package tools + +import ( + "os/exec" + "strconv" +) + +func killProcessGroup(pid int) error { + _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run() + return nil +} diff --git a/pkg/tools/session_test.go b/pkg/tools/session_test.go new file mode 100644 index 000000000..6cfe72a10 --- /dev/null +++ b/pkg/tools/session_test.go @@ -0,0 +1,99 @@ +package tools + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSessionManager_AddGet(t *testing.T) { + sm := NewSessionManager() + session := &ProcessSession{ + ID: "test-1", + Command: "echo hello", + Status: "running", + StartTime: 1000, + } + + sm.Add(session) + + got, err := sm.Get("test-1") + require.NoError(t, err) + require.Equal(t, "test-1", got.ID) +} + +func TestSessionManager_Remove(t *testing.T) { + sm := NewSessionManager() + session := &ProcessSession{ + ID: "test-1", + Command: "echo hello", + Status: "running", + StartTime: 1000, + } + sm.Add(session) + sm.Remove("test-1") + + _, err := sm.Get("test-1") + require.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestSessionManager_List(t *testing.T) { + sm := NewSessionManager() + sm.Add(&ProcessSession{ + ID: "test-1", + Command: "echo hello", + Status: "running", + StartTime: 1000, + }) + sm.Add(&ProcessSession{ + ID: "test-2", + Command: "echo world", + Status: "running", + StartTime: 1001, + }) + sm.Add(&ProcessSession{ + ID: "test-3", + Command: "echo done", + Status: "done", + StartTime: 1002, + }) + + sessions := sm.List() + require.Len(t, sessions, 3) + + ids := make(map[string]bool) + for _, s := range sessions { + ids[s.ID] = true + } + require.True(t, ids["test-1"]) + require.True(t, ids["test-2"]) + require.True(t, ids["test-3"]) +} + +func TestProcessSession_IsDone(t *testing.T) { + session := &ProcessSession{Status: "running"} + require.False(t, session.IsDone()) + + session.Status = "done" + require.True(t, session.IsDone()) + + session.Status = "exited" + require.True(t, session.IsDone()) +} + +func TestProcessSession_ToSessionInfo(t *testing.T) { + session := &ProcessSession{ + ID: "test-1", + PID: 12345, + Command: "echo hello", + Status: "running", + StartTime: 1000, + } + + info := session.ToSessionInfo() + require.Equal(t, "test-1", info.ID) + require.Equal(t, "echo hello", info.Command) + require.Equal(t, "running", info.Status) + require.Equal(t, 12345, info.PID) + require.Equal(t, int64(1000), info.StartedAt) +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 78ad2b26d..6ee1cb993 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -3,20 +3,36 @@ package tools import ( "bytes" "context" + "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" + "sync" "time" + "github.com/creack/pty" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" ) +var ( + globalSessionManager = NewSessionManager() + sessionManagerMu sync.RWMutex +) + +func getSessionManager() *SessionManager { + sessionManagerMu.RLock() + defer sessionManagerMu.RUnlock() + return globalSessionManager +} + type ExecTool struct { workingDir string timeout time.Duration @@ -26,6 +42,7 @@ type ExecTool struct { allowedPathPatterns []*regexp.Regexp restrictToWorkspace bool allowRemote bool + sessionManager *SessionManager } var ( @@ -145,7 +162,7 @@ func NewExecToolWithConfig( denyPatterns = append(denyPatterns, defaultDenyPatterns...) } - timeout := 60 * time.Second + var timeout time.Duration if config != nil && config.Tools.Exec.TimeoutSeconds > 0 { timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second } @@ -159,6 +176,7 @@ func NewExecToolWithConfig( allowedPathPatterns: allowedPathPatterns, restrictToWorkspace: restrict, allowRemote: allowRemote, + sessionManager: getSessionManager(), }, nil } @@ -167,27 +185,82 @@ func (t *ExecTool) Name() string { } func (t *ExecTool) Description() string { - return "Execute a shell command and return its output. Use with caution." + return `Execute shell commands. Use background=true for long-running commands (returns sessionId). Use pty=true for interactive commands (can combine with background=true). Use poll/read/write/send-keys/kill with sessionId to manage background sessions. Sessions auto-cleanup 30 minutes after process exits; use kill to terminate early. Output buffer limit: 1MB.` } func (t *ExecTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"run", "list", "poll", "read", "write", "kill", "send-keys"}, + "description": "Action: run (execute command), list (show sessions), poll (check status), read (get output), write (send input), kill (terminate), send-keys (send keys to PTY)", + }, "command": map[string]any{ "type": "string", - "description": "The shell command to execute", + "description": "Shell command to execute (required for run)", }, - "working_dir": map[string]any{ + "sessionId": map[string]any{ "type": "string", - "description": "Optional working directory for the command", + "description": "Session ID (required for poll/read/write/kill/send-keys)", + }, + "keys": map[string]any{ + "type": "string", + "description": "Key names for send-keys: up, down, left, right, enter, tab, escape, backspace, ctrl-c, ctrl-d, home, end, pageup, pagedown, f1-f12", + }, + "data": map[string]any{ + "type": "string", + "description": "Data to write to stdin (required for write)", + }, + "background": map[string]any{ + "type": "string", + "description": "Run in background immediately", + }, + "pty": map[string]any{ + "type": "string", + "description": "Run in a pseudo-terminal (PTY) when available", + }, + "cwd": map[string]any{ + "type": "string", + "description": "Working directory for the command", + }, + "timeout": map[string]any{ + "type": "integer", + "description": "Timeout in seconds (0 = no timeout)", }, }, - "required": []string{"command"}, + "required": []string{"action"}, } } func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + if action == "" { + return ErrorResult("action is required") + } + + switch action { + case "run": + return t.executeRun(ctx, args) + case "list": + return t.executeList() + case "poll": + return t.executePoll(args) + case "read": + return t.executeRead(args) + case "write": + return t.executeWrite(args) + case "kill": + return t.executeKill(args) + case "send-keys": + return t.executeSendKeys(args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s", action)) + } +} + +func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolResult { command, ok := args["command"].(string) if !ok { return ErrorResult("command is required") @@ -206,8 +279,26 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult } } + getBoolArg := func(key string) bool { + switch v := args[key].(type) { + case bool: + return v + case string: + return v == "true" + } + return false + } + isPty := getBoolArg("pty") + isBackground := getBoolArg("background") + + if isPty { + if runtime.GOOS == "windows" { + return ErrorResult("PTY is not supported on Windows. Use background=true without pty.") + } + } + cwd := t.workingDir - if wd, ok := args["working_dir"].(string); ok && wd != "" { + if wd, ok := args["cwd"].(string); ok && wd != "" { if t.restrictToWorkspace && t.workingDir != "" { resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns) if err != nil { @@ -253,6 +344,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult } } + if isBackground { + return t.runBackground(ctx, command, cwd, isPty) + } + + return t.runSync(ctx, command, cwd) +} + +func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult { // timeout == 0 means no timeout var cmdCtx context.Context var cancel context.CancelFunc @@ -361,6 +460,560 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult } } +func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEnabled bool) *ToolResult { + sessionID := generateSessionID() + session := &ProcessSession{ + ID: sessionID, + Command: command, + PTY: ptyEnabled, + Background: true, + StartTime: time.Now().Unix(), + Status: "running", + ptyKeyMode: PtyKeyModeCSI, + } + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + if cwd != "" { + cmd.Dir = cwd + } + + prepareCommandForTermination(cmd) + + var stdoutReader io.ReadCloser + var stderrReader io.ReadCloser + var stdinWriter io.WriteCloser + + if ptyEnabled { + ptmx, tty, err := pty.Open() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create PTY: %v", err)) + } + + cmd.Stdin = tty + cmd.Stdout = tty + cmd.Stderr = tty + + // For PTY, we need Setsid to create a new session. + // Note: Setsid and Setpgid conflict, so we must replace SysProcAttr entirely. + setSysProcAttrForPty(cmd) + + session.ptyMaster = ptmx + } else { + var err error + stdoutReader, err = cmd.StdoutPipe() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create stdout pipe: %v", err)) + } + stderrReader, err = cmd.StderrPipe() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create stderr pipe: %v", err)) + } + stdinWriter, err = cmd.StdinPipe() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create stdin pipe: %v", err)) + } + session.stdoutPipe = io.MultiReader(stdoutReader, stderrReader) + session.stdinWriter = stdinWriter + } + + if err := cmd.Start(); err != nil { + if session.ptyMaster != nil { + session.ptyMaster.Close() + } + return ErrorResult(fmt.Sprintf("failed to start command: %v", err)) + } + + session.PID = cmd.Process.Pid + t.sessionManager.Add(session) + + session.outputBuffer = &bytes.Buffer{} + + // PTY mode: read from ptyMaster and wait for process + // Note: On Linux, closing ptyMaster doesn't interrupt blocking Read() calls, + // so we need cmd.Wait() in a separate goroutine to detect process exit. + if session.PTY && session.ptyMaster != nil { + go func() { + cmd.Wait() // Wait for process to exit + session.mu.Lock() + if cmd.ProcessState != nil { + session.ExitCode = cmd.ProcessState.ExitCode() + } + session.Status = "done" + session.mu.Unlock() + }() + + go func() { + buf := make([]byte, 4096) + for { + n, err := session.ptyMaster.Read(buf) + if n > 0 { + raw := string(buf[:n]) + if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && mode != session.GetPtyKeyMode() { + session.SetPtyKeyMode(mode) + } + + session.mu.Lock() + if session.outputBuffer.Len() >= maxOutputBufferSize { + if !session.outputTruncated { + session.outputBuffer.WriteString(outputTruncateMarker) + session.outputTruncated = true + } + } else { + session.outputBuffer.Write(buf[:n]) + } + session.mu.Unlock() + } + if err != nil { + break + } + } + }() + } else { + // Non-PTY mode: single goroutine reads pipes. + // When Read() returns EOF (pipe closed), we break. + // When process exits, OS closes pipe write end → Read() returns EOF → we exit. + go func() { + buf := make([]byte, 4096) + + // Read stdout + for { + n, err := stdoutReader.Read(buf) + if n > 0 { + session.mu.Lock() + if session.outputBuffer.Len() >= maxOutputBufferSize { + if !session.outputTruncated { + session.outputBuffer.WriteString(outputTruncateMarker) + session.outputTruncated = true + } + } else { + session.outputBuffer.Write(buf[:n]) + } + session.mu.Unlock() + } + if err != nil { + break + } + } + + // Read stderr + for { + n, err := stderrReader.Read(buf) + if n > 0 { + session.mu.Lock() + if session.outputBuffer.Len() >= maxOutputBufferSize { + if !session.outputTruncated { + session.outputBuffer.WriteString(outputTruncateMarker) + session.outputTruncated = true + } + } else { + session.outputBuffer.Write(buf[:n]) + } + session.mu.Unlock() + } + if err != nil { + break + } + } + + // All pipes closed, get exit status + if stdinWriter != nil { + stdinWriter.Close() + } + cmd.Wait() + + session.mu.Lock() + if cmd.ProcessState != nil { + session.ExitCode = cmd.ProcessState.ExitCode() + } + session.Status = "done" + session.mu.Unlock() + }() + } + + resp := ExecResponse{ + SessionID: sessionID, + Status: "running", + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + ForUser: fmt.Sprintf("Session %s started", sessionID), + IsError: false, + } +} + +func (t *ExecTool) executeList() *ToolResult { + sessions := t.sessionManager.List() + resp := ExecResponse{ + Sessions: sessions, + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + ForUser: fmt.Sprintf("%d active sessions", len(sessions)), + IsError: false, + } +} + +func (t *ExecTool) executePoll(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + resp := ExecResponse{ + SessionID: sessionID, + Status: session.GetStatus(), + ExitCode: session.GetExitCode(), + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + IsError: false, + } +} + +func (t *ExecTool) executeRead(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + output := session.Read() + + resp := ExecResponse{ + SessionID: sessionID, + Output: output, + Status: session.GetStatus(), + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + IsError: false, + } +} + +func (t *ExecTool) executeWrite(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + data, ok := args["data"].(string) + if !ok { + return ErrorResult("data is required") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + if session.IsDone() { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + + if err := session.Write(data); err != nil { + if errors.Is(err, ErrSessionDone) { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + return ErrorResult(fmt.Sprintf("failed to write to session: %v", err)) + } + + resp := ExecResponse{ + SessionID: sessionID, + Status: session.GetStatus(), + } + respData, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(respData), + IsError: false, + } +} + +func (t *ExecTool) executeKill(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + if session.IsDone() { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + + if err := session.Kill(); err != nil { + return ErrorResult(fmt.Sprintf("failed to kill session: %v", err)) + } + + t.sessionManager.Remove(sessionID) + + resp := ExecResponse{ + SessionID: sessionID, + Status: "done", + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + ForUser: fmt.Sprintf("Session %s killed", sessionID), + IsError: false, + } +} + +// keyMap maps key names to their escape sequences. +var keyMap = map[string]string{ + "enter": "\r", + "return": "\r", + "tab": "\t", + "escape": "\x1b", + "esc": "\x1b", + "space": " ", + "backspace": "\x7f", + "bspace": "\x7f", + "up": "\x1b[A", + "down": "\x1b[B", + "right": "\x1b[C", + "left": "\x1b[D", + "home": "\x1b[1~", + "end": "\x1b[4~", + "pageup": "\x1b[5~", + "pagedown": "\x1b[6~", + "pgup": "\x1b[5~", + "pgdn": "\x1b[6~", + "insert": "\x1b[2~", + "ic": "\x1b[2~", + "delete": "\x1b[3~", + "del": "\x1b[3~", + "dc": "\x1b[3~", + "btab": "\x1b[Z", + "f1": "\x1bOP", + "f2": "\x1bOQ", + "f3": "\x1bOR", + "f4": "\x1bOS", + "f5": "\x1b[15~", + "f6": "\x1b[17~", + "f7": "\x1b[18~", + "f8": "\x1b[19~", + "f9": "\x1b[20~", + "f10": "\x1b[21~", + "f11": "\x1b[23~", + "f12": "\x1b[24~", +} + +// ss3KeysMap maps key names to SS3 escape sequences +var ss3KeysMap = map[string]string{ + "up": "\x1bOA", + "down": "\x1bOB", + "right": "\x1bOC", + "left": "\x1bOD", + "home": "\x1bOH", + "end": "\x1bOF", +} + +func detectPtyKeyMode(raw string) PtyKeyMode { + const SMKX = "\x1b[?1h" + const RMKX = "\x1b[?1l" + + lastSmkx := strings.LastIndex(raw, SMKX) + lastRmkx := strings.LastIndex(raw, RMKX) + + if lastSmkx == -1 && lastRmkx == -1 { + return PtyKeyModeNotFound + } + + if lastSmkx > lastRmkx { + return PtyKeyModeSS3 + } + return PtyKeyModeCSI +} + +// encodeKeyToken encodes a single key token into its escape sequence. +// Supports: +// - Named keys: "enter", "tab", "up", "ctrl-c", "alt-x", etc. +// - Ctrl modifier: "ctrl-c" or "c-c" (sends Ctrl+char) +// - Alt modifier: "alt-x" or "m-x" (sends ESC+char) +func encodeKeyToken(token string, ptyKeyMode PtyKeyMode) (string, error) { + token = strings.ToLower(strings.TrimSpace(token)) + if token == "" { + return "", nil + } + + // Handle ctrl-X format (c-x) + if strings.HasPrefix(token, "c-") { + char := token[2] + if char >= 'a' && char <= 'z' { + return string(rune(char) & 0x1f), nil // ctrl-a through ctrl-z + } + return "", fmt.Errorf("invalid ctrl key: %s", token) + } + + // Handle ctrl-X format (ctrl-x) + if strings.HasPrefix(token, "ctrl-") { + char := token[5] + if char >= 'a' && char <= 'z' { + return string(rune(char) & 0x1f), nil + } + return "", fmt.Errorf("invalid ctrl key: %s", token) + } + + // Handle alt-X format (m-x or alt-x) + if strings.HasPrefix(token, "m-") || strings.HasPrefix(token, "alt-") { + var char string + if strings.HasPrefix(token, "m-") { + char = token[2:] + } else { + char = token[4:] + } + if len(char) == 1 { + return "\x1b" + char, nil + } + return "", fmt.Errorf("invalid alt key: %s", token) + } + + // Handle shift modifier for special keys (shift-up, shift-down, etc.) + if strings.HasPrefix(token, "s-") || strings.HasPrefix(token, "shift-") { + var key string + if strings.HasPrefix(token, "s-") { + key = token[2:] + } else { + key = token[6:] + } + // Apply shift modifier: for single-char keys, return uppercase + if seq, ok := keyMap[key]; ok { + // For escape sequences, we can't easily add shift + // For single-char keys (letters), return uppercase + if len(seq) == 1 { + return strings.ToUpper(seq), nil + } + return seq, nil + } + return "", fmt.Errorf("unknown key with shift: %s", key) + } + + if ptyKeyMode == PtyKeyModeSS3 { + if seq, ok := ss3KeysMap[token]; ok { + return seq, nil + } + } + + if seq, ok := keyMap[token]; ok { + return seq, nil + } + + return "", fmt.Errorf("unknown key: %s (use write action for text input)", token) +} + +// encodeKeySequence encodes a slice of key tokens into a single string. +func encodeKeySequence(tokens []string, ptyKeyMode PtyKeyMode) (string, error) { + var result string + for _, token := range tokens { + seq, err := encodeKeyToken(token, ptyKeyMode) + if err != nil { + return "", err + } + result += seq + } + return result, nil +} + +func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + keysStr, ok := args["keys"].(string) + if !ok { + return ErrorResult("keys must be a string") + } + + if keysStr == "" { + return ErrorResult("keys cannot be empty") + } + + // Parse comma-separated key names + keyNames := strings.Split(keysStr, ",") + var keys []string + for _, k := range keyNames { + k = strings.TrimSpace(k) + if k != "" { + keys = append(keys, k) + } + } + + if len(keys) == 0 { + return ErrorResult("keys cannot be empty") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + ptyKeyMode := session.GetPtyKeyMode() + + data, err := encodeKeySequence(keys, ptyKeyMode) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid key: %v", err)) + } + + if session.IsDone() { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + + if err := session.Write(data); err != nil { + if errors.Is(err, ErrSessionDone) { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + return ErrorResult(fmt.Sprintf("failed to send keys: %v", err)) + } + + resp := ExecResponse{ + SessionID: sessionID, + Status: "running", + Output: fmt.Sprintf("Sent keys: %v", keys), + } + respData, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(respData), + IsError: false, + } +} + func (t *ExecTool) guardCommand(command, cwd string) string { cmd := strings.TrimSpace(command) lower := strings.ToLower(cmd) diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index f8f83ea74..a8de2f4c9 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -2,12 +2,16 @@ package tools import ( "context" + "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" "time" + "github.com/stretchr/testify/require" + "github.com/sipeed/picoclaw/pkg/config" ) @@ -20,6 +24,7 @@ func TestShellTool_Success(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "echo 'hello world'", } @@ -50,6 +55,7 @@ func TestShellTool_Failure(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "ls /nonexistent_directory_12345", } @@ -82,6 +88,7 @@ func TestShellTool_Timeout(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "sleep 10", } @@ -112,8 +119,9 @@ func TestShellTool_WorkingDir(t *testing.T) { ctx := context.Background() args := map[string]any{ - "command": "cat test.txt", - "working_dir": tmpDir, + "action": "run", + "command": "cat test.txt", + "cwd": tmpDir, } result := tool.Execute(ctx, args) @@ -136,6 +144,7 @@ func TestShellTool_DangerousCommand(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "rm -rf /", } @@ -159,6 +168,7 @@ func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "kill 12345", } @@ -198,6 +208,7 @@ func TestShellTool_StderrCapture(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "sh -c 'echo stdout; echo stderr >&2'", } @@ -222,6 +233,7 @@ func TestShellTool_OutputTruncation(t *testing.T) { ctx := context.Background() // Generate long output (>10000 chars) args := map[string]any{ + "action": "run", "command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000), } @@ -251,8 +263,9 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { } result := tool.Execute(context.Background(), map[string]any{ - "command": "pwd", - "working_dir": outsideDir, + "action": "run", + "command": "pwd", + "cwd": outsideDir, }) if !result.IsError { @@ -289,8 +302,9 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { } result := tool.Execute(context.Background(), map[string]any{ - "command": "cat secret.txt", - "working_dir": link, + "action": "run", + "command": "cat secret.txt", + "cwd": link, }) if !result.IsError { @@ -312,7 +326,7 @@ func TestShellTool_RemoteChannelBlockedByDefault(t *testing.T) { t.Fatalf("NewExecToolWithConfig() error: %v", err) } ctx := WithToolContext(context.Background(), "telegram", "chat-1") - result := tool.Execute(ctx, map[string]any{"command": "echo hi"}) + result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"}) if !result.IsError { t.Fatal("expected remote-channel exec to be blocked") @@ -333,7 +347,7 @@ func TestShellTool_InternalChannelAllowed(t *testing.T) { t.Fatalf("NewExecToolWithConfig() error: %v", err) } ctx := WithToolContext(context.Background(), "cli", "direct") - result := tool.Execute(ctx, map[string]any{"command": "echo hi"}) + result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"}) if result.IsError { t.Fatalf("expected internal channel exec to succeed, got: %s", result.ForLLM) @@ -373,7 +387,7 @@ func TestShellTool_AllowRemoteBypassesChannelCheck(t *testing.T) { t.Fatalf("NewExecToolWithConfig() error: %v", err) } ctx := WithToolContext(context.Background(), "telegram", "chat-1") - result := tool.Execute(ctx, map[string]any{"command": "echo hi"}) + result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"}) if result.IsError { t.Fatalf("expected allowRemote=true to permit remote channel, got: %s", result.ForLLM) @@ -392,6 +406,7 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "cat ../../etc/passwd", } @@ -429,7 +444,7 @@ func TestShellTool_DevNullAllowed(t *testing.T) { } for _, cmd := range commands { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "blocked") { t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM) } @@ -458,7 +473,7 @@ func TestShellTool_BlockDevices(t *testing.T) { } for _, cmd := range blocked { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if !result.IsError { t.Errorf("expected block device write to be blocked: %s", cmd) } @@ -482,7 +497,7 @@ func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) { } for _, cmd := range commands { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("safe path should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) } @@ -498,6 +513,7 @@ func TestShellTool_ExitCodeDetails(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "sh -c 'exit 42'", } @@ -534,6 +550,7 @@ func TestShellTool_TimeoutWithPartialOutput(t *testing.T) { ctx := context.Background() // Use a command that outputs immediately then sleeps args := map[string]any{ + "action": "run", "command": "echo 'partial output before timeout' && sleep 30", } @@ -608,7 +625,9 @@ func TestShellTool_URLsNotBlocked(t *testing.T) { } for _, cmd := range commands { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) + cancel() if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) } @@ -633,7 +652,7 @@ func TestShellTool_FileURISandboxing(t *testing.T) { } for _, cmd := range blockedCommands { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("file:// URI outside workspace should be blocked: %s", cmd) } @@ -651,7 +670,7 @@ func TestShellTool_FileURISandboxing(t *testing.T) { } for _, cmd := range allowedCommands { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM) } @@ -677,9 +696,920 @@ func TestShellTool_URLBypassPrevented(t *testing.T) { } for _, cmd := range blockedCommands { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM) } } } + +func TestShellTool_Background_ReturnsImmediately(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + ctx := context.Background() + args := map[string]any{ + "action": "run", + "command": "sleep 5", + "background": "true", + } + + start := time.Now() + result := tool.Execute(ctx, args) + elapsed := time.Since(start) + + require.False(t, result.IsError, "background run should not error: %s", result.ForLLM) + require.Less(t, elapsed, time.Second, "background run should return immediately") + require.Contains(t, result.ForLLM, "sessionId") +} + +func TestShellTool_List_Empty(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := context.Background() + args := map[string]any{"action": "list"} + + result := tool.Execute(ctx, args) + require.False(t, result.IsError) + require.Contains(t, result.ForUser, "0 active sessions") +} + +func TestShellTool_RunBackground_List(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 10", + "background": "true", + }) + require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + require.NotEmpty(t, resp.SessionID) + + time.Sleep(100 * time.Millisecond) + + listResult := tool.Execute(ctx, map[string]any{"action": "list"}) + require.False(t, listResult.IsError) + + var listResp ExecResponse + err = json.Unmarshal([]byte(listResult.ForLLM), &listResp) + require.NoError(t, err) + require.Len(t, listResp.Sessions, 1) + require.Equal(t, resp.SessionID, listResp.Sessions[0].ID) + + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) +} + +func TestShellTool_Read_Output(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "echo hello", + "background": "true", + }) + require.False(t, runResult.IsError) + + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + + time.Sleep(200 * time.Millisecond) + + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + + if !readResult.IsError { + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + } +} + +func TestShellTool_Kill(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 100", + "background": "true", + }) + require.False(t, runResult.IsError) + + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) + + time.Sleep(100 * time.Millisecond) + + listResult := tool.Execute(ctx, map[string]any{"action": "list"}) + var listResp ExecResponse + err = json.Unmarshal([]byte(listResult.ForLLM), &listResp) + require.NoError(t, err) + require.Len(t, listResp.Sessions, 0) +} + +func TestShellTool_PTY_AllowedCommands(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Test that PTY is allowed for non-interpreter commands + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "cat", + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "PTY with cat should succeed: %s", result.ForLLM) + require.Contains(t, result.ForLLM, "sessionId") + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + require.NotEmpty(t, resp.SessionID) + + // Clean up + tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) +} + +func TestShellTool_PTY_WriteRead(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a PTY session with a command that waits for input + // Using 'cat' which will wait for stdin + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "cat", + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Write some input to cat + writeResult := tool.Execute(ctx, map[string]any{ + "action": "write", + "sessionId": resp.SessionID, + "data": "hello\n", + }) + require.False(t, writeResult.IsError, "write should succeed: %s", writeResult.ForLLM) + + // Give cat time to process and output + time.Sleep(200 * time.Millisecond) + + // Read the output + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + // PTY output should contain "hello" + require.Contains(t, readResp.Output, "hello") + + // Clean up + tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) +} + +func TestShellTool_PTY_Poll(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a PTY session with a long-running command + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 2", + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Poll should show running + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.False(t, pollResult.IsError, "poll should succeed: %s", pollResult.ForLLM) + + var pollResp ExecResponse + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "running", pollResp.Status) + + // Wait for sleep to complete + time.Sleep(2500 * time.Millisecond) + + // Poll should show done + pollResult = tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.False(t, pollResult.IsError) + + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "done", pollResp.Status) +} + +func TestShellTool_PTY_Kill(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a PTY session with a long-running command + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 10", + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Kill the session + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) + + // Verify kill response shows done status + var killResp ExecResponse + err = json.Unmarshal([]byte(killResult.ForLLM), &killResp) + require.NoError(t, err) + require.Equal(t, "done", killResp.Status) + + // Poll should return error since session is removed after kill + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + // Session is removed after kill, so poll returns error with "session not found" + require.True(t, pollResult.IsError, "poll should error after kill (session removed)") + require.Contains(t, pollResult.ForLLM, "session not found") +} + +func TestShellTool_Write_Read_NonPTY(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a background process that reads from stdin and outputs it + // Using 'cat' which echoes stdin to stdout + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "cat", + "pty": false, + "background": "true", + }) + require.False(t, result.IsError, "run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Write some input to cat + writeResult := tool.Execute(ctx, map[string]any{ + "action": "write", + "sessionId": resp.SessionID, + "data": "hello world\n", + }) + require.False(t, writeResult.IsError, "write should succeed: %s", writeResult.ForLLM) + + // Give cat time to process and output + time.Sleep(200 * time.Millisecond) + + // Read the output + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + require.Contains(t, readResp.Output, "hello world") + + // Clean up + tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) +} + +func TestShellTool_Read_NonPTY_Running(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a long-running process that produces output over time + // Using sh -c with sleep at the end so process doesn't exit immediately + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sh -c 'echo line1; sleep 0.5; echo line2; sleep 0.5; echo line3; sleep 10'", + "pty": false, + "background": "true", + }) + require.False(t, result.IsError, "run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Give time for first outputs to be produced + time.Sleep(300 * time.Millisecond) + + // Read output while process is running + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + // Should have at least line1 + require.Contains(t, readResp.Output, "line1") + + // Wait for line3 to be produced (line1=0s, line2=0.5s, line3=1s, then sleep 10) + time.Sleep(1200 * time.Millisecond) + + // Read again - should have line3 as well + readResult = tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + require.Contains(t, readResp.Output, "line3") + + // Clean up + tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) +} + +func TestShellTool_ProcessGroupKill(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Process group kill not supported on Windows") + } + + // Note: Testing process group kill with PTY is tricky because the command + // must be run through an interpreter (sh, bash) which is blocked for PTY. + // Instead, we test with non-PTY mode which also uses Setsid for background processes. + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a shell that spawns child processes (non-PTY mode) + // The sh -c command creates child sleep processes + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sh -c 'sleep 30 & sleep 30 & wait'", + "pty": false, + "background": "true", + }) + require.False(t, result.IsError, "run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Give time for child processes to spawn + time.Sleep(500 * time.Millisecond) + + // Kill the session - should kill the entire process group + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) + + // Verify kill response shows done status + var killResp ExecResponse + err = json.Unmarshal([]byte(killResult.ForLLM), &killResp) + require.NoError(t, err) + require.Equal(t, "done", killResp.Status) + + // Poll should return error since session is removed after kill + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.True(t, pollResult.IsError, "poll should error after kill (session removed)") + require.Contains(t, pollResult.ForLLM, "session not found") +} + +func TestShellTool_PTY_ProcessGroupKill(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY process group kill not supported on Windows") + } + + // This test binary creates 4 child sleep processes and waits for signals. + // It's not an interpreter, so it's allowed with PTY mode. + // The binary is created in /tmp/test_pgroup.c and compiled as part of test setup. + testBinary := "/tmp/test_pgroup" + if _, err := os.Stat(testBinary); os.IsNotExist(err) { + t.Skip("Test binary /tmp/test_pgroup not found - run: gcc -o /tmp/test_pgroup /tmp/test_pgroup.c") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start the test binary with PTY mode + // It forks 4 child sleep processes and waits for signals + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": testBinary, + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Give time for child processes to spawn + time.Sleep(500 * time.Millisecond) + + // Kill the session - should kill the entire process group + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) + + // Verify kill response shows done status + var killResp ExecResponse + err = json.Unmarshal([]byte(killResult.ForLLM), &killResp) + require.NoError(t, err) + require.Equal(t, "done", killResp.Status) + + // Poll should return error since session is removed after kill + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.True(t, pollResult.IsError, "poll should error after kill (session removed)") + require.Contains(t, pollResult.ForLLM, "session not found") +} + +func TestShellTool_PTY_Background_Read(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a fast command with PTY + background mode + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "echo hello", + "pty": "true", + "background": "true", + }) + require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM) + + var runResp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &runResp) + require.NoError(t, err) + require.NotEmpty(t, runResp.SessionID) + require.Equal(t, "running", runResp.Status) + + // Wait for command to complete + time.Sleep(500 * time.Millisecond) + + // Read output - this is the key test: PTY + background mode should preserve output + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": runResp.SessionID, + }) + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + require.Contains(t, readResult.ForLLM, "hello", "output should contain 'hello'") +} + +func TestShellTool_PTY_Background_ReadNoBlock(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a long-running command with PTY + background mode + // This command produces no output, just sleeps + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 10", + "pty": "true", + "background": "true", + }) + require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM) + + var runResp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &runResp) + require.NoError(t, err) + require.NotEmpty(t, runResp.SessionID) + + // Read immediately - should NOT block even though process is running and has no output + // This tests that Read() returns quickly (within 1 second) instead of blocking for 10 seconds + start := time.Now() + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": runResp.SessionID, + }) + elapsed := time.Since(start) + + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + require.Less(t, elapsed.Seconds(), 1.0, "read should not block, should return within 1 second") + + // Kill the session to clean up + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": runResp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) +} + +func TestShellTool_Poll_Status(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 1", + "background": "true", + }) + require.False(t, runResult.IsError) + + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.False(t, pollResult.IsError) + + var pollResp ExecResponse + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "running", pollResp.Status) + + time.Sleep(1200 * time.Millisecond) + + pollResult = tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.False(t, pollResult.IsError) + + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "done", pollResp.Status) +} + +func TestShellTool_Action_Run_Sync(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "echo hello", + }) + + require.False(t, result.IsError) + require.Contains(t, result.ForLLM, "hello") +} + +// TestShellTool_Background_ReadAfterExit verifies that we can read +// buffered output even after the background process has exited. +func TestShellTool_Background_ReadAfterExit(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + ctx := context.Background() + + // Start a background command that produces output and exits quickly + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "echo hello && sleep 1 && echo world", + "background": "true", + }) + require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForUser) + + // Parse session ID from response + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + require.NotEmpty(t, resp.SessionID) + sessionID := resp.SessionID + + // Wait for process to exit (sleep 1 + some buffer) + time.Sleep(1500 * time.Millisecond) + + // Poll to verify process is done + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": sessionID, + }) + require.False(t, pollResult.IsError, "poll should succeed: %s", pollResult.ForLLM) + var pollResp ExecResponse + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "done", pollResp.Status, "process should be done") + + // Try to read output AFTER process has exited + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": sessionID, + }) + require.False(t, readResult.IsError, "read should succeed after exit: %s", readResult.ForLLM) + + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + + // Output should contain both "hello" and "world" + require.Contains(t, readResp.Output, "hello", "should contain hello") + require.Contains(t, readResp.Output, "world", "should contain world after sleep") +} + +func TestSendKeys_CtrlC(t *testing.T) { + // Note: Ctrl-C as a signal requires sending SIGINT to the process group, + // which requires elevated privileges. Writing "\x03" to PTY passes the byte + // to the process but doesn't generate SIGINT for processes that don't read stdin. + // For interrupting processes, use the kill action instead. + t.Skip("Ctrl-C as signal not supported - use kill action for interruption") +} + +func TestEncodeKeyToken(t *testing.T) { + tests := []struct { + token string + expected string + hasError bool + }{ + // Named keys + {"enter", "\r", false}, + {"return", "\r", false}, + {"tab", "\t", false}, + {"escape", "\x1b", false}, + {"esc", "\x1b", false}, + {"backspace", "\x7f", false}, + {"up", "\x1b[A", false}, + {"down", "\x1b[B", false}, + {"left", "\x1b[D", false}, + {"right", "\x1b[C", false}, + {"home", "\x1b[1~", false}, + {"end", "\x1b[4~", false}, + {"pageup", "\x1b[5~", false}, + {"pagedown", "\x1b[6~", false}, + {"delete", "\x1b[3~", false}, + {"f1", "\x1bOP", false}, + {"f12", "\x1b[24~", false}, + + // Ctrl keys + {"ctrl-c", "\x03", false}, + {"ctrl-d", "\x04", false}, + {"ctrl-a", "\x01", false}, + {"ctrl-z", "\x1a", false}, + {"c-c", "\x03", false}, + {"c-d", "\x04", false}, + + // Alt keys + {"alt-x", "\x1bx", false}, + {"m-x", "\x1bx", false}, + + // Case insensitive tests + {"ENTER", "\r", false}, + {"TAB", "\t", false}, + {"CTRL-C", "\x03", false}, + {"Ctrl-D", "\x04", false}, + {"ALT-X", "\x1bx", false}, + {"M-X", "\x1bx", false}, + {"UP", "\x1b[A", false}, + {"DOWN", "\x1b[B", false}, + + // Unknown keys should return error (use write action for text input) + {"unknown-key", "", true}, + } + + for _, tt := range tests { + t.Run(tt.token, func(t *testing.T) { + result, err := encodeKeyToken(tt.token, PtyKeyModeCSI) + if tt.hasError { + require.Error(t, err, "expected error for %s", tt.token) + } else { + require.NoError(t, err, "unexpected error for %s", tt.token) + require.Equal(t, tt.expected, result, "wrong encoding for %s", tt.token) + } + }) + } +} + +// TestDetectPtyKeyMode tests smkx/rmkx detection in PTY output +func TestDetectPtyKeyMode(t *testing.T) { + tests := []struct { + name string + raw string + expected PtyKeyMode + }{ + {"no toggle", "hello world", PtyKeyModeNotFound}, + {"smkx only", "\x1b[?1h\x1b=", PtyKeyModeSS3}, + {"rmkx only", "\x1b[?1l\x1b>", PtyKeyModeCSI}, + {"both smkx first", "\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI}, + {"both rmkx first", "\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3}, + {"multiple toggles smkx last", "\x1b[?1h\x1b=...\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3}, + {"multiple toggles rmkx last", "\x1b[?1l\x1b>...\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI}, + {"partial smkx", "\x1b[?1h", PtyKeyModeSS3}, + {"partial rmkx", "\x1b[?1l", PtyKeyModeCSI}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := detectPtyKeyMode(tt.raw) + require.Equal(t, tt.expected, result, "wrong mode for %s", tt.name) + }) + } +} + +func TestEncodeKeyTokenWithPtyKeyMode(t *testing.T) { + tests := []struct { + name string + token string + mode PtyKeyMode + expected string + hasError bool + }{ + // CSI mode + {"up csi", "up", PtyKeyModeCSI, "\x1b[A", false}, + {"down csi", "down", PtyKeyModeCSI, "\x1b[B", false}, + {"left csi", "left", PtyKeyModeCSI, "\x1b[D", false}, + {"right csi", "right", PtyKeyModeCSI, "\x1b[C", false}, + + // SS3 mode + {"up ss3", "up", PtyKeyModeSS3, "\x1bOA", false}, + {"down ss3", "down", PtyKeyModeSS3, "\x1bOB", false}, + {"left ss3", "left", PtyKeyModeSS3, "\x1bOD", false}, + {"right ss3", "right", PtyKeyModeSS3, "\x1bOC", false}, + {"home ss3", "home", PtyKeyModeSS3, "\x1bOH", false}, + {"end ss3", "end", PtyKeyModeSS3, "\x1bOF", false}, + + // Other keys unaffected by mode + {"enter ss3", "enter", PtyKeyModeSS3, "\r", false}, + {"tab ss3", "tab", PtyKeyModeSS3, "\t", false}, + {"ctrl-c ss3", "ctrl-c", PtyKeyModeSS3, "\x03", false}, + + // NotFound behaves like CSI + {"up notfound", "up", PtyKeyModeNotFound, "\x1b[A", false}, + {"down notfound", "down", PtyKeyModeNotFound, "\x1b[B", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := encodeKeyToken(tt.token, tt.mode) + if tt.hasError { + require.Error(t, err, "expected error for %s", tt.name) + } else { + require.NoError(t, err, "unexpected error for %s", tt.name) + require.Equal(t, tt.expected, result, "wrong encoding for %s", tt.name) + } + }) + } +} diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go index 357e1276e..dfd28454c 100644 --- a/pkg/tools/shell_timeout_unix_test.go +++ b/pkg/tools/shell_timeout_unix_test.go @@ -30,6 +30,7 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { tool.SetTimeout(500 * time.Millisecond) args := map[string]any{ + "action": "run", // Spawn a child process that would outlive the shell unless process-group kill is used. "command": "sleep 60 & echo $! > child.pid; wait", } diff --git a/pkg/tools/sysproc_unix.go b/pkg/tools/sysproc_unix.go new file mode 100644 index 000000000..0fb03d43a --- /dev/null +++ b/pkg/tools/sysproc_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package tools + +import ( + "os/exec" + "syscall" +) + +func setSysProcAttrForPty(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} diff --git a/pkg/tools/sysproc_windows.go b/pkg/tools/sysproc_windows.go new file mode 100644 index 000000000..150f166fb --- /dev/null +++ b/pkg/tools/sysproc_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package tools + +import "os/exec" + +func setSysProcAttrForPty(cmd *exec.Cmd) { + // Windows doesn't support Setsid, and PTY is not available on Windows anyway. + // This function is a no-op for Windows builds. +} diff --git a/pkg/tools/types.go b/pkg/tools/types.go index a6015cde3..4d1a18d5a 100644 --- a/pkg/tools/types.go +++ b/pkg/tools/types.go @@ -56,3 +56,24 @@ type ToolFunctionDefinition struct { Description string `json:"description"` Parameters map[string]any `json:"parameters"` } + +type ExecRequest struct { + Action string `json:"action"` + Command string `json:"command,omitempty"` + PTY bool `json:"pty,omitempty"` + Background bool `json:"background,omitempty"` + Timeout int `json:"timeout,omitempty"` + Env map[string]string `json:"env,omitempty"` + Cwd string `json:"cwd,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Data string `json:"data,omitempty"` +} + +type ExecResponse struct { + SessionID string `json:"sessionId,omitempty"` + Status string `json:"status,omitempty"` + ExitCode int `json:"exitCode,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Sessions []SessionInfo `json:"sessions,omitempty"` +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index d09f68eac..ce652d4c4 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -19,6 +19,8 @@ type Handler struct { oauthState map[string]string weixinMu sync.Mutex weixinFlows map[string]*weixinFlow + wecomMu sync.Mutex + wecomFlows map[string]*wecomFlow } // NewHandler creates an instance of the API handler. @@ -29,6 +31,7 @@ func NewHandler(configPath string) *Handler { oauthFlows: make(map[string]*oauthFlow), oauthState: make(map[string]string), weixinFlows: make(map[string]*weixinFlow), + wecomFlows: make(map[string]*wecomFlow), } } @@ -75,6 +78,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // WeChat QR login flow h.registerWeixinRoutes(mux) + + // WeCom QR login flow + h.registerWecomRoutes(mux) } // Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler. diff --git a/web/backend/api/wecom.go b/web/backend/api/wecom.go new file mode 100644 index 000000000..7dcec9f49 --- /dev/null +++ b/web/backend/api/wecom.go @@ -0,0 +1,424 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "runtime" + "strconv" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + wecomFlowTTL = 5 * time.Minute + wecomFlowGCAge = 30 * time.Minute + wecomQRSourceID = "picoclaw" + wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate" + wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result" + wecomQRHTTPTimeout = 15 * time.Second + wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com" + wecomPollStartTimeout = 15 * time.Second + wecomPollStatusTimeout = 10 * time.Second +) + +const ( + wecomStatusWait = "wait" + wecomStatusScanned = "scaned" + wecomStatusConfirmed = "confirmed" + wecomStatusExpired = "expired" + wecomStatusError = "error" +) + +type wecomFlow struct { + ID string + SCode string + QRDataURI string + BotID string + Status string + Error string + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time +} + +type wecomFlowResponse struct { + FlowID string `json:"flow_id"` + Status string `json:"status"` + QRDataURI string `json:"qr_data_uri,omitempty"` + BotID string `json:"bot_id,omitempty"` + Error string `json:"error,omitempty"` +} + +type wecomQRGenerateResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Data struct { + SCode string `json:"scode"` + AuthURL string `json:"auth_url"` + } `json:"data"` +} + +type wecomQRQueryResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Data struct { + Status string `json:"status"` + BotInfo struct { + BotID string `json:"botid"` + Secret string `json:"secret"` + } `json:"bot_info"` + } `json:"data"` +} + +// registerWecomRoutes binds WeCom QR login endpoints to the ServeMux. +func (h *Handler) registerWecomRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/wecom/flows", h.handleStartWecomFlow) + mux.HandleFunc("GET /api/wecom/flows/{id}", h.handlePollWecomFlow) +} + +// handleStartWecomFlow starts a new WeCom QR login flow. +// +// POST /api/wecom/flows +func (h *Handler) handleStartWecomFlow(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), wecomPollStartTimeout) + defer cancel() + + session, err := fetchWecomQRCode(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get QR code: %v", err), http.StatusInternalServerError) + return + } + + dataURI, err := generateQRDataURI(session.Data.AuthURL) + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate QR image: %v", err), http.StatusInternalServerError) + return + } + + now := time.Now() + flow := &wecomFlow{ + ID: newWecomFlowID(), + SCode: session.Data.SCode, + QRDataURI: dataURI, + Status: wecomStatusWait, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(wecomFlowTTL), + } + h.storeWecomFlow(flow) + + logger.InfoCF("wecom", "QR flow started", map[string]any{"flow_id": flow.ID}) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + QRDataURI: flow.QRDataURI, + }) +} + +// handlePollWecomFlow polls the WeCom API for QR code status and updates the flow. +// +// GET /api/wecom/flows/{id} +func (h *Handler) handlePollWecomFlow(w http.ResponseWriter, r *http.Request) { + flowID := strings.TrimSpace(r.PathValue("id")) + if flowID == "" { + http.Error(w, "missing flow id", http.StatusBadRequest) + return + } + + flow, ok := h.getWecomFlow(flowID) + if !ok { + http.Error(w, "flow not found", http.StatusNotFound) + return + } + + if flow.Status == wecomStatusConfirmed || + flow.Status == wecomStatusExpired || + flow.Status == wecomStatusError { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + BotID: flow.BotID, + Error: flow.Error, + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), wecomPollStatusTimeout) + defer cancel() + + statusResp, err := queryWecomQRCodeStatus(ctx, flow.SCode) + if err != nil { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + QRDataURI: flow.QRDataURI, + }) + return + } + + switch strings.ToLower(statusResp.Data.Status) { + case wecomStatusWait: + // no-op + case wecomStatusScanned, "scanned": + h.updateWecomFlowStatus(flowID, wecomStatusScanned) + case "success": + if statusResp.Data.BotInfo.BotID == "" || statusResp.Data.BotInfo.Secret == "" { + h.setWecomFlowError(flowID, "login confirmed but missing bot credentials") + break + } + if saveErr := h.saveWecomBinding( + statusResp.Data.BotInfo.BotID, + statusResp.Data.BotInfo.Secret, + ); saveErr != nil { + h.setWecomFlowError(flowID, fmt.Sprintf("failed to save credentials: %v", saveErr)) + logger.ErrorCF("wecom", "failed to save credentials", map[string]any{"error": saveErr.Error()}) + break + } + h.setWecomFlowConfirmed(flowID, statusResp.Data.BotInfo.BotID) + logger.InfoCF("wecom", "QR login confirmed, credentials saved", map[string]any{ + "flow_id": flowID, + "bot_id": statusResp.Data.BotInfo.BotID, + }) + case wecomStatusExpired: + h.updateWecomFlowStatus(flowID, wecomStatusExpired) + } + + flow, _ = h.getWecomFlow(flowID) + w.Header().Set("Content-Type", "application/json") + resp := wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + BotID: flow.BotID, + Error: flow.Error, + } + if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned { + resp.QRDataURI = flow.QRDataURI + } + _ = json.NewEncoder(w).Encode(resp) +} + +func (h *Handler) saveWecomBinding(botID, secret string) error { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + cfg.Channels.WeCom.Enabled = true + cfg.Channels.WeCom.BotID = botID + cfg.Channels.WeCom.SetSecret(secret) + if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" { + cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL + } + if err := config.SaveConfig(h.configPath, cfg); err != nil { + return err + } + + status := h.gatewayStatusData() + gatewayStatus, _ := status["gateway_status"].(string) + if gatewayStatus != "running" { + return nil + } + + if _, err := h.RestartGateway(); err != nil { + logger.ErrorCF("wecom", "failed to restart gateway after saving binding", map[string]any{ + "error": err.Error(), + }) + } + return nil +} + +func fetchWecomQRCode(ctx context.Context) (wecomQRGenerateResponse, error) { + targetURL, err := buildWecomQRGenerateURL(wecomQRGenerateEndpoint, wecomQRSourceID, wecomPlatformCode()) + if err != nil { + return wecomQRGenerateResponse{}, err + } + + var resp wecomQRGenerateResponse + if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil { + return wecomQRGenerateResponse{}, err + } + if resp.ErrCode != 0 { + return wecomQRGenerateResponse{}, fmt.Errorf( + "errcode=%d errmsg=%s", + resp.ErrCode, + resp.ErrMsg, + ) + } + if resp.Data.SCode == "" || resp.Data.AuthURL == "" { + return wecomQRGenerateResponse{}, fmt.Errorf("response missing scode or auth_url") + } + return resp, nil +} + +func queryWecomQRCodeStatus(ctx context.Context, scode string) (wecomQRQueryResponse, error) { + targetURL, err := buildWecomQRQueryURL(wecomQRQueryEndpoint, scode) + if err != nil { + return wecomQRQueryResponse{}, err + } + + var resp wecomQRQueryResponse + if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil { + return wecomQRQueryResponse{}, err + } + if resp.ErrCode != 0 { + return wecomQRQueryResponse{}, fmt.Errorf( + "errcode=%d errmsg=%s", + resp.ErrCode, + resp.ErrMsg, + ) + } + return resp, nil +} + +func buildWecomQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err) + } + + query := u.Query() + query.Set("source", sourceID) + query.Set("sourceID", sourceID) + query.Set("plat", strconv.Itoa(platformCode)) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func buildWecomQRQueryURL(baseURL, scode string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid WeCom QR query URL: %w", err) + } + + query := u.Query() + query.Set("scode", scode) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func doWecomJSONGet(ctx context.Context, targetURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return err + } + + client := &http.Client{Timeout: wecomQRHTTPTimeout} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if readErr != nil { + return fmt.Errorf("unexpected status %s", resp.Status) + } + return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode JSON response: %w", err) + } + return nil +} + +func wecomPlatformCode() int { + switch runtime.GOOS { + case "darwin": + return 1 + case "windows": + return 2 + case "linux": + return 3 + default: + return 0 + } +} + +func newWecomFlowID() string { + buf := make([]byte, 12) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("wc_%d", time.Now().UnixNano()) + } + return "wc_" + hex.EncodeToString(buf) +} + +func (h *Handler) storeWecomFlow(flow *wecomFlow) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + h.gcWecomFlowsLocked(time.Now()) + h.wecomFlows[flow.ID] = flow +} + +func (h *Handler) getWecomFlow(flowID string) (*wecomFlow, bool) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + h.gcWecomFlowsLocked(time.Now()) + flow, ok := h.wecomFlows[flowID] + if !ok { + return nil, false + } + cp := *flow + return &cp, true +} + +func (h *Handler) updateWecomFlowStatus(flowID, status string) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + if flow, ok := h.wecomFlows[flowID]; ok { + flow.Status = status + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) setWecomFlowConfirmed(flowID, botID string) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + if flow, ok := h.wecomFlows[flowID]; ok { + flow.Status = wecomStatusConfirmed + flow.BotID = botID + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) setWecomFlowError(flowID, errMsg string) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + if flow, ok := h.wecomFlows[flowID]; ok { + flow.Status = wecomStatusError + flow.Error = errMsg + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) gcWecomFlowsLocked(now time.Time) { + for id, flow := range h.wecomFlows { + if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned { + if !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) { + flow.Status = wecomStatusExpired + flow.UpdatedAt = now + } + } + if flow.Status != wecomStatusWait && + flow.Status != wecomStatusScanned && + now.Sub(flow.UpdatedAt) > wecomFlowGCAge { + delete(h.wecomFlows, id) + } + } +} diff --git a/web/backend/main.go b/web/backend/main.go index 2f181603e..6987a4515 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -98,7 +98,7 @@ func main() { defer logger.DisableFileLogging() } - logger.InfoC("web", "PicoClaw Launcher starting...") + logger.InfoC("web", fmt.Sprintf("%s Launcher %s starting...", appName, appVersion)) logger.InfoC("web", fmt.Sprintf("PicoClaw Home: %s", picoHome)) // Set language from command line or auto-detect diff --git a/web/backend/systray.go b/web/backend/systray.go index fde2e115e..9dcc025df 100644 --- a/web/backend/systray.go +++ b/web/backend/systray.go @@ -3,7 +3,6 @@ package main import ( - _ "embed" "fmt" "fyne.io/systray" @@ -93,8 +92,3 @@ func onReady() { func onExit() { logger.Info(T(Exiting)) } - -// getIcon returns the system tray icon -func getIcon() []byte { - return iconData -} diff --git a/web/backend/systray_icon_nonwindows.go b/web/backend/systray_icon_nonwindows.go new file mode 100644 index 000000000..0117a9ae8 --- /dev/null +++ b/web/backend/systray_icon_nonwindows.go @@ -0,0 +1,12 @@ +//go:build !windows && ((!darwin && !freebsd) || cgo) + +package main + +import _ "embed" + +//go:embed icon.png +var iconPNG []byte + +func getIcon() []byte { + return iconPNG +} diff --git a/web/backend/systray_windows.go b/web/backend/systray_icon_windows.go similarity index 53% rename from web/backend/systray_windows.go rename to web/backend/systray_icon_windows.go index cc1885155..c265e2f9c 100644 --- a/web/backend/systray_windows.go +++ b/web/backend/systray_icon_windows.go @@ -5,4 +5,8 @@ package main import _ "embed" //go:embed icon.ico -var iconData []byte +var iconICO []byte + +func getIcon() []byte { + return iconICO +} diff --git a/web/backend/tray_stub_nocgo.go b/web/backend/systray_stub_nocgo.go similarity index 88% rename from web/backend/tray_stub_nocgo.go rename to web/backend/systray_stub_nocgo.go index 13ecfd2cb..9e75e112a 100644 --- a/web/backend/tray_stub_nocgo.go +++ b/web/backend/systray_stub_nocgo.go @@ -13,6 +13,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" ) +// runTray falls back to a headless mode on platforms where systray requires cgo. func runTray() { logger.Infof("System tray is unavailable in %s builds without cgo; running without tray", runtime.GOOS) diff --git a/web/backend/systray_unix.go b/web/backend/systray_unix.go deleted file mode 100644 index 0f9d2bb51..000000000 --- a/web/backend/systray_unix.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !windows - -package main - -import _ "embed" - -//go:embed icon.png -var iconData []byte diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index d4c3ac74b..85550ca81 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -72,6 +72,14 @@ export interface WeixinFlowResponse { error?: string } +export interface WecomFlowResponse { + flow_id: string + status: "wait" | "scaned" | "confirmed" | "expired" | "error" + qr_data_uri?: string + bot_id?: string + error?: string +} + export async function startWeixinFlow(): Promise { return request("/api/weixin/flows", { method: "POST" }) } @@ -84,4 +92,16 @@ export async function pollWeixinFlow( ) } +export async function startWecomFlow(): Promise { + return request("/api/wecom/flows", { method: "POST" }) +} + +export async function pollWecomFlow( + flowID: string, +): Promise { + return request( + `/api/wecom/flows/${encodeURIComponent(flowID)}`, + ) +} + export type { ChannelsCatalogResponse, ConfigActionResponse } diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index 7f1f695bc..6af821ac9 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -1,4 +1,4 @@ -import { IconLoader2 } from "@tabler/icons-react" +import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" @@ -15,6 +15,7 @@ import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" import { GenericForm } from "@/components/channels/channel-forms/generic-form" import { SlackForm } from "@/components/channels/channel-forms/slack-form" import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" +import { WecomForm } from "@/components/channels/channel-forms/wecom-form" import { WeixinForm } from "@/components/channels/channel-forms/weixin-form" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" @@ -186,7 +187,7 @@ function getRequiredFieldKeys(channelName: string): string[] { case "onebot": return ["ws_url"] case "wecom": - return ["bot_id", "secret"] + return [] case "whatsapp": return ["bridge_url"] case "pico": @@ -326,6 +327,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { return getChannelDisplayName(channel, t) }, [channel, channelName, t]) + const hidesPageLevelEnableToggle = channel?.name === "wecom" + const hiddenKeys = useMemo(() => { if (!channel) return [] if (channel.name === "whatsapp") { @@ -410,6 +413,36 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { } }, [loadData, t]) + const handleWecomBindSuccess = useCallback(async () => { + try { + setEnabled(true) + await Promise.all([loadData(true), refreshGatewayState({ force: true })]) + } catch (e) { + const message = + e instanceof Error ? e.message : t("channels.page.saveError") + setServerError(message) + await loadData(true) + } + }, [loadData, t]) + + const handleWecomEnabledChange = useCallback( + async (nextEnabled: boolean) => { + try { + setEnabled(nextEnabled) + await Promise.all([ + loadData(true), + refreshGatewayState({ force: true }), + ]) + } catch (e) { + const message = + e instanceof Error ? e.message : t("channels.page.saveError") + setServerError(message) + await loadData(true) + } + }, + [loadData, t], + ) + const renderForm = () => { if (!channel) return null const isEdit = configured @@ -460,6 +493,27 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onBindSuccess={() => void handleWeixinBindSuccess()} /> ) + case "wecom": + return ( + <> + void handleWecomBindSuccess()} + onEnabledChange={(nextEnabled) => + void handleWecomEnabledChange(nextEnabled) + } + /> + + + ) default: return ( -
-

- {t("channels.page.enableLabel")} -

- -
+ {channel?.name === "weixin" && ( +
+
+ +
+

+ {t("channels.weixin.warningTitle")} +

+

+ {t("channels.weixin.warningDesc")} +

+
+
+
+ )} + + {!hidesPageLevelEnableToggle && ( +
+

+ {t("channels.page.enableLabel")} +

+ +
+ )} {renderForm()} diff --git a/web/frontend/src/components/channels/channel-forms/wecom-form.tsx b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx new file mode 100644 index 000000000..744c87ba2 --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx @@ -0,0 +1,367 @@ +import { + IconCheck, + IconLoader2, + IconQrcode, + IconRefresh, + IconX, +} from "@tabler/icons-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ChannelConfig } from "@/api/channels" +import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" + +type BindingState = + | "idle" + | "loading" + | "waiting" + | "scaned" + | "confirmed" + | "expired" + | "error" + +interface WecomFormProps { + config: ChannelConfig + isEdit: boolean + onBindSuccess?: () => void + onEnabledChange?: (enabled: boolean) => void +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +export function WecomForm({ + config, + isEdit, + onBindSuccess, + onEnabledChange, +}: WecomFormProps) { + const { t } = useTranslation() + + const [bindState, setBindState] = useState("idle") + const [qrDataURI, setQrDataURI] = useState(null) + const [botID, setBotID] = useState(null) + const [errorMsg, setErrorMsg] = useState("") + const [enabled, setEnabled] = useState(config.enabled === true) + const [toggleSaving, setToggleSaving] = useState(false) + const [toggleError, setToggleError] = useState("") + + const pollTimerRef = useRef | null>(null) + const pollGenerationRef = useRef(0) + const existingBotID = asString(config.bot_id) + const isBound = isEdit && existingBotID !== "" + + const stopPolling = useCallback(() => { + pollGenerationRef.current += 1 + if (pollTimerRef.current !== null) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + }, []) + + useEffect(() => () => stopPolling(), [stopPolling]) + + useEffect(() => { + setEnabled(config.enabled === true) + }, [config.enabled]) + + useEffect(() => { + if (!existingBotID) return + stopPolling() + setBotID(existingBotID) + setBindState("confirmed") + setErrorMsg("") + }, [existingBotID, stopPolling]) + + const startPolling = useCallback( + (id: string) => { + stopPolling() + const generation = pollGenerationRef.current + let inFlight = false + pollTimerRef.current = setInterval(async () => { + if (inFlight) return + inFlight = true + try { + const resp = await pollWecomFlow(id) + if (generation !== pollGenerationRef.current) { + return + } + if (resp.status === "scaned") { + setBindState("scaned") + } else if (resp.status === "confirmed") { + stopPolling() + setBotID(resp.bot_id ?? existingBotID ?? null) + setBindState("confirmed") + onBindSuccess?.() + } else if (resp.status === "expired") { + stopPolling() + setBindState("expired") + } else if (resp.status === "error") { + stopPolling() + setBindState("error") + setErrorMsg(resp.error ?? t("channels.wecom.errorGeneric")) + } + } catch { + // transient network error — keep polling + } finally { + inFlight = false + } + }, 2000) + }, + [existingBotID, onBindSuccess, stopPolling, t], + ) + + const handleEnabledChange = useCallback( + async (checked: boolean) => { + if (!existingBotID || toggleSaving) { + return + } + setToggleSaving(true) + setToggleError("") + try { + await patchAppConfig({ + channels: { + wecom: { + enabled: checked, + }, + }, + }) + setEnabled(checked) + onEnabledChange?.(checked) + } catch (e) { + setToggleError( + e instanceof Error ? e.message : t("channels.wecom.errorGeneric"), + ) + } finally { + setToggleSaving(false) + } + }, + [existingBotID, onEnabledChange, t, toggleSaving], + ) + + const handleBind = async () => { + setBindState("loading") + setErrorMsg("") + setToggleError("") + setQrDataURI(null) + stopPolling() + try { + const resp = await startWecomFlow() + setQrDataURI(resp.qr_data_uri ?? null) + setBindState("waiting") + startPolling(resp.flow_id) + } catch (e) { + setBindState("error") + setErrorMsg( + e instanceof Error ? e.message : t("channels.wecom.errorGeneric"), + ) + } + } + + const handleRebind = () => { + stopPolling() + setBindState("idle") + setQrDataURI(null) + setBotID(null) + setErrorMsg("") + void handleBind() + } + + const renderBindSection = () => { + if (bindState === "idle") { + if (isBound) { + return ( +
+
+ + {t("channels.wecom.bound")} +
+ {existingBotID && ( +

+ {existingBotID} +

+ )} + +
+ ) + } + return ( +
+

+ {t("channels.wecom.notBound")} +

+ +
+ ) + } + + if (bindState === "loading") { + return ( +
+ +

+ {t("channels.wecom.generating")} +

+
+ ) + } + + if (bindState === "waiting" || bindState === "scaned") { + return ( +
+ {qrDataURI ? ( + WeCom QR Code + ) : ( +
+ +
+ )} + {bindState === "scaned" ? ( +
+ + {t("channels.wecom.scanned")} +
+ ) : ( +

+ {t("channels.wecom.scanHint")} +

+ )} + +
+ ) + } + + if (bindState === "confirmed") { + return ( +
+
+ +
+

+ {t("channels.wecom.bound")} +

+ {botID && ( +

{botID}

+ )} + +
+ ) + } + + if (bindState === "expired") { + return ( +
+
+ +
+

+ {t("channels.wecom.expired")} +

+ +
+ ) + } + + if (bindState === "error") { + return ( +
+
+ +
+

+ {errorMsg || t("channels.wecom.errorGeneric")} +

+ +
+ ) + } + + return null + } + + return ( +
+
+
+
+

+ {t("channels.page.enableLabel")} +

+

+ {isBound + ? t("channels.wecom.enableDesc") + : t("channels.wecom.enableBindFirst")} +

+
+ void handleEnabledChange(checked)} + /> +
+ {toggleError && ( +

{toggleError}

+ )} +
+ +
+
+

{t("channels.wecom.bindTitle")}

+

+ {t("channels.wecom.bindDesc")} +

+
+ {renderBindSection()} +
+
+ ) +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index c34c48709..c5dfee2fa 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -209,6 +209,7 @@ export function ConfigPage() { defaults: { workspace, restrict_to_workspace: form.restrictToWorkspace, + split_on_marker: form.splitOnMarker, tool_feedback: { enabled: form.toolFeedbackEnabled, max_args_length: toolFeedbackMaxArgsLength, diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 1f7426d22..b5bec8acd 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -95,6 +95,16 @@ export function AgentDefaultsSection({ } /> + + onFieldChange("splitOnMarker", checked) + } + /> + = { vllm: 16, mistral: 17, avian: 18, + mimo: 19, } interface ProviderGroup { diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx index 5e2151e2d..814a59834 100644 --- a/web/frontend/src/components/models/provider-icon.tsx +++ b/web/frontend/src/components/models/provider-icon.tsx @@ -37,6 +37,7 @@ const PROVIDER_DOMAINS: Record = { avian: "avian.io", vllm: "vllm.ai", zhipu: "zhipuai.cn", + mimo: "xiaomi.com", } interface ProviderIconProps { diff --git a/web/frontend/src/components/models/provider-label.ts b/web/frontend/src/components/models/provider-label.ts index 923cd9506..82600a96f 100644 --- a/web/frontend/src/components/models/provider-label.ts +++ b/web/frontend/src/components/models/provider-label.ts @@ -18,6 +18,7 @@ const PROVIDER_LABELS: Record = { avian: "Avian", vllm: "VLLM (local)", zhipu: "Zhipu AI (智谱)", + mimo: "Xiaomi MiMo", } export function getProviderKey(model: string): string { diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 7df9c831a..3b0f28988 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -249,7 +249,6 @@ "weixin": { "warningTitle": "Testing phase, use with caution", "warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.", - "bindEnableSuccess": "WeChat connected and the channel has been enabled automatically.", "bindTitle": "WeChat Account Binding", "bindDesc": "Scan the QR code with WeChat to bind your personal account.", "bind": "Bind WeChat", @@ -264,6 +263,23 @@ "refresh": "Refresh QR", "errorGeneric": "An error occurred. Please try again." }, + "wecom": { + "bindTitle": "WeCom Binding", + "bindDesc": "Scan the QR code with WeCom to bind your AI Bot.", + "enableDesc": "Once bound, you can enable or disable the channel here.", + "enableBindFirst": "Bind the bot first, then enable the channel.", + "bind": "Bind WeCom", + "rebind": "Re-bind", + "bound": "WeCom Bound", + "notBound": "WeCom AI Bot not bound yet.", + "generating": "Generating QR code...", + "scanHint": "Open WeCom and scan the QR code", + "scanned": "Scanned, please confirm in WeCom", + "expired": "QR code expired", + "retry": "Try Again", + "refresh": "Refresh QR", + "errorGeneric": "An error occurred. Please try again." + }, "field": { "token": "Bot Token", "tokenPlaceholder": "Enter bot token", @@ -420,6 +436,8 @@ "workspace_hint": "Base directory for agent file operations.", "restrict_workspace": "Restrict to Workspace", "restrict_workspace_hint": "Only allow file operations inside workspace.", + "split_on_marker": "Chatty Mode", + "split_on_marker_hint": "Split long messages into short ones like real human chatting.", "tool_feedback_enabled": "Tool Feedback", "tool_feedback_enabled_hint": "Send a short tool-call preview into the current chat before each tool execution.", "tool_feedback_max_args_length": "Tool Feedback Args Preview Length", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 7eb14e983..9dde090f8 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -247,7 +247,6 @@ "weixin": { "warningTitle": "测试阶段,请谨慎使用", "warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。", - "bindEnableSuccess": "微信已连接,频道已自动启用。", "bindTitle": "微信账号绑定", "bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。", "bind": "绑定微信", @@ -262,6 +261,23 @@ "refresh": "刷新二维码", "errorGeneric": "发生错误,请重试。" }, + "wecom": { + "bindTitle": "企业微信绑定", + "bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。", + "enableDesc": "绑定后可在这里直接启用或停用频道。", + "enableBindFirst": "请先完成绑定,然后再启用频道。", + "bind": "绑定企业微信", + "rebind": "重新绑定", + "bound": "企业微信已绑定", + "notBound": "尚未绑定企业微信 AI Bot。", + "generating": "正在生成二维码...", + "scanHint": "打开企业微信,扫描二维码", + "scanned": "已扫码,请在企业微信中确认", + "expired": "二维码已过期", + "retry": "重试", + "refresh": "刷新二维码", + "errorGeneric": "发生错误,请重试。" + }, "field": { "token": "Bot Token", "tokenPlaceholder": "输入 Bot Token", @@ -418,6 +434,8 @@ "workspace_hint": "智能体执行文件读写操作时使用的基础目录。", "restrict_workspace": "限制工作目录访问", "restrict_workspace_hint": "仅允许在工作目录内执行文件操作。", + "split_on_marker": "连续短消息", + "split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出", "tool_feedback_enabled": "工具反馈", "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览。", "tool_feedback_max_args_length": "工具反馈参数预览长度",