Merge branch 'sipeed:main' into main
This commit is contained in:
commit
693d53a9cb
75 changed files with 5018 additions and 949 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,6 +25,9 @@ build/
|
|||
# Secrets & Config (keep templates, ignore actual secrets)
|
||||
.env
|
||||
config/config.json
|
||||
.security.yml
|
||||
onboard
|
||||
|
||||
|
||||
# Test
|
||||
coverage.txt
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
Makefile
20
Makefile
|
|
@ -47,6 +47,13 @@ define PATCH_MIPS_FLAGS
|
|||
fi
|
||||
endef
|
||||
|
||||
# Patch creack/pty for loong64 support (upstream doesn't have ztypes_loong64.go)
|
||||
PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \
|
||||
if [ -d "$$pty_dir" ] && [ ! -f "$$pty_dir/ztypes_loong64.go" ]; then \
|
||||
chmod +w "$$pty_dir" 2>/dev/null || true; \
|
||||
printf '//go:build linux && loong64\npackage pty\ntype (_C_int int32; _C_uint uint32)\n' > "$$pty_dir/ztypes_loong64.go"; \
|
||||
fi
|
||||
|
||||
# Golangci-lint
|
||||
GOLANGCI_LINT?=golangci-lint
|
||||
|
||||
|
|
@ -131,6 +138,14 @@ build-launcher:
|
|||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||
|
||||
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
||||
build-launcher-tui:
|
||||
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
|
||||
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui"
|
||||
|
||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||
|
|
@ -182,6 +197,7 @@ build-all: generate
|
|||
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
@$(PTY_PATCH_LOONG64)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
|
|
@ -245,11 +261,11 @@ fmt:
|
|||
|
||||
## lint: Run linters
|
||||
lint:
|
||||
@CGO_ENABLED=0 $(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
|
||||
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
|
||||
|
||||
## fix: Fix linting issues
|
||||
fix:
|
||||
@CGO_ENABLED=0 $(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
|
||||
@$(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
|
|
|
|||
|
|
@ -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 |
69
cmd/picoclaw-launcher-tui/README.md
Normal file
69
cmd/picoclaw-launcher-tui/README.md
Normal 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
|
||||
```
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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. 当前支持
|
||||
|
||||
- 文本消息收发
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 | - |
|
||||
|
|
|
|||
644
docs/security_configuration.md
Normal file
644
docs/security_configuration.md
Normal 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
|
||||
|
|
@ -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
5
go.mod
|
|
@ -12,6 +12,7 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/ergochat/irc-go v0.6.0
|
||||
github.com/ergochat/readline v0.1.3
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
|
|
@ -20,7 +21,6 @@ require (
|
|||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||
github.com/mymmrac/telego v1.7.0
|
||||
|
|
@ -41,6 +41,7 @@ require (
|
|||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.26.4
|
||||
modernc.org/sqlite v1.46.1
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -69,6 +70,7 @@ require (
|
|||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
|
|
@ -84,7 +86,6 @@ require (
|
|||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -70,6 +70,8 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
|
|||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 := ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
37
pkg/channels/marker.go
Normal 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
141
pkg/channels/marker_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
252
pkg/tools/session.go
Normal 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"`
|
||||
}
|
||||
14
pkg/tools/session_process_unix.go
Normal file
14
pkg/tools/session_process_unix.go
Normal 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
|
||||
}
|
||||
13
pkg/tools/session_process_windows.go
Normal file
13
pkg/tools/session_process_windows.go
Normal 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
99
pkg/tools/session_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
12
pkg/tools/sysproc_unix.go
Normal 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}
|
||||
}
|
||||
10
pkg/tools/sysproc_windows.go
Normal file
10
pkg/tools/sysproc_windows.go
Normal 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.
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
424
web/backend/api/wecom.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
12
web/backend/systray_icon_nonwindows.go
Normal file
12
web/backend/systray_icon_nonwindows.go
Normal 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
|
||||
}
|
||||
|
|
@ -5,4 +5,8 @@ package main
|
|||
import _ "embed"
|
||||
|
||||
//go:embed icon.ico
|
||||
var iconData []byte
|
||||
var iconICO []byte
|
||||
|
||||
func getIcon() []byte {
|
||||
return iconICO
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed icon.png
|
||||
var iconData []byte
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const PROVIDER_PRIORITY: Record<string, number> = {
|
|||
vllm: 16,
|
||||
mistral: 17,
|
||||
avian: 18,
|
||||
mimo: 19,
|
||||
}
|
||||
|
||||
interface ProviderGroup {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const PROVIDER_DOMAINS: Record<string, string> = {
|
|||
avian: "avian.io",
|
||||
vllm: "vllm.ai",
|
||||
zhipu: "zhipuai.cn",
|
||||
mimo: "xiaomi.com",
|
||||
}
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "工具反馈参数预览长度",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue