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)
|
# Secrets & Config (keep templates, ignore actual secrets)
|
||||||
.env
|
.env
|
||||||
config/config.json
|
config/config.json
|
||||||
|
.security.yml
|
||||||
|
onboard
|
||||||
|
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
coverage.txt
|
coverage.txt
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
|
git:
|
||||||
|
ignore_tags:
|
||||||
|
- nightly
|
||||||
|
- ".*-nightly.*"
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
|
|
|
||||||
20
Makefile
20
Makefile
|
|
@ -47,6 +47,13 @@ define PATCH_MIPS_FLAGS
|
||||||
fi
|
fi
|
||||||
endef
|
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?=golangci-lint
|
GOLANGCI_LINT?=golangci-lint
|
||||||
|
|
||||||
|
|
@ -131,6 +138,14 @@ build-launcher:
|
||||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||||
@echo "Build complete: $(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: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||||
build-whatsapp-native: generate
|
build-whatsapp-native: generate
|
||||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
## @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=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=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)
|
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=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=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)
|
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: Run linters
|
||||||
lint:
|
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: Fix linting issues
|
||||||
fix:
|
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: Download dependencies
|
||||||
deps:
|
deps:
|
||||||
|
|
|
||||||
|
|
@ -322,14 +322,17 @@ This creates `~/.picoclaw/config.json` and the workspace directory.
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4"
|
||||||
"api_key": "sk-your-api-key"
|
// 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.
|
> 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**
|
**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)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.ConfigureFromEnv()
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"max_tool_iterations": 20,
|
"max_tool_iterations": 20,
|
||||||
"summarize_message_threshold": 20,
|
"summarize_message_threshold": 20,
|
||||||
"summarize_token_percent": 75,
|
"summarize_token_percent": 75,
|
||||||
|
"split_on_marker": false,
|
||||||
"tool_feedback": {
|
"tool_feedback": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"max_args_length": 300
|
"max_args_length": 300
|
||||||
|
|
@ -129,6 +130,10 @@
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
"verification_token": "",
|
"verification_token": "",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||||
|
},
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": "",
|
||||||
"random_reaction_emoji": [],
|
"random_reaction_emoji": [],
|
||||||
"is_lark": false
|
"is_lark": false
|
||||||
|
|
@ -160,7 +165,7 @@
|
||||||
},
|
},
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"text": "Thinking... 💭"
|
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||||
},
|
},
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": "",
|
||||||
"crypto_database_path": "",
|
"crypto_database_path": "",
|
||||||
|
|
@ -223,13 +228,8 @@
|
||||||
"nickserv_password": "",
|
"nickserv_password": "",
|
||||||
"sasl_user": "",
|
"sasl_user": "",
|
||||||
"sasl_password": "",
|
"sasl_password": "",
|
||||||
"channels": [
|
"channels": ["#mychannel"],
|
||||||
"#mychannel"
|
"request_caps": ["server-time", "message-tags"],
|
||||||
],
|
|
||||||
"request_caps": [
|
|
||||||
"server-time",
|
|
||||||
"message-tags"
|
|
||||||
],
|
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": true
|
"mention_only": true
|
||||||
|
|
@ -240,79 +240,6 @@
|
||||||
"reasoning_channel_id": ""
|
"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": {
|
"tools": {
|
||||||
"allow_read_paths": null,
|
"allow_read_paths": null,
|
||||||
"allow_write_paths": null,
|
"allow_write_paths": null,
|
||||||
|
|
@ -324,9 +251,7 @@
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"api_keys": [
|
"api_keys": ["YOUR_BRAVE_API_KEY"],
|
||||||
"YOUR_BRAVE_API_KEY"
|
|
||||||
],
|
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"tavily": {
|
"tavily": {
|
||||||
|
|
@ -342,9 +267,7 @@
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "pplx-xxx",
|
||||||
"api_keys": [
|
"api_keys": ["pplx-xxx"],
|
||||||
"pplx-xxx"
|
|
||||||
],
|
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"searxng": {
|
"searxng": {
|
||||||
|
|
@ -359,6 +282,12 @@
|
||||||
"search_engine": "search_std",
|
"search_engine": "search_std",
|
||||||
"max_results": 5
|
"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,
|
"fetch_limit_bytes": 10485760,
|
||||||
"private_host_whitelist": []
|
"private_host_whitelist": []
|
||||||
},
|
},
|
||||||
|
|
@ -387,19 +316,12 @@
|
||||||
"filesystem": {
|
"filesystem": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-filesystem",
|
|
||||||
"/tmp"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"github": {
|
"github": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-github"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
||||||
}
|
}
|
||||||
|
|
@ -407,10 +329,7 @@
|
||||||
"brave-search": {
|
"brave-search": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-brave-search"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
|
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
|
||||||
}
|
}
|
||||||
|
|
@ -427,10 +346,7 @@
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-slack"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
||||||
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ Add this to `config.json`:
|
||||||
},
|
},
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"text": "Thinking..."
|
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||||
},
|
},
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": "",
|
||||||
"message_format": "richtext",
|
"message_format": "richtext",
|
||||||
|
|
@ -45,12 +45,19 @@ Add this to `config.json`:
|
||||||
| join_on_invite | bool | No | Auto-join invited rooms |
|
| join_on_invite | bool | No | Auto-join invited rooms |
|
||||||
| allow_from | []string | No | User whitelist (Matrix user IDs) |
|
| allow_from | []string | No | User whitelist (Matrix user IDs) |
|
||||||
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
|
| 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 |
|
| 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 |
|
| 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_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 |
|
| 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
|
## 3. Currently Supported
|
||||||
|
|
||||||
- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)
|
- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
},
|
},
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"text": "Thinking... 💭"
|
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||||
},
|
},
|
||||||
"reasoning_channel_id": "",
|
"reasoning_channel_id": "",
|
||||||
"message_format": "richtext",
|
"message_format": "richtext",
|
||||||
|
|
@ -51,6 +51,13 @@
|
||||||
| crypto_database_path | string | 否 | 加密数据库存储路径(为空时使用工作空间路径 `~/.picoclaw/workspace`) |
|
| crypto_database_path | string | 否 | 加密数据库存储路径(为空时使用工作空间路径 `~/.picoclaw/workspace`) |
|
||||||
| crypto_passphrase | string | 否 | 加密数据库中 session key 的序列化密钥;设置后不能更改 |
|
| crypto_passphrase | string | 否 | 加密数据库中 session key 的序列化密钥;设置后不能更改 |
|
||||||
|
|
||||||
|
### 占位消息配置 (Placeholder)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---------|-----------------|------|------|
|
||||||
|
| enabled | bool | 否 | 是否启用占位消息(默认:false) |
|
||||||
|
| text | string/[]string | 否 | 占位文本。可以是单个字符串或字符串数组。如果提供多个文本,运行时会随机选择一个。默认:"Thinking..." |
|
||||||
|
|
||||||
## 3. 当前支持
|
## 3. 当前支持
|
||||||
|
|
||||||
- 文本消息收发
|
- 文本消息收发
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
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
|
### Workspace Layout
|
||||||
|
|
||||||
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
|
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
|
- **Load balancing**: Distribute requests across multiple endpoints
|
||||||
- **Centralized configuration**: Manage all providers in one place
|
- **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
|
#### All Supported Vendors
|
||||||
|
|
||||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
| 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
|
#### 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>
|
<details>
|
||||||
<summary><b>OpenAI</b></summary>
|
<summary><b>OpenAI</b></summary>
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4"
|
||||||
"api_key": "sk-..."
|
// api_key: set in .security.yml
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -536,8 +620,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "ark-code-latest",
|
"model_name": "ark-code-latest",
|
||||||
"model": "volcengine/ark-code-latest",
|
"model": "volcengine/ark-code-latest"
|
||||||
"api_key": "sk-..."
|
// api_key: set in .security.yml
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -549,8 +633,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
"model": "zhipu/glm-4.7",
|
"model": "zhipu/glm-4.7"
|
||||||
"api_key": "your-key"
|
// api_key: set in .security.yml
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -562,8 +646,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat"
|
||||||
"api_key": "sk-..."
|
// api_key: set in .security.yml
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -575,8 +659,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6"
|
||||||
"api_key": "sk-ant-your-key"
|
// 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_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1"
|
||||||
"api_key": "sk-..."
|
// 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:
|
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
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -701,18 +814,10 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
|
||||||
"dm_scope": "per-channel-peer",
|
"dm_scope": "per-channel-peer",
|
||||||
"backlog_limit": 20
|
"backlog_limit": 20
|
||||||
},
|
},
|
||||||
"providers": {
|
|
||||||
"openrouter": {
|
|
||||||
"api_key": "sk-or-v1-xxx"
|
|
||||||
},
|
|
||||||
"groq": {
|
|
||||||
"api_key": "gsk_xxx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"channels": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true"
|
||||||
"token": "123456:ABC...",
|
// token: set in .security.yml
|
||||||
"allow_from": ["123456789"]
|
"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>
|
</details>
|
||||||
|
|
||||||
### Scheduled Tasks / Reminders
|
### Scheduled Tasks / Reminders
|
||||||
|
|
@ -754,6 +861,7 @@ Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace
|
||||||
|
|
||||||
| Topic | Description |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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_name": "gpt-4o",
|
||||||
"model": "openai/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"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
> [!TIP]
|
> [!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`.
|
> **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
|
```bash
|
||||||
# 5. Check logs
|
# 5. Check logs
|
||||||
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
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) |
|
| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) |
|
||||||
| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) |
|
| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) |
|
||||||
| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) |
|
| `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)
|
### 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) |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **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) |
|
| `mistral` | LLM (Mistral 直连) | [console.mistral.ai](https://console.mistral.ai) |
|
||||||
| `longcat` | LLM (Longcat 直连) | [longcat.ai](https://longcat.ai) |
|
| `longcat` | LLM (Longcat 直连) | [longcat.ai](https://longcat.ai) |
|
||||||
| `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) |
|
| `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) |
|
||||||
|
| `mimo` | LLM (小米 MiMo 直连) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||||
|
|
||||||
### 模型配置 (model_list)
|
### 模型配置 (model_list)
|
||||||
|
|
||||||
|
|
@ -62,6 +63,7 @@
|
||||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) |
|
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) |
|
||||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
| **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) |
|
| **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 |
|
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **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/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.4.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/irc-go v0.6.0
|
||||||
github.com/ergochat/readline v0.1.3
|
github.com/ergochat/readline v0.1.3
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
|
|
@ -20,7 +21,6 @@ require (
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.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/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||||
github.com/mymmrac/telego v1.7.0
|
github.com/mymmrac/telego v1.7.0
|
||||||
|
|
@ -41,6 +41,7 @@ require (
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mautrix v0.26.4
|
maunium.net/go/mautrix v0.26.4
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.46.1
|
||||||
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -69,6 +70,7 @@ require (
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
|
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
|
@ -84,7 +86,6 @@ require (
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
rsc.io/qr v0.2.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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/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.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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type ContextBuilder struct {
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
toolDiscoveryBM25 bool
|
toolDiscoveryBM25 bool
|
||||||
toolDiscoveryRegex bool
|
toolDiscoveryRegex bool
|
||||||
|
splitOnMarker bool
|
||||||
|
|
||||||
// Cache for system prompt to avoid rebuilding on every call.
|
// Cache for system prompt to avoid rebuilding on every call.
|
||||||
// This fixes issue #607: repeated reprocessing of the entire context.
|
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||||
|
|
@ -52,6 +53,11 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil
|
||||||
return cb
|
return cb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder {
|
||||||
|
cb.splitOnMarker = enabled
|
||||||
|
return cb
|
||||||
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
if home := os.Getenv(config.EnvHome); home != "" {
|
if home := os.Getenv(config.EnvHome); home != "" {
|
||||||
return 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)
|
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
|
// Join with "---" separator
|
||||||
return strings.Join(parts, "\n\n---\n\n")
|
return strings.Join(parts, "\n\n---\n\n")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,10 +103,12 @@ func NewAgentInstance(
|
||||||
sessions := initSessionStore(sessionsDir)
|
sessions := initSessionStore(sessionsDir)
|
||||||
|
|
||||||
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
||||||
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
|
contextBuilder := NewContextBuilder(workspace).
|
||||||
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
WithToolDiscovery(
|
||||||
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
||||||
)
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
||||||
|
).
|
||||||
|
WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker)
|
||||||
|
|
||||||
agentID := routing.DefaultAgentID
|
agentID := routing.DefaultAgentID
|
||||||
agentName := ""
|
agentName := ""
|
||||||
|
|
|
||||||
|
|
@ -236,8 +236,9 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
||||||
t.Fatal("exec tool not registered")
|
t.Fatal("exec tool not registered")
|
||||||
}
|
}
|
||||||
execResult := execTool.Execute(context.Background(), map[string]any{
|
execResult := execTool.Execute(context.Background(), map[string]any{
|
||||||
"command": "cat " + filepath.Base(mediaPath),
|
"action": "run",
|
||||||
"working_dir": mediaDir,
|
"command": "cat " + filepath.Base(mediaPath),
|
||||||
|
"cwd": mediaDir,
|
||||||
})
|
})
|
||||||
if execResult.IsError {
|
if execResult.IsError {
|
||||||
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)
|
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
|
DefaultResponse string // Response when LLM returns empty
|
||||||
EnableSummary bool // Whether to trigger summarization
|
EnableSummary bool // Whether to trigger summarization
|
||||||
SendResponse bool // Whether to send response via bus
|
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)
|
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)
|
SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue)
|
||||||
}
|
}
|
||||||
|
|
@ -1247,14 +1248,15 @@ func (al *AgentLoop) ProcessHeartbeat(
|
||||||
return "", fmt.Errorf("no default agent for heartbeat")
|
return "", fmt.Errorf("no default agent for heartbeat")
|
||||||
}
|
}
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
SessionKey: "heartbeat",
|
SessionKey: "heartbeat",
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
UserMessage: content,
|
UserMessage: content,
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: false,
|
EnableSummary: false,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
NoHistory: true, // Don't load session history for heartbeat
|
SuppressToolFeedback: true,
|
||||||
|
NoHistory: true, // Don't load session history for heartbeat
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1952,6 +1954,7 @@ turnLoop:
|
||||||
|
|
||||||
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
|
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
|
||||||
strings.Contains(errMsg, "context window") ||
|
strings.Contains(errMsg, "context window") ||
|
||||||
|
strings.Contains(errMsg, "context_window") ||
|
||||||
strings.Contains(errMsg, "maximum context length") ||
|
strings.Contains(errMsg, "maximum context length") ||
|
||||||
strings.Contains(errMsg, "token limit") ||
|
strings.Contains(errMsg, "token limit") ||
|
||||||
strings.Contains(errMsg, "too many tokens") ||
|
strings.Contains(errMsg, "too many tokens") ||
|
||||||
|
|
@ -2310,7 +2313,9 @@ turnLoop:
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send tool feedback to chat channel if enabled (from HEAD)
|
// 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(
|
feedbackPreview := utils.Truncate(
|
||||||
string(argsJSON),
|
string(argsJSON),
|
||||||
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package agent
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -1018,6 +1019,40 @@ func (m *artifactThenSendProvider) GetDefaultModel() string {
|
||||||
return "artifact-then-send-model"
|
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{}
|
type toolLimitOnlyProvider struct{}
|
||||||
|
|
||||||
func (m *toolLimitOnlyProvider) Chat(
|
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) {
|
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
|
||||||
store := media.NewFileMediaStore()
|
store := media.NewFileMediaStore()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
@ -2677,3 +2818,111 @@ func TestFilterClientWebSearch_EmptyInput(t *testing.T) {
|
||||||
t.Fatalf("len(result) = %d, want 0", len(result))
|
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
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
text := c.config.Placeholder.Text
|
text := c.config.Placeholder.GetRandomText()
|
||||||
if text == "" {
|
|
||||||
text = "Thinking... 💭"
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, err := c.session.ChannelMessageSend(chatID, text)
|
msg, err := c.session.ChannelMessageSend(chatID, text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -211,10 +211,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
text := c.config.Placeholder.Text
|
text := c.config.Placeholder.GetRandomText()
|
||||||
if text == "" {
|
|
||||||
text = "Thinking..."
|
|
||||||
}
|
|
||||||
|
|
||||||
cardContent, err := buildMarkdownCard(text)
|
cardContent, err := buildMarkdownCard(text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -608,8 +608,10 @@ func newChannelWorker(name string, ch Channel) *channelWorker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runWorker processes outbound messages for a single channel, splitting
|
// runWorker processes outbound messages for a single channel.
|
||||||
// messages that exceed the channel's maximum message length.
|
// 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) {
|
func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) {
|
||||||
defer close(w.done)
|
defer close(w.done)
|
||||||
for {
|
for {
|
||||||
|
|
@ -622,15 +624,29 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
|
||||||
if mlp, ok := w.ch.(MessageLengthProvider); ok {
|
if mlp, ok := w.ch.(MessageLengthProvider); ok {
|
||||||
maxLen = mlp.MaxMessageLength()
|
maxLen = mlp.MaxMessageLength()
|
||||||
}
|
}
|
||||||
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
|
|
||||||
chunks := SplitMessage(msg.Content, maxLen)
|
// Collect all message chunks to send
|
||||||
for _, chunk := range chunks {
|
var chunks []string
|
||||||
chunkMsg := msg
|
|
||||||
chunkMsg.Content = chunk
|
// Step 1: Try marker-based splitting if enabled
|
||||||
m.sendWithRetry(ctx, name, w, chunkMsg)
|
if m.config != nil && m.config.Agents.Defaults.SplitOnMarker {
|
||||||
|
if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 {
|
||||||
|
for _, chunk := range markerChunks {
|
||||||
|
chunks = append(chunks, splitByLength(chunk, maxLen)...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
m.sendWithRetry(ctx, name, w, msg)
|
|
||||||
|
// Step 2: Fallback to length-based splitting if no chunks from marker
|
||||||
|
if len(chunks) == 0 {
|
||||||
|
chunks = splitByLength(msg.Content, maxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Send all chunks
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
chunkMsg := msg
|
||||||
|
chunkMsg.Content = chunk
|
||||||
|
m.sendWithRetry(ctx, name, w, chunkMsg)
|
||||||
}
|
}
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
|
@ -638,6 +654,14 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitByLength splits content by maxLen if needed, otherwise returns single chunk.
|
||||||
|
func splitByLength(content string, maxLen int) []string {
|
||||||
|
if maxLen > 0 && len([]rune(content)) > maxLen {
|
||||||
|
return SplitMessage(content, maxLen)
|
||||||
|
}
|
||||||
|
return []string{content}
|
||||||
|
}
|
||||||
|
|
||||||
// sendWithRetry sends a message through the channel with rate limiting and
|
// sendWithRetry sends a message through the channel with rate limiting and
|
||||||
// retry logic. It classifies errors to determine the retry strategy:
|
// retry logic. It classifies errors to determine the retry strategy:
|
||||||
// - ErrNotRunning / ErrSendFailed: permanent, no retry
|
// - 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")
|
return "", fmt.Errorf("matrix room ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
text := strings.TrimSpace(c.config.Placeholder.Text)
|
text := c.config.Placeholder.GetRandomText()
|
||||||
if text == "" {
|
|
||||||
text = "Thinking... 💭"
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
|
resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
|
||||||
MsgType: event.MsgNotice,
|
MsgType: event.MsgNotice,
|
||||||
|
|
|
||||||
|
|
@ -275,10 +275,7 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
text := c.config.Placeholder.Text
|
text := c.config.Placeholder.GetRandomText()
|
||||||
if text == "" {
|
|
||||||
text = "Thinking... 💭"
|
|
||||||
}
|
|
||||||
|
|
||||||
msgID := uuid.New().String()
|
msgID := uuid.New().String()
|
||||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -402,10 +402,7 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
text := phCfg.Text
|
text := phCfg.GetRandomText()
|
||||||
if text == "" {
|
|
||||||
text = "Thinking... 💭"
|
|
||||||
}
|
|
||||||
|
|
||||||
cid, threadID, err := parseTelegramChatID(chatID)
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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"
|
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_"`
|
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
|
||||||
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
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
|
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
||||||
|
|
@ -386,8 +388,20 @@ type TypingConfig struct {
|
||||||
|
|
||||||
// PlaceholderConfig controls placeholder message behavior (Phase 10).
|
// PlaceholderConfig controls placeholder message behavior (Phase 10).
|
||||||
type PlaceholderConfig struct {
|
type PlaceholderConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
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 {
|
type StreamingConfig struct {
|
||||||
|
|
@ -1325,17 +1339,29 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Load security configuration
|
|
||||||
securityPath := securityPath(path)
|
// Legacy config (no version field)
|
||||||
sec, err := loadSecurityConfig(securityPath)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load security config: %w", err)
|
return nil, fmt.Errorf("failed to load security config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply security references from .security.yml BEFORE resolveAPIKeys
|
// Merge security configs: config.json takes precedence over .security.yml
|
||||||
// This resolves ref: references to actual values
|
if err := applySecurityConfigWithPrecedence(cfg, tmpCfgMigrated, sec); err != nil {
|
||||||
if err := applySecurityConfig(cfg, sec); err != nil {
|
return nil, fmt.Errorf("failed to merge security config: %w", err)
|
||||||
return nil, fmt.Errorf("failed to apply security config: %w", err)
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
||||||
|
|
@ -1568,6 +1594,28 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
||||||
return nil
|
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 {
|
func toNameIndex(list []*ModelConfig) []string {
|
||||||
nameList := make([]string, 0, len(list))
|
nameList := make([]string, 0, len(list))
|
||||||
countMap := make(map[string]int)
|
countMap := make(map[string]int)
|
||||||
|
|
|
||||||
|
|
@ -834,6 +834,7 @@ type webToolsConfigV0 struct {
|
||||||
Perplexity perplexityConfigV0 ` json:"perplexity"`
|
Perplexity perplexityConfigV0 ` json:"perplexity"`
|
||||||
SearXNG SearXNGConfig ` json:"searxng"`
|
SearXNG SearXNGConfig ` json:"searxng"`
|
||||||
GLMSearch glmSearchConfigV0 ` json:"glm_search"`
|
GLMSearch glmSearchConfigV0 ` json:"glm_search"`
|
||||||
|
BaiduSearch baiduSearchConfigV0 ` json:"baidu_search"`
|
||||||
PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
|
PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
|
||||||
Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||||
FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
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
|
}, 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) {
|
func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) {
|
||||||
brave, braveSecurity := v.Brave.ToBraveConfig()
|
brave, braveSecurity := v.Brave.ToBraveConfig()
|
||||||
tavily, tavilySecurity := v.Tavily.ToTavilyConfig()
|
tavily, tavilySecurity := v.Tavily.ToTavilyConfig()
|
||||||
perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig()
|
perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig()
|
||||||
glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig()
|
glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig()
|
||||||
|
baiduSearch, baiduSearchSecurity := v.BaiduSearch.ToBaiduSearchConfig()
|
||||||
|
|
||||||
return WebToolsConfig{
|
return WebToolsConfig{
|
||||||
ToolConfig: v.ToolConfig,
|
ToolConfig: v.ToolConfig,
|
||||||
|
|
@ -939,16 +963,18 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity)
|
||||||
Perplexity: perplexity,
|
Perplexity: perplexity,
|
||||||
SearXNG: v.SearXNG,
|
SearXNG: v.SearXNG,
|
||||||
GLMSearch: glmSearch,
|
GLMSearch: glmSearch,
|
||||||
|
BaiduSearch: baiduSearch,
|
||||||
PreferNative: v.PreferNative,
|
PreferNative: v.PreferNative,
|
||||||
Proxy: v.Proxy,
|
Proxy: v.Proxy,
|
||||||
FetchLimitBytes: v.FetchLimitBytes,
|
FetchLimitBytes: v.FetchLimitBytes,
|
||||||
Format: v.Format,
|
Format: v.Format,
|
||||||
PrivateHostWhitelist: v.PrivateHostWhitelist,
|
PrivateHostWhitelist: v.PrivateHostWhitelist,
|
||||||
}, WebToolsSecurity{
|
}, WebToolsSecurity{
|
||||||
Brave: braveSecurity,
|
Brave: braveSecurity,
|
||||||
Tavily: tavilySecurity,
|
Tavily: tavilySecurity,
|
||||||
Perplexity: perplexitySecurity,
|
Perplexity: perplexitySecurity,
|
||||||
GLMSearch: glmSearchSecurity,
|
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) {
|
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configPath := filepath.Join(dir, "config.json")
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,10 @@ func DefaultConfig() *Config {
|
||||||
SummarizeTokenPercent: 75,
|
SummarizeTokenPercent: 75,
|
||||||
SteeringMode: "one-at-a-time",
|
SteeringMode: "one-at-a-time",
|
||||||
ToolFeedback: ToolFeedbackConfig{
|
ToolFeedback: ToolFeedbackConfig{
|
||||||
Enabled: true,
|
Enabled: false,
|
||||||
MaxArgsLength: 300,
|
MaxArgsLength: 300,
|
||||||
},
|
},
|
||||||
|
SplitOnMarker: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Bindings: []AgentBinding{},
|
Bindings: []AgentBinding{},
|
||||||
|
|
@ -62,7 +63,7 @@ func DefaultConfig() *Config {
|
||||||
Typing: TypingConfig{Enabled: true},
|
Typing: TypingConfig{Enabled: true},
|
||||||
Placeholder: PlaceholderConfig{
|
Placeholder: PlaceholderConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Text: "Thinking... 💭",
|
Text: FlexibleStringSlice{"Thinking... 💭"},
|
||||||
},
|
},
|
||||||
Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200},
|
Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200},
|
||||||
UseMarkdownV2: false,
|
UseMarkdownV2: false,
|
||||||
|
|
@ -111,7 +112,7 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Placeholder: PlaceholderConfig{
|
Placeholder: PlaceholderConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Text: "Thinking... 💭",
|
Text: FlexibleStringSlice{"Thinking... 💭"},
|
||||||
},
|
},
|
||||||
CryptoDatabasePath: "",
|
CryptoDatabasePath: "",
|
||||||
CryptoPassphrase: "",
|
CryptoPassphrase: "",
|
||||||
|
|
|
||||||
|
|
@ -11,20 +11,33 @@ Package config
|
||||||
|
|
||||||
# Example: Using Security Configuration
|
# 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
|
```yaml
|
||||||
# Model API Keys
|
# Model API Keys
|
||||||
# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
|
# All models MUST use 'api_keys' (plural) array format
|
||||||
# Single key should be provided as an array with one element
|
# Even a single key must be provided as an array with one element
|
||||||
model_list:
|
model_list:
|
||||||
|
|
||||||
gpt-5.4:
|
gpt-5.4:
|
||||||
api_keys:
|
api_keys:
|
||||||
- "sk-proj-your-actual-openai-key-1"
|
- "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:
|
claude-sonnet-4.6:
|
||||||
api_keys:
|
api_keys:
|
||||||
- "sk-ant-your-actual-anthropic-key" # Single key in array format
|
- "sk-ant-your-actual-anthropic-key" # Single key in array format
|
||||||
|
|
@ -38,80 +51,95 @@ channels:
|
||||||
token: "your-discord-bot-token"
|
token: "your-discord-bot-token"
|
||||||
|
|
||||||
# Web Tool Keys
|
# Web Tool Keys
|
||||||
# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
|
# Brave, Tavily, Perplexity: Use 'api_keys' array
|
||||||
# For GLMSearch, use 'api_key' (single string)
|
# GLMSearch, BaiduSearch: Use 'api_key' single string
|
||||||
web:
|
web:
|
||||||
|
|
||||||
brave:
|
brave:
|
||||||
api_keys:
|
api_keys:
|
||||||
- "BSAyour-brave-api-key-1"
|
- "BSAyour-brave-api-key-1"
|
||||||
- "BSAyour-brave-api-key-2" # Failover key
|
- "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover
|
||||||
tavily:
|
tavily:
|
||||||
api_keys:
|
api_keys:
|
||||||
- "tvly-your-tavily-api-key" # Single key in array format
|
- "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:
|
glm_search:
|
||||||
api_key: "your-glm-search-api-key" # Single key (not array)
|
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
|
File: ~/.picoclaw/config.json
|
||||||
|
|
||||||
|
Note: Sensitive fields are omitted because they're loaded from .security.yml
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/picoclaw-workspace",
|
"workspace": "~/picoclaw-workspace",
|
||||||
"model_name": "gpt-5.4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api.openai.com/v1",
|
"api_base": "https://api.openai.com/v1"
|
||||||
"api_key": "ref:model_list.gpt-5.4.api_key"
|
// api_key is automatically loaded from .security.yml
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_base": "https://api.anthropic.com/v1",
|
"api_base": "https://api.anthropic.com/v1"
|
||||||
"api_key": "ref:model_list.claude-sonnet-4.6.api_key"
|
// api_key is automatically loaded from .security.yml
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"channels": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"token": "ref:channels.telegram.token"
|
// token is automatically loaded from .security.yml
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"token": "ref:channels.discord.token"
|
// token is automatically loaded from .security.yml
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"api_key": "ref:web.brave.api_key"
|
// api_key is automatically loaded from .security.yml
|
||||||
},
|
},
|
||||||
"tavily": {
|
"tavily": {
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"api_key": "ref:web.tavily.api_key"
|
// api_key is automatically loaded from .security.yml
|
||||||
|
},
|
||||||
|
"glm_search": {
|
||||||
|
"enabled": true
|
||||||
|
// api_key is automatically loaded from .security.yml
|
||||||
|
},
|
||||||
|
"baidu_search": {
|
||||||
|
"enabled": true
|
||||||
|
// api_key is automatically loaded from .security.yml
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Set proper permissions
|
## 3. Set proper permissions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod 600 ~/.picoclaw/security.yml
|
chmod 600 ~/.picoclaw/.security.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Add to .gitignore
|
## 4. Add to .gitignore
|
||||||
|
|
@ -127,51 +155,131 @@ chmod 600 ~/.picoclaw/security.yml
|
||||||
picoclaw --version
|
picoclaw --version
|
||||||
```
|
```
|
||||||
|
|
||||||
# Available Reference Paths
|
# Supported Fields in .security.yml
|
||||||
|
|
||||||
## Model API Keys
|
## 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:
|
Examples:
|
||||||
- ref:model_list.gpt-5.4.api_key
|
```yaml
|
||||||
- ref:model_list.claude-sonnet-4.6.api_key
|
model_list:
|
||||||
|
|
||||||
**Note:** In .security.yml, use `api_keys` (array) format for models.
|
gpt-5.4:
|
||||||
Both single and multiple keys should use the array format.
|
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
|
## Channel Tokens/Secrets
|
||||||
- ref:channels.telegram.token
|
|
||||||
- ref:channels.feishu.app_secret
|
```yaml
|
||||||
- ref:channels.feishu.encrypt_key
|
channels:
|
||||||
- ref:channels.feishu.verification_token
|
|
||||||
- ref:channels.discord.token
|
telegram:
|
||||||
- ref:channels.qq.app_secret
|
token: "value"
|
||||||
- ref:channels.dingtalk.client_secret
|
feishu:
|
||||||
- ref:channels.slack.bot_token
|
app_secret: "value"
|
||||||
- ref:channels.slack.app_token
|
encrypt_key: "value"
|
||||||
- ref:channels.matrix.access_token
|
verification_token: "value"
|
||||||
- ref:channels.line.channel_secret
|
discord:
|
||||||
- ref:channels.line.channel_access_token
|
token: "value"
|
||||||
- ref:channels.onebot.access_token
|
weixin:
|
||||||
- ref:channels.wecom.secret
|
token: "value"
|
||||||
- ref:channels.pico.token
|
qq:
|
||||||
- ref:channels.irc.password
|
app_secret: "value"
|
||||||
- ref:channels.irc.nickserv_password
|
dingtalk:
|
||||||
- ref:channels.irc.sasl_password
|
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
|
## 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:**
|
||||||
- Brave, Tavily, Perplexity: Use `api_keys` (array) format in .security.yml
|
```yaml
|
||||||
- GLMSearch: Use `api_key` (single string) format in .security.yml
|
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
|
## Skills Registry Tokens
|
||||||
- ref:skills.github.token
|
|
||||||
- ref:skills.clawhub.auth_token
|
```yaml
|
||||||
|
skills:
|
||||||
|
|
||||||
|
github:
|
||||||
|
token: "value"
|
||||||
|
clawhub:
|
||||||
|
auth_token: "value"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
# Backward Compatibility
|
# Backward Compatibility
|
||||||
|
|
||||||
|
|
@ -185,14 +293,14 @@ You can still use direct values in config.json if needed:
|
||||||
"model_name": "local-model",
|
"model_name": "local-model",
|
||||||
"model": "ollama/llama3",
|
"model": "ollama/llama3",
|
||||||
"api_base": "http://localhost:11434/v1",
|
"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
|
```json
|
||||||
|
|
||||||
|
|
@ -200,10 +308,12 @@ You can also mix references and direct values:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "cloud-model",
|
"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_name": "local-model",
|
||||||
|
"model": "ollama/llama3",
|
||||||
|
"api_base": "http://localhost:11434/v1",
|
||||||
"api_key": "ollama" // Direct value
|
"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
|
# Migration from Old Config
|
||||||
|
|
||||||
## Step 1: Backup your 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
|
cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 2: Copy the example security file
|
## Step 2: Create .security.yml
|
||||||
```bash
|
```bash
|
||||||
cp security.example.yml ~/.picoclaw/.security.yml
|
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
|
## Step 3: Fill in your API keys
|
||||||
Edit ~/.picoclaw/.security.yml and replace placeholders with your actual keys.
|
Edit ~/.picoclaw/.security.yml and replace placeholders with your actual keys.
|
||||||
|
|
||||||
## Step 4: Update config.json references
|
## Step 4: Simplify config.json (Recommended)
|
||||||
Replace sensitive values in ~/.picoclaw/config.json with ref: references.
|
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
|
```bash
|
||||||
picoclaw --version
|
picoclaw --version
|
||||||
```
|
```
|
||||||
|
|
@ -243,9 +367,11 @@ rm ~/.picoclaw/config.json.backup
|
||||||
|
|
||||||
## Multiple API Keys (Load Balancing & Failover)
|
## 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
|
- **Load balancing**: Requests are distributed across multiple keys
|
||||||
- **Failover**: If a key fails, the system automatically switches to another key
|
- **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
|
### Example: Model with Multiple Keys
|
||||||
|
|
||||||
|
|
@ -269,7 +395,7 @@ model_list:
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/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": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"api_key": "ref:web.brave.api_key"
|
},
|
||||||
|
"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
|
```yaml
|
||||||
model_list:
|
model_list:
|
||||||
|
|
||||||
|
|
@ -322,36 +453,32 @@ model_list:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multiple Keys (Load Balancing & Failover)
|
**GLMSearch, BaiduSearch:**
|
||||||
|
|
||||||
Use array format with multiple elements:
|
|
||||||
```yaml
|
```yaml
|
||||||
model_list:
|
web:
|
||||||
|
|
||||||
gpt-5.4:
|
glm_search:
|
||||||
api_keys:
|
api_key: "your-glm-key" # Single key (not array)
|
||||||
- "sk-proj-key-1"
|
|
||||||
- "sk-proj-key-2"
|
|
||||||
- "sk-proj-key-3"
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important:** All model keys in .security.yml must use the `api_keys` (plural) array format.
|
## Model Name Matching
|
||||||
The single `api_key` (singular) format is NOT supported for models.
|
|
||||||
|
|
||||||
### Model Index Matching
|
|
||||||
|
|
||||||
The system supports intelligent model name matching in .security.yml:
|
The system supports intelligent model name matching in .security.yml:
|
||||||
|
|
||||||
**Example 1: Exact Match**
|
### Example 1: Exact Match
|
||||||
```yaml
|
|
||||||
# config.json
|
**config.json:**
|
||||||
|
```json
|
||||||
|
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4:0"
|
"model_name": "gpt-5.4:0"
|
||||||
}
|
}
|
||||||
|
|
||||||
# .security.yml (exact match with index)
|
```
|
||||||
|
|
||||||
|
**.security.yml (exact match with index):**
|
||||||
|
```yaml
|
||||||
model_list:
|
model_list:
|
||||||
|
|
||||||
gpt-5.4:0:
|
gpt-5.4:0:
|
||||||
|
|
@ -359,26 +486,30 @@ model_list:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example 2: Base Name Match**
|
### Example 2: Base Name Match
|
||||||
```yaml
|
|
||||||
# config.json
|
**config.json:**
|
||||||
|
```json
|
||||||
|
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4:0"
|
"model_name": "gpt-5.4:0"
|
||||||
}
|
}
|
||||||
|
|
||||||
# .security.yml (base name without index)
|
```
|
||||||
|
|
||||||
|
**.security.yml (base name without index):**
|
||||||
|
```yaml
|
||||||
model_list:
|
model_list:
|
||||||
|
|
||||||
gpt-5.4:
|
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
|
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.
|
even when your config uses indexed model names for load balancing.
|
||||||
|
|
||||||
### Security File Permissions
|
## Security File Permissions
|
||||||
|
|
||||||
The security file should have restricted 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
|
# Security Best Practices
|
||||||
|
|
||||||
1. Never commit .security.yml to version control
|
1. Never commit .security.yml to version control
|
||||||
2. Set file permissions: chmod 600 ~/.picoclaw/.security.yml
|
2. Add .security.yml to your .gitignore file
|
||||||
3. Use different keys for different environments
|
3. Set file permissions: chmod 600 ~/.picoclaw/.security.yml
|
||||||
4. Rotate keys regularly and update .security.yml
|
4. Use different keys for different environments (dev, staging, production)
|
||||||
5. Encrypt backups containing .security.yml
|
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
|
# 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"
|
## Error: "model security entry not found"
|
||||||
- Check that the model name in config.json matches exactly in .security.yml
|
- Check that the model name in config.json matches exactly in .security.yml
|
||||||
- Verify the model_list section exists 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"
|
## Multiple API Keys Not Working
|
||||||
- Ensure .security.yml exists in the same directory as config.json
|
- Ensure you're using `api_keys` (plural) in .security.yml for models and web tools (except GLMSearch/BaiduSearch)
|
||||||
- Check YAML syntax is valid
|
- Check that the array format is correct in YAML (proper indentation with dashes)
|
||||||
- Verify file permissions allow reading
|
- 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"
|
## Keys Not Being Applied
|
||||||
- Verify the reference format is correct
|
- Check that .security.yml is in the same directory as config.json
|
||||||
- Check the path structure matches the examples above
|
- Verify the file permissions allow reading (chmod 600 ~/.picoclaw/.security.yml)
|
||||||
- Ensure all required sections exist in .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
|
package config
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ func TestSecurityConfigIntegration(t *testing.T) {
|
||||||
t.Run("Full workflow with security references", func(t *testing.T) {
|
t.Run("Full workflow with security references", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
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")
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
configContent := `{
|
configContent := `{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|
@ -52,25 +53,25 @@ func TestSecurityConfigIntegration(t *testing.T) {
|
||||||
"model_name": "test-model",
|
"model_name": "test-model",
|
||||||
"model": "openai/test-model",
|
"model": "openai/test-model",
|
||||||
"api_base": "https://api.openai.com/v1",
|
"api_base": "https://api.openai.com/v1",
|
||||||
"api_key": "ref:model_list.test-model.api_key"
|
"api_key": "sk-from-config-json-direct"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"channels": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "ref:channels.telegram.token"
|
"token": "token-from-config-json-direct"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"api_key": "ref:web.brave.api_key"
|
"api_key": "BSA-from-config-json-direct"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"github": {
|
"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)
|
err := os.WriteFile(configPath, []byte(configContent), 0o644)
|
||||||
require.NoError(t, err)
|
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)
|
securityPath := filepath.Join(tmpDir, SecurityConfigFile)
|
||||||
securityContent := `model_list:
|
securityContent := `model_list:
|
||||||
test-model:
|
test-model:
|
||||||
api_keys:
|
api_keys:
|
||||||
- "sk-test-api-key-12345"
|
- "sk-from-security-yml"
|
||||||
|
|
||||||
channels:
|
channels:
|
||||||
telegram:
|
telegram:
|
||||||
token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
token: "token-from-security-yml"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
brave:
|
brave:
|
||||||
api_keys:
|
api_keys:
|
||||||
- "BSAbrave-api-key-67890"
|
- "BSA-from-security-yml"
|
||||||
|
|
||||||
skills:
|
skills:
|
||||||
github:
|
github:
|
||||||
token: "ghp_github-token-abc123"`
|
token: "ghp-from-security-yml"`
|
||||||
err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
|
err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Load config and verify references are resolved
|
// Load config and verify config.json values take precedence
|
||||||
cfg, err := LoadConfig(configPath)
|
cfg, err := LoadConfig(configPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, cfg)
|
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, 1, len(cfg.ModelList))
|
||||||
assert.Equal(t, "test-model", cfg.ModelList[0].ModelName)
|
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
|
// Verify channel token from config.json takes precedence
|
||||||
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.token)
|
assert.Equal(t, "token-from-config-json-direct", cfg.Channels.Telegram.token)
|
||||||
|
|
||||||
// Verify web tool API key is resolved
|
// Verify web tool API key from config.json takes precedence
|
||||||
assert.Equal(t, "BSAbrave-api-key-67890", cfg.Tools.Web.Brave.APIKey())
|
assert.Equal(t, "BSA-from-config-json-direct", cfg.Tools.Web.Brave.APIKey())
|
||||||
|
|
||||||
// Verify skills token is resolved
|
// Verify skills token from config.json takes precedence
|
||||||
assert.Equal(t, "ghp_github-token-abc123", cfg.Tools.Skills.Github.token)
|
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
|
package gateway
|
||||||
|
|
||||||
|
|
@ -12,6 +12,9 @@ import (
|
||||||
// - netbsd/*: modernc.org/sqlite v1.46.1 fails to compile due to broken
|
// - 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
|
// 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).
|
// 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
|
// 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
|
// long-term fix is to split Matrix basic support from its E2EE/sqlite-backed
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
@ -100,6 +101,12 @@ func SetConsoleLevel(level LogLevel) {
|
||||||
logger = logger.Level(level)
|
logger = logger.Level(level)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DisableConsole() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
logger = zerolog.New(io.Discard).With().Timestamp().Caller().Logger()
|
||||||
|
}
|
||||||
|
|
||||||
func GetLevel() LogLevel {
|
func GetLevel() LogLevel {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
defer mu.RUnlock()
|
defer mu.RUnlock()
|
||||||
|
|
@ -170,6 +177,22 @@ func DisableFileLogging() {
|
||||||
fileLogger = zerolog.Logger{}
|
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 {
|
func getCallerSkip() int {
|
||||||
for i := 2; i < 15; i++ {
|
for i := 2; i < 15; i++ {
|
||||||
pc, file, _, ok := runtime.Caller(i)
|
pc, file, _, ok := runtime.Caller(i)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"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")
|
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("messages.1.content.1.tool_use.id"),
|
||||||
substr("invalid request format"),
|
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{
|
imageDimensionPatterns = []errorPattern{
|
||||||
rxp(`image dimensions exceed max`),
|
rxp(`image dimensions exceed max`),
|
||||||
|
|
@ -201,6 +210,9 @@ func classifyByMessage(msg string) FailoverReason {
|
||||||
if matchesAny(msg, formatPatterns) {
|
if matchesAny(msg, formatPatterns) {
|
||||||
return FailoverFormat
|
return FailoverFormat
|
||||||
}
|
}
|
||||||
|
if matchesAny(msg, contextOverflowPatterns) {
|
||||||
|
return FailoverContextOverflow
|
||||||
|
}
|
||||||
return ""
|
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) {
|
func TestClassifyError_ImageSizeError(t *testing.T) {
|
||||||
err := errors.New("image exceeds 20 mb limit")
|
err := errors.New("image exceeds 20 mb limit")
|
||||||
result := ClassifyError(err, "openai", "gpt-4o")
|
result := ClassifyError(err, "openai", "gpt-4o")
|
||||||
|
|
@ -265,6 +289,7 @@ func TestFailoverError_IsRetriable(t *testing.T) {
|
||||||
{FailoverTimeout, true},
|
{FailoverTimeout, true},
|
||||||
{FailoverOverloaded, true},
|
{FailoverOverloaded, true},
|
||||||
{FailoverFormat, false},
|
{FailoverFormat, false},
|
||||||
|
{FailoverContextOverflow, false},
|
||||||
{FailoverUnknown, true},
|
{FailoverUnknown, true},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||||
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
|
"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
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey() == "" && cfg.APIBase == "" {
|
if cfg.APIKey() == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
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"
|
return "https://api.longcat.chat/openai"
|
||||||
case "modelscope":
|
case "modelscope":
|
||||||
return "https://api-inference.modelscope.cn/v1"
|
return "https://api-inference.modelscope.cn/v1"
|
||||||
|
case "mimo":
|
||||||
|
return "https://api.xiaomimimo.com/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||||
{"ollama", "ollama"},
|
{"ollama", "ollama"},
|
||||||
{"longcat", "longcat"},
|
{"longcat", "longcat"},
|
||||||
{"modelscope", "modelscope"},
|
{"modelscope", "modelscope"},
|
||||||
|
{"mimo", "mimo"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
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) {
|
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-anthropic",
|
ModelName: "test-anthropic",
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,14 @@ type NativeSearchCapable interface {
|
||||||
type FailoverReason string
|
type FailoverReason string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FailoverAuth FailoverReason = "auth"
|
FailoverAuth FailoverReason = "auth"
|
||||||
FailoverRateLimit FailoverReason = "rate_limit"
|
FailoverRateLimit FailoverReason = "rate_limit"
|
||||||
FailoverBilling FailoverReason = "billing"
|
FailoverBilling FailoverReason = "billing"
|
||||||
FailoverTimeout FailoverReason = "timeout"
|
FailoverTimeout FailoverReason = "timeout"
|
||||||
FailoverFormat FailoverReason = "format"
|
FailoverFormat FailoverReason = "format"
|
||||||
FailoverOverloaded FailoverReason = "overloaded"
|
FailoverContextOverflow FailoverReason = "context_overflow"
|
||||||
FailoverUnknown FailoverReason = "unknown"
|
FailoverOverloaded FailoverReason = "overloaded"
|
||||||
|
FailoverUnknown FailoverReason = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FailoverError wraps an LLM provider error with classification metadata.
|
// FailoverError wraps an LLM provider error with classification metadata.
|
||||||
|
|
@ -101,7 +102,7 @@ func (e *FailoverError) Unwrap() error {
|
||||||
// IsRetriable returns true if this error should trigger fallback to next candidate.
|
// IsRetriable returns true if this error should trigger fallback to next candidate.
|
||||||
// Non-retriable: Format errors (bad request structure, image dimension/size).
|
// Non-retriable: Format errors (bad request structure, image dimension/size).
|
||||||
func (e *FailoverError) IsRetriable() bool {
|
func (e *FailoverError) IsRetriable() bool {
|
||||||
return e.Reason != FailoverFormat
|
return e.Reason != FailoverFormat && e.Reason != FailoverContextOverflow
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelConfig holds primary model and fallback list.
|
// 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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"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 {
|
type ExecTool struct {
|
||||||
workingDir string
|
workingDir string
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
|
|
@ -26,6 +42,7 @@ type ExecTool struct {
|
||||||
allowedPathPatterns []*regexp.Regexp
|
allowedPathPatterns []*regexp.Regexp
|
||||||
restrictToWorkspace bool
|
restrictToWorkspace bool
|
||||||
allowRemote bool
|
allowRemote bool
|
||||||
|
sessionManager *SessionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -145,7 +162,7 @@ func NewExecToolWithConfig(
|
||||||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout := 60 * time.Second
|
var timeout time.Duration
|
||||||
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
|
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
|
||||||
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
|
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +176,7 @@ func NewExecToolWithConfig(
|
||||||
allowedPathPatterns: allowedPathPatterns,
|
allowedPathPatterns: allowedPathPatterns,
|
||||||
restrictToWorkspace: restrict,
|
restrictToWorkspace: restrict,
|
||||||
allowRemote: allowRemote,
|
allowRemote: allowRemote,
|
||||||
|
sessionManager: getSessionManager(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,27 +185,82 @@ func (t *ExecTool) Name() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) Description() 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 {
|
func (t *ExecTool) Parameters() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"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{
|
"command": map[string]any{
|
||||||
"type": "string",
|
"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",
|
"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 {
|
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)
|
command, ok := args["command"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("command is required")
|
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
|
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 != "" {
|
if t.restrictToWorkspace && t.workingDir != "" {
|
||||||
resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns)
|
resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns)
|
||||||
if err != nil {
|
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
|
// timeout == 0 means no timeout
|
||||||
var cmdCtx context.Context
|
var cmdCtx context.Context
|
||||||
var cancel context.CancelFunc
|
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 {
|
func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
cmd := strings.TrimSpace(command)
|
cmd := strings.TrimSpace(command)
|
||||||
lower := strings.ToLower(cmd)
|
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)
|
tool.SetTimeout(500 * time.Millisecond)
|
||||||
|
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
|
"action": "run",
|
||||||
// Spawn a child process that would outlive the shell unless process-group kill is used.
|
// Spawn a child process that would outlive the shell unless process-group kill is used.
|
||||||
"command": "sleep 60 & echo $! > child.pid; wait",
|
"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"`
|
Description string `json:"description"`
|
||||||
Parameters map[string]any `json:"parameters"`
|
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
|
oauthState map[string]string
|
||||||
weixinMu sync.Mutex
|
weixinMu sync.Mutex
|
||||||
weixinFlows map[string]*weixinFlow
|
weixinFlows map[string]*weixinFlow
|
||||||
|
wecomMu sync.Mutex
|
||||||
|
wecomFlows map[string]*wecomFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates an instance of the API handler.
|
// NewHandler creates an instance of the API handler.
|
||||||
|
|
@ -29,6 +31,7 @@ func NewHandler(configPath string) *Handler {
|
||||||
oauthFlows: make(map[string]*oauthFlow),
|
oauthFlows: make(map[string]*oauthFlow),
|
||||||
oauthState: make(map[string]string),
|
oauthState: make(map[string]string),
|
||||||
weixinFlows: make(map[string]*weixinFlow),
|
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
|
// WeChat QR login flow
|
||||||
h.registerWeixinRoutes(mux)
|
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.
|
// 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()
|
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))
|
logger.InfoC("web", fmt.Sprintf("PicoClaw Home: %s", picoHome))
|
||||||
|
|
||||||
// Set language from command line or auto-detect
|
// Set language from command line or auto-detect
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"fyne.io/systray"
|
"fyne.io/systray"
|
||||||
|
|
@ -93,8 +92,3 @@ func onReady() {
|
||||||
func onExit() {
|
func onExit() {
|
||||||
logger.Info(T(Exiting))
|
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"
|
import _ "embed"
|
||||||
|
|
||||||
//go:embed icon.ico
|
//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"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// runTray falls back to a headless mode on platforms where systray requires cgo.
|
||||||
func runTray() {
|
func runTray() {
|
||||||
logger.Infof("System tray is unavailable in %s builds without cgo; running without tray", runtime.GOOS)
|
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
|
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> {
|
export async function startWeixinFlow(): Promise<WeixinFlowResponse> {
|
||||||
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" })
|
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 }
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
||||||
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-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 { WeixinForm } from "@/components/channels/channel-forms/weixin-form"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -186,7 +187,7 @@ function getRequiredFieldKeys(channelName: string): string[] {
|
||||||
case "onebot":
|
case "onebot":
|
||||||
return ["ws_url"]
|
return ["ws_url"]
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return ["bot_id", "secret"]
|
return []
|
||||||
case "whatsapp":
|
case "whatsapp":
|
||||||
return ["bridge_url"]
|
return ["bridge_url"]
|
||||||
case "pico":
|
case "pico":
|
||||||
|
|
@ -326,6 +327,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
return getChannelDisplayName(channel, t)
|
return getChannelDisplayName(channel, t)
|
||||||
}, [channel, channelName, t])
|
}, [channel, channelName, t])
|
||||||
|
|
||||||
|
const hidesPageLevelEnableToggle = channel?.name === "wecom"
|
||||||
|
|
||||||
const hiddenKeys = useMemo(() => {
|
const hiddenKeys = useMemo(() => {
|
||||||
if (!channel) return []
|
if (!channel) return []
|
||||||
if (channel.name === "whatsapp") {
|
if (channel.name === "whatsapp") {
|
||||||
|
|
@ -410,6 +413,36 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
}
|
}
|
||||||
}, [loadData, t])
|
}, [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 = () => {
|
const renderForm = () => {
|
||||||
if (!channel) return null
|
if (!channel) return null
|
||||||
const isEdit = configured
|
const isEdit = configured
|
||||||
|
|
@ -460,6 +493,27 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
onBindSuccess={() => void handleWeixinBindSuccess()}
|
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:
|
default:
|
||||||
return (
|
return (
|
||||||
<GenericForm
|
<GenericForm
|
||||||
|
|
@ -524,12 +578,33 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
|
{channel?.name === "weixin" && (
|
||||||
<p className="text-sm font-medium">
|
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
|
||||||
{t("channels.page.enableLabel")}
|
<div className="flex items-start gap-3">
|
||||||
</p>
|
<IconAlertTriangle
|
||||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
size={18}
|
||||||
</div>
|
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()}
|
{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: {
|
defaults: {
|
||||||
workspace,
|
workspace,
|
||||||
restrict_to_workspace: form.restrictToWorkspace,
|
restrict_to_workspace: form.restrictToWorkspace,
|
||||||
|
split_on_marker: form.splitOnMarker,
|
||||||
tool_feedback: {
|
tool_feedback: {
|
||||||
enabled: form.toolFeedbackEnabled,
|
enabled: form.toolFeedbackEnabled,
|
||||||
max_args_length: toolFeedbackMaxArgsLength,
|
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
|
<SwitchCardField
|
||||||
label={t("pages.config.tool_feedback_enabled")}
|
label={t("pages.config.tool_feedback_enabled")}
|
||||||
hint={t("pages.config.tool_feedback_enabled_hint")}
|
hint={t("pages.config.tool_feedback_enabled_hint")}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export interface RoutingTier {
|
||||||
export interface CoreConfigForm {
|
export interface CoreConfigForm {
|
||||||
workspace: string
|
workspace: string
|
||||||
restrictToWorkspace: boolean
|
restrictToWorkspace: boolean
|
||||||
|
splitOnMarker: boolean
|
||||||
toolFeedbackEnabled: boolean
|
toolFeedbackEnabled: boolean
|
||||||
toolFeedbackMaxArgsLength: string
|
toolFeedbackMaxArgsLength: string
|
||||||
execEnabled: boolean
|
execEnabled: boolean
|
||||||
|
|
@ -72,7 +73,8 @@ export const DM_SCOPE_OPTIONS = [
|
||||||
export const EMPTY_FORM: CoreConfigForm = {
|
export const EMPTY_FORM: CoreConfigForm = {
|
||||||
workspace: "",
|
workspace: "",
|
||||||
restrictToWorkspace: true,
|
restrictToWorkspace: true,
|
||||||
toolFeedbackEnabled: true,
|
splitOnMarker: false,
|
||||||
|
toolFeedbackEnabled: false,
|
||||||
toolFeedbackMaxArgsLength: "300",
|
toolFeedbackMaxArgsLength: "300",
|
||||||
execEnabled: true,
|
execEnabled: true,
|
||||||
allowRemote: true,
|
allowRemote: true,
|
||||||
|
|
@ -164,6 +166,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
defaults.restrict_to_workspace === undefined
|
defaults.restrict_to_workspace === undefined
|
||||||
? EMPTY_FORM.restrictToWorkspace
|
? EMPTY_FORM.restrictToWorkspace
|
||||||
: asBool(defaults.restrict_to_workspace),
|
: asBool(defaults.restrict_to_workspace),
|
||||||
|
splitOnMarker:
|
||||||
|
defaults.split_on_marker === undefined
|
||||||
|
? EMPTY_FORM.splitOnMarker
|
||||||
|
: asBool(defaults.split_on_marker),
|
||||||
toolFeedbackEnabled:
|
toolFeedbackEnabled:
|
||||||
toolFeedback.enabled === undefined
|
toolFeedback.enabled === undefined
|
||||||
? EMPTY_FORM.toolFeedbackEnabled
|
? EMPTY_FORM.toolFeedbackEnabled
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const PROVIDER_PRIORITY: Record<string, number> = {
|
||||||
vllm: 16,
|
vllm: 16,
|
||||||
mistral: 17,
|
mistral: 17,
|
||||||
avian: 18,
|
avian: 18,
|
||||||
|
mimo: 19,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderGroup {
|
interface ProviderGroup {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ const PROVIDER_DOMAINS: Record<string, string> = {
|
||||||
avian: "avian.io",
|
avian: "avian.io",
|
||||||
vllm: "vllm.ai",
|
vllm: "vllm.ai",
|
||||||
zhipu: "zhipuai.cn",
|
zhipu: "zhipuai.cn",
|
||||||
|
mimo: "xiaomi.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderIconProps {
|
interface ProviderIconProps {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||||
avian: "Avian",
|
avian: "Avian",
|
||||||
vllm: "VLLM (local)",
|
vllm: "VLLM (local)",
|
||||||
zhipu: "Zhipu AI (智谱)",
|
zhipu: "Zhipu AI (智谱)",
|
||||||
|
mimo: "Xiaomi MiMo",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderKey(model: string): string {
|
export function getProviderKey(model: string): string {
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,6 @@
|
||||||
"weixin": {
|
"weixin": {
|
||||||
"warningTitle": "Testing phase, use with caution",
|
"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.",
|
"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",
|
"bindTitle": "WeChat Account Binding",
|
||||||
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
||||||
"bind": "Bind WeChat",
|
"bind": "Bind WeChat",
|
||||||
|
|
@ -264,6 +263,23 @@
|
||||||
"refresh": "Refresh QR",
|
"refresh": "Refresh QR",
|
||||||
"errorGeneric": "An error occurred. Please try again."
|
"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": {
|
"field": {
|
||||||
"token": "Bot Token",
|
"token": "Bot Token",
|
||||||
"tokenPlaceholder": "Enter bot token",
|
"tokenPlaceholder": "Enter bot token",
|
||||||
|
|
@ -420,6 +436,8 @@
|
||||||
"workspace_hint": "Base directory for agent file operations.",
|
"workspace_hint": "Base directory for agent file operations.",
|
||||||
"restrict_workspace": "Restrict to Workspace",
|
"restrict_workspace": "Restrict to Workspace",
|
||||||
"restrict_workspace_hint": "Only allow file operations inside 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": "Tool Feedback",
|
||||||
"tool_feedback_enabled_hint": "Send a short tool-call preview into the current chat before each tool execution.",
|
"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",
|
"tool_feedback_max_args_length": "Tool Feedback Args Preview Length",
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,6 @@
|
||||||
"weixin": {
|
"weixin": {
|
||||||
"warningTitle": "测试阶段,请谨慎使用",
|
"warningTitle": "测试阶段,请谨慎使用",
|
||||||
"warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
|
"warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
|
||||||
"bindEnableSuccess": "微信已连接,频道已自动启用。",
|
|
||||||
"bindTitle": "微信账号绑定",
|
"bindTitle": "微信账号绑定",
|
||||||
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
||||||
"bind": "绑定微信",
|
"bind": "绑定微信",
|
||||||
|
|
@ -262,6 +261,23 @@
|
||||||
"refresh": "刷新二维码",
|
"refresh": "刷新二维码",
|
||||||
"errorGeneric": "发生错误,请重试。"
|
"errorGeneric": "发生错误,请重试。"
|
||||||
},
|
},
|
||||||
|
"wecom": {
|
||||||
|
"bindTitle": "企业微信绑定",
|
||||||
|
"bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。",
|
||||||
|
"enableDesc": "绑定后可在这里直接启用或停用频道。",
|
||||||
|
"enableBindFirst": "请先完成绑定,然后再启用频道。",
|
||||||
|
"bind": "绑定企业微信",
|
||||||
|
"rebind": "重新绑定",
|
||||||
|
"bound": "企业微信已绑定",
|
||||||
|
"notBound": "尚未绑定企业微信 AI Bot。",
|
||||||
|
"generating": "正在生成二维码...",
|
||||||
|
"scanHint": "打开企业微信,扫描二维码",
|
||||||
|
"scanned": "已扫码,请在企业微信中确认",
|
||||||
|
"expired": "二维码已过期",
|
||||||
|
"retry": "重试",
|
||||||
|
"refresh": "刷新二维码",
|
||||||
|
"errorGeneric": "发生错误,请重试。"
|
||||||
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"token": "Bot Token",
|
"token": "Bot Token",
|
||||||
"tokenPlaceholder": "输入 Bot Token",
|
"tokenPlaceholder": "输入 Bot Token",
|
||||||
|
|
@ -418,6 +434,8 @@
|
||||||
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
|
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
|
||||||
"restrict_workspace": "限制工作目录访问",
|
"restrict_workspace": "限制工作目录访问",
|
||||||
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
|
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
|
||||||
|
"split_on_marker": "连续短消息",
|
||||||
|
"split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出",
|
||||||
"tool_feedback_enabled": "工具反馈",
|
"tool_feedback_enabled": "工具反馈",
|
||||||
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览。",
|
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览。",
|
||||||
"tool_feedback_max_args_length": "工具反馈参数预览长度",
|
"tool_feedback_max_args_length": "工具反馈参数预览长度",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue