Merge branch 'sipeed:main' into main

This commit is contained in:
Nhat Tan 2026-03-26 11:54:30 +07:00 committed by GitHub
commit 693d53a9cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 5018 additions and 949 deletions

3
.gitignore vendored
View file

@ -25,6 +25,9 @@ build/
# Secrets & Config (keep templates, ignore actual secrets)
.env
config/config.json
.security.yml
onboard
# Test
coverage.txt

View file

@ -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

View file

@ -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:

View file

@ -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**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 357 KiB

View file

@ -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-<platform>-<arch>
# 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
```

View file

@ -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")

View file

@ -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"

View file

@ -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.)

View file

@ -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. 当前支持
- 文本消息收发

View file

@ -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).
<details>
<summary><b>OpenAI</b></summary>
```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.
</details>
<details>
@ -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.
</details>
### 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 |

View file

@ -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"
}
]

View file

@ -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

View file

@ -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 | - |

View file

@ -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:
<model_name>:
api_keys:
- "key-1"
- "key-2"
```
**Mapping:**
- Field `api_keys` (array) maps to the model's API keys
- The `<model_name>` 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_<SECTION>_<KEY>_<FIELD>` 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

View file

@ -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 | - |

5
go.mod
View file

@ -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 (

2
go.sum
View file

@ -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=

View file

@ -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")
}

View file

@ -103,10 +103,12 @@ func NewAgentInstance(
sessions := initSessionStore(sessionsDir)
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
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 := ""

View file

@ -236,8 +236,9 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
t.Fatal("exec tool not registered")
}
execResult := execTool.Execute(context.Background(), map[string]any{
"action": "run",
"command": "cat " + filepath.Base(mediaPath),
"working_dir": mediaDir,
"cwd": mediaDir,
})
if execResult.IsError {
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)

View file

@ -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)
}
@ -1254,6 +1255,7 @@ func (al *AgentLoop) ProcessHeartbeat(
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(),

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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,22 +624,44 @@ 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)
// 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)...)
}
}
}
// 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)
}
} else {
m.sendWithRetry(ctx, name, w, msg)
}
case <-ctx.Done():
return
}
}
}
// 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

37
pkg/channels/marker.go Normal file
View file

@ -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
}

141
pkg/channels/marker_test.go Normal file
View file

@ -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])
}
}

View file

@ -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,

View file

@ -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{

View file

@ -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 {

View file

@ -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.<model_name>.api_key`
Example: `ref:model_list.gpt-5.4.api_key`
### Channel Tokens/Secrets
Format: `ref:channels.<channel_name>.<field>`
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.<provider>.<field>`
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.<registry>.<field>`
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_<SECTION>_<KEY1>_<KEY2>_<FIELD>` 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

View file

@ -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
@ -387,7 +389,19 @@ type TypingConfig struct {
// PlaceholderConfig controls placeholder message behavior (Phase 10).
type PlaceholderConfig struct {
Enabled bool `json:"enabled"`
Text string `json:"text,omitempty"`
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)

View file

@ -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,6 +963,7 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity)
Perplexity: perplexity,
SearXNG: v.SearXNG,
GLMSearch: glmSearch,
BaiduSearch: baiduSearch,
PreferNative: v.PreferNative,
Proxy: v.Proxy,
FetchLimitBytes: v.FetchLimitBytes,
@ -949,6 +974,7 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity)
Tavily: tavilySecurity,
Perplexity: perplexitySecurity,
GLMSearch: glmSearchSecurity,
BaiduSearch: baiduSearchSecurity,
}
}

View file

@ -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")

View file

@ -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: "",

View file

@ -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,26 +51,33 @@ 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
{
@ -72,35 +92,43 @@ File: ~/.picoclaw/config.json
{
"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"
"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": "ref:model_list.claude-sonnet-4.6.api_key"
"api_base": "https://api.anthropic.com/v1"
// api_key is automatically loaded from .security.yml
}
],
"channels": {
"telegram": {
"enabled": true,
"token": "ref:channels.telegram.token"
"enabled": true
// token is automatically loaded from .security.yml
},
"discord": {
"enabled": true,
"token": "ref:channels.discord.token"
"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
}
}
}
@ -111,7 +139,7 @@ File: ~/.picoclaw/config.json
## 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.<model_name>.api_key
All models MUST use the `api_keys` (plural) array format in .security.yml.
```yaml
model_list:
<model_name>:
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

View file

@ -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)
})
}

View file

@ -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

View file

@ -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)

View file

@ -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()
}

View file

@ -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 ""
}

View file

@ -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},
}

View file

@ -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 ""
}

View file

@ -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",

View file

@ -76,6 +76,7 @@ const (
FailoverBilling FailoverReason = "billing"
FailoverTimeout FailoverReason = "timeout"
FailoverFormat FailoverReason = "format"
FailoverContextOverflow FailoverReason = "context_overflow"
FailoverOverloaded FailoverReason = "overloaded"
FailoverUnknown FailoverReason = "unknown"
)
@ -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.

252
pkg/tools/session.go Normal file
View file

@ -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"`
}

View file

@ -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
}

View file

@ -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
}

99
pkg/tools/session_test.go Normal file
View file

@ -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)
}

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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",
}

12
pkg/tools/sysproc_unix.go Normal file
View file

@ -0,0 +1,12 @@
//go:build !windows
package tools
import (
"os/exec"
"syscall"
)
func setSysProcAttrForPty(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}

View file

@ -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.
}

View file

@ -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"`
}

View file

@ -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.

424
web/backend/api/wecom.go Normal file
View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -5,4 +5,8 @@ package main
import _ "embed"
//go:embed icon.ico
var iconData []byte
var iconICO []byte
func getIcon() []byte {
return iconICO
}

View file

@ -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)

View file

@ -1,8 +0,0 @@
//go:build !windows
package main
import _ "embed"
//go:embed icon.png
var iconData []byte

View file

@ -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<WeixinFlowResponse> {
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" })
}
@ -84,4 +92,16 @@ export async function pollWeixinFlow(
)
}
export async function startWecomFlow(): Promise<WecomFlowResponse> {
return request<WecomFlowResponse>("/api/wecom/flows", { method: "POST" })
}
export async function pollWecomFlow(
flowID: string,
): Promise<WecomFlowResponse> {
return request<WecomFlowResponse>(
`/api/wecom/flows/${encodeURIComponent(flowID)}`,
)
}
export type { ChannelsCatalogResponse, ConfigActionResponse }

View file

@ -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 (
<>
<WecomForm
config={editConfig}
isEdit={isEdit}
onBindSuccess={() => void handleWecomBindSuccess()}
onEnabledChange={(nextEnabled) =>
void handleWecomEnabledChange(nextEnabled)
}
/>
<GenericForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
hiddenKeys={[...hiddenKeys, "bot_id"]}
requiredKeys={requiredKeys}
fieldErrors={fieldErrors}
/>
</>
)
default:
return (
<GenericForm
@ -524,12 +578,33 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
)}
</div>
{channel?.name === "weixin" && (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
<div className="flex items-start gap-3">
<IconAlertTriangle
size={18}
className="mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
<div className="space-y-1">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
{t("channels.weixin.warningTitle")}
</p>
<p className="text-sm text-amber-700/90 dark:text-amber-300/90">
{t("channels.weixin.warningDesc")}
</p>
</div>
</div>
</div>
)}
{!hidesPageLevelEnableToggle && (
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
<p className="text-sm font-medium">
{t("channels.page.enableLabel")}
</p>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
)}
{renderForm()}

View file

@ -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<BindingState>("idle")
const [qrDataURI, setQrDataURI] = useState<string | null>(null)
const [botID, setBotID] = useState<string | null>(null)
const [errorMsg, setErrorMsg] = useState("")
const [enabled, setEnabled] = useState(config.enabled === true)
const [toggleSaving, setToggleSaving] = useState(false)
const [toggleError, setToggleError] = useState("")
const pollTimerRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex items-center gap-2 rounded-full bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<IconCheck size={16} />
{t("channels.wecom.bound")}
</div>
{existingBotID && (
<p className="text-muted-foreground font-mono text-xs">
{existingBotID}
</p>
)}
<Button
variant="outline"
size="sm"
onClick={handleRebind}
className="mt-1 gap-2"
>
<IconRefresh size={14} />
{t("channels.wecom.rebind")}
</Button>
</div>
)
}
return (
<div className="flex flex-col items-center gap-4 py-6">
<p className="text-muted-foreground text-sm">
{t("channels.wecom.notBound")}
</p>
<Button onClick={handleBind} className="gap-2">
<IconQrcode size={16} />
{t("channels.wecom.bind")}
</Button>
</div>
)
}
if (bindState === "loading") {
return (
<div className="flex flex-col items-center gap-3 py-8">
<IconLoader2
className="text-muted-foreground animate-spin"
size={32}
/>
<p className="text-muted-foreground text-sm">
{t("channels.wecom.generating")}
</p>
</div>
)
}
if (bindState === "waiting" || bindState === "scaned") {
return (
<div className="flex flex-col items-center gap-4 py-4">
{qrDataURI ? (
<img
src={qrDataURI}
alt="WeCom QR Code"
className="border-border/60 h-48 w-48 rounded-xl border bg-white p-2 shadow-sm"
/>
) : (
<div className="border-border/60 bg-muted flex h-48 w-48 items-center justify-center rounded-xl border">
<IconLoader2
className="text-muted-foreground animate-spin"
size={32}
/>
</div>
)}
{bindState === "scaned" ? (
<div className="flex items-center gap-2 rounded-full bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-600 dark:text-amber-400">
<IconLoader2 size={14} className="animate-spin" />
{t("channels.wecom.scanned")}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("channels.wecom.scanHint")}
</p>
)}
<Button
variant="ghost"
size="sm"
onClick={handleRebind}
className="text-muted-foreground"
>
<IconRefresh size={14} className="mr-1" />
{t("channels.wecom.refresh")}
</Button>
</div>
)
}
if (bindState === "confirmed") {
return (
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
<IconCheck
size={28}
className="text-emerald-600 dark:text-emerald-400"
/>
</div>
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
{t("channels.wecom.bound")}
</p>
{botID && (
<p className="text-muted-foreground font-mono text-xs">{botID}</p>
)}
<Button
variant="outline"
size="sm"
onClick={handleRebind}
className="mt-1 gap-2"
>
<IconRefresh size={14} />
{t("channels.wecom.rebind")}
</Button>
</div>
)
}
if (bindState === "expired") {
return (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
<IconX size={28} className="text-amber-600 dark:text-amber-400" />
</div>
<p className="text-sm text-amber-600 dark:text-amber-400">
{t("channels.wecom.expired")}
</p>
<Button onClick={handleRebind} className="gap-2">
<IconRefresh size={14} />
{t("channels.wecom.retry")}
</Button>
</div>
)
}
if (bindState === "error") {
return (
<div className="flex flex-col items-center gap-4 py-6">
<div className="bg-destructive/10 flex h-14 w-14 items-center justify-center rounded-full">
<IconX size={28} className="text-destructive" />
</div>
<p className="text-destructive text-sm">
{errorMsg || t("channels.wecom.errorGeneric")}
</p>
<Button variant="outline" onClick={handleRebind} className="gap-2">
<IconRefresh size={14} />
{t("channels.wecom.retry")}
</Button>
</div>
)
}
return null
}
return (
<div className="space-y-5">
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">
{t("channels.page.enableLabel")}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{isBound
? t("channels.wecom.enableDesc")
: t("channels.wecom.enableBindFirst")}
</p>
</div>
<Switch
checked={enabled}
disabled={!isBound || toggleSaving}
onCheckedChange={(checked) => void handleEnabledChange(checked)}
/>
</div>
{toggleError && (
<p className="text-destructive mt-2 text-sm">{toggleError}</p>
)}
</div>
<div className="border-border/60 bg-muted/30 rounded-xl border">
<div className="border-border/60 border-b px-4 py-3">
<p className="text-sm font-medium">{t("channels.wecom.bindTitle")}</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{t("channels.wecom.bindDesc")}
</p>
</div>
{renderBindSection()}
</div>
</div>
)
}

View file

@ -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,

View file

@ -95,6 +95,16 @@ export function AgentDefaultsSection({
}
/>
<SwitchCardField
label={t("pages.config.split_on_marker")}
hint={t("pages.config.split_on_marker_hint")}
layout="setting-row"
checked={form.splitOnMarker}
onCheckedChange={(checked) =>
onFieldChange("splitOnMarker", checked)
}
/>
<SwitchCardField
label={t("pages.config.tool_feedback_enabled")}
hint={t("pages.config.tool_feedback_enabled_hint")}

View file

@ -8,6 +8,7 @@ export interface RoutingTier {
export interface CoreConfigForm {
workspace: string
restrictToWorkspace: boolean
splitOnMarker: boolean
toolFeedbackEnabled: boolean
toolFeedbackMaxArgsLength: string
execEnabled: boolean
@ -72,7 +73,8 @@ export const DM_SCOPE_OPTIONS = [
export const EMPTY_FORM: CoreConfigForm = {
workspace: "",
restrictToWorkspace: true,
toolFeedbackEnabled: true,
splitOnMarker: false,
toolFeedbackEnabled: false,
toolFeedbackMaxArgsLength: "300",
execEnabled: true,
allowRemote: true,
@ -164,6 +166,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
defaults.restrict_to_workspace === undefined
? EMPTY_FORM.restrictToWorkspace
: asBool(defaults.restrict_to_workspace),
splitOnMarker:
defaults.split_on_marker === undefined
? EMPTY_FORM.splitOnMarker
: asBool(defaults.split_on_marker),
toolFeedbackEnabled:
toolFeedback.enabled === undefined
? EMPTY_FORM.toolFeedbackEnabled

View file

@ -32,6 +32,7 @@ const PROVIDER_PRIORITY: Record<string, number> = {
vllm: 16,
mistral: 17,
avian: 18,
mimo: 19,
}
interface ProviderGroup {

View file

@ -37,6 +37,7 @@ const PROVIDER_DOMAINS: Record<string, string> = {
avian: "avian.io",
vllm: "vllm.ai",
zhipu: "zhipuai.cn",
mimo: "xiaomi.com",
}
interface ProviderIconProps {

View file

@ -18,6 +18,7 @@ const PROVIDER_LABELS: Record<string, string> = {
avian: "Avian",
vllm: "VLLM (local)",
zhipu: "Zhipu AI (智谱)",
mimo: "Xiaomi MiMo",
}
export function getProviderKey(model: string): string {

View file

@ -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",

View file

@ -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": "工具反馈参数预览长度",