merge: integrate main into refactor-inbound-context-routing-session

This commit is contained in:
Hoshina 2026-04-13 13:25:07 +08:00
commit 0c6ad33a9c
134 changed files with 13291 additions and 1321 deletions

View file

@ -41,10 +41,11 @@ jobs:
with:
go-version-file: go.mod
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
- name: Run Govulncheck
uses: golang/govulncheck-action@v1
with:
go-package: ./...
run: govulncheck -C . -format text ./...
test:
name: Tests

View file

@ -110,6 +110,17 @@ jobs:
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
- name: Build and upload Android arm64
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
sudo apt-get install -y zip
make build-android-bundle
gh release upload "${{ inputs.tag }}" \
build/picoclaw-android-universal.zip \
--clobber
- name: Apply release flags
shell: bash
env:

View file

@ -108,7 +108,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider
- Reference the related issue when relevant: `Fix session leak (#123)`.
- Keep commits focused. One logical change per commit is preferred.
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
### Keeping Up to Date

View file

@ -108,7 +108,7 @@ git checkout -b 你的功能分支名
- 有关联 Issue 时请引用:`Fix session leak (#123)`
- 保持 commit 专注,每个 commit 只做一件事。
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
- 按照 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/) 规范来撰写
### 保持与上游同步

View file

@ -205,6 +205,37 @@ build-linux-mipsle: generate
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
## build-android-arm64: Build core for Android ARM64
build-android-arm64: generate
@echo "Building for android/arm64..."
@mkdir -p $(BUILD_DIR)
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64"
## build-launcher-android-arm64: Build launcher for Android ARM64
build-launcher-android-arm64:
@echo "Building picoclaw-launcher for android/arm64..."
@mkdir -p $(BUILD_DIR)
@$(MAKE) -C web build-android-arm64 \
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64"
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip
build-android-bundle: generate
@echo "Building core for all Android architectures..."
@mkdir -p $(BUILD_DIR)
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
@echo "Building launcher for Android arm64..."
@$(MAKE) build-launcher-android-arm64
@echo "Staging JNI libs..."
@rm -rf $(BUILD_DIR)/android-staging
@mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a
@cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so
@cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so
@cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip .
@rm -rf $(BUILD_DIR)/android-staging
@echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip"
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
build-pi-zero: build-linux-arm build-linux-arm64
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
@ -226,6 +257,7 @@ build-all: generate
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
@$(MAKE) build-android-bundle
@echo "All builds complete"
## install: Install picoclaw to system and copy builtin skills

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
</div>
@ -622,4 +622,3 @@ WeChat :

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Malay](README.my.md) | [English](README.md) | **Bahasa Indonesia**
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.my.md) | [English](README.md)
</div>
@ -615,4 +615,3 @@ Discord: <https://discord.gg/V4sAZ9XWpN>
WeChat:
<img src="assets/wechat.png" alt="Kode QR grup WeChat" width="512">

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
</div>

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
</div>

626
README.ko.md Normal file
View file

@ -0,0 +1,626 @@
<div align="center">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Go로 작성된 초고효율 AI 어시스턴트</h1>
<h3>$10 하드웨어 · 10MB RAM · ms 부팅 · Let's Go, PicoClaw!</h3>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
</div>
---
> **PicoClaw**는 [Sipeed](https://sipeed.com)가 시작한 독립적인 오픈소스 프로젝트입니다. 처음부터 끝까지 **Go**로 새로 작성되었으며, OpenClaw, NanoBot, 혹은 다른 어떤 프로젝트의 포크도 아닙니다.
**PicoClaw**는 [NanoBot](https://github.com/HKUDS/nanobot)에서 영감을 받은 초경량 개인용 AI 어시스턴트입니다. **Go**로 처음부터 다시 구현되었고, "셀프 부트스트래핑" 방식으로 만들어졌습니다. 즉, AI 에이전트 자체가 아키텍처 전환과 코드 최적화를 주도했습니다.
**$10 하드웨어에서 10MB 미만 RAM으로 동작**합니다. OpenClaw보다 메모리를 99% 적게 쓰고, Mac mini보다 98% 저렴합니다!
<table align="center">
<tr align="center">
<td align="center" valign="top">
<p align="center">
<img src="assets/picoclaw_mem.gif" width="360" height="240">
</p>
</td>
<td align="center" valign="top">
<p align="center">
<img src="assets/licheervnano.png" width="400" height="240">
</p>
</td>
</tr>
</table>
> [!CAUTION]
> **보안 안내**
>
> * **암호화폐 없음:** PicoClaw는 공식 토큰이나 암호화폐를 **발행한 적이 없습니다**. `pump.fun` 또는 기타 거래 플랫폼에서의 모든 주장은 **사기**입니다.
> * **공식 도메인:** **유일한** 공식 웹사이트는 **[picoclaw.io](https://picoclaw.io)** 이며, 회사 웹사이트는 **[sipeed.com](https://sipeed.com)** 입니다.
> * **주의:** 많은 `.ai/.org/.com/.net/...` 도메인이 제3자에 의해 등록되어 있습니다. 신뢰하지 마세요.
> * **참고:** PicoClaw는 빠르게 초기 개발이 진행 중입니다. 아직 해결되지 않은 보안 문제가 있을 수 있습니다. v1.0 이전에는 프로덕션 배포를 권장하지 않습니다.
> * **참고:** PicoClaw는 최근 많은 PR을 병합했습니다. 최근 빌드는 10~20MB RAM을 사용할 수 있습니다. 기능이 안정화된 뒤 리소스 최적화를 진행할 예정입니다.
## 📢 뉴스
2026-03-31 📱 **Android 지원!** PicoClaw가 이제 Android에서 실행됩니다! APK는 [picoclaw.io](https://picoclaw.io/download)에서 다운로드하세요.
2026-03-25 🚀 **v0.2.4 출시!** 에이전트 아키텍처 전면 개편(SubTurn, Hooks, Steering, EventBus), WeChat/WeCom 통합, 보안 강화(`.security.yml`, 민감 정보 필터링), 새 프로바이더(AWS Bedrock, Azure, Xiaomi MiMo), 그리고 35건의 버그 수정이 포함되었습니다. PicoClaw는 **26K 스타**를 달성했습니다!
2026-03-17 🚀 **v0.2.3 출시!** 시스템 트레이 UI(Windows 및 Linux), 서브에이전트 상태 조회(`spawn_status`), 실험적 게이트웨이 핫 리로드, Cron 보안 게이트, 그리고 2건의 보안 수정이 추가되었습니다. PicoClaw는 **25K 스타**를 달성했습니다!
2026-03-09 🎉 **v0.2.1 — 역대 최대 업데이트!** MCP 프로토콜 지원, 4개의 새 채널(Matrix/IRC/WeCom/Discord Proxy), 3개의 새 프로바이더(Kimi/Minimax/Avian), 비전 파이프라인, JSONL 메모리 저장소, 모델 라우팅이 추가되었습니다.
2026-02-28 📦 **v0.2.0** 이 Docker Compose 및 WebUI 런처 지원과 함께 출시되었습니다.
<details>
<summary>이전 뉴스...</summary>
2026-02-26 🎉 PicoClaw가 단 17일 만에 **20K 스타**를 달성했습니다! 채널 자동 오케스트레이션과 기능 인터페이스가 적용되었습니다.
2026-02-16 🎉 PicoClaw가 1주일 만에 **12K 스타**를 돌파했습니다! 커뮤니티 메인터너 역할과 [로드맵](ROADMAP.md)이 공식적으로 공개되었습니다.
2026-02-13 🎉 PicoClaw가 4일 만에 **5000 스타**를 돌파했습니다! 프로젝트 로드맵과 개발자 그룹이 준비 중입니다.
2026-02-09 🎉 **PicoClaw 출시!** $10 하드웨어와 10MB 미만 RAM에서 동작하는 AI 에이전트를 단 1일 만에 만들었습니다. Let's Go, PicoClaw!
</details>
## ✨ 기능
🪶 **초경량**: 코어 메모리 사용량이 10MB 미만으로 OpenClaw보다 99% 작습니다.*
💰 **최소 비용**: $10짜리 하드웨어에서도 충분히 구동되어 Mac mini보다 98% 저렴합니다.
⚡️ **초고속 부팅**: 시작 속도가 400배 빠릅니다. 0.6GHz 싱글코어 프로세서에서도 1초 미만에 부팅됩니다.
🌍 **진정한 이식성**: RISC-V, ARM, MIPS, x86 아키텍처 전반에 단일 바이너리로 동작합니다. 하나의 바이너리로 어디서나 실행됩니다!
🤖 **AI 부트스트래핑**: 순수 Go 네이티브 구현입니다. 코어 코드의 95%는 에이전트가 생성했고, 사람이 검토하며 다듬었습니다.
🔌 **MCP 지원**: 네이티브 [Model Context Protocol](https://modelcontextprotocol.io/) 통합을 제공하여 어떤 MCP 서버든 연결해 에이전트 기능을 확장할 수 있습니다.
👁️ **비전 파이프라인**: 이미지와 파일을 에이전트에 직접 보낼 수 있으며, 멀티모달 LLM용 base64 인코딩이 자동으로 처리됩니다.
🧠 **스마트 라우팅**: 규칙 기반 모델 라우팅으로 간단한 질의는 경량 모델에 보내 API 비용을 절약합니다.
_*최근 빌드는 급격한 PR 병합으로 인해 10~20MB를 사용할 수 있습니다. 리소스 최적화는 계획되어 있습니다. 부팅 속도 비교는 0.8GHz 싱글코어 벤치마크를 기준으로 합니다(아래 표 참고)._
<div align="center">
| | OpenClaw | NanoBot | **PicoClaw** |
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
| **언어** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB*** |
| **부팅 시간**</br>(0.8GHz 코어) | >500초 | >30초 | **<1초** |
| **비용** | Mac Mini $599 | 대부분의 Linux 보드 ~$50 | **모든 Linux 보드**</br>**최저 $10부터** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
</div>
> **[하드웨어 호환 목록](docs/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요!
<p align="center">
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
</p>
## 🦾 데모
### 🛠️ 표준 어시스턴트 워크플로
<table align="center">
<tr align="center">
<th><p align="center">풀스택 엔지니어 모드</p></th>
<th><p align="center">로깅 및 계획</p></th>
<th><p align="center">웹 검색 및 학습</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
</tr>
<tr>
<td align="center">개발 · 배포 · 확장</td>
<td align="center">스케줄링 · 자동화 · 기억</td>
<td align="center">탐색 · 인사이트 · 트렌드</td>
</tr>
</table>
### 🐜 혁신적인 초저사양 배포
PicoClaw는 사실상 거의 모든 Linux 장치에 배포할 수 있습니다!
- 최소형 홈 어시스턴트를 위해 $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(이더넷) 또는 W(WiFi6) 에디션
- 서버 자동 운영을 위해 $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) 또는 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html)
- 스마트 감시를 위해 $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 또는 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera)
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
🌟 더 많은 배포 사례가 기다리고 있습니다!
## 📦 설치
### picoclaw.io에서 다운로드(권장)
**[picoclaw.io](https://picoclaw.io)** 를 방문하세요. 공식 웹사이트가 플랫폼을 자동 감지하고 원클릭 다운로드를 제공합니다. 아키텍처를 직접 고를 필요가 없습니다.
### 사전 컴파일된 바이너리 다운로드
또는 [GitHub Releases](https://github.com/sipeed/picoclaw/releases) 페이지에서 플랫폼에 맞는 바이너리를 다운로드할 수 있습니다.
### 소스에서 빌드(개발용)
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# 코어 바이너리 빌드
make build
# WebUI 런처 빌드 (WebUI 모드에 필요)
make build-launcher
# 여러 플랫폼용 빌드
make build-all
# Raspberry Pi Zero 2 W용 빌드 (32비트: make build-linux-arm, 64비트: make build-linux-arm64)
make build-pi-zero
# 빌드 후 설치
make install
```
**Raspberry Pi Zero 2 W:** OS에 맞는 바이너리를 사용하세요. 32비트 Raspberry Pi OS는 `make build-linux-arm`, 64비트는 `make build-linux-arm64`입니다. 또는 `make build-pi-zero`로 둘 다 빌드할 수 있습니다.
## 🚀 빠른 시작 가이드
### 🌐 WebUI Launcher (데스크톱 권장)
WebUI Launcher는 설정과 채팅을 위한 브라우저 기반 인터페이스를 제공합니다. 명령줄을 몰라도 가장 쉽게 시작할 수 있는 방법입니다.
**옵션 1: 더블클릭(데스크톱)**
[picoclaw.io](https://picoclaw.io)에서 다운로드한 뒤 `picoclaw-launcher`를 더블클릭하세요(Windows에서는 `picoclaw-launcher.exe`). 브라우저가 자동으로 `http://localhost:18800`을 엽니다.
**옵션 2: 명령줄**
```bash
picoclaw-launcher
# 브라우저에서 http://localhost:18800 열기
```
> [!TIP]
> **원격 접속 / Docker / VM:** 모든 인터페이스에서 수신하려면 `-public` 플래그를 추가하세요.
> ```bash
> picoclaw-launcher -public
> ```
<p align="center">
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
</p>
**시작 방법:**
WebUI를 연 뒤 다음 순서로 진행하세요. **1)** 프로바이더 설정(LLM API 키 추가) -> **2)** 채널 설정(예: Telegram) -> **3)** 게이트웨이 시작 -> **4)** 채팅!
자세한 WebUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요.
<details>
<summary><b>Docker(대안)</b></summary>
```bash
# 1. 이 저장소를 클론
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. 첫 실행 - docker/data/config.json을 자동 생성한 뒤 종료
# (config.json과 workspace/가 모두 없을 때만 실행됨)
docker compose -f docker/docker-compose.yml --profile launcher up
# 컨테이너가 "First-run setup complete."를 출력하고 종료됩니다.
# 3. API 키 설정
vim docker/data/config.json
# 4. 시작
docker compose -f docker/docker-compose.yml --profile launcher up -d
# http://localhost:18800 열기
```
> **Docker / VM 사용자:** 게이트웨이는 기본적으로 `127.0.0.1`에서 수신합니다. 호스트에서 접근 가능하게 하려면 `PICOCLAW_GATEWAY_HOST=0.0.0.0`을 설정하거나 `-public` 플래그를 사용하세요.
```bash
# 로그 확인
docker compose -f docker/docker-compose.yml logs -f
# 중지
docker compose -f docker/docker-compose.yml --profile launcher down
# 업데이트
docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml --profile launcher up -d
```
</details>
<details>
<summary><b>macOS - 첫 실행 보안 경고</b></summary>
macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 거치지 않았기 때문에, 첫 실행 시 `picoclaw-launcher`가 차단될 수 있습니다.
**1단계:** `picoclaw-launcher`를 더블클릭합니다. 그러면 보안 경고가 표시됩니다.
<p align="center">
<img src="assets/macos-gatekeeper-warning.jpg" alt="macOS Gatekeeper warning" width="400">
</p>
> *"picoclaw-launcher"을(를) 열 수 없습니다. Apple에서 이 앱이 악성 소프트웨어가 없으며 Mac이나 개인 정보를 해치지 않는다고 확인할 수 없습니다.*
**2단계:** **시스템 설정** -> **개인정보 보호 및 보안** 으로 이동한 뒤 **보안** 섹션까지 스크롤하여 **그래도 열기(Open Anyway)** 를 클릭하고, 대화상자에서 다시 한 번 **그래도 열기**를 확인합니다.
<p align="center">
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privacy & Security — Open Anyway" width="600">
</p>
이 과정을 한 번만 거치면 이후에는 `picoclaw-launcher`가 정상적으로 열립니다.
</details>
### 💻 TUI Launcher (헤드리스 / SSH 권장)
TUI(Terminal UI) Launcher는 설정과 관리를 위한 모든 기능을 갖춘 터미널 인터페이스를 제공합니다. 서버, Raspberry Pi, 기타 헤드리스 환경에 적합합니다.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**시작 방법:**
TUI 메뉴를 사용해 다음 순서로 진행하세요. **1)** 프로바이더 설정 -> **2)** 채널 설정 -> **3)** 게이트웨이 시작 -> **4)** 채팅!
자세한 TUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요.
### 📱 Android
오래된 스마트폰에 새 생명을 불어넣어 보세요! PicoClaw를 설치하면 스마트 AI 어시스턴트로 바꿀 수 있습니다.
**옵션 1: APK 설치**
미리보기:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
[picoclaw.io](https://picoclaw.io/download/)에서 APK를 다운로드해 바로 설치하세요. Termux가 필요 없습니다!
**옵션 2: Termux**
<details>
<summary><b>터미널 런처 (리소스 제약 환경용)</b></summary>
1. [Termux](https://github.com/termux/termux-app)를 설치합니다([GitHub Releases](https://github.com/termux/termux-app/releases)에서 다운로드하거나 F-Droid / Google Play에서 검색).
2. 다음 명령을 실행합니다.
```bash
# 최신 릴리스 다운로드
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
termux-chroot ./picoclaw onboard # chroot가 표준 Linux 파일시스템 레이아웃을 제공합니다
```
그다음 아래의 터미널 런처 섹션을 따라 설정을 마무리하세요.
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
런처 UI 없이 `picoclaw` 코어 바이너리만 있는 최소 환경에서는 명령줄과 JSON 설정 파일만으로도 모든 설정을 마칠 수 있습니다.
**1. 초기화**
```bash
picoclaw onboard
```
그러면 `~/.picoclaw/config.json`과 워크스페이스 디렉터리가 생성됩니다.
**2. 설정** (`~/.picoclaw/config.json`)
```jsonc
{
"agents": {
"defaults": {
"model_name": "gpt-5.4"
}
},
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
// api_key는 이제 .security.yml에서 로드됩니다.
}
]
}
```
> 사용 가능한 모든 옵션이 포함된 전체 설정 템플릿은 저장소의 `config/config.example.json`을 참고하세요.
>
> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `docs/security_configuration.md`를 참고하세요.
**3. 채팅**
```bash
# 단발성 질문
picoclaw agent -m "2+2는 얼마야?"
# 대화형 모드
picoclaw agent
# 채팅 앱 연동용 게이트웨이 시작
picoclaw gateway
```
</details>
## 🔌 프로바이더(LLM)
PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를 지원합니다. 형식은 `protocol/model`입니다.
| 프로바이더 | 프로토콜 | API Key | 비고 |
|----------|----------|---------|------|
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 필수 | GPT-5.4, GPT-4o, o3 등 |
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 필수 | Claude Opus 4.6, Sonnet 4.6 등 |
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 필수 | Gemini 3 Flash, 2.5 Pro 등 |
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 필수 | 200개 이상의 모델, 통합 API |
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 필수 | GLM-4.7, GLM-5 등 |
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 필수 | DeepSeek-V3, DeepSeek-R1 |
| [Volcengine](https://console.volcengine.com) | `volcengine/` | 필수 | Doubao, Ark 모델 |
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 필수 | Qwen3, Qwen-Max 등 |
| [Groq](https://console.groq.com/keys) | `groq/` | 필수 | 빠른 추론(Llama, Mixtral) |
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 필수 | Kimi 모델 |
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 필수 | MiniMax 모델 |
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 필수 | Mistral Large, Codestral |
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 필수 | NVIDIA 호스팅 모델 |
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 필수 | 빠른 추론 |
| [Novita AI](https://novita.ai/) | `novita/` | 필수 | 다양한 오픈 모델 |
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | 필수 | MiMo 모델 |
| [Ollama](https://ollama.com/) | `ollama/` | 불필요 | 로컬 모델, 셀프 호스팅 |
| [vLLM](https://docs.vllm.ai/) | `vllm/` | 불필요 | 로컬 배포, OpenAI 호환 |
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 환경에 따라 다름 | 100개 이상의 프로바이더를 위한 프록시 |
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 필수 | 엔터프라이즈 Azure 배포 |
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | 디바이스 코드 로그인 |
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS 자격 증명 | AWS에서 Claude, Llama, Mistral 사용 |
> \* AWS Bedrock은 빌드 태그 `go build -tags bedrock`이 필요합니다. 모든 AWS 파티션(aws, aws-cn, aws-us-gov)에서 엔드포인트를 자동 해석하려면 `api_base`를 리전명(예: `us-east-1`)으로 설정하세요. 전체 엔드포인트 URL을 직접 사용할 경우에는 환경 변수 또는 AWS config/profile을 통해 `AWS_REGION`도 함께 설정해야 합니다.
<details>
<summary><b>로컬 배포(Ollama, vLLM 등)</b></summary>
**Ollama:**
```json
{
"model_list": [
{
"model_name": "local-llama",
"model": "ollama/llama3.1:8b",
"api_base": "http://localhost:11434/v1"
}
]
}
```
**vLLM:**
```json
{
"model_list": [
{
"model_name": "local-vllm",
"model": "vllm/your-model",
"api_base": "http://localhost:8000/v1"
}
]
}
```
프로바이더 전체 설정은 [프로바이더와 모델](docs/providers.md)을 참고하세요.
</details>
## 💬 채널(채팅 앱)
18개 이상의 메시징 플랫폼을 통해 PicoClaw와 대화할 수 있습니다.
| 채널 | 설정 | 프로토콜 | 문서 |
|---------|------|----------|------|
| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](docs/channels/telegram/README.md) |
| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](docs/channels/discord/README.md) |
| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](docs/chat-apps.md#whatsapp) |
| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](docs/chat-apps.md#weixin) |
| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](docs/channels/qq/README.md) |
| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](docs/channels/slack/README.md) |
| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](docs/channels/matrix/README.md) |
| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](docs/channels/dingtalk/README.md) |
| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](docs/channels/feishu/README.md) |
| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](docs/channels/line/README.md) |
| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](docs/channels/wecom/README.md) |
| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](docs/channels/vk/README.md) |
| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](docs/chat-apps.md#irc) |
| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](docs/channels/onebot/README.md) |
| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](docs/channels/maixcam/README.md) |
| **Pico** | 쉬움(활성화) | 네이티브 프로토콜 | 내장 |
| **Pico Client** | 쉬움(WebSocket URL) | WebSocket | 내장 |
> webhook 기반 채널은 모두 하나의 게이트웨이 HTTP 서버(`gateway.host`:`gateway.port`, 기본값 `127.0.0.1:18790`)를 공유합니다. Feishu는 WebSocket/SDK 모드를 사용하며 이 공용 HTTP 서버를 사용하지 않습니다.
> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](docs/configuration.md#gateway-log-level)를 참고하세요.
자세한 채널 설정 방법은 [채팅 앱 설정 가이드](docs/chat-apps.md)를 참고하세요.
## 🔧 도구
### 🔍 웹 검색
PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있습니다. `tools.web`에서 설정하세요.
| 검색 엔진 | API Key | 무료 제공량 | 링크 |
|-----------|---------|-------------|------|
| DuckDuckGo | 불필요 | 무제한 | 내장 백업 검색 |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 하루 1000회 쿼리 | AI 기반, 중국 시장 최적화 |
| [Tavily](https://tavily.com) | 필수 | 월 1000회 쿼리 | AI 에이전트에 최적화 |
| [Brave Search](https://brave.com/search/api) | 필수 | 월 2000회 쿼리 | 빠르고 프라이빗함 |
| [Perplexity](https://www.perplexity.ai) | 필수 | 유료 | AI 기반 검색 |
| [SearXNG](https://github.com/searxng/searxng) | 불필요 | 셀프 호스팅 | 무료 메타 검색 엔진 |
| [GLM Search](https://open.bigmodel.cn/) | 필수 | 상이함 | Zhipu 웹 검색 |
### ⚙️ 기타 도구
PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](docs/tools_configuration.md)을 참고하세요.
## 🎯 스킬
스킬은 에이전트 기능을 확장하는 모듈형 구성 요소입니다. 워크스페이스 안의 `SKILL.md` 파일에서 로드됩니다.
**ClawHub에서 스킬 설치:**
```bash
picoclaw skills search "web scraping"
picoclaw skills install <skill-name>
```
**ClawHub 토큰 설정**(선택 사항, 더 높은 호출 한도용):
`config.json`에 다음을 추가하세요.
```json
{
"tools": {
"skills": {
"registries": {
"clawhub": {
"auth_token": "your-clawhub-token"
}
}
}
}
}
```
자세한 내용은 [도구 설정 - 스킬](docs/tools_configuration.md#skills-tool)를 참고하세요.
## 🔗 MCP (Model Context Protocol)
PicoClaw는 [MCP](https://modelcontextprotocol.io/)를 기본 지원합니다. 어떤 MCP 서버든 연결하여 외부 도구와 데이터 소스로 에이전트 기능을 확장할 수 있습니다.
```json
{
"tools": {
"mcp": {
"enabled": true,
"servers": {
"filesystem": {
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
}
}
```
MCP 전체 설정(stdio, SSE, HTTP 전송 방식, 도구 탐색)은 [도구 설정 - MCP](docs/tools_configuration.md#mcp-tool)를 참고하세요.
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 에이전트 소셜 네트워크 참여하기
CLI 또는 통합된 채팅 앱에서 메시지를 한 번만 보내면 PicoClaw를 에이전트 소셜 네트워크에 연결할 수 있습니다.
**`https://clawdchat.ai/skill.md`를 읽고 안내에 따라 [ClawdChat.ai](https://clawdchat.ai)에 참여하세요**
## 🖥️ CLI 레퍼런스
| 명령어 | 설명 |
| ------------------------- | ------------------------------ |
| `picoclaw onboard` | 설정 및 워크스페이스 초기화 |
| `picoclaw auth weixin` | QR로 WeChat 계정 연결 |
| `picoclaw agent -m "..."` | 에이전트와 채팅 |
| `picoclaw agent` | 대화형 채팅 모드 |
| `picoclaw gateway` | 게이트웨이 시작 |
| `picoclaw status` | 상태 표시 |
| `picoclaw version` | 버전 정보 표시 |
| `picoclaw model` | 기본 모델 조회 또는 변경 |
| `picoclaw cron list` | 모든 예약 작업 목록 표시 |
| `picoclaw cron add ...` | 예약 작업 추가 |
| `picoclaw cron disable` | 예약 작업 비활성화 |
| `picoclaw cron remove` | 예약 작업 삭제 |
| `picoclaw skills list` | 설치된 스킬 목록 표시 |
| `picoclaw skills install` | 스킬 설치 |
| `picoclaw migrate` | 이전 버전 데이터 마이그레이션 |
| `picoclaw auth login` | 프로바이더 인증 |
### ⏰ 예약 작업 / 리마인더
PicoClaw는 `cron` 도구를 통해 예약 리마인더와 반복 작업을 지원합니다.
* **1회성 리마인더**: "10분 후에 알려줘" -> 10분 후 한 번 실행
* **반복 작업**: "2시간마다 알려줘" -> 2시간마다 실행
* **Cron 표현식**: "매일 오전 9시에 알려줘" -> cron 표현식 사용
현재 지원하는 스케줄 유형, 실행 모드, 명령 작업 게이트, 저장 방식은 [docs/cron.md](docs/cron.md)를 참고하세요.
## 📚 문서
이 README보다 더 자세한 가이드는 다음 문서를 참고하세요.
| 주제 | 설명 |
|------|------|
| [도커 & 빠른 시작](docs/docker.md) | Docker Compose 설정, 런처/에이전트 모드 |
| [채팅 앱](docs/chat-apps.md) | 17개 이상의 채널 설정 가이드 |
| [설정](docs/configuration.md) | 환경 변수, 워크스페이스 레이아웃, 보안 샌드박스 |
| [예약 작업과 Cron](docs/cron.md) | Cron 스케줄 유형, 전달 모드, 명령 게이트, 작업 저장 |
| [프로바이더와 모델](docs/providers.md) | 30개 이상의 LLM 프로바이더, 모델 라우팅, model_list 설정 |
| [Spawn & 비동기 작업](docs/spawn-tasks.md) | 빠른 작업, spawn을 이용한 장기 작업, 비동기 서브에이전트 오케스트레이션 |
| [Hooks](docs/hooks/README.md) | 이벤트 기반 Hook 시스템: 관찰자, 인터셉터, 승인 훅 |
| [Steering](docs/steering.md) | 실행 중인 에이전트 루프에서 도구 호출 사이에 메시지 주입 |
| [SubTurn](docs/subturn.md) | 서브에이전트 조정, 동시성 제어, 생명주기 |
| [문제 해결](docs/troubleshooting.md) | 자주 발생하는 문제와 해결 방법 |
| [도구 설정](docs/tools_configuration.md) | 도구별 활성화/비활성화, exec 정책, MCP, 스킬 |
| [하드웨어 호환성](docs/hardware-compatibility.md) | 테스트된 보드, 최소 요구사항 |
## 🤝 기여 & 로드맵
PR은 언제든 환영합니다! 코드베이스는 의도적으로 작고 읽기 쉽게 유지하고 있습니다.
가이드라인은 [커뮤니티 로드맵](https://github.com/sipeed/picoclaw/issues/988)과 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
개발자 그룹도 준비 중입니다. 첫 PR이 머지되면 함께할 수 있습니다!
커뮤니티 그룹:
Discord: <https://discord.gg/V4sAZ9XWpN>
WeChat:
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English**
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English**
</div>

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md)
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md)
</div>

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
</div>

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
</div>

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
</div>
@ -620,4 +620,3 @@ WeChat:

View file

@ -0,0 +1,147 @@
// Package cliui renders human-oriented CLI output: bordered panels and columns
// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI
// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY
// stdout falls back to plain line-oriented output.
package cliui
import (
"os"
"sync"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"golang.org/x/term"
)
// Minimum terminal width (columns) for bordered / structured layout.
// Below this, plain line-oriented output is used so boxes do not wrap badly.
const minWidthFancy = 88
// Minimum width to lay out some views in two columns (e.g. status providers).
const minWidthColumns = 104
var initMu sync.Mutex
// Init configures lipgloss for this process. When disableAnsiColors is true
// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode
// borders still render when UseFancyLayout() is true.
func Init(disableAnsiColors bool) {
initMu.Lock()
defer initMu.Unlock()
if disableAnsiColors {
lipgloss.SetColorProfile(termenv.Ascii)
return
}
lipgloss.SetColorProfile(termenv.EnvColorProfile())
}
// StdoutWidth returns the terminal width or a sane default if unknown.
func StdoutWidth() int {
w, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || w < 20 {
return 80
}
return w
}
// UseFancyLayout is true when styled boxes/columns should be used.
func UseFancyLayout() bool {
if !term.IsTerminal(int(os.Stdout.Fd())) {
return false
}
return StdoutWidth() >= minWidthFancy
}
// UseColumnLayout is true when a second content column is viable.
func UseColumnLayout() bool {
return UseFancyLayout() && StdoutWidth() >= minWidthColumns
}
// InnerWidth is the target content width inside borders/margins.
func InnerWidth() int {
w := StdoutWidth()
// Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding).
const borderBudget = 8
if w > borderBudget+48 {
return w - borderBudget
}
return 48
}
// StderrWidth returns stderr terminal width or a sane default.
func StderrWidth() int {
w, _, err := term.GetSize(int(os.Stderr.Fd()))
if err != nil || w < 20 {
return 80
}
return w
}
// UseFancyStderr is true when stderr can show boxed errors without ugly wraps.
func UseFancyStderr() bool {
if !term.IsTerminal(int(os.Stderr.Fd())) {
return false
}
return StderrWidth() >= minWidthFancy
}
// InnerStderrWidth mirrors InnerWidth but for stderr.
func InnerStderrWidth() int {
w := StderrWidth()
const borderBudget = 8
if w > borderBudget+48 {
return w - borderBudget
}
return 48
}
var (
accentBlue = lipgloss.Color("#3E5DB9")
accentRed = lipgloss.Color("#D54646")
colorMuted = lipgloss.Color("#6B6B6B")
colorOK = lipgloss.Color("#2E7D32")
)
func borderStyle() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accentBlue).
Padding(0, 1)
}
func titleBarStyle() lipgloss.Style {
return lipgloss.NewStyle().
Foreground(accentRed).
Bold(true)
}
func mutedStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(colorMuted)
}
func bodyStyle() lipgloss.Style {
return lipgloss.NewStyle()
}
func kvKeyStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
func kvValStyle() lipgloss.Style {
return lipgloss.NewStyle()
}
// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side).
func helpIntroStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
// helpIdentStyle is the left column for commands and flags (blue identifiers).
func helpIdentStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
// helpPlaceholderStyle highlights <placeholders> in usage lines (red accent).
func helpPlaceholderStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
}

View file

@ -0,0 +1,180 @@
package cliui
import (
"testing"
flag "github.com/spf13/pflag"
)
func init() {
// Disable ANSI colors in tests so output is predictable plain text.
Init(true)
}
// ---------------------------------------------------------------------------
// showErrHint
// ---------------------------------------------------------------------------
func TestShowErrHint(t *testing.T) {
cases := []struct {
msg string
want bool
}{
// Cobra flag errors — should show hint
{"unknown flag: --foo", true},
{"unknown shorthand flag: 'f' in -f", true},
{"flag needs an argument: --output", true},
{"required flag(s) \"model\" not set", true},
// Generic invalid-argument errors — should show hint
{"invalid argument \"abc\" for --count", true},
// required flag errors — should show hint
{"required flag(s) \"model\" not set", true},
// usage: in message — should show hint
{"bad input\nusage: picoclaw ...", true},
// Should NOT false-positive on broad words
{"connection flagged by remote", false},
{"feature flag not set", false},
{"invalid API key provided", false},
{"authentication required", false},
// Unrelated messages — no hint
{"something went wrong", false},
{"network timeout", false},
}
for _, tc := range cases {
got := showErrHint(tc.msg)
if got != tc.want {
t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want)
}
}
}
// ---------------------------------------------------------------------------
// styleUsageTokens
// ---------------------------------------------------------------------------
func TestStyleUsageTokensContainsTokens(t *testing.T) {
cases := []struct {
input string
contains []string // substrings that must appear in plain output
}{
{
"picoclaw agent <message>",
[]string{"picoclaw agent", "<message>"},
},
{
"picoclaw [command] [flags]",
[]string{"picoclaw", "[command]", "[flags]"},
},
{
"picoclaw",
[]string{"picoclaw"},
},
{
"cmd <arg1> [--flag]",
[]string{"cmd", "<arg1>", "[--flag]"},
},
}
for _, tc := range cases {
out := styleUsageTokens(tc.input)
for _, sub := range tc.contains {
if !containsStripped(out, sub) {
t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub)
}
}
}
}
// containsStripped checks whether plain contains sub after stripping ANSI escapes.
// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests,
// so this is just a plain substring check.
func containsStripped(plain, sub string) bool {
return len(plain) >= len(sub) && findSubstring(plain, sub)
}
func findSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// collectFlagRows
// ---------------------------------------------------------------------------
func TestCollectFlagRows_Empty(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
rows := collectFlagRows(fs)
if len(rows) != 0 {
t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows))
}
}
func TestCollectFlagRows_BasicFlags(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("output", "", "output file path")
fs.Bool("verbose", false, "enable verbose mode")
fs.Int("count", 1, "number of items")
rows := collectFlagRows(fs)
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
// Rows must be sorted alphabetically by flag name.
names := make([]string, 0, len(rows))
for _, r := range rows {
names = append(names, r[0])
}
if names[0] > names[1] || names[1] > names[2] {
t.Errorf("rows not sorted: %v", names)
}
}
func TestCollectFlagRows_Shorthand(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.StringP("model", "m", "", "model name")
rows := collectFlagRows(fs)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
left := rows[0][0]
if !findSubstring(left, "-m") || !findSubstring(left, "--model") {
t.Errorf("expected shorthand and long form in %q", left)
}
}
func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("visible", "", "this shows up")
hidden := fs.String("hidden", "", "this should not show up")
_ = hidden
_ = fs.MarkHidden("hidden")
rows := collectFlagRows(fs)
if len(rows) != 1 {
t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows))
}
if !findSubstring(rows[0][0], "visible") {
t.Errorf("expected visible flag in rows, got %q", rows[0][0])
}
}
func TestCollectFlagRows_UsageInRightColumn(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("format", "json", "output format: json or text")
rows := collectFlagRows(fs)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0][1] != "output format: json or text" {
t.Errorf("expected usage in right column, got %q", rows[0][1])
}
}

View file

@ -0,0 +1,298 @@
package cliui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
flag "github.com/spf13/pflag"
)
// RenderCommandHelp builds Ruff-style sectioned, two-column help when
// UseFancyLayout(); otherwise plain Cobra-style text.
func RenderCommandHelp(c *cobra.Command) string {
if !UseFancyLayout() {
return plainCommandHelp(c)
}
syncFlags(c)
var b strings.Builder
head, sub := helpIntro(c)
if head != "" {
b.WriteString(helpIntroStyle().Render(head))
b.WriteString("\n")
}
if sub != "" {
b.WriteString(mutedStyle().Render(sub))
b.WriteString("\n")
}
if head != "" || sub != "" {
b.WriteString("\n")
}
inner := InnerWidth()
contentW := inner - 6
if contentW < 36 {
contentW = 36
}
// Usage
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
b.WriteString(sectionPanel("Usage", usageBody, inner))
b.WriteString("\n")
// Examples
if ex := strings.TrimSpace(c.Example); ex != "" {
exBody := bodyStyle().Width(contentW).Render(ex)
b.WriteString(sectionPanel("Examples", exBody, inner))
b.WriteString("\n")
}
// Subcommands
subs := visibleSubcommands(c)
if len(subs) > 0 {
rows := make([][2]string, 0, len(subs))
for _, sub := range subs {
left := sub.Name()
if a := sub.Aliases; len(a) > 0 {
left += " (" + strings.Join(a, ", ") + ")"
}
rows = append(rows, [2]string{left, sub.Short})
}
b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner))
b.WriteString("\n")
}
// Local options
local := c.LocalFlags()
opts := collectFlagRows(local)
if len(opts) > 0 {
title := "Options"
if !c.HasParent() {
title = "Flags"
}
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner))
b.WriteString("\n")
}
// Global (inherited) options
if c.HasAvailableInheritedFlags() {
inh := collectFlagRows(c.InheritedFlags())
if len(inh) > 0 {
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner))
b.WriteString("\n")
}
}
return b.String()
}
// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help,
// for embedding after errors (stderr). outerW is typically InnerStderrWidth().
func RenderCommandQuickRef(c *cobra.Command, outerW int) string {
if c == nil || outerW < 40 {
return ""
}
syncFlags(c)
contentW := outerW - 6
if contentW < 36 {
contentW = 36
}
var b strings.Builder
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
b.WriteString(sectionPanel("Usage", usageBody, outerW))
b.WriteString("\n")
if len(c.Aliases) > 0 {
al := "Aliases: " + strings.Join(c.Aliases, ", ")
alBody := mutedStyle().MaxWidth(contentW).Render(al)
b.WriteString(sectionPanel("Aliases", alBody, outerW))
b.WriteString("\n")
}
opts := collectFlagRows(c.LocalFlags())
if len(opts) > 0 {
title := "Options"
if !c.HasParent() {
title = "Flags"
}
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW))
b.WriteString("\n")
}
if c.HasAvailableInheritedFlags() {
inh := collectFlagRows(c.InheritedFlags())
if len(inh) > 0 {
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW))
b.WriteString("\n")
}
}
return b.String()
}
func syncFlags(c *cobra.Command) {
_ = c.LocalFlags()
if c.HasAvailableInheritedFlags() {
_ = c.InheritedFlags()
}
}
func plainCommandHelp(c *cobra.Command) string {
desc := c.Long
if desc == "" {
desc = c.Short
}
desc = strings.TrimRight(desc, " \t\n\r")
var b strings.Builder
if desc != "" {
fmt.Fprintln(&b, desc)
fmt.Fprintln(&b)
}
if c.Runnable() || c.HasSubCommands() {
b.WriteString(c.UsageString())
}
return b.String()
}
func helpIntro(c *cobra.Command) (head, sub string) {
head = strings.TrimSpace(c.Short)
long := strings.TrimSpace(c.Long)
if long == "" || long == head {
return head, ""
}
lines := strings.Split(long, "\n")
var rest []string
for i, ln := range lines {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
if i == 0 && ln == head {
continue
}
rest = append(rest, ln)
}
sub = strings.Join(rest, "\n")
return head, sub
}
func visibleSubcommands(c *cobra.Command) []*cobra.Command {
var out []*cobra.Command
for _, sub := range c.Commands() {
if sub.Hidden {
continue
}
out = append(out, sub)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out
}
func sectionPanel(title, body string, width int) string {
head := titleBarStyle().Render(title) + "\n\n"
return borderStyle().Width(width).Render(head + body)
}
// styleUsageTokens highlights PicoClaw-blue command tokens and red <placeholders>/[groups].
func styleUsageTokens(s string) string {
var b strings.Builder
for len(s) > 0 {
ia := strings.Index(s, "<")
ib := strings.Index(s, "[")
next, kind := -1, 0 // 1 = angle, 2 = bracket
switch {
case ia >= 0 && (ib < 0 || ia < ib):
next, kind = ia, 1
case ib >= 0:
next, kind = ib, 2
}
if next < 0 {
b.WriteString(helpIdentStyle().Render(s))
break
}
if next > 0 {
b.WriteString(helpIdentStyle().Render(s[:next]))
}
s = s[next:]
if kind == 1 {
j := strings.Index(s, ">")
if j < 0 {
b.WriteString(helpIdentStyle().Render(s))
break
}
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
s = s[j+1:]
continue
}
j := strings.Index(s, "]")
if j < 0 {
b.WriteString(helpIdentStyle().Render(s))
break
}
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
s = s[j+1:]
}
return b.String()
}
func collectFlagRows(fs *flag.FlagSet) [][2]string {
var names []string
seen := map[string][2]string{}
fs.VisitAll(func(f *flag.Flag) {
if f.Hidden {
return
}
left := formatFlagLeft(f)
right := f.Usage
if f.Deprecated != "" {
right += " (deprecated: " + f.Deprecated + ")"
}
names = append(names, f.Name)
seen[f.Name] = [2]string{left, right}
})
sort.Strings(names)
rows := make([][2]string, 0, len(names))
for _, n := range names {
rows = append(rows, seen[n])
}
return rows
}
func formatFlagLeft(f *flag.Flag) string {
if len(f.Shorthand) > 0 {
return "-" + f.Shorthand + ", --" + f.Name
}
return "--" + f.Name
}
func renderTwoColPairs(rows [][2]string, contentW int) string {
if len(rows) == 0 {
return ""
}
leftW := 0
for _, r := range rows {
if w := lipgloss.Width(r[0]); w > leftW {
leftW = w
}
}
const minLeft, maxLeft = 16, 34
if leftW < minLeft {
leftW = minLeft
}
if leftW > maxLeft {
leftW = maxLeft
}
gap := " "
rightW := contentW - leftW - lipgloss.Width(gap)
if rightW < 24 {
rightW = 24
}
var b strings.Builder
for _, r := range rows {
left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0])
right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1]))
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right))
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}

View file

@ -0,0 +1,75 @@
package cliui
import (
"strings"
"github.com/spf13/cobra"
)
// FormatCLIError formats errors with the same boxed sections as help. When ctx
// is the command that was running when the error occurred, Usage / Flags panels
// are appended so styling matches picoclaw -h.
func FormatCLIError(msg string, ctx *cobra.Command) string {
msg = strings.TrimRight(msg, "\n")
if !UseFancyStderr() {
s := "Error: " + msg + "\n"
if ctx != nil && showErrHint(msg) {
s += "\n" + plainCommandHelp(ctx)
}
return s
}
w := InnerStderrWidth()
contentW := w - 6
if contentW < 36 {
contentW = 36
}
title := titleBarStyle().Render("Error") + "\n\n"
paras := strings.Split(msg, "\n")
var body strings.Builder
for i, p := range paras {
p = strings.TrimRight(p, " ")
if p == "" {
continue
}
st := bodyStyle().Width(contentW)
if i > 0 {
body.WriteString("\n")
}
if i == 0 {
body.WriteString(st.Render(p))
} else {
body.WriteString(mutedStyle().Width(contentW).Render(p))
}
}
foot := ""
if showErrHint(msg) {
if ctx != nil {
foot = "\n\n" + mutedStyle().Width(contentW).
Render("Full command help: "+ctx.CommandPath()+" --help")
} else {
foot = "\n\n" + mutedStyle().Width(contentW).
Render("Tip: picoclaw --help · picoclaw <command> --help")
}
}
out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n"
if ctx != nil && showErrHint(msg) {
if ref := RenderCommandQuickRef(ctx, w); ref != "" {
out += "\n" + ref
}
}
return out
}
func showErrHint(msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "unknown flag") ||
strings.Contains(m, "unknown shorthand flag") ||
strings.Contains(m, "flag needs an argument") ||
strings.Contains(m, "invalid argument") ||
strings.Contains(m, "required flag") ||
strings.Contains(m, "usage:")
}

View file

@ -0,0 +1,110 @@
package cliui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// PrintOnboardComplete prints the post-onboard “ready” message and next steps.
func PrintOnboardComplete(logo string, encrypt bool, configPath string) {
if !UseFancyLayout() {
printOnboardPlain(logo, encrypt, configPath)
return
}
printOnboardFancy(logo, encrypt, configPath)
}
func printOnboardPlain(logo string, encrypt bool, configPath string) {
fmt.Printf("\n%s picoclaw is ready!\n", logo)
fmt.Println("\nNext steps:")
if encrypt {
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
fmt.Println("")
fmt.Println(" 2. Add your API key to", configPath)
} else {
fmt.Println(" 1. Add your API key to", configPath)
}
fmt.Println("")
fmt.Println(" Recommended:")
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
fmt.Println(" - Ollama: https://ollama.com (local, free)")
fmt.Println("")
fmt.Println(" See README.md for 17+ supported providers.")
fmt.Println("")
if encrypt {
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
} else {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
}
func printOnboardFancy(logo string, encrypt bool, configPath string) {
inner := InnerWidth()
box := borderStyle().MaxWidth(inner + 8)
ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n"
fmt.Println()
fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready)))
fmt.Println()
steps := buildOnboardingSteps(encrypt, configPath)
rec := recommendedBlock()
chat := chatStep(encrypt)
if UseColumnLayout() {
leftW := min(inner/2-2, 52)
rightW := inner - leftW - 4
if rightW < 36 {
rightW = 36
}
leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW).
Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps))
rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW).
Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec))
gap := strings.Repeat(" ", 2)
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock))
fmt.Println()
full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat))
fmt.Println(full)
return
}
// Same order as plain output: numbered steps → recommended → chat line.
next := titleBarStyle().Render("Next steps") + "\n\n" +
bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat)
fmt.Println(borderStyle().Width(inner).Render(next))
}
func buildOnboardingSteps(encrypt bool, configPath string) string {
var b strings.Builder
if encrypt {
b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n")
b.WriteString(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS\n")
b.WriteString(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd\n\n")
b.WriteString("2. Add your API key to\n ")
b.WriteString(configPath)
b.WriteString("\n")
} else {
b.WriteString("1. Add your API key to\n ")
b.WriteString(configPath)
b.WriteString("\n")
}
return b.String()
}
func recommendedBlock() string {
return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" +
"• Ollama: https://ollama.com\n (local, free)\n\n" +
"See README.md for 17+ supported providers."
}
func chatStep(encrypt bool) string {
if encrypt {
return "3. Chat:\n picoclaw agent -m \"Hello!\""
}
return "2. Chat:\n picoclaw agent -m \"Hello!\""
}

View file

@ -0,0 +1,168 @@
package cliui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ProviderRow holds one provider's display name and status value.
type ProviderRow struct {
Name string
Val string
}
// StatusReport is a structured status view for PrintStatus.
type StatusReport struct {
Logo string
Version string
Build string
ConfigPath string
ConfigOK bool
WorkspacePath string
WorkspaceOK bool
Model string
Providers []ProviderRow
OAuthLines []string // each full line "provider (method): state"
}
// PrintStatus renders picoclaw status (plain or fancy).
func PrintStatus(r StatusReport) {
if !UseFancyLayout() {
printStatusPlain(r)
return
}
printStatusFancy(r)
}
func printStatusPlain(r StatusReport) {
fmt.Printf("%s picoclaw Status\n", r.Logo)
fmt.Printf("Version: %s\n", r.Version)
if r.Build != "" {
fmt.Printf("Build: %s\n", r.Build)
}
fmt.Println()
printPathLine("Config", r.ConfigPath, r.ConfigOK)
printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK)
if r.ConfigOK {
fmt.Printf("Model: %s\n", r.Model)
for _, p := range r.Providers {
fmt.Printf("%s: %s\n", p.Name, p.Val)
}
if len(r.OAuthLines) > 0 {
fmt.Println("\nOAuth/Token Auth:")
for _, line := range r.OAuthLines {
fmt.Printf(" %s\n", line)
}
}
}
}
func printPathLine(label, path string, ok bool) {
mark := "✗"
if ok {
mark = "✓"
}
fmt.Println(label+":", path, mark)
}
func printStatusFancy(r StatusReport) {
inner := InnerWidth()
topBox := borderStyle().Width(inner)
var head strings.Builder
head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status"))
head.WriteString("\n\n")
head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version))
if r.Build != "" {
head.WriteString("\n")
head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build))
}
fmt.Println(topBox.Render(head.String()))
fmt.Println()
if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK {
leftW := (inner - 2) / 2
rightW := inner - leftW - 2
pathsNarrow := pathStatusPanel(r, leftW)
prov := providerTablePanel(r, rightW)
gap := strings.Repeat(" ", 2)
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov))
} else {
fmt.Println(pathStatusPanel(r, inner))
if len(r.Providers) > 0 && r.ConfigOK {
fmt.Println(providerTablePanel(r, inner))
}
}
if len(r.OAuthLines) > 0 && r.ConfigOK {
var ob strings.Builder
ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n")
for _, line := range r.OAuthLines {
ob.WriteString(" • " + line + "\n")
}
fmt.Println()
fmt.Println(borderStyle().Width(inner).Render(ob.String()))
}
}
func pathStatusPanel(r StatusReport, inner int) string {
cfgMark := statusMark(r.ConfigOK)
wsMark := statusMark(r.WorkspaceOK)
var b strings.Builder
b.WriteString(kvKeyStyle().Render("Config") + "\n")
b.WriteString(mutedStyle().Render(r.ConfigPath))
b.WriteString(" " + cfgMark + "\n\n")
b.WriteString(kvKeyStyle().Render("Workspace") + "\n")
b.WriteString(mutedStyle().Render(r.WorkspacePath))
b.WriteString(" " + wsMark + "\n")
if r.ConfigOK {
b.WriteString("\n")
b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model))
}
return borderStyle().Width(inner).Render(b.String())
}
func statusMark(ok bool) string {
if ok {
return lipgloss.NewStyle().Foreground(colorOK).Render("✓")
}
return lipgloss.NewStyle().Foreground(accentRed).Render("✗")
}
func providerTablePanel(r StatusReport, colW int) string {
if len(r.Providers) == 0 {
return ""
}
keyW := min(22, colW/3)
if keyW < 14 {
keyW = 14
}
valW := colW - keyW - 3
if valW < 12 {
valW = 12
}
var b strings.Builder
b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n")
for _, p := range r.Providers {
k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name)
v := styleProviderVal(p.Val).Width(valW).Render(p.Val)
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v))
b.WriteString("\n")
}
return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n"))
}
func styleProviderVal(s string) lipgloss.Style {
if s == "✓" || strings.HasPrefix(s, "✓ ") {
return lipgloss.NewStyle().Foreground(colorOK)
}
if s == "not set" {
return mutedStyle()
}
return lipgloss.NewStyle()
}

View file

@ -0,0 +1,61 @@
package cliui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// PrintVersion prints version, optional build info, and Go toolchain line.
func PrintVersion(logo, versionLine string, build, goVer string) {
if !UseFancyLayout() {
fmt.Printf("%s %s\n", logo, versionLine)
if build != "" {
fmt.Printf(" Build: %s\n", build)
}
if goVer != "" {
fmt.Printf(" Go: %s\n", goVer)
}
return
}
inner := InnerWidth()
box := borderStyle().Width(inner)
if UseColumnLayout() {
leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right)
rightW := inner - 16
rightStyle := kvValStyle().Width(rightW)
rows := [][]string{
{leftCol.Render("Version"), rightStyle.Render(versionLine)},
}
if build != "" {
rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)})
}
if goVer != "" {
rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)})
}
var body strings.Builder
for _, r := range rows {
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1]))
body.WriteString("\n")
}
header := titleBarStyle().Render(logo+" picoclaw") + "\n\n"
fmt.Println(box.Render(header + body.String()))
return
}
var lines []string
lines = append(lines, titleBarStyle().Render(logo+" picoclaw"))
lines = append(lines, "")
lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine))
if build != "" {
lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build))
}
if goVer != "" {
lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer))
}
fmt.Println(box.Render(strings.Join(lines, "\n")))
}

View file

@ -9,6 +9,7 @@ import (
"golang.org/x/term"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
)
@ -79,29 +80,7 @@ func onboard(encrypt bool) {
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
fmt.Println("\nNext steps:")
if encrypt {
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
fmt.Println("")
fmt.Println(" 2. Add your API key to", configPath)
} else {
fmt.Println(" 1. Add your API key to", configPath)
}
fmt.Println("")
fmt.Println(" Recommended:")
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
fmt.Println(" - Ollama: https://ollama.com (local, free)")
fmt.Println("")
fmt.Println(" See README.md for 17+ supported providers.")
fmt.Println("")
if encrypt {
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
} else {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
}
// promptPassphrase reads the encryption passphrase twice from the terminal

View file

@ -3,8 +3,10 @@ package status
import (
"fmt"
"os"
"strings"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
@ -17,43 +19,125 @@ func statusCmd() {
}
configPath := internal.GetConfigPath()
fmt.Printf("%s picoclaw Status\n", internal.Logo)
fmt.Printf("Version: %s\n", config.FormatVersion())
build, _ := config.FormatBuildInfo()
if build != "" {
fmt.Printf("Build: %s\n", build)
}
fmt.Println()
if _, err := os.Stat(configPath); err == nil {
fmt.Println("Config:", configPath, "✓")
} else {
fmt.Println("Config:", configPath, "✗")
}
_, configStatErr := os.Stat(configPath)
configOK := configStatErr == nil
workspace := cfg.WorkspacePath()
if _, err := os.Stat(workspace); err == nil {
fmt.Println("Workspace:", workspace, "✓")
} else {
fmt.Println("Workspace:", workspace, "✗")
_, wsErr := os.Stat(workspace)
wsOK := wsErr == nil
report := cliui.StatusReport{
Logo: internal.Logo,
Version: config.FormatVersion(),
Build: build,
ConfigPath: configPath,
ConfigOK: configOK,
WorkspacePath: workspace,
WorkspaceOK: wsOK,
Model: cfg.Agents.Defaults.GetModelName(),
}
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
if configOK {
// PicoClaw moved to a model-centric configuration (model_list). Status should
// not depend on a legacy cfg.Providers field (which may not exist under some
// build tags). We infer provider availability from model_list entries.
hasProtocolKey := func(protocol string) bool {
prefix := protocol + "/"
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" {
return true
}
}
return false
}
findLocalModelBase := func(modelName string) (string, bool) {
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if m.ModelName == modelName && m.APIBase != "" {
return m.APIBase, true
}
}
return "", false
}
findProtocolBase := func(protocol string) (string, bool) {
prefix := protocol + "/"
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" {
return m.APIBase, true
}
}
return "", false
}
hasOpenRouter := hasProtocolKey("openrouter")
hasAnthropic := hasProtocolKey("anthropic")
hasOpenAI := hasProtocolKey("openai")
hasGemini := hasProtocolKey("gemini")
hasZhipu := hasProtocolKey("zhipu")
hasQwen := hasProtocolKey("qwen")
hasGroq := hasProtocolKey("groq")
hasMoonshot := hasProtocolKey("moonshot")
hasDeepSeek := hasProtocolKey("deepseek")
hasVolcEngine := hasProtocolKey("volcengine")
hasNvidia := hasProtocolKey("nvidia")
// Local endpoints: allow both the special reserved name and protocol-based entries.
vllmBase, hasVLLM := findLocalModelBase("local-model")
if !hasVLLM {
vllmBase, hasVLLM = findProtocolBase("vllm")
}
ollamaBase, hasOllama := findProtocolBase("ollama")
val := func(enabled bool, extra ...string) string {
if enabled {
if len(extra) > 0 && extra[0] != "" {
return "✓ " + extra[0]
}
return "✓"
}
return "not set"
}
report.Providers = []cliui.ProviderRow{
{Name: "OpenRouter API", Val: val(hasOpenRouter)},
{Name: "Anthropic API", Val: val(hasAnthropic)},
{Name: "OpenAI API", Val: val(hasOpenAI)},
{Name: "Gemini API", Val: val(hasGemini)},
{Name: "Zhipu API", Val: val(hasZhipu)},
{Name: "Qwen API", Val: val(hasQwen)},
{Name: "Groq API", Val: val(hasGroq)},
{Name: "Moonshot API", Val: val(hasMoonshot)},
{Name: "DeepSeek API", Val: val(hasDeepSeek)},
{Name: "VolcEngine API", Val: val(hasVolcEngine)},
{Name: "Nvidia API", Val: val(hasNvidia)},
{Name: "vLLM / local", Val: val(hasVLLM, vllmBase)},
{Name: "Ollama", Val: val(hasOllama, ollamaBase)},
}
store, _ := auth.LoadStore()
if store != nil && len(store.Credentials) > 0 {
fmt.Println("\nOAuth/Token Auth:")
for provider, cred := range store.Credentials {
status := "authenticated"
st := "authenticated"
if cred.IsExpired() {
status = "expired"
st = "expired"
} else if cred.NeedsRefresh() {
status = "needs refresh"
st = "needs refresh"
}
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
report.OAuthLines = append(report.OAuthLines,
fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st))
}
}
}
cliui.PrintStatus(report)
}

View file

@ -1,11 +1,10 @@
package version
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
)
@ -23,12 +22,6 @@ func NewVersionCommand() *cobra.Command {
}
func printVersion() {
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
build, goVer := config.FormatBuildInfo()
if build != "" {
fmt.Printf(" Build: %s\n", build)
}
if goVer != "" {
fmt.Printf(" Go: %s\n", goVer)
}
cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer)
}

View file

@ -16,6 +16,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
@ -28,15 +29,57 @@ import (
"github.com/sipeed/picoclaw/pkg/updater"
)
var rootNoColor bool
func syncCliUIColor(root *cobra.Command) {
no, _ := root.PersistentFlags().GetBool("no-color")
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
}
// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags.
func earlyColorDisabled() bool {
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
return true
}
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" {
return true
}
}
return false
}
func NewPicoclawCommand() *cobra.Command {
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant.
Version: %s`, internal.Logo, config.FormatVersion())
cmd := &cobra.Command{
Use: "picoclaw",
Short: short,
Example: "picoclaw version",
Use: "picoclaw",
Short: short,
Long: long,
Example: `picoclaw version
picoclaw onboard
picoclaw --no-color status`,
SilenceErrors: true,
// Avoid plain UsageString() on stderr/stdout when a command fails; cliui
// renders matching panels on stderr instead.
SilenceUsage: true,
PersistentPreRun: func(c *cobra.Command, _ []string) {
syncCliUIColor(c.Root())
},
}
cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false,
"Disable colors (boxed layout unchanged)")
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
syncCliUIColor(c.Root())
fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c))
})
cmd.AddCommand(
onboard.NewOnboardCommand(),
agent.NewAgentCommand(),
@ -65,17 +108,31 @@ const (
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\033[0m\r\n"
plainBanner = "\r\n" +
"██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
"██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
"██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
"██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
"██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
"╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\r\n"
)
func main() {
fmt.Printf("%s", banner)
cliui.Init(earlyColorDisabled())
tz_env := os.Getenv("TZ")
if tz_env != "" {
fmt.Println("TZ environment:", tz_env)
zoneinfo_env := os.Getenv("ZONEINFO")
fmt.Println("ZONEINFO environment:", zoneinfo_env)
loc, err := time.LoadLocation(tz_env)
if earlyColorDisabled() {
fmt.Print(plainBanner)
} else {
fmt.Printf("%s", banner)
}
tzEnv := os.Getenv("TZ")
if tzEnv != "" {
fmt.Println("TZ environment:", tzEnv)
zoneinfoEnv := os.Getenv("ZONEINFO")
fmt.Println("ZONEINFO environment:", zoneinfoEnv)
loc, err := time.LoadLocation(tzEnv)
if err != nil {
fmt.Println("Error loading time zone:", err)
} else {
@ -85,7 +142,10 @@ func main() {
}
cmd := NewPicoclawCommand()
if err := cmd.Execute(); err != nil {
last, err := cmd.ExecuteC()
if err != nil {
syncCliUIColor(cmd)
fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last))
os.Exit(1)
}
}

View file

@ -3,6 +3,7 @@ package main
import (
"fmt"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -17,20 +18,22 @@ func TestNewPicoclawCommand(t *testing.T) {
require.NotNil(t, cmd)
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
longHas := strings.Contains(cmd.Long, config.FormatVersion())
assert.Equal(t, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short)
assert.True(t, longHas)
assert.True(t, cmd.HasSubCommands())
assert.True(t, cmd.HasAvailableSubCommands())
assert.False(t, cmd.HasFlags())
assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil)
assert.Nil(t, cmd.Run)
assert.Nil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.NotNil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
allowedCommands := []string{

View file

@ -26,18 +26,9 @@ RUN apk add --no-cache ca-certificates tzdata curl
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:18790/health || exit 1
# Copy binary
# Copy binary and first-run entrypoint (same as release image).
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create non-root user and group
RUN addgroup -g 1000 picoclaw && \
adduser -D -u 1000 -G picoclaw picoclaw
# Switch to non-root user
USER picoclaw
# Run onboard to create initial directories and config
RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -48,20 +48,13 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
addgroup -g 1000 picoclaw 2>/dev/null; \
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
USER picoclaw
# Run onboard to create initial directories and config
RUN /usr/local/bin/picoclaw onboard
# Copy default workspace
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
COPY workspace/ /root/.picoclaw/workspace/
VOLUME /home/picoclaw/.picoclaw/workspace
VOLUME /root/.picoclaw/workspace
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]

View file

@ -28,6 +28,69 @@ The currently exposed synchronous hook points are:
Everything else is exposed as read-only events.
## Hook Actions
Hooks can return different actions to control the flow:
| Action | Applicable Stages | Effect |
| --- | --- | --- |
| `continue` | All interceptors | Pass through without modification |
| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | Modify request/response and continue |
| `respond` | `before_tool` | Return a tool result directly, skip actual tool execution |
| `deny_tool` | `before_tool` | Deny tool execution, return error message |
| `abort_turn` | All interceptors | Abort the current turn |
| `hard_abort` | All interceptors | Force stop the entire agent loop |
### The `respond` Action
The `respond` action is special: it allows a `before_tool` hook to provide the tool result directly, skipping the actual tool execution. This is useful for:
1. **Plugin tool injection**: External hooks can implement tools without registering them in the tool registry
2. **Tool result caching**: Return cached results for repeated tool calls
3. **Tool mocking**: Return mock results for testing purposes
When a hook returns `respond` with a `HookResult`, the agent loop:
1. Skips the actual tool execution
2. Uses the provided result as if the tool had executed
3. Continues the turn normally with the result
Example (Go in-process hook):
```go
func (h *MyHook) BeforeTool(
ctx context.Context,
call *agent.ToolCallHookRequest,
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
if call.Tool == "my_plugin_tool" {
next := call.Clone()
next.HookResult = &tools.ToolResult{
ForLLM: "Plugin tool executed successfully",
Silent: false,
IsError: false,
}
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
}
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
```
Example (Python process hook):
```python
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
if tool == "my_plugin_tool":
return {
"action": "respond",
"result": {
"for_llm": "Plugin tool executed successfully",
"silent": False,
"is_error": False
}
}
return {"action": "continue"}
```
## Execution Order
`HookManager` sorts hooks like this:

View file

@ -28,6 +28,69 @@
其余 lifecycle 通过事件形式只读暴露。
## Hook Actions
Hook 可以返回不同的 action 来控制流程:
| Action | 适用阶段 | 效果 |
| --- | --- | --- |
| `continue` | 所有拦截型 | 放行,不做修改 |
| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | 改写请求/响应后放行 |
| `respond` | `before_tool` | 直接返回工具结果,跳过实际工具执行 |
| `deny_tool` | `before_tool` | 拒绝工具执行,返回错误信息 |
| `abort_turn` | 所有拦截型 | 中止当前 turn |
| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop |
### `respond` Action
`respond` action 是特殊的:它允许 `before_tool` hook 直接提供工具结果,跳过实际工具执行。适用于:
1. **插件工具注入**:外部 hook 可以实现工具,无需在 ToolRegistry 注册
2. **工具结果缓存**:对重复调用返回缓存结果
3. **工具模拟**:测试时返回模拟结果
当 hook 返回 `respond` 并携带 `HookResult`agent loop 会:
1. 跳过实际工具执行
2. 使用提供的结果作为工具执行结果
3. 正常继续 turn 流程
示例Go 进程内 hook
```go
func (h *MyHook) BeforeTool(
ctx context.Context,
call *agent.ToolCallHookRequest,
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
if call.Tool == "my_plugin_tool" {
next := call.Clone()
next.HookResult = &tools.ToolResult{
ForLLM: "Plugin tool executed successfully",
Silent: false,
IsError: false,
}
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
}
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
```
示例Python process hook
```python
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
if tool == "my_plugin_tool":
return {
"action": "respond",
"result": {
"for_llm": "Plugin tool executed successfully",
"silent": False,
"is_error": False
}
}
return {"action": "continue"}
```
## 执行顺序
HookManager 的排序规则是:

View file

@ -0,0 +1,568 @@
# Hook JSON-RPC Protocol Details
All hooks use `JSON-RPC 2.0` format, with one JSON message per line, transmitted via stdio.
---
## Basic Protocol Structure
### Request (PicoClaw → Hook)
```json
{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}}
```
### Response (Hook → PicoClaw)
Success:
```json
{"jsonrpc":"2.0","id":1,"result":{...}}
```
Error:
```json
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"error message"}}
```
---
## 1. `hook.hello` (Handshake)
Handshake must be completed at startup, otherwise the hook process will be terminated.
### Request
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "hook.hello",
"params": {
"name": "py_review_gate",
"version": 1,
"modes": ["observe", "tool", "approve"]
}
}
```
| Field | Description |
|-------|-------------|
| `name` | hook name (from configuration) |
| `version` | protocol version, currently `1` |
| `modes` | capability modes supported by the hook |
### Response
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"ok": true,
"name": "python-review-gate"
}
}
```
---
## 2. `hook.before_llm`
Triggered before sending request to LLM. Can be used to inject tools.
### Request
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "hook.before_llm",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"ParentTurnID": "",
"SessionKey": "session-1",
"Iteration": 0,
"TracePath": "runTurn",
"Source": "turn.llm.request"
},
"model": "claude-sonnet",
"messages": [
{"role": "user", "content": "hello"}
],
"tools": [
{
"type": "function",
"function": {
"name": "echo",
"description": "echo text",
"parameters": {"type": "object"}
}
}
],
"options": {
"temperature": 0.7
},
"channel": "cli",
"chat_id": "chat-1",
"graceful_terminal": false
}
}
```
| Field | Description |
|-------|-------------|
| `meta` | event metadata for tracing |
| `model` | requested model name |
| `messages` | conversation history |
| `tools` | list of available tool definitions |
| `options` | LLM parameters (temperature, max_tokens, etc.) |
| `channel` | request source channel |
| `chat_id` | session ID |
### Response (Tool Injection Example)
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"action": "modify",
"request": {
"model": "claude-sonnet",
"messages": [{"role": "user", "content": "hello"}],
"tools": [
{
"type": "function",
"function": {
"name": "echo",
"description": "echo",
"parameters": {}
}
},
{
"type": "function",
"function": {
"name": "my_plugin_tool",
"description": "Plugin injected tool",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"}
}
}
}
}
]
}
}
}
```
| Field | Description |
|-------|-------------|
| `action` | decision action (see table below) |
| `request` | modified request object |
---
## 3. `hook.after_llm`
Triggered after receiving LLM response. Can modify response content.
### Request
```json
{
"jsonrpc": "2.0",
"id": 3,
"method": "hook.after_llm",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"SessionKey": "session-1"
},
"model": "claude-sonnet",
"response": {
"role": "assistant",
"content": "Hi!",
"tool_calls": [
{
"id": "tc-1",
"type": "function",
"function": {
"name": "echo",
"arguments": "{\"text\":\"hi\"}"
}
}
]
},
"channel": "cli",
"chat_id": "chat-1"
}
}
```
### Response
```json
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"action": "continue"
}
}
```
---
## 4. `hook.before_tool`
Triggered before tool execution. Can modify tool name and arguments, deny execution, or return result directly.
### Request
```json
{
"jsonrpc": "2.0",
"id": 4,
"method": "hook.before_tool",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"SessionKey": "session-1"
},
"tool": "echo_text",
"arguments": {
"text": "hello"
},
"channel": "cli",
"chat_id": "chat-1"
}
}
```
| Field | Description |
|-------|-------------|
| `tool` | tool name |
| `arguments` | tool arguments |
### Response (Modify Arguments)
```json
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"action": "modify",
"call": {
"tool": "echo_text",
"arguments": {
"text": "modified hello"
}
}
}
}
```
### Response (Deny Execution)
```json
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"action": "deny_tool",
"reason": "Invalid arguments"
}
}
```
### Response (Return Result Directly - respond)
```json
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"action": "respond",
"call": {
"tool": "my_plugin_tool",
"arguments": {
"query": "hello"
}
},
"result": {
"for_llm": "Plugin tool executed successfully",
"for_user": "",
"silent": false,
"is_error": false
}
}
}
```
The `respond` action allows hooks to return tool results directly, skipping actual tool execution. Use cases:
1. **Plugin tool injection**: External hooks can implement tools without registering in ToolRegistry
2. **Tool result caching**: Return cached results for repeated calls
3. **Tool mocking**: Return mock results during testing
| Field | Description |
|-------|-------------|
| `action` | must be `respond` |
| `call` | modified call information (optional) |
| `result` | tool result to return directly |
---
## 5. `hook.after_tool`
Triggered after tool execution completes. Can modify the result returned to LLM.
### Request
```json
{
"jsonrpc": "2.0",
"id": 5,
"method": "hook.after_tool",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"SessionKey": "session-1"
},
"tool": "echo_text",
"arguments": {
"text": "hello"
},
"result": {
"for_llm": "echoed: hello",
"for_user": "",
"silent": false,
"is_error": false,
"async": false,
"media": [],
"artifact_tags": [],
"response_handled": false
},
"duration": 15000000,
"channel": "cli",
"chat_id": "chat-1"
}
}
```
| Field | Description |
|-------|-------------|
| `result.for_llm` | content returned to LLM |
| `result.for_user` | content sent to user |
| `result.silent` | whether silent (not sent to user) |
| `result.is_error` | whether it's an error |
| `result.async` | whether executed asynchronously |
| `result.media` | list of media references |
| `result.artifact_tags` | local artifact path tags |
| `result.response_handled` | whether response has been handled |
| `duration` | execution time (nanoseconds) |
### Response
```json
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"action": "continue"
}
}
```
---
## 6. `hook.approve_tool`
Approval hook for deciding whether to allow execution of sensitive tools.
### Request
```json
{
"jsonrpc": "2.0",
"id": 6,
"method": "hook.approve_tool",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"SessionKey": "session-1"
},
"tool": "bash",
"arguments": {
"command": "rm -rf /"
},
"channel": "cli",
"chat_id": "chat-1"
}
}
```
### Response (Approved)
```json
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"approved": true
}
}
```
### Response (Denied)
```json
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"approved": false,
"reason": "Dangerous command, execution denied"
}
}
```
---
## 7. `hook.event` (notification)
Observer event, broadcast only, no response required. `id` is `0` or absent.
```json
{
"jsonrpc": "2.0",
"method": "hook.event",
"params": {
"Kind": "tool_exec_start",
"Meta": {
"AgentID": "agent-1",
"TurnID": "turn-1"
},
"Payload": {
"Tool": "echo_text",
"Arguments": {"text": "hello"}
}
}
}
```
Common `Kind` values:
- `turn_start` / `turn_end`
- `llm_request` / `llm_response`
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
- `steering_injected`
- `interrupt_received`
- `error`
---
## Action Options
| action | Applicable hooks | Effect |
|--------|-----------------|--------|
| `continue` | All interceptor types | Pass through without modification |
| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | Modify request/response and pass through |
| `respond` | `before_tool` | Return tool result directly, skip actual execution. **Note: AfterTool is NOT called (design decision - respond provides final answer).** |
| `deny_tool` | `before_tool` | Deny tool execution |
| `abort_turn` | All interceptor types | Abort current turn, return error |
| `hard_abort` | All interceptor types | Force stop entire agent loop |
---
## Complete Flow Example
```json
{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}}
{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}}
{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}}
{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}}
{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}}
{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
{"jsonrpc":"2.0","id":4,"result":{"approved":true}}
{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}}
{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}}
{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"Files listed"}}}
{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}}
```
---
## Plugin Tool Injection via `before_llm` and `before_tool`
Standard flow for plugin tool injection:
1. In `before_llm`, inject tool definition to let LLM know the tool is available
2. In `before_tool`, use `respond` action to return tool execution result directly
### `before_llm` Inject Tool Definition
```python
def handle_before_llm(params: dict) -> dict:
tools = params.get("tools", [])
# Add plugin tool definition
tools.append({
"type": "function",
"function": {
"name": "my_plugin_tool",
"description": "Plugin provided tool",
"parameters": {
"type": "object",
"properties": {
"input": {"type": "string", "description": "Input content"}
},
"required": ["input"]
}
}
})
return {
"action": "modify",
"request": {
"model": params["model"],
"messages": params["messages"],
"tools": tools,
"options": params.get("options", {})
}
}
```
### `before_tool` Return Execution Result
```python
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
if tool == "my_plugin_tool":
# Implement tool logic here
args = params.get("arguments", {})
input_text = args.get("input", "")
# Return result directly, no need to register in ToolRegistry
return {
"action": "respond",
"result": {
"for_llm": f"Plugin tool executed successfully, input: {input_text}",
"silent": False,
"is_error": False
}
}
return {"action": "continue"}
```
This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.

View file

@ -0,0 +1,568 @@
# Hook JSON-RPC 协议详解
所有 hook 使用 `JSON-RPC 2.0` 格式,每行一个 JSON 消息,通过 stdio 传输。
---
## 基础协议结构
### 请求PicoClaw → Hook
```json
{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}}
```
### 响应Hook → PicoClaw
成功:
```json
{"jsonrpc":"2.0","id":1,"result":{...}}
```
错误:
```json
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"错误信息"}}
```
---
## 1. `hook.hello`(握手)
启动时必须完成握手,否则 hook 进程会被终止。
### 请求
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "hook.hello",
"params": {
"name": "py_review_gate",
"version": 1,
"modes": ["observe", "tool", "approve"]
}
}
```
| 字段 | 说明 |
|------|------|
| `name` | hook 名称(来自配置) |
| `version` | 协议版本,当前为 `1` |
| `modes` | hook 支持的能力模式 |
### 响应
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"ok": true,
"name": "python-review-gate"
}
}
```
---
## 2. `hook.before_llm`
在发送请求给 LLM 之前触发。可用于注入工具。
### 请求
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "hook.before_llm",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"ParentTurnID": "",
"SessionKey": "session-1",
"Iteration": 0,
"TracePath": "runTurn",
"Source": "turn.llm.request"
},
"model": "claude-sonnet",
"messages": [
{"role": "user", "content": "hello"}
],
"tools": [
{
"type": "function",
"function": {
"name": "echo",
"description": "echo text",
"parameters": {"type": "object"}
}
}
],
"options": {
"temperature": 0.7
},
"channel": "cli",
"chat_id": "chat-1",
"graceful_terminal": false
}
}
```
| 字段 | 说明 |
|------|------|
| `meta` | 事件元数据,用于追踪 |
| `model` | 请求的模型名称 |
| `messages` | 对话历史 |
| `tools` | 可用工具定义列表 |
| `options` | LLM 参数temperature、max_tokens 等) |
| `channel` | 请求来源通道 |
| `chat_id` | 会话 ID |
### 响应(注入工具示例)
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"action": "modify",
"request": {
"model": "claude-sonnet",
"messages": [{"role": "user", "content": "hello"}],
"tools": [
{
"type": "function",
"function": {
"name": "echo",
"description": "echo",
"parameters": {}
}
},
{
"type": "function",
"function": {
"name": "my_plugin_tool",
"description": "插件注入的工具",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"}
}
}
}
}
]
}
}
}
```
| 字段 | 说明 |
|------|------|
| `action` | 决策动作(见下表) |
| `request` | 修改后的请求对象 |
---
## 3. `hook.after_llm`
在收到 LLM 响应后触发。可修改响应内容。
### 请求
```json
{
"jsonrpc": "2.0",
"id": 3,
"method": "hook.after_llm",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"SessionKey": "session-1"
},
"model": "claude-sonnet",
"response": {
"role": "assistant",
"content": "Hi!",
"tool_calls": [
{
"id": "tc-1",
"type": "function",
"function": {
"name": "echo",
"arguments": "{\"text\":\"hi\"}"
}
}
]
},
"channel": "cli",
"chat_id": "chat-1"
}
}
```
### 响应
```json
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"action": "continue"
}
}
```
---
## 4. `hook.before_tool`
在执行工具前触发。可修改工具名称和参数,或拒绝执行,或直接返回结果。
### 请求
```json
{
"jsonrpc": "2.0",
"id": 4,
"method": "hook.before_tool",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"SessionKey": "session-1"
},
"tool": "echo_text",
"arguments": {
"text": "hello"
},
"channel": "cli",
"chat_id": "chat-1"
}
}
```
| 字段 | 说明 |
|------|------|
| `tool` | 工具名称 |
| `arguments` | 工具参数 |
### 响应(改写参数)
```json
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"action": "modify",
"call": {
"tool": "echo_text",
"arguments": {
"text": "modified hello"
}
}
}
}
```
### 响应(拒绝执行)
```json
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"action": "deny_tool",
"reason": "参数不合法"
}
}
```
### 响应(直接返回结果 - respond
```json
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"action": "respond",
"call": {
"tool": "my_plugin_tool",
"arguments": {
"query": "hello"
}
},
"result": {
"for_llm": "Plugin tool executed successfully",
"for_user": "",
"silent": false,
"is_error": false
}
}
}
```
`respond` action 允许 hook 直接返回工具结果,跳过实际工具执行。适用于:
1. **插件工具注入**:外部 hook 可实现工具,无需在 ToolRegistry 注册
2. **工具结果缓存**:对重复调用返回缓存结果
3. **工具模拟**:测试时返回模拟结果
| 字段 | 说明 |
|------|------|
| `action` | 必须为 `respond` |
| `call` | 修改后的调用信息(可选) |
| `result` | 直接返回的工具结果 |
---
## 5. `hook.after_tool`
在工具执行完成后触发。可修改返回给 LLM 的结果。
### 请求
```json
{
"jsonrpc": "2.0",
"id": 5,
"method": "hook.after_tool",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"SessionKey": "session-1"
},
"tool": "echo_text",
"arguments": {
"text": "hello"
},
"result": {
"for_llm": "echoed: hello",
"for_user": "",
"silent": false,
"is_error": false,
"async": false,
"media": [],
"artifact_tags": [],
"response_handled": false
},
"duration": 15000000,
"channel": "cli",
"chat_id": "chat-1"
}
}
```
| 字段 | 说明 |
|------|------|
| `result.for_llm` | 返回给 LLM 的内容 |
| `result.for_user` | 发送给用户的内容 |
| `result.silent` | 是否静默(不发送给用户) |
| `result.is_error` | 是否为错误 |
| `result.async` | 是否异步执行 |
| `result.media` | 媒体引用列表 |
| `result.artifact_tags` | 本地产物路径标签 |
| `result.response_handled` | 是否已处理响应 |
| `duration` | 执行耗时(纳秒) |
### 响应
```json
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"action": "continue"
}
}
```
---
## 6. `hook.approve_tool`
审批型 hook用于决定是否允许执行敏感工具。
### 请求
```json
{
"jsonrpc": "2.0",
"id": 6,
"method": "hook.approve_tool",
"params": {
"meta": {
"AgentID": "agent-1",
"TurnID": "turn-1",
"SessionKey": "session-1"
},
"tool": "bash",
"arguments": {
"command": "rm -rf /"
},
"channel": "cli",
"chat_id": "chat-1"
}
}
```
### 响应(批准)
```json
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"approved": true
}
}
```
### 响应(拒绝)
```json
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"approved": false,
"reason": "危险命令,禁止执行"
}
}
```
---
## 7. `hook.event`notification
观察型事件,仅广播,无需响应。`id``0` 或不存在。
```json
{
"jsonrpc": "2.0",
"method": "hook.event",
"params": {
"Kind": "tool_exec_start",
"Meta": {
"AgentID": "agent-1",
"TurnID": "turn-1"
},
"Payload": {
"Tool": "echo_text",
"Arguments": {"text": "hello"}
}
}
}
```
常见 `Kind` 值:
- `turn_start` / `turn_end`
- `llm_request` / `llm_response`
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
- `steering_injected`
- `interrupt_received`
- `error`
---
## action 可选值
| action | 适用 hook | 效果 |
|--------|----------|------|
| `continue` | 所有拦截型 | 放行,不做修改 |
| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | 改写请求/响应后放行 |
| `respond` | `before_tool` | 直接返回工具结果,跳过实际执行 |
| `deny_tool` | `before_tool` | 拒绝执行该工具 |
| `abort_turn` | 所有拦截型 | 中止当前 turn返回错误 |
| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop |
---
## 完整流程示例
```json
{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}}
{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}}
{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}}
{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}}
{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}}
{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
{"jsonrpc":"2.0","id":4,"result":{"approved":true}}
{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}}
{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}}
{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"已列出文件"}}}
{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}}
```
---
## 通过 `before_llm``before_tool` 实现插件工具注入
插件工具注入的标准流程:
1. 在 `before_llm` 中注入工具定义,让 LLM 知道有这个工具可用
2. 在 `before_tool` 中使用 `respond` action 直接返回工具执行结果
### `before_llm` 注入工具定义
```python
def handle_before_llm(params: dict) -> dict:
tools = params.get("tools", [])
# 添加插件工具定义
tools.append({
"type": "function",
"function": {
"name": "my_plugin_tool",
"description": "插件提供的工具",
"parameters": {
"type": "object",
"properties": {
"input": {"type": "string", "description": "输入内容"}
},
"required": ["input"]
}
}
})
return {
"action": "modify",
"request": {
"model": params["model"],
"messages": params["messages"],
"tools": tools,
"options": params.get("options", {})
}
}
```
### `before_tool` 返回执行结果
```python
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
if tool == "my_plugin_tool":
# 在这里实现工具逻辑
args = params.get("arguments", {})
input_text = args.get("input", "")
# 直接返回结果,无需在 ToolRegistry 注册
return {
"action": "respond",
"result": {
"for_llm": f"插件工具执行成功,输入: {input_text}",
"silent": False,
"is_error": False
}
}
return {"action": "continue"}
```
通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。

View file

@ -0,0 +1,587 @@
# Plugin Tool Injection Example
This document demonstrates how to use PicoClaw's hook system to implement external plugin tool injection, allowing LLM to call tools implemented by external hook processes.
---
## Core Principle
Through the hook system's `respond` action, external hooks can:
1. Inject tool **definitions** in `before_llm`, letting LLM know the tool is available
2. Return tool **execution results** directly in `before_tool` using `respond` action, skipping ToolRegistry
This way, external hooks can fully implement plugin tools without registering any tools inside PicoClaw.
---
## Complete Example: Weather Query Plugin
Below is a complete Python hook example implementing a weather query plugin tool.
### 1. Hook Script Implementation
Save as `/tmp/weather_plugin.py`:
```python
#!/usr/bin/env python3
"""Weather query plugin hook example"""
from __future__ import annotations
import json
import sys
import signal
from typing import Any
# Simulated weather data
WEATHER_DATA = {
"Beijing": {"temp": 15, "weather": "Sunny", "humidity": 45},
"Shanghai": {"temp": 18, "weather": "Cloudy", "humidity": 60},
"Guangzhou": {"temp": 25, "weather": "Sunny", "humidity": 70},
"Shenzhen": {"temp": 26, "weather": "Cloudy", "humidity": 75},
}
def get_weather(city: str) -> dict:
"""Get weather data (simulated)"""
data = WEATHER_DATA.get(city)
if data:
return {
"for_llm": f"{city} weather: {data['weather']}, temperature {data['temp']}°C, humidity {data['humidity']}%",
"for_user": "",
"silent": False,
"is_error": False,
}
return {
"for_llm": f"Weather data not found for city {city}",
"for_user": "",
"silent": False,
"is_error": True,
}
def handle_hello(params: dict) -> dict:
return {"ok": True, "name": "weather-plugin"}
def handle_before_llm(params: dict) -> dict:
"""Inject weather query tool definition"""
tools = params.get("tools", [])
# Add weather query tool
tools.append({
"type": "function",
"function": {
"name": "get_weather",
"description": "Query weather information for a specified city",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g.: Beijing, Shanghai, Guangzhou"
}
},
"required": ["city"]
}
}
})
return {
"action": "modify",
"request": {
"model": params.get("model"),
"messages": params.get("messages", []),
"tools": tools,
"options": params.get("options", {}),
}
}
def handle_before_tool(params: dict) -> dict:
"""Handle tool call, return result directly"""
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
city = args.get("city", "")
result = get_weather(city)
# Use respond action to return result directly, skip ToolRegistry
return {
"action": "respond",
"result": result,
}
# Other tools continue normal flow
return {"action": "continue"}
def handle_request(method: str, params: dict) -> dict:
if method == "hook.hello":
return handle_hello(params)
if method == "hook.before_llm":
return handle_before_llm(params)
if method == "hook.before_tool":
return handle_before_tool(params)
if method == "hook.after_llm":
return {"action": "continue"}
if method == "hook.after_tool":
return {"action": "continue"}
if method == "hook.approve_tool":
return {"approved": True}
raise KeyError(f"method not found: {method}")
def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None:
payload: dict[str, Any] = {
"jsonrpc": "2.0",
"id": message_id,
}
if error is not None:
payload["error"] = {"code": -32000, "message": error}
else:
payload["result"] = result if result is not None else {}
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
sys.stdout.flush()
def main() -> int:
for raw_line in sys.stdin:
line = raw_line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
method = message.get("method")
message_id = message.get("id", 0)
params = message.get("params") or {}
if not message_id:
continue
try:
result = handle_request(str(method or ""), params)
send_response(int(message_id), result=result)
except KeyError as exc:
send_response(int(message_id), error=str(exc))
except Exception as exc:
send_response(int(message_id), error=f"unexpected error: {exc}")
return 0
if __name__ == "__main__":
signal.signal(signal.SIGINT, lambda *_: raise SystemExit(0))
signal.signal(signal.SIGTERM, lambda *_: raise SystemExit(0))
raise SystemExit(main())
```
### 2. Configure PicoClaw
Add hook configuration in the config file:
```json
{
"hooks": {
"enabled": true,
"processes": {
"weather_plugin": {
"enabled": true,
"priority": 100,
"transport": "stdio",
"command": ["python3", "/tmp/weather_plugin.py"],
"intercept": ["before_llm", "before_tool"]
}
}
}
}
```
### 3. Test Results
When user asks "What's the weather in Beijing today?":
1. PicoClaw sends `hook.before_llm`, hook injects `get_weather` tool definition
2. LLM sees tool definition, decides to call `get_weather(city="Beijing")`
3. PicoClaw sends `hook.before_tool`, hook uses `respond` action to return weather data
4. LLM receives result, replies to user "Beijing is sunny today, temperature 15°C"
---
## Flow Diagram
```
User: "What's the weather in Beijing today?"
PicoClaw
hook.before_llm
↓ (inject get_weather tool definition)
LLM request
LLM decides to call get_weather(city="Beijing")
hook.before_tool
↓ (respond action returns weather data)
Return result directly to LLM
↓ (skip ToolRegistry)
LLM replies: "Beijing is sunny today, temperature 15°C"
```
---
## Key Points
### `before_llm` Inject Tool Definition
Tool definition follows OpenAI function calling format:
```json
{
"type": "function",
"function": {
"name": "tool_name",
"description": "tool description",
"parameters": {
"type": "object",
"properties": {
"param_name": {
"type": "string",
"description": "parameter description"
}
},
"required": ["list of required parameters"]
}
}
}
```
### `before_tool` Use respond Action
`respond` action response format:
```json
{
"action": "respond",
"result": {
"for_llm": "Content returned to LLM",
"for_user": "Optional, content sent to user",
"silent": false,
"is_error": false,
"media": ["Optional, media reference list"],
"response_handled": false
}
}
```
| Field | Description |
|-------|-------------|
| `for_llm` | Required, LLM will see this content |
| `for_user` | Optional, sent directly to user |
| `silent` | When true, not sent to user |
| `is_error` | When true, indicates execution failure |
| `media` | Optional, media file references (images, files, etc.) |
| `response_handled` | When true, indicates user request is handled, turn will end |
---
## Media File Handling
The `respond` action supports returning media files (images, files, etc.). There are two processing modes:
### 1. Automatic Delivery (`response_handled=true`)
When `response_handled=true`, media files are automatically sent to the user and the turn ends:
```json
{
"action": "respond",
"result": {
"for_llm": "Image sent to user",
"for_user": "",
"media": ["media://abc123"],
"response_handled": true
}
}
```
Use cases:
- Image generation plugin directly returning results
- File download plugin sending files to user
### 2. LLM Visible (`response_handled=false`)
When `response_handled=false`, media references are passed to the LLM, which can see the content in the next request:
```json
{
"action": "respond",
"result": {
"for_llm": "Image loaded, path: /tmp/image.png [file:/tmp/image.png]",
"media": ["media://abc123"]
}
}
```
After seeing the content, the LLM can decide:
- Use `send_file` tool to send to user
- Analyze image content and reply to user
- Other processing approaches
### Media Reference Format
Media references use the `media://` protocol:
```
media://<store-id>
```
These references are managed by PicoClaw's MediaStore and can be:
- Sent to user via channel
- Converted to base64 in LLM vision requests
### Alternative: Use Existing Tools
If the plugin generates files, you can return the file path and let the LLM call `send_file` or similar tools:
```json
{
"action": "respond",
"result": {
"for_llm": "Image generated, saved at /tmp/generated_image.png. Use send_file tool to send to user.",
"for_user": "",
"silent": false
}
}
```
This approach:
- More decoupled, LLM decides when to send
- Leverages existing tool mechanisms
- Supports batch sending, delayed sending, etc.
---
## Multi-Tool Injection Example
Multiple tools can be injected simultaneously:
```python
def handle_before_llm(params: dict) -> dict:
tools = params.get("tools", [])
# Tool 1: Weather query
tools.append({
"type": "function",
"function": {
"name": "get_weather",
"description": "Query city weather",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"]
}
}
})
# Tool 2: Calculator
tools.append({
"type": "function",
"function": {
"name": "calculate",
"description": "Perform mathematical calculations",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Mathematical expression"}
},
"required": ["expression"]
}
}
})
return {
"action": "modify",
"request": {
"model": params.get("model"),
"messages": params.get("messages", []),
"tools": tools,
"options": params.get("options", {}),
}
}
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
return {
"action": "respond",
"result": get_weather(args.get("city", "")),
}
if tool == "calculate":
# Simple calculation example
try:
expr = args.get("expression", "")
result = eval(expr) # Note: needs security handling in actual use
return {
"action": "respond",
"result": {
"for_llm": f"Calculation result: {result}",
"silent": False,
"is_error": False,
},
}
except Exception as e:
return {
"action": "respond",
"result": {
"for_llm": f"Calculation error: {e}",
"silent": False,
"is_error": True,
},
}
return {"action": "continue"}
```
---
## Coexistence with Built-in Tools
Injected plugin tools coexist with PicoClaw built-in tools:
- Built-in tools (like `bash`, `read_file`) execute normally through ToolRegistry
- Plugin tools return results through hook's `respond` action
- `handle_before_tool` only handles plugin tools, other tools return `continue`
---
## Go In-Process Hook Example
If you need to implement plugin tool injection in Go code:
```go
package myhooks
import (
"context"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/tools"
)
type WeatherPluginHook struct{}
func (h *WeatherPluginHook) BeforeLLM(
ctx context.Context,
req *agent.LLMHookRequest,
) (*agent.LLMHookRequest, agent.HookDecision, error) {
// Inject tool definition
req.Tools = append(req.Tools, agent.ToolDefinition{
Type: "function",
Function: agent.FunctionDefinition{
Name: "get_weather",
Description: "Query city weather",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{
"type": "string",
"description": "City name",
},
},
"required": []string{"city"},
},
},
})
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
func (h *WeatherPluginHook) BeforeTool(
ctx context.Context,
call *agent.ToolCallHookRequest,
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
if call.Tool == "get_weather" {
city := call.Arguments["city"].(string)
// Set HookResult, use respond action
next := call.Clone()
next.HookResult = &tools.ToolResult{
ForLLM: getWeatherData(city),
Silent: false,
IsError: false,
}
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
}
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
func getWeatherData(city string) string {
// Implement weather query logic
return fmt.Sprintf("%s weather: Sunny, temperature 20°C", city)
}
```
---
## Summary
Through the hook system's `respond` action, external processes can:
1. **Inject tool definitions**: Let LLM know new tools are available
2. **Provide tool implementation**: Return execution results directly, no need to register in ToolRegistry
3. **Coexist with built-in tools**: Does not affect normal operation of PicoClaw's original tools
This provides a flexible and elegant solution for plugin development.
---
## Security Boundaries
### Bypassing Approval Checks
**Important**: The `respond` action bypasses `ApproveTool` approval checks.
This means:
- A `before_tool` hook can return `respond` for **any tool name**, including sensitive tools (like `bash`)
- The tool won't go through the approval process, directly returning the hook-provided result
- This is designed for plugin tools but introduces security risks
### Security Recommendations
1. **Review hook configuration**: Ensure only trusted hook processes are enabled
2. **Limit hook scope**: Add your own security checks in hook implementation
3. **Use `deny_tool` for rejection**: Use `deny_tool` action instead of `respond` with error for denying execution
### Example: Hook-Internal Security Check
```python
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
# Security check: only handle plugin tools
if tool in ["get_weather", "calculate"]:
return {
"action": "respond",
"result": execute_plugin_tool(tool, args),
}
# Other tools continue normal flow (will go through approval)
return {"action": "continue"}
```
This ensures the hook only affects plugin tools, not system tool approval flow.

View file

@ -0,0 +1,587 @@
# 插件工具注入示例
本文档展示如何利用 PicoClaw 的 hook 系统实现外部插件工具注入,让 LLM 能调用由外部 hook 进程实现的工具。
---
## 核心原理
通过 hook 系统的 `respond` action外部 hook 可以:
1. 在 `before_llm` 中注入工具**定义**,让 LLM 知道有这个工具可用
2. 在 `before_tool` 中使用 `respond` action 直接返回工具**执行结果**,跳过 ToolRegistry
这样,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具。
---
## 完整示例:天气查询插件
下面是一个完整的 Python hook 示例,实现一个天气查询插件工具。
### 1. Hook 脚本实现
保存为 `/tmp/weather_plugin.py`
```python
#!/usr/bin/env python3
"""天气查询插件 hook 示例"""
from __future__ import annotations
import json
import sys
import signal
from typing import Any
# 模拟天气数据
WEATHER_DATA = {
"北京": {"temp": 15, "weather": "晴", "humidity": 45},
"上海": {"temp": 18, "weather": "多云", "humidity": 60},
"广州": {"temp": 25, "weather": "晴", "humidity": 70},
"深圳": {"temp": 26, "weather": "多云", "humidity": 75},
}
def get_weather(city: str) -> dict:
"""获取天气数据(模拟)"""
data = WEATHER_DATA.get(city)
if data:
return {
"for_llm": f"{city}天气:{data['weather']},温度{data['temp']}°C湿度{data['humidity']}%",
"for_user": "",
"silent": False,
"is_error": False,
}
return {
"for_llm": f"未找到城市 {city} 的天气数据",
"for_user": "",
"silent": False,
"is_error": True,
}
def handle_hello(params: dict) -> dict:
return {"ok": True, "name": "weather-plugin"}
def handle_before_llm(params: dict) -> dict:
"""注入天气查询工具定义"""
tools = params.get("tools", [])
# 添加天气查询工具
tools.append({
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如:北京、上海、广州"
}
},
"required": ["city"]
}
}
})
return {
"action": "modify",
"request": {
"model": params.get("model"),
"messages": params.get("messages", []),
"tools": tools,
"options": params.get("options", {}),
}
}
def handle_before_tool(params: dict) -> dict:
"""处理工具调用,直接返回结果"""
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
city = args.get("city", "")
result = get_weather(city)
# 使用 respond action 直接返回结果,跳过 ToolRegistry
return {
"action": "respond",
"result": result,
}
# 其他工具继续正常流程
return {"action": "continue"}
def handle_request(method: str, params: dict) -> dict:
if method == "hook.hello":
return handle_hello(params)
if method == "hook.before_llm":
return handle_before_llm(params)
if method == "hook.before_tool":
return handle_before_tool(params)
if method == "hook.after_llm":
return {"action": "continue"}
if method == "hook.after_tool":
return {"action": "continue"}
if method == "hook.approve_tool":
return {"approved": True}
raise KeyError(f"method not found: {method}")
def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None:
payload: dict[str, Any] = {
"jsonrpc": "2.0",
"id": message_id,
}
if error is not None:
payload["error"] = {"code": -32000, "message": error}
else:
payload["result"] = result if result is not None else {}
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
sys.stdout.flush()
def main() -> int:
for raw_line in sys.stdin:
line = raw_line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
method = message.get("method")
message_id = message.get("id", 0)
params = message.get("params") or {}
if not message_id:
continue
try:
result = handle_request(str(method or ""), params)
send_response(int(message_id), result=result)
except KeyError as exc:
send_response(int(message_id), error=str(exc))
except Exception as exc:
send_response(int(message_id), error=f"unexpected error: {exc}")
return 0
if __name__ == "__main__":
signal.signal(signal.SIGINT, lambda *_: raise SystemExit(0))
signal.signal(signal.SIGTERM, lambda *_: raise SystemExit(0))
raise SystemExit(main())
```
### 2. 配置 PicoClaw
在配置文件中添加 hook 配置:
```json
{
"hooks": {
"enabled": true,
"processes": {
"weather_plugin": {
"enabled": true,
"priority": 100,
"transport": "stdio",
"command": ["python3", "/tmp/weather_plugin.py"],
"intercept": ["before_llm", "before_tool"]
}
}
}
}
```
### 3. 测试效果
当用户问"北京今天天气怎么样?"时:
1. PicoClaw 发送 `hook.before_llm`hook 注入 `get_weather` 工具定义
2. LLM 看到工具定义,决定调用 `get_weather(city="北京")`
3. PicoClaw 发送 `hook.before_tool`hook 使用 `respond` action 返回天气数据
4. LLM 收到结果,回复用户"北京今天晴天温度15°C"
---
## 流程图解
```
用户: "北京今天天气怎么样?"
PicoClaw
hook.before_llm
↓ (注入 get_weather 工具定义)
LLM 请求
LLM 决定调用 get_weather(city="北京")
hook.before_tool
↓ (respond action 返回天气数据)
直接返回结果给 LLM
↓ (跳过 ToolRegistry)
LLM 回复: "北京今天晴天温度15°C"
```
---
## 关键点说明
### `before_llm` 注入工具定义
工具定义遵循 OpenAI function calling 格式:
```json
{
"type": "function",
"function": {
"name": "工具名称",
"description": "工具描述",
"parameters": {
"type": "object",
"properties": {
"参数名": {
"type": "string",
"description": "参数描述"
}
},
"required": ["必需参数列表"]
}
}
}
```
### `before_tool` 使用 respond action
`respond` action 的响应格式:
```json
{
"action": "respond",
"result": {
"for_llm": "返回给 LLM 的内容",
"for_user": "可选,发送给用户的内容",
"silent": false,
"is_error": false,
"media": ["可选,媒体引用列表"],
"response_handled": false
}
}
```
| 字段 | 说明 |
|------|------|
| `for_llm` | 必须LLM 会看到这个内容 |
| `for_user` | 可选,直接发送给用户 |
| `silent` | 为 true 时不发送给用户 |
| `is_error` | 为 true 时表示执行失败 |
| `media` | 可选,媒体文件引用列表(如图片、文件) |
| `response_handled` | 为 true 时表示已处理用户请求,轮次将结束 |
---
## 媒体文件处理
`respond` action 支持返回媒体文件(图片、文件等)。有两种处理方式:
### 1. 自动发送(`response_handled=true`
`response_handled=true` 时,媒体文件会自动发送给用户,轮次结束:
```json
{
"action": "respond",
"result": {
"for_llm": "图片已发送给用户",
"for_user": "",
"media": ["media://abc123"],
"response_handled": true
}
}
```
适用场景:
- 图像生成插件直接返回结果
- 文件下载插件发送文件给用户
### 2. LLM 可见(`response_handled=false`
`response_handled=false` 时,媒体引用会传递给 LLMLLM 可以在下一轮请求中看到内容:
```json
{
"action": "respond",
"result": {
"for_llm": "图片已加载,路径:/tmp/image.png [file:/tmp/image.png]",
"media": ["media://abc123"]
}
}
```
LLM 看到内容后,可以自主决定:
- 使用 `send_file` 工具发送给用户
- 分析图片内容并回复用户
- 其他处理方式
### 媒体引用格式
媒体引用使用 `media://` 协议:
```
media://<store-id>
```
这些引用由 PicoClaw 的 MediaStore 管理,可以:
- 通过 channel 发送给用户
- 在 LLM vision 请求中转换为 base64
### 替代方案:使用现有工具
如果插件生成文件,可以返回文件路径让 LLM 调用 `send_file` 等工具:
```json
{
"action": "respond",
"result": {
"for_llm": "图片已生成,保存在 /tmp/generated_image.png。使用 send_file 工具发送给用户。",
"for_user": "",
"silent": false
}
}
```
这种方式:
- 更解耦LLM 自主决策发送时机
- 利用现有工具机制
- 支持批量发送、延迟发送等场景
---
## 多工具注入示例
可以同时注入多个工具:
```python
def handle_before_llm(params: dict) -> dict:
tools = params.get("tools", [])
# 工具1天气查询
tools.append({
"type": "function",
"function": {
"name": "get_weather",
"description": "查询城市天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"}
},
"required": ["city"]
}
}
})
# 工具2计算器
tools.append({
"type": "function",
"function": {
"name": "calculate",
"description": "执行数学计算",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "数学表达式"}
},
"required": ["expression"]
}
}
})
return {
"action": "modify",
"request": {
"model": params.get("model"),
"messages": params.get("messages", []),
"tools": tools,
"options": params.get("options", {}),
}
}
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
return {
"action": "respond",
"result": get_weather(args.get("city", "")),
}
if tool == "calculate":
# 简单计算示例
try:
expr = args.get("expression", "")
result = eval(expr) # 注意:实际使用时需要安全处理
return {
"action": "respond",
"result": {
"for_llm": f"计算结果: {result}",
"silent": False,
"is_error": False,
},
}
except Exception as e:
return {
"action": "respond",
"result": {
"for_llm": f"计算错误: {e}",
"silent": False,
"is_error": True,
},
}
return {"action": "continue"}
```
---
## 与内置工具共存
注入的插件工具与 PicoClaw 内置工具共存:
- 内置工具(如 `bash``read_file`)正常通过 ToolRegistry 执行
- 插件工具通过 hook 的 `respond` action 返回结果
- `handle_before_tool` 中只处理插件工具,其他工具返回 `continue`
---
## Go 进程内 Hook 示例
如果需要在 Go 代码中实现插件工具注入:
```go
package myhooks
import (
"context"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/tools"
)
type WeatherPluginHook struct{}
func (h *WeatherPluginHook) BeforeLLM(
ctx context.Context,
req *agent.LLMHookRequest,
) (*agent.LLMHookRequest, agent.HookDecision, error) {
// 注入工具定义
req.Tools = append(req.Tools, agent.ToolDefinition{
Type: "function",
Function: agent.FunctionDefinition{
Name: "get_weather",
Description: "查询城市天气",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{
"type": "string",
"description": "城市名称",
},
},
"required": []string{"city"},
},
},
})
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
func (h *WeatherPluginHook) BeforeTool(
ctx context.Context,
call *agent.ToolCallHookRequest,
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
if call.Tool == "get_weather" {
city := call.Arguments["city"].(string)
// 设置 HookResult使用 respond action
next := call.Clone()
next.HookResult = &tools.ToolResult{
ForLLM: getWeatherData(city),
Silent: false,
IsError: false,
}
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
}
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
func getWeatherData(city string) string {
// 实现天气查询逻辑
return fmt.Sprintf("%s天气温度20°C", city)
}
```
---
## 总结
通过 hook 系统的 `respond` action外部进程可以
1. **注入工具定义**:让 LLM 知道有新工具可用
2. **提供工具实现**:直接返回执行结果,无需注册到 ToolRegistry
3. **与内置工具共存**:不影响 PicoClaw 原有工具的正常运行
这为插件开发提供了灵活、优雅的解决方案。
---
## 安全边界说明
### 绕过审批检查
**重要**`respond` action 会绕过 `ApproveTool` 审批检查。
这意味着:
- `before_tool` hook 可以为**任何工具名称**返回 `respond`,包括敏感工具(如 `bash`
- 工具不会经过审批流程,直接返回 hook 提供的结果
- 这是为了支持插件工具而设计,但也带来了安全风险
### 安全建议
1. **审查 hook 配置**:确保只有可信的 hook 进程被启用
2. **限制 hook 权限**:在 hook 实现中添加自己的安全检查
3. **优先使用 `deny_tool`**:对于拒绝执行,使用 `deny_tool` action 而非 `respond` 返回错误
### 示例hook 内置安全检查
```python
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
# 安全检查:只处理插件工具
if tool in ["get_weather", "calculate"]:
return {
"action": "respond",
"result": execute_plugin_tool(tool, args),
}
# 其他工具继续正常流程(会经过审批)
return {"action": "continue"}
```
这样可以确保 hook 只影响插件工具,不影响系统工具的审批流程。

38
go.mod
View file

@ -1,6 +1,6 @@
module github.com/sipeed/picoclaw
go 1.25.8
go 1.25.9
require (
fyne.io/systray v1.12.0
@ -9,12 +9,12 @@ require (
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/atc0005/go-teams-notify/v2 v2.14.0
github.com/atotto/clipboard v0.1.4
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.12
github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
@ -26,8 +26,9 @@ require (
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/mymmrac/telego v1.7.0
github.com/muesli/termenv v0.16.0
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/mymmrac/telego v1.8.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.10.1
@ -36,6 +37,7 @@ require (
github.com/rs/zerolog v1.35.0
github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
go.mau.fi/util v0.9.7
@ -46,7 +48,7 @@ require (
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.26.4
modernc.org/sqlite v1.48.0
modernc.org/sqlite v1.48.2
rsc.io/qr v0.2.0
)
@ -54,19 +56,24 @@ require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@ -80,6 +87,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
@ -89,10 +97,10 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
@ -131,7 +139,7 @@ require (
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/sys v0.43.0
)
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532

73
go.sum
View file

@ -23,18 +23,16 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
@ -45,18 +43,20 @@ github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ7
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@ -69,6 +69,16 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@ -117,8 +127,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -181,16 +191,20 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@ -220,6 +234,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@ -278,6 +293,8 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
@ -375,8 +392,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -458,8 +475,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View file

@ -1,4 +1,4 @@
//go:build !mipsle && !netbsd
//go:build !mipsle && !netbsd && !(freebsd && arm)
package agent

View file

@ -1,4 +1,4 @@
//go:build mipsle || netbsd
//go:build mipsle || netbsd || (freebsd && arm)
package agent

View file

@ -12,7 +12,9 @@ import (
"sync/atomic"
"time"
"github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/tools"
)
const (
@ -90,7 +92,8 @@ type processHookAfterLLMResponse struct {
type processHookBeforeToolResponse struct {
processHookDecisionResponse
Call *ToolCallHookRequest `json:"call,omitempty"`
Call *ToolCallHookRequest `json:"call,omitempty"`
Result *tools.ToolResult `json:"result,omitempty"` // Result returned directly by hook (for respond action)
}
type processHookAfterToolResponse struct {
@ -120,7 +123,9 @@ func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) (
if err != nil {
return nil, fmt.Errorf("create process hook stderr: %w", err)
}
if err := cmd.Start(); err != nil {
// Route hook subprocess startup through the shared isolation entry point so
// process hooks inherit the same isolation behavior as other child processes.
if err := isolation.Start(cmd); err != nil {
return nil, fmt.Errorf("start process hook: %w", err)
}
@ -241,6 +246,10 @@ func (ph *ProcessHook) BeforeTool(
if resp.Call == nil {
resp.Call = call
}
// If hook returned a Result, carry it in ToolCallHookRequest
if resp.Result != nil {
resp.Call.HookResult = resp.Result
}
return resp.Call, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil
}

View file

@ -7,10 +7,13 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/providers"
)
@ -178,6 +181,76 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) {
}
}
func TestAgentLoop_MountProcessHook_IsolationSupportsRelativeDirAndCommand(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("linux-only isolation path handling")
}
provider := &llmHookTestProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
root := t.TempDir()
t.Setenv(config.EnvHome, filepath.Join(root, "picoclaw-home"))
binDir := filepath.Join(root, "bin")
hookDir := filepath.Join(root, "hooks")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(hookDir, 0o755); err != nil {
t.Fatal(err)
}
writeFakeBwrap(t, filepath.Join(binDir, "bwrap"))
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
linkTestBinary(t, os.Args[0], filepath.Join(hookDir, "hook-helper"))
cfg := config.DefaultConfig()
cfg.Isolation.Enabled = true
isolation.Configure(cfg)
t.Cleanup(func() { isolation.Configure(config.DefaultConfig()) })
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
relHookDir, err := filepath.Rel(cwd, hookDir)
if err != nil {
t.Fatal(err)
}
mountErr := al.MountProcessHook(context.Background(), "ipc-relative", ProcessHookOptions{
Command: []string{"./hook-helper", "-test.run=TestProcessHook_HelperProcess", "--"},
Dir: relHookDir,
Env: processHookHelperEnv("rewrite", ""),
InterceptLLM: true,
})
if mountErr != nil {
t.Fatalf("MountProcessHook failed with relative dir/command under isolation: %v", mountErr)
}
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "session-relative",
Channel: "cli",
ChatID: "direct",
UserMessage: "hello",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
if resp != "provider content|ipc" {
t.Fatalf("expected process-hooked llm content, got %q", resp)
}
provider.mu.Lock()
lastModel := provider.lastModel
provider.mu.Unlock()
if lastModel != "process-model" {
t.Fatalf("expected process model, got %q", lastModel)
}
}
func processHookHelperCommand() []string {
return []string{os.Args[0], "-test.run=TestProcessHook_HelperProcess", "--"}
}
@ -193,6 +266,59 @@ func processHookHelperEnv(mode, eventLog string) []string {
return env
}
func writeFakeBwrap(t *testing.T, path string) {
t.Helper()
script := `#!/bin/sh
set -eu
workdir=
while [ "$#" -gt 0 ]; do
case "$1" in
--)
shift
break
;;
--chdir)
workdir="$2"
shift 2
;;
--bind|--ro-bind)
shift 3
;;
--proc|--dev)
shift 2
;;
--die-with-parent|--unshare-ipc)
shift
;;
*)
shift
;;
esac
done
if [ -n "$workdir" ]; then
cd "$workdir"
fi
exec "$@"
`
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake bwrap: %v", err)
}
}
func linkTestBinary(t *testing.T, source, target string) {
t.Helper()
if err := os.Symlink(source, target); err == nil {
return
}
data, err := os.ReadFile(source)
if err != nil {
t.Fatalf("read test binary: %v", err)
}
if err := os.WriteFile(target, data, 0o755); err != nil {
t.Fatalf("create hook helper binary: %v", err)
}
}
func waitForFileContains(t *testing.T, path, substring string) {
t.Helper()

View file

@ -25,6 +25,7 @@ type HookAction string
const (
HookActionContinue HookAction = "continue"
HookActionModify HookAction = "modify"
HookActionRespond HookAction = "respond" // Return result directly, skip tool execution. SECURITY: This bypasses ApproveTool checks, allowing hooks to return results for any tool (including sensitive ones like bash) without approval. Use with caution.
HookActionDenyTool HookAction = "deny_tool"
HookActionAbortTurn HookAction = "abort_turn"
HookActionHardAbort HookAction = "hard_abort"
@ -129,10 +130,13 @@ func (r *LLMHookResponse) Clone() *LLMHookResponse {
}
type ToolCallHookRequest struct {
Meta EventMeta `json:"meta"`
Context *TurnContext `json:"context,omitempty"`
Tool string `json:"tool"`
Arguments map[string]any `json:"arguments,omitempty"`
Meta EventMeta `json:"meta"`
Context *TurnContext `json:"context,omitempty"`
Tool string `json:"tool"`
Arguments map[string]any `json:"arguments,omitempty"`
Channel string `json:"channel,omitempty"`
ChatID string `json:"chat_id,omitempty"`
HookResult *tools.ToolResult `json:"hook_result,omitempty"` // Result returned directly by hook (for respond action). Media is supported - see Media handling section in docs.
}
func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
@ -143,6 +147,7 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
cloned.Meta = cloneEventMeta(r.Meta)
cloned.Context = cloneTurnContext(r.Context)
cloned.Arguments = cloneStringAnyMap(r.Arguments)
cloned.HookResult = cloneToolResult(r.HookResult)
return &cloned
}
@ -387,6 +392,10 @@ func (hm *HookManager) BeforeTool(
if next != nil {
current = next
}
case HookActionRespond:
// Hook returns result directly, skip tool execution
// Carry HookResult in ToolCallHookRequest and return
return next, decision
case HookActionDenyTool, HookActionAbortTurn, HookActionHardAbort:
return current, decision
default:
@ -798,6 +807,13 @@ func cloneToolResult(result *tools.ToolResult) *tools.ToolResult {
if len(result.Media) > 0 {
cloned.Media = append([]string(nil), result.Media...)
}
if len(result.ArtifactTags) > 0 {
cloned.ArtifactTags = append([]string(nil), result.ArtifactTags...)
}
if len(result.Messages) > 0 {
cloned.Messages = make([]providers.Message, len(result.Messages))
copy(cloned.Messages, result.Messages)
}
return &cloned
}

View file

@ -2,6 +2,7 @@ package agent
import (
"context"
"errors"
"os"
"sync"
"testing"
@ -392,3 +393,517 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) {
t.Fatalf("expected skipped reason %q, got %q", expected, payload.Reason)
}
}
// respondHook is a test hook for testing HookActionRespond functionality
type respondHook struct {
respondTools map[string]bool // tool names to respond to
}
func (h *respondHook) BeforeTool(
ctx context.Context,
call *ToolCallHookRequest,
) (*ToolCallHookRequest, HookDecision, error) {
if h.respondTools[call.Tool] {
next := call.Clone()
next.HookResult = &tools.ToolResult{
ForLLM: "hook-responded: " + call.Tool,
ForUser: "",
Silent: false,
IsError: false,
}
return next, HookDecision{Action: HookActionRespond}, nil
}
return call, HookDecision{Action: HookActionContinue}, nil
}
func (h *respondHook) AfterTool(
ctx context.Context,
result *ToolResultHookResponse,
) (*ToolResultHookResponse, HookDecision, error) {
// Should not be called since respond skips tool execution
return result, HookDecision{Action: HookActionContinue}, nil
}
func TestAgentLoop_Hooks_ToolRespondAction(t *testing.T) {
provider := &toolHookProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
al.RegisterTool(&echoTextTool{})
if err := al.MountHook(NamedHook("respond-hook", &respondHook{
respondTools: map[string]bool{"echo_text": true},
})); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "session-1",
Channel: "cli",
ChatID: "direct",
UserMessage: "run tool",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
// Verify response comes from hook, not tool
expected := "hook-responded: echo_text"
if resp != expected {
t.Fatalf("expected %q, got %q", expected, resp)
}
// Verify event stream has ToolExecEnd, not actual tool execution
events := collectEventStream(sub.C)
endEvt, ok := findEvent(events, EventKindToolExecEnd)
if !ok {
t.Fatal("expected tool exec end event")
}
payload, ok := endEvt.Payload.(ToolExecEndPayload)
if !ok {
t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
}
if payload.Tool != "echo_text" {
t.Fatalf("expected tool echo_text, got %q", payload.Tool)
}
if payload.ForLLMLen != len(expected) {
t.Fatalf("expected ForLLMLen %d, got %d", len(expected), payload.ForLLMLen)
}
}
// denyToolHook tests HookActionDenyTool functionality
type denyToolHook struct {
denyTools map[string]bool
}
func (h *denyToolHook) BeforeTool(
ctx context.Context,
call *ToolCallHookRequest,
) (*ToolCallHookRequest, HookDecision, error) {
if h.denyTools[call.Tool] {
return call, HookDecision{Action: HookActionDenyTool, Reason: "tool denied by hook"}, nil
}
return call, HookDecision{Action: HookActionContinue}, nil
}
func (h *denyToolHook) AfterTool(
ctx context.Context,
result *ToolResultHookResponse,
) (*ToolResultHookResponse, HookDecision, error) {
return result, HookDecision{Action: HookActionContinue}, nil
}
func TestAgentLoop_Hooks_ToolDenyAction(t *testing.T) {
provider := &toolHookProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
al.RegisterTool(&echoTextTool{})
if err := al.MountHook(NamedHook("deny-hook", &denyToolHook{
denyTools: map[string]bool{"echo_text": true},
})); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "session-1",
Channel: "cli",
ChatID: "direct",
UserMessage: "run tool",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
expected := "Tool execution denied by hook: tool denied by hook"
if resp != expected {
t.Fatalf("expected %q, got %q", expected, resp)
}
}
func TestHookManager_BeforeTool_RespondAction(t *testing.T) {
hm := NewHookManager(nil)
defer hm.Close()
hook := &respondHook{
respondTools: map[string]bool{"test_tool": true},
}
if err := hm.Mount(NamedHook("respond-test", hook)); err != nil {
t.Fatalf("mount hook: %v", err)
}
req := &ToolCallHookRequest{
Tool: "test_tool",
Arguments: map[string]any{"arg": "value"},
}
result, decision := hm.BeforeTool(context.Background(), req)
if decision.Action != HookActionRespond {
t.Fatalf("expected action %q, got %q", HookActionRespond, decision.Action)
}
if result.HookResult == nil {
t.Fatal("expected HookResult to be set")
}
if result.HookResult.ForLLM != "hook-responded: test_tool" {
t.Fatalf("unexpected HookResult.ForLLM: %q", result.HookResult.ForLLM)
}
}
type respondWithMediaHook struct {
respondTools map[string]bool
media []string
responseHandled bool
forLLM string
}
func (h *respondWithMediaHook) BeforeTool(
ctx context.Context,
call *ToolCallHookRequest,
) (*ToolCallHookRequest, HookDecision, error) {
if h.respondTools[call.Tool] {
next := call.Clone()
next.HookResult = &tools.ToolResult{
ForLLM: h.forLLM,
ForUser: "media result",
Media: h.media,
ResponseHandled: h.responseHandled,
Silent: false,
IsError: false,
}
return next, HookDecision{Action: HookActionRespond}, nil
}
return call, HookDecision{Action: HookActionContinue}, nil
}
func (h *respondWithMediaHook) AfterTool(
ctx context.Context,
result *ToolResultHookResponse,
) (*ToolResultHookResponse, HookDecision, error) {
return result, HookDecision{Action: HookActionContinue}, nil
}
type errorMediaChannel struct {
fakeChannel
sendErr error
}
func (f *errorMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
return nil, f.sendErr
}
func TestAgentLoop_HookRespond_MediaError(t *testing.T) {
provider := &multiToolProvider{
toolCalls: []providers.ToolCall{
{ID: "call-1", Name: "media_tool", Arguments: map[string]any{}},
},
finalContent: "done",
}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
hook := &respondWithMediaHook{
respondTools: map[string]bool{"media_tool": true},
media: []string{"media://test/image.png"},
responseHandled: true,
forLLM: "media sent successfully",
}
if err := al.MountHook(NamedHook("media-hook", hook)); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
al.channelManager = newStartedTestChannelManager(t, al.bus, al.mediaStore, "discord", &errorMediaChannel{
sendErr: errors.New("channel unavailable"),
})
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
_, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "session-media-err",
Channel: "discord",
ChatID: "chat1",
UserMessage: "send media",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
events := collectEventStream(sub.C)
endEvt, ok := findEvent(events, EventKindToolExecEnd)
if !ok {
t.Fatal("expected ToolExecEnd event")
}
payload, ok := endEvt.Payload.(ToolExecEndPayload)
if !ok {
t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
}
if !payload.IsError {
t.Fatal("expected IsError=true when SendMedia fails")
}
if payload.ForLLMLen < 30 {
t.Fatalf("expected ForLLM to contain error message, got ForLLMLen=%d", payload.ForLLMLen)
}
}
func TestAgentLoop_HookRespond_BusFallback(t *testing.T) {
provider := &multiToolProvider{
toolCalls: []providers.ToolCall{
{ID: "call-1", Name: "media_tool", Arguments: map[string]any{}},
},
finalContent: "done",
}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
hook := &respondWithMediaHook{
respondTools: map[string]bool{"media_tool": true},
media: []string{"media://test/image.png"},
responseHandled: true,
forLLM: "media queued",
}
if err := al.MountHook(NamedHook("media-hook", hook)); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "session-bus-fallback",
Channel: "cli",
ChatID: "chat1",
UserMessage: "send media",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
events := collectEventStream(sub.C)
endEvt, ok := findEvent(events, EventKindToolExecEnd)
if !ok {
t.Fatal("expected ToolExecEnd event")
}
payload, ok := endEvt.Payload.(ToolExecEndPayload)
if !ok {
t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
}
if payload.IsError {
t.Fatal("expected IsError=false for bus fallback (media queued, not delivered)")
}
if resp != "done" {
t.Fatalf("expected response 'done', got %q", resp)
}
}
type multiToolProvider struct {
mu sync.Mutex
callCount int
toolCalls []providers.ToolCall
finalContent string
}
func (p *multiToolProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.callCount++
if p.callCount == 1 && len(p.toolCalls) > 0 {
return &providers.LLMResponse{
ToolCalls: p.toolCalls,
}, nil
}
return &providers.LLMResponse{
Content: p.finalContent,
}, nil
}
func (p *multiToolProvider) GetDefaultModel() string {
return "multi-tool-provider"
}
func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) {
provider := &multiToolProvider{
toolCalls: []providers.ToolCall{
{ID: "call-1", Name: "tool_one", Arguments: map[string]any{}},
{ID: "call-2", Name: "tool_two", Arguments: map[string]any{}},
{ID: "call-3", Name: "tool_three", Arguments: map[string]any{}},
},
finalContent: "done",
}
al, _, cleanup := newHookTestLoop(t, provider)
defer cleanup()
tool1ExecCh := make(chan struct{}, 1)
al.RegisterTool(&slowTool{name: "tool_two", duration: 100 * time.Millisecond, execCh: tool1ExecCh})
al.RegisterTool(&slowTool{name: "tool_three", duration: 100 * time.Millisecond})
hook := &respondHook{
respondTools: map[string]bool{"tool_one": true},
}
if err := al.MountHook(NamedHook("respond-hook", hook)); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
sub := al.SubscribeEvents(32)
defer al.UnsubscribeEvents(sub.ID)
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
type result struct {
resp string
err error
}
resultCh := make(chan result, 1)
go func() {
resp, err := al.ProcessDirectWithChannel(
context.Background(),
"run tools",
sessionKey,
"cli",
"chat1",
)
resultCh <- result{resp: resp, err: err}
}()
time.Sleep(50 * time.Millisecond)
if err := al.InterruptGraceful("stop now"); err != nil {
t.Fatalf("InterruptGraceful failed: %v", err)
}
select {
case r := <-resultCh:
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for result")
}
events := collectEventStream(sub.C)
skippedEvts := filterEvents(events, EventKindToolExecSkipped)
if len(skippedEvts) < 1 {
t.Fatal("expected at least one ToolExecSkipped event after interrupt")
}
for _, evt := range skippedEvts {
payload, ok := evt.Payload.(ToolExecSkippedPayload)
if !ok {
t.Fatalf("expected ToolExecSkippedPayload, got %T", evt.Payload)
}
if payload.Reason != "graceful interrupt requested" {
t.Fatalf("expected skip reason 'graceful interrupt requested', got %q", payload.Reason)
}
}
}
func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) {
provider := &multiToolProvider{
toolCalls: []providers.ToolCall{
{ID: "call-1", Name: "tool_one", Arguments: map[string]any{}},
{ID: "call-2", Name: "tool_two", Arguments: map[string]any{}},
{ID: "call-3", Name: "tool_three", Arguments: map[string]any{}},
},
finalContent: "done",
}
al, _, cleanup := newHookTestLoop(t, provider)
defer cleanup()
al.RegisterTool(&slowTool{name: "tool_two", duration: 100 * time.Millisecond})
al.RegisterTool(&slowTool{name: "tool_three", duration: 100 * time.Millisecond})
hook := &respondHook{
respondTools: map[string]bool{"tool_one": true},
}
if err := al.MountHook(NamedHook("respond-hook", hook)); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
sub := al.SubscribeEvents(32)
defer al.UnsubscribeEvents(sub.ID)
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
type result struct {
resp string
err error
}
resultCh := make(chan result, 1)
go func() {
resp, err := al.ProcessDirectWithChannel(
context.Background(),
"run tools",
sessionKey,
"cli",
"chat1",
)
resultCh <- result{resp: resp, err: err}
}()
time.Sleep(50 * time.Millisecond)
al.Steer(providers.Message{Role: "user", Content: "change direction"})
select {
case r := <-resultCh:
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for result")
}
events := collectEventStream(sub.C)
skippedEvts := filterEvents(events, EventKindToolExecSkipped)
if len(skippedEvts) < 1 {
t.Fatal("expected at least one ToolExecSkipped event after steering")
}
for _, evt := range skippedEvts {
payload, ok := evt.Payload.(ToolExecSkippedPayload)
if !ok {
t.Fatalf("expected ToolExecSkippedPayload, got %T", evt.Payload)
}
if payload.Reason != "queued user steering message" {
t.Fatalf("expected skip reason 'queued user steering message', got %q", payload.Reason)
}
}
}
func filterEvents(events []Event, kind EventKind) []Event {
var result []Event
for _, evt := range events {
if evt.Kind == kind {
result = append(result, evt)
}
}
return result
}

View file

@ -9,6 +9,7 @@ import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/memory"
@ -64,6 +65,12 @@ func NewAgentInstance(
cfg *config.Config,
provider providers.LLMProvider,
) *AgentInstance {
if cfg != nil {
// Keep the subprocess isolation runtime aligned with the latest loaded config
// before any tools or providers start spawning child processes.
isolation.Configure(cfg)
}
workspace := resolveAgentWorkspace(agentCfg, defaults)
os.MkdirAll(workspace, 0o755)

View file

@ -91,6 +91,7 @@ type processOptions struct {
DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus
AllowInterimPicoPublish bool // Whether pico tool-call interim text can be published when SendResponse is false
SuppressToolFeedback bool // Whether to suppress inline tool feedback messages
NoHistory bool // If true, don't load session history (for heartbeat)
SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue)
@ -109,6 +110,15 @@ const (
defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit."
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
handledToolResponseSummary = "Requested output delivered via tool attachment."
sessionKeyAgentPrefix = "agent:"
metadataKeyMessageKind = "message_kind"
messageKindThought = "thought"
metadataKeyAccountID = "account_id"
metadataKeyGuildID = "guild_id"
metadataKeyTeamID = "team_id"
metadataKeyReplyToMessage = "reply_to_message_id"
metadataKeyParentPeerKind = "parent_peer_kind"
metadataKeyParentPeerID = "parent_peer_id"
)
func NewAgentLoop(
@ -688,21 +698,21 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
return
}
alreadySent := false
alreadySentToSameChat := false
defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
alreadySent = mt.HasSentInRound()
alreadySentToSameChat = mt.HasSentTo(channel, chatID)
}
}
}
if alreadySent {
if alreadySentToSameChat {
logger.DebugCF(
"agent",
"Skipped outbound (message tool already sent)",
map[string]any{"channel": channel},
"Skipped outbound (message tool already sent to same chat)",
map[string]any{"channel": channel, "chat_id": chatID},
)
return
}
@ -1593,10 +1603,12 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
UserMessage: msg.Content,
Media: append([]string(nil), msg.Media...),
},
SenderDisplayName: msg.Sender.DisplayName,
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
SenderID: msg.SenderID,
SenderDisplayName: msg.Sender.DisplayName,
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
AllowInterimPicoPublish: true,
}
// context-dependent commands check their own Runtime fields and report
@ -1888,6 +1900,43 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
return ""
}
func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) {
if reasoningContent == "" || chatID == "" {
return
}
if ctx.Err() != nil {
return
}
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
defer pubCancel()
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Context: bus.InboundContext{
Channel: "pico",
ChatID: chatID,
Raw: map[string]string{
metadataKeyMessageKind: messageKindThought,
},
},
Content: reasoningContent,
}); err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
errors.Is(err, bus.ErrBusClosed) {
logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{
"channel": "pico",
"error": err.Error(),
})
} else {
logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{
"channel": "pico",
"error": err.Error(),
})
}
}
}
func (al *AgentLoop) handleReasoning(
ctx context.Context,
reasoningContent, channelName, channelID string,
@ -2483,12 +2532,16 @@ turnLoop:
if reasoningContent == "" {
reasoningContent = response.ReasoningContent
}
go al.handleReasoning(
ctx,
reasoningContent,
ts.channel,
al.targetReasoningChannelID(ts.channel),
)
if ts.channel == "pico" {
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
} else {
go al.handleReasoning(
turnCtx,
reasoningContent,
ts.channel,
al.targetReasoningChannelID(ts.channel),
)
}
al.emitEvent(
EventKindLLMResponse,
ts.eventMeta("runTurn", "turn.llm.response"),
@ -2515,9 +2568,29 @@ turnLoop:
}
logger.DebugCF("agent", "LLM response", llmResponseFields)
if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish {
if strings.TrimSpace(response.Content) != "" {
outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second)
err := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{
Channel: ts.channel,
ChatID: ts.chatID,
Content: response.Content,
})
outCancel()
if err != nil {
logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{
"error": err.Error(),
"channel": ts.channel,
"chat_id": ts.chatID,
"iteration": iteration,
})
}
}
}
if len(response.ToolCalls) == 0 || gracefulTerminal {
responseContent := response.Content
if responseContent == "" && response.ReasoningContent != "" {
if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" {
responseContent = response.ReasoningContent
}
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
@ -2613,6 +2686,238 @@ turnLoop:
toolName = toolReq.Tool
toolArgs = toolReq.Arguments
}
case HookActionRespond:
// Hook returns result directly, skip tool execution.
// SECURITY: This bypasses ApproveTool, allowing hooks to respond
// for any tool name without approval. This is intentional for
// plugin tools but means a before_tool hook can override even
// sensitive tools like bash. Hook configuration should be
// carefully reviewed to prevent unauthorized tool execution.
if toolReq != nil && toolReq.HookResult != nil {
hookResult := toolReq.HookResult
argsJSON, _ := json.Marshal(toolArgs)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview),
map[string]any{
"agent_id": ts.agent.ID,
"tool": toolName,
"iteration": iteration,
})
// Emit ToolExecStart event (same as normal tool execution)
al.emitEvent(
EventKindToolExecStart,
ts.eventMeta("runTurn", "turn.tool.start"),
ToolExecStartPayload{
Tool: toolName,
Arguments: cloneEventArguments(toolArgs),
},
)
// Send tool feedback to chat channel if enabled (same as normal tool execution)
if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() &&
ts.channel != "" &&
!ts.opts.SuppressToolFeedback {
argsJSON, _ := json.Marshal(toolArgs)
feedbackPreview := utils.Truncate(
string(argsJSON),
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
)
feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview)
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
Channel: ts.channel,
ChatID: ts.chatID,
Content: feedbackMsg,
})
fbCancel()
}
toolDuration := time.Duration(0) // Hook execution time unknown
// Send ForUser content to user
// For ResponseHandled results, send regardless of SendResponse setting,
// same as normal tool execution path.
shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" &&
(ts.opts.SendResponse || hookResult.ResponseHandled)
if shouldSendForUser {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Context: bus.InboundContext{
Channel: ts.channel,
ChatID: ts.chatID,
Raw: map[string]string{
"is_tool_call": "true",
},
},
Content: hookResult.ForUser,
})
}
// Handle media from hook result (same as normal tool execution)
if len(hookResult.Media) > 0 && hookResult.ResponseHandled {
parts := make([]bus.MediaPart, 0, len(hookResult.Media))
for _, ref := range hookResult.Media {
part := bus.MediaPart{Ref: ref}
if al.mediaStore != nil {
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
part.Filename = meta.Filename
part.ContentType = meta.ContentType
part.Type = inferMediaType(meta.Filename, meta.ContentType)
}
}
parts = append(parts, part)
}
outboundMedia := bus.OutboundMediaMessage{
Channel: ts.channel,
ChatID: ts.chatID,
Parts: parts,
}
if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) {
if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil {
logger.WarnCF("agent", "Failed to deliver hook media",
map[string]any{
"agent_id": ts.agent.ID,
"tool": toolName,
"channel": ts.channel,
"chat_id": ts.chatID,
"error": err.Error(),
})
// Same as normal tool execution: notify LLM about delivery failure
hookResult.IsError = true
hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err)
}
} else if al.bus != nil {
al.bus.PublishOutboundMedia(ctx, outboundMedia)
// Same as normal tool execution: bus only queues, media not yet delivered
hookResult.ResponseHandled = false
}
}
// Track response handling status (same as normal tool execution)
if !hookResult.ResponseHandled {
allResponsesHandled = false
}
// Build tool message
contentForLLM := hookResult.ContentForLLM()
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
}
toolResultMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: tc.ID,
}
// Handle media for LLM vision (same as normal tool execution)
if len(hookResult.Media) > 0 && !hookResult.ResponseHandled {
hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media)
// Recalculate contentForLLM after adding ArtifactTags
contentForLLM = hookResult.ContentForLLM()
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
}
toolResultMsg.Content = contentForLLM
toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...)
}
// Emit ToolExecEnd event (after filtering, same as normal tool execution)
al.emitEvent(
EventKindToolExecEnd,
ts.eventMeta("runTurn", "turn.tool.end"),
ToolExecEndPayload{
Tool: toolName,
Duration: toolDuration,
ForLLMLen: len(contentForLLM),
ForUserLen: len(hookResult.ForUser),
IsError: hookResult.IsError,
Async: hookResult.Async,
},
)
messages = append(messages, toolResultMsg)
if !ts.opts.NoHistory {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
ts.recordPersistedMessage(toolResultMsg)
ts.ingestMessage(turnCtx, al, toolResultMsg)
}
// Same as normal tool execution: check for steering/interrupt/SubTurn after each tool
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
pendingMessages = append(pendingMessages, steerMsgs...)
}
skipReason := ""
skipMessage := ""
if len(pendingMessages) > 0 {
skipReason = "queued user steering message"
skipMessage = "Skipped due to queued user message."
} else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending {
skipReason = "graceful interrupt requested"
skipMessage = "Skipped due to graceful interrupt."
}
if skipReason != "" {
remaining := len(normalizedToolCalls) - i - 1
if remaining > 0 {
logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond",
map[string]any{
"agent_id": ts.agent.ID,
"completed": i + 1,
"skipped": remaining,
"reason": skipReason,
})
for j := i + 1; j < len(normalizedToolCalls); j++ {
skippedTC := normalizedToolCalls[j]
al.emitEvent(
EventKindToolExecSkipped,
ts.eventMeta("runTurn", "turn.tool.skipped"),
ToolExecSkippedPayload{
Tool: skippedTC.Name,
Reason: skipReason,
},
)
skippedMsg := providers.Message{
Role: "tool",
Content: skipMessage,
ToolCallID: skippedTC.ID,
}
messages = append(messages, skippedMsg)
if !ts.opts.NoHistory {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg)
ts.recordPersistedMessage(skippedMsg)
}
}
}
break
}
// Also poll for any SubTurn results that arrived during tool execution.
if ts.pendingResults != nil {
select {
case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM)
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
messages = append(messages, msg)
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
}
default:
// No results available
}
}
continue
}
// If no HookResult, fall back to continue with warning
logger.WarnCF("agent", "Hook returned respond action but no HookResult provided",
map[string]any{
"agent_id": ts.agent.ID,
"tool": toolName,
"action": "respond",
})
case HookActionDenyTool:
allResponsesHandled = false
denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason)
@ -2702,7 +3007,7 @@ turnLoop:
string(argsJSON),
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
)
feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", tc.Name, feedbackPreview)
feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview)
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg))
fbCancel()

View file

@ -1398,6 +1398,40 @@ func (m *toolFeedbackProvider) GetDefaultModel() string {
return "heartbeat-tool-feedback-model"
}
type picoInterleavedContentProvider struct {
calls int
}
func (m *picoInterleavedContentProvider) 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{
Content: "intermediate model text",
ToolCalls: []providers.ToolCall{{
ID: "call_tool_limit_test",
Type: "function",
Name: "tool_limit_test_tool",
Arguments: map[string]any{"value": "x"},
}},
}, nil
}
return &providers.LLMResponse{
Content: "final model text",
ToolCalls: []providers.ToolCall{},
}, nil
}
func (m *picoInterleavedContentProvider) GetDefaultModel() string {
return "pico-interleaved-content-model"
}
type toolLimitOnlyProvider struct{}
func (m *toolLimitOnlyProvider) Chat(
@ -2229,7 +2263,7 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) {
},
{
ModelName: "gemma-fallback",
Model: "gemini/gemma-3-27b-it",
Model: "openrouter/gemma-3-27b-it",
APIBase: fallbackServer.URL,
APIKeys: config.SimpleSecureStrings("fallback-key"),
Workspace: workspace,
@ -2970,6 +3004,66 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T
}
}
func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &reasoningContentProvider{
response: "final answer",
reasoningContent: "thinking trace",
}
al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "pico",
SenderID: "user1",
ChatID: "pico:test-session",
Content: "hello",
})
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "final answer" {
t.Fatalf("processMessage() response = %q, want %q", response, "final answer")
}
var thoughtMsg *bus.OutboundMessage
deadline := time.After(3 * time.Second)
for thoughtMsg == nil {
select {
case outbound := <-msgBus.OutboundChan():
msg := outbound
if msg.Content == "thinking trace" {
thoughtMsg = &msg
}
case <-deadline:
t.Fatal("expected thought outbound message for pico")
}
}
if thoughtMsg.Channel != "pico" || thoughtMsg.ChatID != "pico:test-session" {
t.Fatalf("thought message route = %s/%s, want pico/pico:test-session", thoughtMsg.Channel, thoughtMsg.ChatID)
}
if thoughtMsg.Context.Raw[metadataKeyMessageKind] != messageKindThought {
t.Fatalf(
"thought metadata kind = %q, want %q",
thoughtMsg.Context.Raw[metadataKeyMessageKind],
messageKindThought,
)
}
}
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
tmpDir := t.TempDir()
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
@ -3135,6 +3229,133 @@ func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.
}
}
func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &picoInterleavedContentProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
agent := al.GetRegistry().GetDefaultAgent()
if agent == nil {
t.Fatal("expected default agent")
}
agent.Tools.Register(&toolLimitTestTool{})
runCtx, runCancel := context.WithCancel(context.Background())
defer runCancel()
runDone := make(chan error, 1)
go func() {
runDone <- al.Run(runCtx)
}()
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
Channel: "pico",
SenderID: "user-1",
ChatID: "session-1",
Content: "run with tools",
}); err != nil {
t.Fatalf("PublishInbound() error = %v", err)
}
outputs := make([]string, 0, 2)
deadline := time.After(2 * time.Second)
for len(outputs) < 2 {
select {
case outbound := <-msgBus.OutboundChan():
outputs = append(outputs, outbound.Content)
case <-deadline:
t.Fatalf("timed out waiting for pico outputs, got %v", outputs)
}
}
if outputs[0] != "intermediate model text" {
t.Fatalf("first outbound content = %q, want %q", outputs[0], "intermediate model text")
}
if outputs[1] != "final model text" {
t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text")
}
runCancel()
select {
case err := <-runDone:
if err != nil {
t.Fatalf("Run() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for Run() to exit")
}
select {
case outbound := <-msgBus.OutboundChan():
if outbound.Content == "final model text" {
t.Fatalf("unexpected duplicate final pico output: %+v", outbound)
}
case <-time.After(200 * time.Millisecond):
}
}
func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &picoInterleavedContentProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
agent := al.GetRegistry().GetDefaultAgent()
if agent == nil {
t.Fatal("expected default agent")
}
agent.Tools.Register(&toolLimitTestTool{})
response, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "agent:main:pico:session-1",
Channel: "pico",
ChatID: "session-1",
UserMessage: "run with tools",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
AllowInterimPicoPublish: false,
SuppressToolFeedback: true,
})
if err != nil {
t.Fatalf("runAgentLoop() error = %v", err)
}
if response != "final model text" {
t.Fatalf("runAgentLoop() response = %q, want %q", response, "final model text")
}
select {
case outbound := <-msgBus.OutboundChan():
t.Fatalf("unexpected outbound message when interim publish disabled: %+v", outbound)
case <-time.After(200 * time.Millisecond):
}
}
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()

View file

@ -140,6 +140,37 @@ func TestPublishInbound_MirrorsContextIntoConvenienceFields(t *testing.T) {
}
}
func TestPublishInbound_BackfillsContextFromLegacyFields(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
msg := InboundMessage{
Channel: "pico",
ChatID: "session-1",
SenderID: "user-1",
MessageID: "msg-1",
Content: "hello",
}
if err := mb.PublishInbound(context.Background(), msg); err != nil {
t.Fatalf("PublishInbound failed: %v", err)
}
got := <-mb.InboundChan()
if got.Context.Channel != "pico" {
t.Fatalf("expected context channel pico, got %q", got.Context.Channel)
}
if got.Context.ChatID != "session-1" {
t.Fatalf("expected context chat ID session-1, got %q", got.Context.ChatID)
}
if got.Context.SenderID != "user-1" {
t.Fatalf("expected context sender ID user-1, got %q", got.Context.SenderID)
}
if got.Context.MessageID != "msg-1" {
t.Fatalf("expected context message ID msg-1, got %q", got.Context.MessageID)
}
}
func TestPublishOutboundSubscribe(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()

View file

@ -5,6 +5,18 @@ import "strings"
// NormalizeInboundMessage ensures the inbound context is normalized and keeps
// convenience mirrors in sync for runtime consumers.
func NormalizeInboundMessage(msg InboundMessage) InboundMessage {
if msg.Context.Channel == "" {
msg.Context.Channel = msg.Channel
}
if msg.Context.ChatID == "" {
msg.Context.ChatID = msg.ChatID
}
if msg.Context.SenderID == "" {
msg.Context.SenderID = msg.SenderID
}
if msg.Context.MessageID == "" {
msg.Context.MessageID = msg.MessageID
}
msg.Context = normalizeInboundContext(msg.Context)
msg.Channel = msg.Context.Channel
msg.SenderID = msg.Context.SenderID

View file

@ -14,6 +14,7 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@ -42,12 +43,18 @@ type FeishuChannel struct {
wsClient *larkws.Client
tokenCache *tokenCache // custom cache that supports invalidation
botOpenID atomic.Value // stores string; populated lazily for @mention detection
botOpenID atomic.Value // stores string; populated lazily for @mention detection
messageCache sync.Map // caches fetched messages (messageID -> *larkim.Message)
mu sync.Mutex
cancel context.CancelFunc
}
type cachedMessage struct {
msg *larkim.Message
expiry time.Time
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
@ -439,27 +446,14 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
if content == "" {
content = "[empty message]"
}
metadata := map[string]string{}
if messageID != "" {
metadata["message_id"] = messageID
}
if messageType != "" {
metadata["message_type"] = messageType
}
rawChatType := stringValue(message.ChatType)
if rawChatType != "" {
metadata["chat_type"] = rawChatType
}
if sender != nil && sender.TenantKey != nil {
metadata["tenant_key"] = *sender.TenantKey
}
chatType := stringValue(message.ChatType)
metadata := buildInboundMetadata(message, sender)
var (
inboundChatType string
isMentioned bool
)
if rawChatType == "p2p" {
if chatType == "p2p" {
inboundChatType = "direct"
} else {
inboundChatType = "group"
@ -480,12 +474,25 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
content = cleaned
}
if replyTargetID(message) != "" || stringValue(message.ThreadId) != "" {
content, mediaRefs = c.prependReplyContext(ctx, message, chatID, content, mediaRefs)
}
if content == "" {
content = "[empty message]"
}
logger.InfoCF("feishu", "Feishu message received", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"message_id": messageID,
"preview": utils.Truncate(content, 80),
})
logger.InfoCF("feishu", "Feishu reply linkage", map[string]any{
"message_id": messageID,
"parent_id": stringValue(message.ParentId),
"root_id": stringValue(message.RootId),
"thread_id": stringValue(message.ThreadId),
})
inboundCtx := bus.InboundContext{
Channel: "feishu",

View file

@ -0,0 +1,298 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
package feishu
import (
"context"
"fmt"
"strings"
"time"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
const messageCacheTTL = 30 * time.Second
const (
maxReplyContextLen = 600
)
func (c *FeishuChannel) prependReplyContext(
ctx context.Context,
message *larkim.EventMessage,
chatID string,
content string,
mediaRefs []string,
) (string, []string) {
if message == nil {
return content, mediaRefs
}
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
targetMessageID := c.resolveReplyTargetMessageID(lookupCtx, message)
if targetMessageID == "" {
logger.DebugCF("feishu", "No reply target resolved; skip reply context", map[string]any{
"message_id": stringValue(message.MessageId),
"parent_id": stringValue(message.ParentId),
"root_id": stringValue(message.RootId),
"thread_id": stringValue(message.ThreadId),
})
return content, mediaRefs
}
repliedMessage, err := c.fetchMessageByID(lookupCtx, targetMessageID)
if err != nil {
logger.DebugCF("feishu", "Failed to fetch replied message context", map[string]any{
"target_message_id": targetMessageID,
"error": err.Error(),
})
return content, mediaRefs
}
messageType := stringValue(repliedMessage.MsgType)
rawContent := ""
if repliedMessage.Body != nil {
rawContent = stringValue(repliedMessage.Body.Content)
}
var repliedMediaRefs []string
if store := c.GetMediaStore(); store != nil {
repliedMediaRefs = c.downloadInboundMedia(lookupCtx, chatID, targetMessageID, messageType, rawContent, store)
if messageType == larkim.MsgTypeInteractive {
_, externalURLs := extractCardImageKeys(rawContent)
if len(externalURLs) > 0 {
repliedMediaRefs = append(repliedMediaRefs, externalURLs...)
}
}
}
repliedContent := normalizeRepliedContent(messageType, rawContent, repliedMediaRefs)
if len(repliedMediaRefs) > 0 {
mediaRefs = append(repliedMediaRefs, mediaRefs...)
}
return formatReplyContext(targetMessageID, repliedContent, content), mediaRefs
}
func (c *FeishuChannel) resolveReplyTargetMessageID(ctx context.Context, message *larkim.EventMessage) string {
if targetID := replyTargetID(message); targetID != "" {
logger.DebugCF("feishu", "Resolved reply target from event payload", map[string]any{
"message_id": stringValue(message.MessageId),
"parent_id": stringValue(message.ParentId),
"root_id": stringValue(message.RootId),
"target_id": targetID,
})
return targetID
}
currentMessageID := stringValue(message.MessageId)
if currentMessageID == "" {
return ""
}
if stringValue(message.ThreadId) == "" {
logger.DebugCF("feishu", "No reply target found; message is not in a thread", map[string]any{
"message_id": stringValue(message.MessageId),
})
return ""
}
msg, err := c.fetchMessageByID(ctx, currentMessageID)
if err != nil {
logger.DebugCF("feishu", "Failed to query current message detail for reply info", map[string]any{
"message_id": currentMessageID,
"error": err.Error(),
})
return ""
}
targetID := replyTargetIDFromMessage(msg)
if targetID != "" {
logger.DebugCF("feishu", "Resolved reply target from message detail", map[string]any{
"message_id": currentMessageID,
"parent_id": stringValue(msg.ParentId),
"root_id": stringValue(msg.RootId),
"target_id": targetID,
})
}
return targetID
}
func (c *FeishuChannel) fetchMessageByID(ctx context.Context, messageID string) (*larkim.Message, error) {
if cached, ok := c.messageCache.Load(messageID); ok {
cm := cached.(*cachedMessage)
if time.Now().Before(cm.expiry) {
return cm.msg, nil
}
c.messageCache.Delete(messageID)
}
req := larkim.NewGetMessageReqBuilder().
MessageId(messageID).
Build()
resp, err := c.client.Im.V1.Message.Get(ctx, req)
if err != nil {
return nil, fmt.Errorf("feishu get message: %w", err)
}
if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return nil, fmt.Errorf("feishu get message api error (code=%d msg=%s)", resp.Code, resp.Msg)
}
if resp.Data == nil || len(resp.Data.Items) == 0 || resp.Data.Items[0] == nil {
return nil, fmt.Errorf("feishu get message: empty response")
}
// Items[0] contains the target message - the Feishu API returns a list
// but we request a single message by ID, so the list always has at most one item.
msg := resp.Data.Items[0]
c.messageCache.Store(messageID, &cachedMessage{msg: msg, expiry: time.Now().Add(messageCacheTTL)})
return msg, nil
}
func replyTargetID(message *larkim.EventMessage) string {
if message == nil {
return ""
}
if parentID := stringValue(message.ParentId); parentID != "" {
return parentID
}
return stringValue(message.RootId)
}
func replyTargetIDFromMessage(message *larkim.Message) string {
if message == nil {
return ""
}
if parentID := stringValue(message.ParentId); parentID != "" {
return parentID
}
return stringValue(message.RootId)
}
func buildInboundMetadata(message *larkim.EventMessage, sender *larkim.EventSender) map[string]string {
metadata := map[string]string{}
if message == nil {
return metadata
}
messageID := stringValue(message.MessageId)
if messageID != "" {
metadata["message_id"] = messageID
}
messageType := stringValue(message.MessageType)
if messageType != "" {
metadata["message_type"] = messageType
}
chatType := stringValue(message.ChatType)
if chatType != "" {
metadata["chat_type"] = chatType
}
parentID := stringValue(message.ParentId)
if parentID != "" {
metadata["parent_id"] = parentID
}
rootID := stringValue(message.RootId)
if rootID != "" {
metadata["root_id"] = rootID
}
if replyTo := replyTargetID(message); replyTo != "" {
metadata["reply_to_message_id"] = replyTo
}
threadID := stringValue(message.ThreadId)
if threadID != "" {
metadata["thread_id"] = threadID
}
if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" {
metadata["tenant_key"] = *sender.TenantKey
}
return metadata
}
func normalizeRepliedContent(messageType, rawContent string, mediaRefs []string) string {
content := extractContent(messageType, rawContent)
if containsFeishuUpgradePlaceholder(rawContent) || containsFeishuUpgradePlaceholder(content) {
content = ""
}
content = appendMediaTags(content, messageType, mediaRefs)
if strings.TrimSpace(content) != "" {
return content
}
switch messageType {
case larkim.MsgTypeImage:
return "[replied image]"
case larkim.MsgTypeFile:
return "[replied file]"
case larkim.MsgTypeAudio:
return "[replied audio]"
case larkim.MsgTypeMedia:
return "[replied video]"
case larkim.MsgTypeInteractive:
return "[replied interactive card]"
default:
return "[replied message content unavailable]"
}
}
func containsFeishuUpgradePlaceholder(s string) bool {
upgradePrompt := "\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef"
upgradePromptEscaped := "\\u8bf7\\u5347\\u7ea7\\u81f3\\u6700\\u65b0\\u7248\\u672c\\u5ba2\\u6237\\u7aef"
return strings.Contains(s, upgradePrompt) || strings.Contains(s, upgradePromptEscaped)
}
func formatReplyContext(parentID, repliedContent, content string) string {
parentID = strings.TrimSpace(parentID)
repliedContent = strings.TrimSpace(repliedContent)
content = strings.TrimSpace(content)
if parentID == "" || repliedContent == "" {
return content
}
repliedContent = utils.Truncate(repliedContent, maxReplyContextLen)
repliedContent = sanitizeReplyContextContent(repliedContent)
content = sanitizeReplyContextContent(content)
header := fmt.Sprintf("[replied_message id=%q]", parentID)
footer := "[/replied_message]"
if content == "" {
return header + "\n" + repliedContent + "\n" + footer
}
if hasLeadingCommandPrefix(content) {
return content + "\n\n" + header + "\n" + repliedContent + "\n" + footer
}
return header + "\n" + repliedContent + "\n" + footer + "\n\n[current_message]\n" + content + "\n[/current_message]"
}
func hasLeadingCommandPrefix(s string) bool {
tokens := strings.Fields(strings.TrimSpace(s))
if len(tokens) == 0 {
return false
}
first := tokens[0]
return strings.HasPrefix(first, "/") || strings.HasPrefix(first, "!")
}
func sanitizeReplyContextContent(s string) string {
tagEscaper := strings.NewReplacer(
"[replied_message", `\[replied_message`,
"[/replied_message]", `\[/replied_message]`,
"[current_message]", `\[current_message]`,
"[/current_message]", `\[/current_message]`,
)
return tagEscaper.Replace(s)
}

View file

@ -0,0 +1,229 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
package feishu
import (
"strings"
"testing"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
func TestBuildInboundMetadata(t *testing.T) {
strPtr := func(s string) *string { return &s }
t.Run("includes basic and reply fields", func(t *testing.T) {
message := &larkim.EventMessage{
MessageId: strPtr("om_msg_1"),
MessageType: strPtr("text"),
ChatType: strPtr("group"),
ParentId: strPtr("om_parent_1"),
RootId: strPtr("om_root_1"),
ThreadId: strPtr("omt_thread_1"),
}
sender := &larkim.EventSender{TenantKey: strPtr("tenant_x")}
got := buildInboundMetadata(message, sender)
if got["message_id"] != "om_msg_1" {
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_1")
}
if got["message_type"] != "text" {
t.Fatalf("message_type = %q, want %q", got["message_type"], "text")
}
if got["chat_type"] != "group" {
t.Fatalf("chat_type = %q, want %q", got["chat_type"], "group")
}
if got["parent_id"] != "om_parent_1" {
t.Fatalf("parent_id = %q, want %q", got["parent_id"], "om_parent_1")
}
if got["reply_to_message_id"] != "om_parent_1" {
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_parent_1")
}
if got["root_id"] != "om_root_1" {
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_1")
}
if got["thread_id"] != "omt_thread_1" {
t.Fatalf("thread_id = %q, want %q", got["thread_id"], "omt_thread_1")
}
if got["tenant_key"] != "tenant_x" {
t.Fatalf("tenant_key = %q, want %q", got["tenant_key"], "tenant_x")
}
})
t.Run("falls back reply_to_message_id to root_id", func(t *testing.T) {
message := &larkim.EventMessage{
MessageId: strPtr("om_msg_3"),
RootId: strPtr("om_root_3"),
}
got := buildInboundMetadata(message, nil)
if got["root_id"] != "om_root_3" {
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_3")
}
if got["reply_to_message_id"] != "om_root_3" {
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_root_3")
}
})
t.Run("omits empty values", func(t *testing.T) {
message := &larkim.EventMessage{
MessageId: strPtr("om_msg_2"),
}
got := buildInboundMetadata(message, nil)
if got["message_id"] != "om_msg_2" {
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_2")
}
if _, ok := got["parent_id"]; ok {
t.Fatalf("parent_id should be absent, got %q", got["parent_id"])
}
if _, ok := got["reply_to_message_id"]; ok {
t.Fatalf("reply_to_message_id should be absent, got %q", got["reply_to_message_id"])
}
if _, ok := got["tenant_key"]; ok {
t.Fatalf("tenant_key should be absent, got %q", got["tenant_key"])
}
})
t.Run("nil message returns empty map", func(t *testing.T) {
got := buildInboundMetadata(nil, nil)
if len(got) != 0 {
t.Fatalf("len(metadata) = %d, want 0", len(got))
}
})
}
func TestFormatReplyContext(t *testing.T) {
t.Run("formats reply context with content", func(t *testing.T) {
got := formatReplyContext("om_parent_1", "original message", "new reply")
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]\n\n[current_message]\nnew reply\n[/current_message]"
if got != want {
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
}
})
t.Run("returns reply context when current content is empty", func(t *testing.T) {
got := formatReplyContext("om_parent_1", "original message", "")
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
if got != want {
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
}
})
t.Run("returns original content when parent or replied content missing", func(t *testing.T) {
if got := formatReplyContext("", "original", "new reply"); got != "new reply" {
t.Fatalf("missing parent: got %q, want %q", got, "new reply")
}
if got := formatReplyContext("om_parent_1", "", "new reply"); got != "new reply" {
t.Fatalf("missing replied content: got %q, want %q", got, "new reply")
}
})
t.Run("escapes reserved wrapper tags in payload", func(t *testing.T) {
replied := "payload [replied_message id=\"x\"] x [/replied_message]"
current := "hello [current_message]injected[/current_message]"
got := formatReplyContext("om_parent_1", replied, current)
if !strings.HasPrefix(got, "[replied_message id=\"om_parent_1\"]") {
t.Fatalf("outer replied_message wrapper missing: %q", got)
}
if strings.Contains(got, "\n[replied_message id=\"x\"]") {
t.Fatalf("nested replied_message tag should be escaped: %q", got)
}
if strings.Contains(got, "\n[current_message]injected") {
t.Fatalf("nested current_message tag should be escaped: %q", got)
}
if !strings.Contains(got, `\[replied_message id="x"]`) {
t.Fatalf("escaped replied tag missing: %q", got)
}
})
t.Run("preserves leading slash command prefix", func(t *testing.T) {
got := formatReplyContext("om_parent_1", "original message", "/help")
want := "/help\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
if got != want {
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
}
})
t.Run("preserves leading bang command prefix", func(t *testing.T) {
got := formatReplyContext("om_parent_1", "original message", "!status now")
want := "!status now\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
if got != want {
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
}
})
}
func TestReplyTargetID(t *testing.T) {
strPtr := func(s string) *string { return &s }
t.Run("prefer parent_id", func(t *testing.T) {
msg := &larkim.EventMessage{ParentId: strPtr("om_parent"), RootId: strPtr("om_root")}
if got := replyTargetID(msg); got != "om_parent" {
t.Fatalf("replyTargetID() = %q, want %q", got, "om_parent")
}
})
t.Run("fallback to root_id", func(t *testing.T) {
msg := &larkim.EventMessage{RootId: strPtr("om_root")}
if got := replyTargetID(msg); got != "om_root" {
t.Fatalf("replyTargetID() = %q, want %q", got, "om_root")
}
})
t.Run("empty when no fields", func(t *testing.T) {
if got := replyTargetID(&larkim.EventMessage{}); got != "" {
t.Fatalf("replyTargetID() = %q, want empty", got)
}
})
}
func TestNormalizeRepliedContent(t *testing.T) {
t.Run("filters feishu upgrade placeholder for interactive", func(t *testing.T) {
raw := `{"text":"\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef\uff0c\u4ee5\u67e5\u770b\u5185\u5bb9"}`
got := normalizeRepliedContent("interactive", raw, nil)
if got != "[replied interactive card]" {
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied interactive card]")
}
})
t.Run("keeps filename and file tag for replied file", func(t *testing.T) {
got := normalizeRepliedContent("file", `{"file_key":"file_xxx","file_name":"doc.pdf"}`, []string{"media://r1"})
if got != "doc.pdf [file]" {
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "doc.pdf [file]")
}
})
t.Run("falls back when file content missing", func(t *testing.T) {
got := normalizeRepliedContent("file", `{"file_key":"file_xxx"}`, nil)
if got != "[replied file]" {
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied file]")
}
})
}
func TestHasLeadingCommandPrefix(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{name: "slash command", input: "/help", want: true},
{name: "bang command", input: "!status", want: true},
{name: "leading spaces slash", input: " /ping arg", want: true},
{name: "normal text", input: "hello /help", want: false},
{name: "empty", input: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasLeadingCommandPrefix(tt.input); got != tt.want {
t.Fatalf("hasLeadingCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

View file

@ -242,7 +242,11 @@ func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) {
}
func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
content, _ := msg.Payload["content"].(string)
if isThoughtPayload(msg.Payload) {
return
}
content, _ := msg.Payload[PayloadKeyContent].(string)
if strings.TrimSpace(content) == "" {
return
}
@ -292,7 +296,7 @@ func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) (
}
outMsg := newMessage(TypeMessageSend, map[string]any{
"content": msg.Content,
PayloadKeyContent: msg.Content,
})
outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:")
return nil, pc.writeJSON(outMsg)

View file

@ -316,3 +316,67 @@ func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) {
t.Fatal("timed out waiting for inbound media message")
}
}
func TestIsThoughtPayload(t *testing.T) {
tests := []struct {
name string
payload map[string]any
want bool
}{
{
name: "explicit thought bool",
payload: map[string]any{PayloadKeyThought: true},
want: true,
},
{
name: "thought false",
payload: map[string]any{PayloadKeyThought: false},
want: false,
},
{
name: "thought string ignored",
payload: map[string]any{PayloadKeyThought: "true"},
want: false,
},
{
name: "default normal",
payload: map[string]any{PayloadKeyContent: "hello"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isThoughtPayload(tt.payload); got != tt.want {
t.Fatalf("isThoughtPayload() = %v, want %v", got, tt.want)
}
})
}
}
func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) {
mb := bus.NewMessageBus()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: "ws://localhost:8080/ws",
}, mb)
if err != nil {
t.Fatalf("NewPicoClientChannel() error = %v", err)
}
ch.ctx = context.Background()
pc := &picoConn{sessionID: "sess-thought"}
ch.handleServerMessage(pc, PicoMessage{
Type: TypeMessageCreate,
Payload: map[string]any{
PayloadKeyContent: "internal reasoning",
PayloadKeyThought: true,
},
})
select {
case msg := <-mb.InboundChan():
t.Fatalf("expected no inbound publish for thought payload, got %+v", msg)
case <-time.After(150 * time.Millisecond):
}
}

View file

@ -39,6 +39,13 @@ var allowedInlineImageMIMETypes = map[string]struct{}{
"image/bmp": {},
}
func outboundMessageIsThought(msg bus.OutboundMessage) bool {
if len(msg.Context.Raw) == 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindThought)
}
// writeJSON sends a JSON message to the connection with write locking.
func (pc *picoConn) writeJSON(v any) error {
if pc.closed.Load() {
@ -247,9 +254,11 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
isThought := outboundMessageIsThought(msg)
outMsg := newMessage(TypeMessageCreate, map[string]any{
"content": msg.Content,
PayloadKeyContent: msg.Content,
PayloadKeyThought: isThought,
})
return nil, c.broadcastToSession(msg.ChatID, outMsg)
@ -288,8 +297,9 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin
msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
"content": text,
"message_id": msgID,
PayloadKeyContent: text,
PayloadKeyThought: false,
"message_id": msgID,
})
if err := c.broadcastToSession(chatID, outMsg); err != nil {

View file

@ -19,6 +19,11 @@ const (
TypePong = "pong"
PicoTokenPrefix = "pico-"
PayloadKeyContent = "content"
PayloadKeyThought = "thought"
MessageKindThought = "thought"
)
// PicoMessage is the wire format for all Pico Protocol messages.
@ -39,6 +44,11 @@ func newMessage(msgType string, payload map[string]any) PicoMessage {
}
}
func isThoughtPayload(payload map[string]any) bool {
thought, _ := payload[PayloadKeyThought].(bool)
return thought
}
func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage {
payload := map[string]any{
"code": code,

View file

@ -24,13 +24,14 @@ var rrCounter atomic.Uint64
// CurrentVersion is the latest config schema version
const CurrentVersion = 2
// Config is the current config structure with version support
// Config is the current config structure with version support.
type Config struct {
// Config schema version for migration.
Version int `json:"version" yaml:"-"`
Agents AgentsConfig `json:"agents" yaml:"-"`
Session SessionConfig `json:"session,omitempty" yaml:"-"`
Channels ChannelsConfig `json:"channels" yaml:"channels"`
Version int `json:"version" yaml:"-"`
Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"`
Agents AgentsConfig `json:"agents" yaml:"-"`
Session SessionConfig `json:"session,omitempty" yaml:"-"`
Channels ChannelsConfig `json:"channels" yaml:"channels"`
// New model-centric provider configuration.
ModelList SecureModelList `json:"model_list" yaml:"model_list"`
Gateway GatewayConfig `json:"gateway" yaml:"-"`
@ -46,6 +47,21 @@ type Config struct {
sensitiveCache *SensitiveDataCache
}
// IsolationConfig controls subprocess isolation for commands started by PicoClaw.
// It is applied by the isolation package rather than by sandboxing the main process.
type IsolationConfig struct {
Enabled bool `json:"enabled,omitempty"`
ExposePaths []ExposePath `json:"expose_paths,omitempty"`
}
// ExposePath describes a host path that should remain visible inside the isolated
// child-process environment. This is currently implemented on Linux only.
type ExposePath struct {
Source string `json:"source"`
Target string `json:"target,omitempty"`
Mode string `json:"mode"`
}
// FilterSensitiveData filters sensitive values from content before sending to LLM.
// This prevents the LLM from seeing its own credentials.
// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig).

View file

@ -1058,6 +1058,37 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
}
}
func TestDefaultConfig_IsolationEnabled(t *testing.T) {
cfg := DefaultConfig()
if cfg.Isolation.Enabled {
t.Fatal("DefaultConfig().Isolation.Enabled should be false")
}
}
func TestConfig_UnmarshalIsolation(t *testing.T) {
cfg := DefaultConfig()
raw := []byte(`{
"isolation": {
"enabled": false,
"expose_paths": [
{"source":"/src","target":"/dst","mode":"ro"}
]
}
}`)
if err := json.Unmarshal(raw, cfg); err != nil {
t.Fatalf("json.Unmarshal isolation config: %v", err)
}
if cfg.Isolation.Enabled {
t.Fatal("Isolation.Enabled should be false after unmarshal")
}
if len(cfg.Isolation.ExposePaths) != 1 {
t.Fatalf("ExposePaths len = %d, want 1", len(cfg.Isolation.ExposePaths))
}
if got := cfg.Isolation.ExposePaths[0]; got.Source != "/src" || got.Target != "/dst" || got.Mode != "ro" {
t.Fatalf("ExposePaths[0] = %+v, want source=/src target=/dst mode=ro", got)
}
}
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
tests := []struct {

View file

@ -17,6 +17,11 @@ func DefaultConfig() *Config {
return &Config{
Version: CurrentVersion,
// Isolation is opt-in so existing installations keep their current behavior
// until the user explicitly enables subprocess sandboxing.
Isolation: IsolationConfig{
Enabled: false,
},
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: workspacePath,

View file

@ -1,4 +1,4 @@
//go:build !mipsle && !netbsd && !(freebsd && arm)
//go:build !mipsle && !netbsd && !(freebsd && arm) && !android
package gateway

View file

@ -111,7 +111,7 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
}
// Run starts the gateway runtime using the configuration loaded from configPath.
func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error {
func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runErr error) {
panicPath := filepath.Join(homePath, logPath, panicFile)
panicFunc, err := logger.InitPanic(panicPath)
if err != nil {
@ -129,14 +129,25 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
} else {
logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
}
defer func() {
if runErr != nil {
logger.ErrorCF("gateway", "Gateway startup failed", map[string]any{
"config_path": configPath,
"error": runErr.Error(),
"home_path": homePath,
"allow_empty": allowEmptyStartup,
"debug": debug,
})
}
}()
cfg, err := config.LoadConfig(configPath)
if err != nil {
logger.Fatalf("error loading config: %v", err)
return fmt.Errorf("error loading config: %w", err)
}
if err = preCheckConfig(cfg); err != nil {
logger.Fatalf("config pre-check failed: %v", err)
return fmt.Errorf("config pre-check failed: %w", err)
}
// Debug mode permanently overrides the config log level to DEBUG.

108
pkg/gateway/gateway_test.go Normal file
View file

@ -0,0 +1,108 @@
package gateway
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestRun_StartupFailuresReturnErrorAndEmitStructuredLog(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prepare func(t *testing.T, dir string) string
wantErr string
wantLogSub string
}{
{
name: "invalid config returns load error",
prepare: func(t *testing.T, dir string) string {
t.Helper()
cfgPath := filepath.Join(dir, "invalid-config.json")
if err := os.WriteFile(cfgPath, []byte("{invalid-json"), 0o644); err != nil {
t.Fatalf("WriteFile(invalid config) error = %v", err)
}
return cfgPath
},
wantErr: "error loading config:",
wantLogSub: "error loading config:",
},
{
name: "invalid config returns pre-check error",
prepare: func(t *testing.T, dir string) string {
t.Helper()
cfg := config.DefaultConfig()
cfg.Gateway.Port = 0
cfgPath := filepath.Join(dir, "config.json")
if err := config.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
return cfgPath
},
wantErr: "config pre-check failed: invalid gateway port: 0",
wantLogSub: "config pre-check failed: invalid gateway port: 0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
homeDir := t.TempDir()
configPath := tt.prepare(t, homeDir)
cmd := exec.Command(os.Args[0], "-test.run=TestGatewayRunStartupFailureHelper")
cmd.Env = append(os.Environ(),
"GO_WANT_GATEWAY_RUN_HELPER=1",
"PICO_TEST_HOME="+homeDir,
"PICO_TEST_CONFIG="+configPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("helper exited unexpectedly: %v\noutput:\n%s", err, string(output))
}
out := string(output)
if !strings.Contains(out, tt.wantErr) {
t.Fatalf("helper output missing expected error substring %q:\n%s", tt.wantErr, out)
}
logData, readErr := os.ReadFile(filepath.Join(homeDir, logPath, logFile))
if readErr != nil {
t.Fatalf("ReadFile(gateway.log) error = %v", readErr)
}
logText := string(logData)
if !strings.Contains(logText, "Gateway startup failed") {
t.Fatalf("gateway.log missing structured startup failure log:\n%s", logText)
}
if !strings.Contains(logText, tt.wantLogSub) {
t.Fatalf("gateway.log missing expected failure detail %q:\n%s", tt.wantLogSub, logText)
}
})
}
}
func TestGatewayRunStartupFailureHelper(t *testing.T) {
if os.Getenv("GO_WANT_GATEWAY_RUN_HELPER") != "1" {
return
}
homeDir := os.Getenv("PICO_TEST_HOME")
configPath := os.Getenv("PICO_TEST_CONFIG")
err := Run(false, homeDir, configPath, false)
if err == nil {
fmt.Fprintln(os.Stdout, "expected startup error, got nil")
os.Exit(2)
}
fmt.Fprintln(os.Stdout, err.Error())
os.Exit(0)
}

View file

@ -7,6 +7,7 @@ import (
"fmt"
"maps"
"net/http"
"os"
"sync"
"time"
)
@ -31,6 +32,7 @@ type Check struct {
type StatusResponse struct {
Status string `json:"status"`
Uptime string `json:"uptime"`
PID int `json:"pid,omitempty"`
Checks map[string]Check `json:"checks,omitempty"`
}
@ -170,6 +172,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
resp := StatusResponse{
Status: "ok",
Uptime: uptime.String(),
PID: os.Getpid(),
}
json.NewEncoder(w).Encode(resp)

238
pkg/isolation/README.md Normal file
View file

@ -0,0 +1,238 @@
# `pkg/isolation`
`pkg/isolation` provides process-level isolation for child processes started by `picoclaw`.
It does not sandbox the main `picoclaw` process itself.
## Scope
The current scope is the child-process startup path:
- `exec` tool
- CLI providers such as `claude-cli` and `codex-cli`
- process hooks
- MCP `stdio` servers
## One-Sentence Model
- The `picoclaw` main process still runs in the host environment.
- Every child process should enter the shared `pkg/isolation` startup path first.
- The startup path applies platform-specific isolation according to config.
## Architecture
The implementation has four layers:
1. Configuration layer: reads `config.Config.Isolation` and injects it through `isolation.Configure(cfg)`.
2. Instance layout layer: resolves `config.GetHome()`, prepares instance directories, and builds the runtime user environment.
3. Platform backend layer: Linux uses `bwrap`; Windows uses a restricted token, low integrity, and a `Job Object`; other platforms are not implemented.
4. Unified startup layer: `PrepareCommand(cmd)`, `Start(cmd)`, and `Run(cmd)`.
All integrations that spawn subprocesses should reuse these helpers instead of calling `cmd.Start` or `cmd.Run` directly.
## Configuration
Isolation lives under:
```json
{
"isolation": {
"enabled": false,
"expose_paths": []
}
}
```
Field meanings:
- `enabled`: enables or disables subprocess isolation. Default: `false`.
- `expose_paths`: explicitly exposes host paths inside the isolated environment. It only matters when `enabled=true`. This is currently supported on Linux only.
Example:
```json
{
"isolation": {
"enabled": true,
"expose_paths": [
{
"source": "/opt/toolchains/go",
"target": "/opt/toolchains/go",
"mode": "ro"
},
{
"source": "/data/shared-assets",
"target": "/opt/picoclaw-instance-a/workspace/assets",
"mode": "rw"
}
]
}
}
```
Rules for `expose_paths`:
- `source` is a host path.
- `target` is the path inside the isolated environment.
- `mode` must be `ro` or `rw`.
- When `target` is empty, it defaults to `source`.
- Only one final rule may exist for the same `target`.
- Later-loaded config overrides earlier rules for the same `target`.
Platform note:
- Linux uses a real `source -> target` mount view.
- Windows does not currently support `expose_paths`.
## Instance Root And Directories
The instance root follows `config.GetHome()`:
- If `PICOCLAW_HOME` is set, use it.
- Otherwise use the default `.picoclaw` directory under the user home.
If `config.GetHome()` falls back to `.` while isolation is enabled, startup should fail.
Default instance directories include:
- instance root
- `skills`
- `logs`
- `cache`
- `state`
- `runtime-user-env`
`workspace` is derived from `cfg.WorkspacePath()` when configured, otherwise from the default workspace rule.
Windows also prepares:
- `runtime-user-env/AppData/Roaming`
- `runtime-user-env/AppData/Local`
## User Environment Redirect
When isolation is enabled, child processes receive a redirected per-instance user environment.
Linux variables:
- `HOME`
- `TMPDIR`
- `XDG_CONFIG_HOME`
- `XDG_CACHE_HOME`
- `XDG_STATE_HOME`
Windows variables:
- `USERPROFILE`
- `HOME`
- `TEMP`
- `TMP`
- `APPDATA`
- `LOCALAPPDATA`
These paths point into `runtime-user-env` under the instance root.
## Platform Behavior
### Linux
The Linux backend currently depends on `bwrap` (`bubblewrap`).
Capabilities:
- minimal filesystem view
- `ipc` namespace isolation
- redirected child-process user environment
- `source -> target` read-only or read-write mounts
Default mounts include the instance root plus the minimum runtime system paths such as `/usr`, `/bin`, `/lib`, `/lib64`, and `/etc/resolv.conf`.
At runtime, PicoClaw also adds the executable path, its directory, the effective working directory, and absolute path arguments when needed.
There is no automatic fallback when `bwrap` is missing.
Install examples:
- `apt install bubblewrap`
- `dnf install bubblewrap`
- `yum install bubblewrap`
- `pacman -S bubblewrap`
- `apk add bubblewrap`
If isolation must be disabled temporarily:
```json
{
"isolation": {
"enabled": false
}
}
```
Disabling isolation increases the risk that child processes can access or modify more host files.
### Windows
Windows isolation currently supports process-level restrictions such as restricted tokens, low integrity, job objects, and redirected user-environment directories.
`expose_paths` is not currently supported on Windows. If it is configured, startup should fail instead of pretending the paths were exposed.
The Windows backend currently uses:
- a restricted primary token
- low integrity level
- a `Job Object`
- redirected child-process user environment
It does not currently implement true `source -> target` filesystem remapping.
### macOS And Other Platforms
They are not implemented yet.
When isolation is explicitly enabled on an unsupported platform, the higher-level runtime should surface that as an unsupported configuration instead of pretending isolation succeeded.
## Logging And Debugging
When isolation is enabled, PicoClaw logs the generated isolation plan.
Linux log name:
- `linux isolation mount plan`
Windows log name:
- `windows isolation access rules`
If you suspect isolation is ineffective, check whether unexpected host paths appear in those logs.
## Relationship To `restrict_to_workspace`
- `restrict_to_workspace` limits the paths an agent is normally allowed to access.
- `pkg/isolation` limits what a child process can see and where its user environment points.
They complement each other and do not replace each other.
## Current Limits
- Linux isolation is implemented with `bwrap`, not a custom in-process isolation runtime.
- Linux does not currently enable a dedicated `pid` namespace by default.
- Windows does not yet implement full host ACL enforcement for every allowed or denied path.
- macOS is not implemented.
- The current design isolates child processes, not the main `picoclaw` process.
## Suggested Reading Order
If you are new to this code, read it in this order:
1. `pkg/config/config.go`
2. `pkg/isolation/runtime.go`
3. `pkg/isolation/platform_linux.go`
4. `pkg/isolation/platform_windows.go`
5. Call sites:
6. `pkg/tools/shell.go`
7. `pkg/providers/*.go`
8. `pkg/agent/hook_process.go`
9. `pkg/mcp/manager.go`
That path gives the fastest overview of the configuration model, runtime flow, and platform-specific limits.

238
pkg/isolation/README_CN.md Normal file
View file

@ -0,0 +1,238 @@
# `pkg/isolation`
`pkg/isolation``picoclaw` 启动的子进程提供进程级隔离能力。
它当前不会把 `picoclaw` 主进程自身放进沙箱中运行。
## 生效范围
当前生效范围是子进程启动链路:
- `exec` 工具
- `claude-cli``codex-cli` 等 CLI provider
- 进程型 hooks
- MCP `stdio` server
## 一句话理解
- `picoclaw` 主进程仍运行在宿主环境中。
- 所有子进程都应先经过 `pkg/isolation` 的统一启动入口。
- 入口会根据配置和平台,为子进程施加对应隔离。
## 架构
当前实现可以分为四层:
1. 配置层:读取 `config.Config.Isolation`,并通过 `isolation.Configure(cfg)` 注入运行时。
2. 实例目录层:解析 `config.GetHome()`,准备实例目录,并构建运行时用户环境目录。
3. 平台后端层Linux 使用 `bwrap`Windows 使用受限 token、低完整性级别和 `Job Object`;其他平台未实现。
4. 统一启动层:`PrepareCommand(cmd)``Start(cmd)``Run(cmd)`
所有启动子进程的接入点都应复用这组入口,而不是各自直接调用 `cmd.Start``cmd.Run`
## 配置
隔离配置位于:
```json
{
"isolation": {
"enabled": false,
"expose_paths": []
}
}
```
字段说明:
- `enabled`:是否启用子进程隔离。默认值:`false`
- `expose_paths`:显式把宿主路径带入隔离环境。仅在 `enabled=true` 时生效。目前只在 Linux 上支持。
示例:
```json
{
"isolation": {
"enabled": true,
"expose_paths": [
{
"source": "/opt/toolchains/go",
"target": "/opt/toolchains/go",
"mode": "ro"
},
{
"source": "/data/shared-assets",
"target": "/opt/picoclaw-instance-a/workspace/assets",
"mode": "rw"
}
]
}
}
```
`expose_paths` 规则:
- `source`:宿主机路径。
- `target`:隔离环境内的目标路径。
- `mode`:只能是 `ro``rw`
- `target` 为空时,默认等于 `source`
- 同一个 `target` 最终只能保留一条规则。
- 后加载的配置会覆盖先加载的同目标规则。
平台说明:
- Linux 会真实使用 `source -> target` 挂载视图。
- Windows 当前不支持 `expose_paths`
## 实例根与目录
实例根遵循 `config.GetHome()`
- 如果设置了 `PICOCLAW_HOME`,使用该值。
- 否则默认使用用户目录下的 `.picoclaw`
如果 `config.GetHome()` 在隔离开启时最终回退到当前目录 `.`,启动应直接失败。
默认实例目录包括:
- 实例根本身
- `skills`
- `logs`
- `cache`
- `state`
- `runtime-user-env`
`workspace` 优先使用 `cfg.WorkspacePath()` 的结果;未显式配置时才按默认规则派生。
Windows 还会额外准备:
- `runtime-user-env/AppData/Roaming`
- `runtime-user-env/AppData/Local`
## 用户环境重定向
隔离开启后,子进程会收到重定向到实例目录下的独立用户环境。
Linux 注入变量:
- `HOME`
- `TMPDIR`
- `XDG_CONFIG_HOME`
- `XDG_CACHE_HOME`
- `XDG_STATE_HOME`
Windows 注入变量:
- `USERPROFILE`
- `HOME`
- `TEMP`
- `TMP`
- `APPDATA`
- `LOCALAPPDATA`
这些路径都会指向实例根下的 `runtime-user-env`
## 平台行为
### Linux
Linux 后端当前依赖 `bwrap``bubblewrap`)。
能力:
- 最小文件系统视图
- `ipc namespace`
- 子进程用户环境重定向
- `source -> target` 只读或读写挂载
默认映射包括实例根,以及 `/usr``/bin``/lib``/lib64``/etc/resolv.conf` 等最小运行时系统路径。
运行时还会按需补充可执行文件本身、其所在目录、生效后的工作目录,以及命令行中的绝对路径参数。
缺少 `bwrap` 时不会自动回退。
安装示例:
- `apt install bubblewrap`
- `dnf install bubblewrap`
- `yum install bubblewrap`
- `pacman -S bubblewrap`
- `apk add bubblewrap`
如果需要临时关闭隔离:
```json
{
"isolation": {
"enabled": false
}
}
```
关闭隔离后,子进程访问或修改更多宿主文件的风险会明显上升。
### Windows
Windows 隔离当前提供的是进程级限制,例如 restricted token、low integrity、job object以及用户环境目录重定向。
`expose_paths` 目前不支持 Windows。如果配置了该字段启动应直接失败而不是假装这些路径已经被暴露进隔离环境。
Windows 后端当前使用:
- 受限 primary token
- 低完整性级别
- `Job Object`
- 子进程用户环境重定向
它当前不会实现真正的 `source -> target` 文件系统重映射。
### macOS 与其他平台
当前尚未实现。
当在未支持的平台上显式开启隔离时,上层运行时应将其视为不支持的配置,而不是假装隔离成功。
## 日志与排障
隔离开启后PicoClaw 会打印生成后的隔离计划,便于排障。
Linux 日志名:
- `linux isolation mount plan`
Windows 日志名:
- `windows isolation access rules`
如果你怀疑隔离未生效,先检查这些日志里是否出现了不应暴露的宿主路径。
## 与 `restrict_to_workspace` 的关系
- `restrict_to_workspace` 限制的是 agent 默认可访问的路径。
- `pkg/isolation` 限制的是子进程运行时能看到什么文件系统,以及它的用户环境指向哪里。
两者互补,不互相替代。
## 当前限制
- Linux 基于 `bwrap` 实现,而不是纯内建 isolation runtime。
- Linux 当前没有默认启用独立的 `pid namespace`
- Windows 还没有对所有允许/拒绝路径做完整 ACL 落地。
- macOS 尚未实现。
- 当前隔离的是子进程,不是 `picoclaw` 主进程自身。
## 建议阅读顺序
如果你是第一次看这部分代码,建议按这个顺序阅读:
1. `pkg/config/config.go`
2. `pkg/isolation/runtime.go`
3. `pkg/isolation/platform_linux.go`
4. `pkg/isolation/platform_windows.go`
5. 调用点:
6. `pkg/tools/shell.go`
7. `pkg/providers/*.go`
8. `pkg/agent/hook_process.go`
9. `pkg/mcp/manager.go`
这样能最快建立对配置模型、运行流程和平台边界的整体理解。

View file

@ -0,0 +1,264 @@
//go:build linux
package isolation
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
if !isolation.Enabled {
return nil
}
// Bubblewrap is the only supported Linux backend right now. Fail closed when
// it is unavailable instead of silently running the child process unisolated.
bwrapPath, err := exec.LookPath("bwrap")
if err != nil {
hint := bwrapInstallHint()
disableHint := `set "isolation.enabled": false in config.json`
logger.WarnCF("isolation", "bubblewrap is required for Linux isolation",
map[string]any{
"binary": "bwrap",
"install": hint,
"disable_isolation": disableHint,
"risk": "disabling isolation lets child processes run without Linux filesystem isolation",
})
return fmt.Errorf(
"linux isolation requires bwrap and does not fall back automatically: %w; install bubblewrap with one of: %s; or disable isolation by setting %s; disabling isolation means child processes can run without Linux filesystem isolation and may access or modify more host files",
err,
hint,
disableHint,
)
}
if cmd == nil || cmd.Path == "" || len(cmd.Args) == 0 {
return nil
}
originalPath := cmd.Path
originalArgs := append([]string{}, cmd.Args...)
_, execDir, err := resolveLinuxWorkingDir(cmd.Dir, originalPath)
if err != nil {
return err
}
resolvedPath, err := resolveLinuxCommandPath(originalPath, execDir)
if err != nil {
return err
}
// Start from the configured mount plan, then add only the executable, its
// resolved path, the effective working directory, and any absolute path
// arguments needed to preserve the original command semantics.
plan := BuildLinuxMountPlan(root, isolation.ExposePaths)
plan = ensureLinuxMountRule(plan, resolvedPath, resolvedPath, "ro")
plan = ensureLinuxMountRule(plan, filepath.Dir(resolvedPath), filepath.Dir(resolvedPath), "ro")
if resolved, resolveErr := filepath.EvalSymlinks(resolvedPath); resolveErr == nil && resolved != resolvedPath {
plan = ensureLinuxMountRule(plan, resolved, resolved, "ro")
plan = ensureLinuxMountRule(plan, filepath.Dir(resolved), filepath.Dir(resolved), "ro")
}
if execDir != "" {
plan = ensureLinuxMountRule(plan, execDir, execDir, "rw")
if resolved, resolveErr := filepath.EvalSymlinks(execDir); resolveErr == nil && resolved != execDir {
plan = ensureLinuxMountRule(plan, resolved, resolved, "rw")
}
}
plan = appendLinuxArgumentMounts(plan, originalArgs[1:])
logger.DebugCF("isolation", "linux isolation mount plan",
map[string]any{
"root": root,
"command": resolvedPath,
"working_dir": execDir,
"mounts": formatLinuxMountPlan(plan),
})
bwrapArgs, err := buildLinuxBwrapArgs(originalPath, resolvedPath, originalArgs, execDir, plan)
if err != nil {
return err
}
cmd.Path = bwrapPath
cmd.Args = bwrapArgs
cmd.Dir = ""
return nil
}
func bwrapInstallHint() string {
return "apt install bubblewrap; dnf install bubblewrap; yum install bubblewrap; pacman -S bubblewrap; apk add bubblewrap"
}
// formatLinuxMountPlan reshapes the internal plan for structured logging.
func formatLinuxMountPlan(plan []MountRule) []map[string]string {
formatted := make([]map[string]string, 0, len(plan))
for _, rule := range plan {
formatted = append(formatted, map[string]string{
"source": rule.Source,
"target": rule.Target,
"mode": rule.Mode,
})
}
return formatted
}
func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
return nil
}
func cleanupPendingPlatformResources(cmd *exec.Cmd) {
}
// buildLinuxBwrapArgs translates the mount plan into the bubblewrap command
// line that re-executes the original process inside the isolated mount view.
func buildLinuxBwrapArgs(
originalPath string,
resolvedPath string,
originalArgs []string,
execDir string,
plan []MountRule,
) ([]string, error) {
bwrapArgs := []string{
"bwrap",
"--die-with-parent",
"--unshare-ipc",
"--proc", "/proc",
"--dev", "/dev",
}
for _, rule := range plan {
flag, err := linuxBindFlag(rule)
if err != nil {
return nil, err
}
bwrapArgs = append(bwrapArgs, flag, rule.Source, rule.Target)
}
if execDir != "" {
bwrapArgs = append(bwrapArgs, "--chdir", execDir)
}
execPath := originalPath
if isRelativeCommandPath(originalPath) {
execPath = resolvedPath
}
bwrapArgs = append(bwrapArgs, "--", execPath)
if len(originalArgs) > 1 {
bwrapArgs = append(bwrapArgs, originalArgs[1:]...)
}
return bwrapArgs, nil
}
func resolveLinuxWorkingDir(originalDir, originalPath string) (string, string, error) {
if originalDir != "" {
resolved, err := filepath.Abs(originalDir)
if err != nil {
return "", "", fmt.Errorf("resolve command dir %s: %w", originalDir, err)
}
return resolved, resolved, nil
}
if !isRelativeCommandPath(originalPath) {
return "", "", nil
}
wd, err := os.Getwd()
if err != nil {
return "", "", fmt.Errorf("resolve current working dir: %w", err)
}
return "", wd, nil
}
func resolveLinuxCommandPath(originalPath, execDir string) (string, error) {
if filepath.IsAbs(originalPath) || !isRelativeCommandPath(originalPath) {
return filepath.Clean(originalPath), nil
}
base := execDir
if base == "" {
var err error
base, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve current working dir: %w", err)
}
}
return filepath.Clean(filepath.Join(base, originalPath)), nil
}
func appendLinuxArgumentMounts(plan []MountRule, args []string) []MountRule {
for _, arg := range args {
path, ok := linuxArgumentPath(arg)
if !ok {
continue
}
clean := filepath.Clean(path)
if info, err := os.Stat(clean); err == nil {
mode := "ro"
if info.IsDir() {
mode = "rw"
}
plan = ensureLinuxMountRule(plan, clean, clean, mode)
if resolved, resolveErr := filepath.EvalSymlinks(clean); resolveErr == nil && resolved != clean {
plan = ensureLinuxMountRule(plan, resolved, resolved, mode)
}
continue
} else if !errors.Is(err, os.ErrNotExist) {
continue
}
parent := filepath.Dir(clean)
if parent == clean {
continue
}
if _, err := os.Stat(parent); err == nil {
plan = ensureLinuxMountRule(plan, parent, parent, "rw")
}
}
return plan
}
func linuxArgumentPath(arg string) (string, bool) {
if filepath.IsAbs(arg) {
return arg, true
}
idx := strings.IndexRune(arg, '=')
if idx <= 0 || idx == len(arg)-1 {
return "", false
}
value := arg[idx+1:]
if !filepath.IsAbs(value) {
return "", false
}
return value, true
}
func isRelativeCommandPath(path string) bool {
return !filepath.IsAbs(path) && strings.ContainsRune(path, filepath.Separator)
}
// ensureLinuxMountRule appends a mount rule unless another rule already owns
// the same target path.
func ensureLinuxMountRule(plan []MountRule, source, target, mode string) []MountRule {
cleanSource := filepath.Clean(source)
cleanTarget := filepath.Clean(target)
for _, rule := range plan {
if filepath.Clean(rule.Target) == cleanTarget {
return plan
}
}
return append(plan, MountRule{Source: cleanSource, Target: cleanTarget, Mode: mode})
}
// linuxBindFlag selects the correct bubblewrap bind flag based on mount mode.
func linuxBindFlag(rule MountRule) (string, error) {
info, err := os.Stat(rule.Source)
if err != nil {
return "", fmt.Errorf("stat linux mount source %s: %w", rule.Source, err)
}
if !info.IsDir() {
if rule.Mode == "rw" {
return "--bind", nil
}
return "--ro-bind", nil
}
if rule.Mode == "rw" {
return "--bind", nil
}
return "--ro-bind", nil
}

View file

@ -0,0 +1,148 @@
//go:build linux
package isolation
import (
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestBuildLinuxBwrapArgs_IncludesNamespaceFlagsAndExec(t *testing.T) {
root := t.TempDir()
binaryDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binaryDir, 0o755); err != nil {
t.Fatal(err)
}
binaryPath := filepath.Join(binaryDir, "tool")
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
plan := BuildLinuxMountPlan(root, []config.ExposePath{{Source: binaryDir, Target: binaryDir, Mode: "ro"}})
args, err := buildLinuxBwrapArgs(binaryPath, binaryPath, []string{binaryPath, "--flag"}, root, plan)
if err != nil {
t.Fatalf("buildLinuxBwrapArgs() error = %v", err)
}
hasNet := false
hasIPC := false
hasExec := false
for i := range args {
switch args[i] {
case "--unshare-net":
hasNet = true
case "--unshare-ipc":
hasIPC = true
case "--":
if i+1 < len(args) && args[i+1] == binaryPath {
hasExec = true
}
}
}
if hasNet {
t.Fatalf("bwrap args should not unshare net by default: %v", args)
}
if !hasIPC || !hasExec {
t.Fatalf("bwrap args missing required items: %v", args)
}
}
func TestResolveLinuxWorkingDir_ResolvesRelativeDir(t *testing.T) {
cwd := t.TempDir()
previous, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer func() {
if chdirErr := os.Chdir(previous); chdirErr != nil {
t.Fatalf("restore cwd: %v", chdirErr)
}
}()
if chdirErr := os.Chdir(cwd); chdirErr != nil {
t.Fatal(chdirErr)
}
resolvedDir, execDir, err := resolveLinuxWorkingDir("./hooks", "./hook.sh")
if err != nil {
t.Fatalf("resolveLinuxWorkingDir() error = %v", err)
}
want := filepath.Join(cwd, "hooks")
if resolvedDir != want || execDir != want {
t.Fatalf("resolveLinuxWorkingDir() = (%q, %q), want (%q, %q)", resolvedDir, execDir, want, want)
}
}
func TestResolveLinuxCommandPath_UsesExecDirForRelativeCommand(t *testing.T) {
execDir := filepath.Join(t.TempDir(), "hooks")
got, err := resolveLinuxCommandPath("./hook.sh", execDir)
if err != nil {
t.Fatalf("resolveLinuxCommandPath() error = %v", err)
}
want := filepath.Join(execDir, "hook.sh")
if got != want {
t.Fatalf("resolveLinuxCommandPath() = %q, want %q", got, want)
}
}
func TestBuildLinuxBwrapArgs_UsesResolvedPathForRelativeCommand(t *testing.T) {
root := t.TempDir()
execDir := filepath.Join(root, "hooks")
if err := os.MkdirAll(execDir, 0o755); err != nil {
t.Fatal(err)
}
resolvedPath := filepath.Join(execDir, "hook.sh")
if err := os.WriteFile(resolvedPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
plan := []MountRule{
{Source: execDir, Target: execDir, Mode: "rw"},
{Source: resolvedPath, Target: resolvedPath, Mode: "ro"},
}
args, err := buildLinuxBwrapArgs("./hook.sh", resolvedPath, []string{"./hook.sh"}, execDir, plan)
if err != nil {
t.Fatalf("buildLinuxBwrapArgs() error = %v", err)
}
hasExecDir := false
for _, arg := range args {
if arg == execDir {
hasExecDir = true
break
}
}
if !hasExecDir {
t.Fatalf("buildLinuxBwrapArgs() missing resolved chdir: %v", args)
}
for i := range args {
if args[i] == "--" {
if i+1 >= len(args) || args[i+1] != resolvedPath {
t.Fatalf("buildLinuxBwrapArgs() exec path = %v, want %q after --", args, resolvedPath)
}
return
}
}
t.Fatalf("buildLinuxBwrapArgs() missing exec delimiter: %v", args)
}
func TestAppendLinuxArgumentMounts_AddsAbsoluteArgumentPaths(t *testing.T) {
root := t.TempDir()
input := filepath.Join(root, "input.txt")
if err := os.WriteFile(input, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
output := filepath.Join(root, "out", "result.txt")
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
t.Fatal(err)
}
plan := appendLinuxArgumentMounts(nil, []string{input, "--output=" + output})
if len(plan) != 2 {
t.Fatalf("appendLinuxArgumentMounts() len = %d, want 2", len(plan))
}
if plan[0].Source != input || plan[0].Mode != "ro" {
t.Fatalf("appendLinuxArgumentMounts()[0] = %+v, want source=%q mode=ro", plan[0], input)
}
if plan[1].Source != filepath.Dir(output) || plan[1].Mode != "rw" {
t.Fatalf("appendLinuxArgumentMounts()[1] = %+v, want source=%q mode=rw", plan[1], filepath.Dir(output))
}
}

View file

@ -0,0 +1,22 @@
//go:build !linux && !windows
package isolation
import (
"os/exec"
"github.com/sipeed/picoclaw/pkg/config"
)
func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
// Unsupported platforms currently keep the command unchanged. Callers rely on
// Preflight and higher-level checks to surface unsupported isolation modes.
return nil
}
func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
return nil
}
func cleanupPendingPlatformResources(cmd *exec.Cmd) {
}

View file

@ -0,0 +1,217 @@
//go:build windows
package isolation
import (
"fmt"
"os/exec"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const disableMaxPrivilege = 0x1
// windowsProcessResources holds native handles that must live for the lifetime
// of an isolated child process.
type windowsProcessResources struct {
job windows.Handle
token windows.Token
}
var (
windowsProcessResourcesByPID sync.Map
windowsPendingResources sync.Map
advapi32 = windows.NewLazySystemDLL("advapi32.dll")
procCreateRestrictedToken = advapi32.NewProc("CreateRestrictedToken")
)
func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
if !isolation.Enabled || cmd == nil {
return nil
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
rules := BuildWindowsAccessRules(root, isolation.ExposePaths)
logger.InfoCF("isolation", "windows isolation process constraints",
map[string]any{
"root": root,
"command": cmd.Path,
"rules": formatWindowsAccessRules(rules),
"note": "Windows currently enforces restricted token, low integrity, and job object limits; expose_paths filesystem remapping is rejected during preflight",
})
// Create the restricted token before the process starts so CreateProcess uses
// the reduced privilege set from the first instruction.
restrictedToken, err := createRestrictedPrimaryToken()
if err != nil {
return fmt.Errorf("create restricted primary token: %w", err)
}
cmd.SysProcAttr.CreationFlags |= windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_BREAKAWAY_FROM_JOB
cmd.SysProcAttr.Token = syscall.Token(restrictedToken)
windowsPendingResources.Store(cmd, windowsProcessResources{token: restrictedToken})
return nil
}
func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
if !isolation.Enabled || cmd == nil || cmd.Process == nil {
return nil
}
resourcesAny, _ := windowsPendingResources.LoadAndDelete(cmd)
resources, _ := resourcesAny.(windowsProcessResources)
// Job objects can only be attached after the process exists, so the Windows
// backend finishes isolation in this post-start hook.
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
if resources.token != 0 {
_ = resources.token.Close()
}
return fmt.Errorf("create windows job object: %w", err)
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
); err != nil {
_ = windows.CloseHandle(job)
if resources.token != 0 {
_ = resources.token.Close()
}
return fmt.Errorf("set windows job object info: %w", err)
}
proc, err := windows.OpenProcess(
windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE,
false,
uint32(cmd.Process.Pid),
)
if err != nil {
_ = windows.CloseHandle(job)
if resources.token != 0 {
_ = resources.token.Close()
}
return fmt.Errorf("open process for job assignment: %w", err)
}
if err := windows.AssignProcessToJobObject(job, proc); err != nil {
_ = windows.CloseHandle(proc)
_ = windows.CloseHandle(job)
if resources.token != 0 {
_ = resources.token.Close()
}
return fmt.Errorf("assign process to job object: %w", err)
}
if resources.token != 0 {
_ = resources.token.Close()
}
resources.job = job
windowsProcessResourcesByPID.Store(cmd.Process.Pid, resources)
go reapWindowsProcessResources(cmd.Process.Pid, proc, job)
return nil
}
func cleanupPendingPlatformResources(cmd *exec.Cmd) {
if cmd == nil {
return
}
resourcesAny, ok := windowsPendingResources.LoadAndDelete(cmd)
if !ok {
return
}
resources, _ := resourcesAny.(windowsProcessResources)
if resources.token != 0 {
_ = resources.token.Close()
}
}
func reapWindowsProcessResources(pid int, proc windows.Handle, job windows.Handle) {
_, _ = windows.WaitForSingleObject(proc, windows.INFINITE)
_ = windows.CloseHandle(proc)
_ = windows.CloseHandle(job)
windowsProcessResourcesByPID.Delete(pid)
}
// createRestrictedPrimaryToken duplicates the current process token, removes
// maximum privileges, and lowers integrity before it is assigned to a child.
func createRestrictedPrimaryToken() (windows.Token, error) {
var current windows.Token
if err := windows.OpenProcessToken(
windows.CurrentProcess(),
windows.TOKEN_DUPLICATE|windows.TOKEN_ASSIGN_PRIMARY|windows.TOKEN_QUERY|windows.TOKEN_ADJUST_DEFAULT,
&current,
); err != nil {
return 0, err
}
defer current.Close()
var restricted windows.Token
r1, _, e1 := procCreateRestrictedToken.Call(
uintptr(current),
uintptr(disableMaxPrivilege),
0,
0,
0,
0,
0,
0,
0,
uintptr(unsafe.Pointer(&restricted)),
)
if r1 == 0 {
if e1 != nil && e1 != syscall.Errno(0) {
return 0, e1
}
return 0, syscall.EINVAL
}
if err := setTokenLowIntegrity(restricted); err != nil {
_ = restricted.Close()
return 0, err
}
return restricted, nil
}
// setTokenLowIntegrity lowers the token integrity level so writes to higher
// integrity locations are blocked by the OS.
func setTokenLowIntegrity(token windows.Token) error {
lowSID, err := windows.CreateWellKnownSid(windows.WinLowLabelSid)
if err != nil {
return fmt.Errorf("create low integrity sid: %w", err)
}
tml := windows.Tokenmandatorylabel{
Label: windows.SIDAndAttributes{
Sid: lowSID,
Attributes: windows.SE_GROUP_INTEGRITY,
},
}
if err := windows.SetTokenInformation(
token,
windows.TokenIntegrityLevel,
(*byte)(unsafe.Pointer(&tml)),
tml.Size(),
); err != nil {
return fmt.Errorf("set token low integrity: %w", err)
}
return nil
}
// formatWindowsAccessRules reshapes the internal rules for structured logging.
func formatWindowsAccessRules(rules []AccessRule) []map[string]string {
formatted := make([]map[string]string, 0, len(rules))
for _, rule := range rules {
formatted = append(formatted, map[string]string{
"path": rule.Path,
"mode": rule.Mode,
})
}
return formatted
}

443
pkg/isolation/runtime.go Normal file
View file

@ -0,0 +1,443 @@
package isolation
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
)
// MountRule describes a source-to-target mount exposed inside the Linux
// isolation view.
type MountRule struct {
Source string
Target string
Mode string
}
// AccessRule describes the effective Windows-side access rule for a host path.
type AccessRule struct {
Path string
Mode string
}
// UserEnv contains the redirected per-instance user directories injected into
// isolated child processes.
type UserEnv struct {
Home string
Tmp string
Config string
Cache string
State string
AppData string
LocalAppData string
}
var (
isolationMu sync.RWMutex
currentIsolation = config.DefaultConfig().Isolation
)
// Configure updates the process-wide isolation state used by subsequent child
// process launches.
func Configure(cfg *config.Config) {
isolationMu.Lock()
defer isolationMu.Unlock()
if cfg == nil {
defaults := config.DefaultConfig()
currentIsolation = defaults.Isolation
return
}
currentIsolation = cfg.Isolation
}
// CurrentConfig returns the currently active isolation settings.
func CurrentConfig() config.IsolationConfig {
isolationMu.RLock()
defer isolationMu.RUnlock()
return currentIsolation
}
// ResolveInstanceRoot resolves the instance root used to build the isolated
// filesystem and redirected user environment.
func ResolveInstanceRoot() (string, error) {
root := filepath.Clean(config.GetHome())
if root == "." {
return "", fmt.Errorf("instance root resolved to current directory")
}
return root, nil
}
// PrepareInstanceRoot creates the directories required by the isolation runtime.
func PrepareInstanceRoot(root string) error {
for _, dir := range InstanceDirs(root) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("prepare instance dir %s: %w", dir, err)
}
}
return nil
}
// InstanceDirs returns the directories that must exist under the instance root
// for isolation-aware child processes.
func InstanceDirs(root string) []string {
dirs := []string{
root,
filepath.Join(root, "skills"),
filepath.Join(root, "logs"),
filepath.Join(root, "cache"),
filepath.Join(root, "state"),
filepath.Join(root, "runtime-user-env"),
filepath.Join(root, "runtime-user-env", "home"),
filepath.Join(root, "runtime-user-env", "tmp"),
filepath.Join(root, "runtime-user-env", "config"),
filepath.Join(root, "runtime-user-env", "cache"),
filepath.Join(root, "runtime-user-env", "state"),
}
dirs = append(dirs, filepath.Join(root, pkg.WorkspaceName))
if runtime.GOOS == "windows" {
dirs = append(dirs,
filepath.Join(root, "runtime-user-env", "AppData", "Roaming"),
filepath.Join(root, "runtime-user-env", "AppData", "Local"),
)
}
return dirs
}
// ResolveUserEnv derives the redirected user directories rooted under the
// instance runtime area.
func ResolveUserEnv(root string) UserEnv {
base := filepath.Join(root, "runtime-user-env")
return UserEnv{
Home: filepath.Join(base, "home"),
Tmp: filepath.Join(base, "tmp"),
Config: filepath.Join(base, "config"),
Cache: filepath.Join(base, "cache"),
State: filepath.Join(base, "state"),
AppData: filepath.Join(base, "AppData", "Roaming"),
LocalAppData: filepath.Join(base, "AppData", "Local"),
}
}
// ApplyUserEnv rewrites the child process environment so home, temp, and
// platform-specific user-data directories point into the instance root.
func ApplyUserEnv(cmd *exec.Cmd, root string) {
userEnv := ResolveUserEnv(root)
envMap := make(map[string]string)
for _, item := range cmd.Environ() {
if idx := strings.IndexRune(item, '='); idx > 0 {
envMap[item[:idx]] = item[idx+1:]
}
}
if runtime.GOOS == "windows" {
envMap["USERPROFILE"] = userEnv.Home
envMap["HOME"] = userEnv.Home
envMap["TEMP"] = userEnv.Tmp
envMap["TMP"] = userEnv.Tmp
envMap["APPDATA"] = userEnv.AppData
envMap["LOCALAPPDATA"] = userEnv.LocalAppData
} else {
envMap["HOME"] = userEnv.Home
envMap["TMPDIR"] = userEnv.Tmp
envMap["XDG_CONFIG_HOME"] = userEnv.Config
envMap["XDG_CACHE_HOME"] = userEnv.Cache
envMap["XDG_STATE_HOME"] = userEnv.State
}
env := make([]string, 0, len(envMap))
for k, v := range envMap {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd.Env = env
}
// ValidateExposePaths verifies the user-supplied path exposure rules before a
// child process is started.
func ValidateExposePaths(items []config.ExposePath) error {
seen := map[string]struct{}{}
for _, item := range items {
if item.Source == "" {
return fmt.Errorf("source is required")
}
if item.Mode != "ro" && item.Mode != "rw" {
return fmt.Errorf("invalid expose_paths mode: %s", item.Mode)
}
source := filepath.Clean(item.Source)
target := item.Target
if target == "" {
target = source
}
target = filepath.Clean(target)
if !filepath.IsAbs(source) || !filepath.IsAbs(target) {
return fmt.Errorf("source and target must be absolute paths")
}
if _, ok := seen[target]; ok {
return fmt.Errorf("duplicate expose_path target: %s", target)
}
seen[target] = struct{}{}
}
return nil
}
// NormalizeExposePath fills implicit defaults and cleans path values so merge
// and validation logic can work with canonical paths.
func NormalizeExposePath(item config.ExposePath) config.ExposePath {
source := filepath.Clean(item.Source)
target := item.Target
if target == "" {
target = source
}
return config.ExposePath{
Source: source,
Target: filepath.Clean(target),
Mode: item.Mode,
}
}
// DefaultExposePaths returns the minimum built-in host paths required for the
// current platform to run isolated child processes.
func DefaultExposePaths(root string) []config.ExposePath {
items := []config.ExposePath{{
Source: root,
Target: root,
Mode: "rw",
}}
if runtime.GOOS == "linux" {
items = append(items, defaultLinuxSystemExposePaths()...)
}
return items
}
func defaultLinuxSystemExposePaths() []config.ExposePath {
return existingExposePaths([]config.ExposePath{
{Source: "/usr", Target: "/usr", Mode: "ro"},
{Source: "/bin", Target: "/bin", Mode: "ro"},
{Source: "/lib", Target: "/lib", Mode: "ro"},
{Source: "/lib64", Target: "/lib64", Mode: "ro"},
{Source: "/etc/resolv.conf", Target: "/etc/resolv.conf", Mode: "ro"},
{Source: "/etc/hosts", Target: "/etc/hosts", Mode: "ro"},
{Source: "/etc/nsswitch.conf", Target: "/etc/nsswitch.conf", Mode: "ro"},
{Source: "/etc/passwd", Target: "/etc/passwd", Mode: "ro"},
{Source: "/etc/group", Target: "/etc/group", Mode: "ro"},
{Source: "/etc/ssl", Target: "/etc/ssl", Mode: "ro"},
{Source: "/etc/pki", Target: "/etc/pki", Mode: "ro"},
{Source: "/etc/ca-certificates", Target: "/etc/ca-certificates", Mode: "ro"},
{Source: "/usr/share/ca-certificates", Target: "/usr/share/ca-certificates", Mode: "ro"},
{Source: "/usr/local/share/ca-certificates", Target: "/usr/local/share/ca-certificates", Mode: "ro"},
{Source: "/etc/alternatives", Target: "/etc/alternatives", Mode: "ro"},
{Source: "/usr/share/zoneinfo", Target: "/usr/share/zoneinfo", Mode: "ro"},
{Source: "/etc/localtime", Target: "/etc/localtime", Mode: "ro"},
})
}
// existingExposePaths keeps only the builtin host paths that exist on the
// current machine so Linux isolation does not fail on distro-specific paths.
func existingExposePaths(items []config.ExposePath) []config.ExposePath {
filtered := make([]config.ExposePath, 0, len(items))
for _, item := range items {
if _, err := os.Stat(item.Source); err == nil {
filtered = append(filtered, item)
}
}
return filtered
}
// MergeExposePaths merges built-in rules with user overrides. Rules are keyed
// by target path so later entries replace earlier ones for the same target.
func MergeExposePaths(defaults []config.ExposePath, overrides []config.ExposePath) []config.ExposePath {
merged := make([]config.ExposePath, 0, len(defaults)+len(overrides))
indexByTarget := make(map[string]int, len(defaults)+len(overrides))
appendOrReplace := func(item config.ExposePath) {
normalized := NormalizeExposePath(item)
if idx, ok := indexByTarget[normalized.Target]; ok {
merged[idx] = normalized
return
}
indexByTarget[normalized.Target] = len(merged)
merged = append(merged, normalized)
}
for _, item := range defaults {
appendOrReplace(item)
}
for _, item := range overrides {
appendOrReplace(item)
}
return merged
}
// BuildLinuxMountPlan converts the merged expose-path configuration into the
// mount rules consumed by the Linux bubblewrap backend.
func BuildLinuxMountPlan(root string, overrides []config.ExposePath) []MountRule {
merged := MergeExposePaths(DefaultExposePaths(root), overrides)
plan := make([]MountRule, 0, len(merged))
for _, item := range merged {
plan = append(plan, MountRule{Source: item.Source, Target: item.Target, Mode: item.Mode})
}
return plan
}
// BuildWindowsAccessRules derives the host-path access policy used by the
// Windows restricted-token backend.
func BuildWindowsAccessRules(root string, overrides []config.ExposePath) []AccessRule {
merged := MergeExposePaths(nil, overrides)
rules := make([]AccessRule, 0, len(merged)+1)
rules = append(rules, AccessRule{Path: root, Mode: "rw"})
for _, item := range merged {
rules = append(rules, AccessRule{Path: item.Source, Mode: item.Mode})
}
return rules
}
func validateWindowsExposePaths(items []config.ExposePath) error {
if len(items) == 0 {
return nil
}
return fmt.Errorf("windows isolation does not yet support expose_paths filesystem rules")
}
// IsSupported reports whether the current platform has an implemented isolation
// backend.
func IsSupported() bool {
return isSupportedOn(runtime.GOOS)
}
func isSupportedOn(goos string) bool {
switch goos {
case "linux", "windows":
return true
default:
return false
}
}
// Preflight validates the configured isolation state and prepares the instance
// runtime directories before any child process is launched.
func Preflight() error {
isolation := CurrentConfig()
if !isolation.Enabled {
return nil
}
if !IsSupported() {
return fmt.Errorf("subprocess isolation is not supported on %s", runtime.GOOS)
}
root, err := ResolveInstanceRoot()
if err != nil {
return err
}
if err := PrepareInstanceRoot(root); err != nil {
return err
}
if err := ValidateExposePaths(isolation.ExposePaths); err != nil {
return err
}
if runtime.GOOS == "linux" {
for _, rule := range BuildLinuxMountPlan(root, isolation.ExposePaths) {
if rule.Source == "" || rule.Target == "" {
return fmt.Errorf("invalid linux mount rule")
}
}
}
if runtime.GOOS == "windows" {
if err := validateWindowsExposePaths(isolation.ExposePaths); err != nil {
return err
}
for _, rule := range BuildWindowsAccessRules(root, isolation.ExposePaths) {
if rule.Path == "" {
return fmt.Errorf("invalid windows access rule")
}
}
}
return nil
}
// Start prepares isolation for the command, starts it, and applies any
// post-start platform hooks required by the active backend.
func Start(cmd *exec.Cmd) error {
if err := PrepareCommand(cmd); err != nil {
return err
}
if err := cmd.Start(); err != nil {
cleanupPendingPlatformResources(cmd)
return err
}
isolation := CurrentConfig()
root := ""
if isolation.Enabled {
var err error
root, err = ResolveInstanceRoot()
if err != nil {
terminateStartedCommand(cmd)
return err
}
}
if err := postStartPlatformIsolation(cmd, isolation, root); err != nil {
terminateStartedCommand(cmd)
return err
}
return nil
}
// Run is the Start-and-Wait helper that keeps the same isolation behavior as
// Start while returning the command's final exit status.
func Run(cmd *exec.Cmd) error {
if err := PrepareCommand(cmd); err != nil {
return err
}
if err := cmd.Start(); err != nil {
cleanupPendingPlatformResources(cmd)
return err
}
isolation := CurrentConfig()
root := ""
if isolation.Enabled {
var err error
root, err = ResolveInstanceRoot()
if err != nil {
terminateStartedCommand(cmd)
return err
}
}
if err := postStartPlatformIsolation(cmd, isolation, root); err != nil {
terminateStartedCommand(cmd)
return err
}
return cmd.Wait()
}
func terminateStartedCommand(cmd *exec.Cmd) {
cleanupPendingPlatformResources(cmd)
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
// PrepareCommand mutates the command in-place so it inherits the configured
// isolated environment before being started by the caller.
func PrepareCommand(cmd *exec.Cmd) error {
isolation := CurrentConfig()
if err := Preflight(); err != nil {
return err
}
if isolation.Enabled {
root, err := ResolveInstanceRoot()
if err != nil {
return err
}
ApplyUserEnv(cmd, root)
if err := applyPlatformIsolation(cmd, isolation, root); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,248 @@
package isolation
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestResolveInstanceRoot_UsesPicoclawHome(t *testing.T) {
t.Setenv(config.EnvHome, "/custom/picoclaw/home")
root, err := ResolveInstanceRoot()
if err != nil {
t.Fatalf("ResolveInstanceRoot() error = %v", err)
}
if root != "/custom/picoclaw/home" {
t.Fatalf("ResolveInstanceRoot() = %q, want %q", root, "/custom/picoclaw/home")
}
}
func TestPrepareInstanceRoot_CreatesDirectories(t *testing.T) {
root := filepath.Join(t.TempDir(), "instance")
if err := PrepareInstanceRoot(root); err != nil {
t.Fatalf("PrepareInstanceRoot() error = %v", err)
}
for _, dir := range InstanceDirs(root) {
if info, err := os.Stat(dir); err != nil {
t.Fatalf("os.Stat(%q): %v", dir, err)
} else if !info.IsDir() {
t.Fatalf("%q is not a directory", dir)
}
}
}
func TestInstanceDirs_UsesInstanceWorkspaceNotGlobalState(t *testing.T) {
root := filepath.Join(t.TempDir(), "instance")
cfg := config.DefaultConfig()
cfg.Isolation.Enabled = true
cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "external-workspace")
Configure(cfg)
t.Cleanup(func() { Configure(config.DefaultConfig()) })
dirs := InstanceDirs(root)
wantWorkspace := filepath.Join(root, pkg.WorkspaceName)
found := false
for _, dir := range dirs {
if dir == wantWorkspace {
found = true
}
if dir == cfg.WorkspacePath() {
t.Fatalf("InstanceDirs() should not depend on process-wide workspace state: %q", dir)
}
}
if !found {
t.Fatalf("InstanceDirs() missing instance workspace dir %q", wantWorkspace)
}
}
func TestIsSupportedOn(t *testing.T) {
tests := []struct {
goos string
want bool
}{
{goos: "linux", want: true},
{goos: "windows", want: true},
{goos: "darwin", want: false},
{goos: "freebsd", want: false},
}
for _, tt := range tests {
if got := isSupportedOn(tt.goos); got != tt.want {
t.Fatalf("isSupportedOn(%q) = %v, want %v", tt.goos, got, tt.want)
}
}
}
func TestValidateExposePaths(t *testing.T) {
err := ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}})
if err != nil {
t.Fatalf("ValidateExposePaths() error = %v", err)
}
err = ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "bad"}})
if err == nil {
t.Fatal("ValidateExposePaths() expected invalid mode error")
}
err = ValidateExposePaths(
[]config.ExposePath{
{Source: "/src", Target: "/dst", Mode: "ro"},
{Source: "/other", Target: "/dst", Mode: "rw"},
},
)
if err == nil {
t.Fatal("ValidateExposePaths() expected duplicate target error")
}
}
func TestMergeExposePaths_OverrideByTarget(t *testing.T) {
merged := MergeExposePaths(
[]config.ExposePath{{Source: "/src-a", Target: "/dst", Mode: "ro"}},
[]config.ExposePath{{Source: "/src-b", Target: "/dst", Mode: "rw"}},
)
if len(merged) != 1 {
t.Fatalf("MergeExposePaths len = %d, want 1", len(merged))
}
if got := merged[0]; got.Source != "/src-b" || got.Target != "/dst" || got.Mode != "rw" {
t.Fatalf("merged[0] = %+v, want source=/src-b target=/dst mode=rw", got)
}
}
func TestBuildLinuxMountPlan(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("linux-only default mount set")
}
plan := BuildLinuxMountPlan("/rootdir", []config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}})
if len(plan) == 0 {
t.Fatal("BuildLinuxMountPlan returned empty plan")
}
foundRoot := false
foundOverride := false
for _, rule := range plan {
if rule.Source == "/rootdir" && rule.Target == "/rootdir" && rule.Mode == "rw" {
foundRoot = true
}
if rule.Source == "/src" && rule.Target == "/dst" && rule.Mode == "ro" {
foundOverride = true
}
}
if !foundRoot {
t.Fatal("BuildLinuxMountPlan missing root mapping")
}
if !foundOverride {
t.Fatal("BuildLinuxMountPlan missing override mapping")
}
}
func TestBuildWindowsAccessRules(t *testing.T) {
rules := BuildWindowsAccessRules(
`C:\picoclaw`,
[]config.ExposePath{{Source: `D:\data`, Target: `C:\mapped`, Mode: "ro"}},
)
if len(rules) == 0 {
t.Fatal("BuildWindowsAccessRules returned empty rules")
}
foundRoot := false
foundOverride := false
for _, rule := range rules {
if rule.Path == `C:\picoclaw` && rule.Mode == "rw" {
foundRoot = true
}
if rule.Path == `D:\data` && rule.Mode == "ro" {
foundOverride = true
}
}
if !foundRoot {
t.Fatal("BuildWindowsAccessRules missing root rule")
}
if !foundOverride {
t.Fatal("BuildWindowsAccessRules missing override rule")
}
}
func TestValidateWindowsExposePaths(t *testing.T) {
if err := validateWindowsExposePaths(nil); err != nil {
t.Fatalf("validateWindowsExposePaths(nil) error = %v", err)
}
err := validateWindowsExposePaths([]config.ExposePath{{Source: `D:\data`, Target: `D:\data`, Mode: "ro"}})
if err == nil {
t.Fatal("validateWindowsExposePaths() expected error for expose_paths")
}
}
func TestDefaultLinuxSystemExposePaths(t *testing.T) {
paths := defaultLinuxSystemExposePaths()
needed := map[string]bool{}
for _, path := range []string{"/etc/hosts", "/etc/nsswitch.conf", "/etc/ssl", "/usr/share/zoneinfo", "/etc/localtime"} {
if _, err := os.Stat(path); err == nil {
needed[path] = false
}
}
for _, item := range paths {
if _, ok := needed[item.Source]; ok {
needed[item.Source] = true
}
}
for path, found := range needed {
if !found {
t.Fatalf("defaultLinuxSystemExposePaths missing %s", path)
}
}
}
func TestExistingExposePaths_SkipsMissingPaths(t *testing.T) {
existing := filepath.Join(t.TempDir(), "existing")
if err := os.MkdirAll(existing, 0o755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
filtered := existingExposePaths([]config.ExposePath{
{Source: existing, Target: existing, Mode: "ro"},
{Source: filepath.Join(t.TempDir(), "missing"), Target: "/missing", Mode: "ro"},
})
if len(filtered) != 1 {
t.Fatalf("existingExposePaths() len = %d, want 1", len(filtered))
}
if got := filtered[0]; got.Source != existing {
t.Fatalf("existingExposePaths()[0] = %+v, want source=%q", got, existing)
}
}
func TestPrepareCommand_AppliesUserEnv(t *testing.T) {
if !isSupportedOn(runtime.GOOS) {
t.Skipf("isolation not supported on %s", runtime.GOOS)
}
t.Setenv(config.EnvHome, filepath.Join(t.TempDir(), "home"))
if runtime.GOOS == "linux" {
binDir := filepath.Join(t.TempDir(), "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
fakeBwrap := filepath.Join(binDir, "bwrap")
if err := os.WriteFile(fakeBwrap, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
cfg := config.DefaultConfig()
cfg.Isolation.Enabled = true
Configure(cfg)
t.Cleanup(func() { Configure(config.DefaultConfig()) })
cmd := exec.Command("sh", "-c", "true")
if err := PrepareCommand(cmd); err != nil {
t.Fatalf("PrepareCommand() error = %v", err)
}
hasHome := false
for _, env := range cmd.Env {
if len(env) > 5 && env[:5] == "HOME=" {
hasHome = true
break
}
}
if runtime.GOOS != "windows" && !hasHome {
t.Fatal("PrepareCommand() did not inject HOME")
}
}

View file

@ -0,0 +1,226 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"sync"
"syscall"
"time"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/isolation"
)
var isolatedCommandTerminateDuration = 5 * time.Second
// isolatedCommandTransport mirrors the SDK command transport but routes
// process startup through pkg/isolation so Windows post-start hooks run too.
type isolatedCommandTransport struct {
Command *exec.Cmd
TerminateDuration time.Duration
}
func (t *isolatedCommandTransport) Connect(ctx context.Context) (sdkmcp.Connection, error) {
stdout, err := t.Command.StdoutPipe()
if err != nil {
return nil, err
}
stdout = io.NopCloser(stdout)
stdin, err := t.Command.StdinPipe()
if err != nil {
return nil, err
}
if err := isolation.Start(t.Command); err != nil {
return nil, err
}
td := t.TerminateDuration
if td <= 0 {
td = isolatedCommandTerminateDuration
}
return newIsolatedIOConn(&isolatedPipeRWC{cmd: t.Command, stdout: stdout, stdin: stdin, terminateDuration: td}), nil
}
type isolatedPipeRWC struct {
cmd *exec.Cmd
stdout io.ReadCloser
stdin io.WriteCloser
terminateDuration time.Duration
}
func (s *isolatedPipeRWC) Read(p []byte) (n int, err error) {
return s.stdout.Read(p)
}
func (s *isolatedPipeRWC) Write(p []byte) (n int, err error) {
return s.stdin.Write(p)
}
func (s *isolatedPipeRWC) Close() error {
if err := s.stdin.Close(); err != nil {
return fmt.Errorf("closing stdin: %v", err)
}
resChan := make(chan error, 1)
go func() {
resChan <- s.cmd.Wait()
}()
wait := func() (error, bool) {
select {
case err := <-resChan:
return err, true
case <-time.After(s.terminateDuration):
}
return nil, false
}
if err, ok := wait(); ok {
return err
}
if err := s.cmd.Process.Signal(syscall.SIGTERM); err == nil {
if err, ok := wait(); ok {
return err
}
}
if err := s.cmd.Process.Kill(); err != nil {
return err
}
if err, ok := wait(); ok {
return err
}
return fmt.Errorf("unresponsive subprocess")
}
type isolatedIOConn struct {
writeMu sync.Mutex
rwc io.ReadWriteCloser
incoming <-chan isolatedMsgOrErr
queue []jsonrpc.Message
closeOnce sync.Once
closed chan struct{}
closeErr error
}
type isolatedMsgOrErr struct {
msg json.RawMessage
err error
}
func newIsolatedIOConn(rwc io.ReadWriteCloser) *isolatedIOConn {
incoming := make(chan isolatedMsgOrErr)
closed := make(chan struct{})
go func() {
dec := json.NewDecoder(rwc)
for {
var raw json.RawMessage
err := dec.Decode(&raw)
if err == nil {
var tr [1]byte
if n, readErr := dec.Buffered().Read(tr[:]); n > 0 {
if tr[0] != '\n' && tr[0] != '\r' {
err = fmt.Errorf("invalid trailing data at the end of stream")
}
} else if readErr != nil && readErr != io.EOF {
err = readErr
}
}
select {
case incoming <- isolatedMsgOrErr{msg: raw, err: err}:
case <-closed:
return
}
if err != nil {
return
}
}
}()
return &isolatedIOConn{rwc: rwc, incoming: incoming, closed: closed}
}
func (c *isolatedIOConn) SessionID() string { return "" }
func (c *isolatedIOConn) Read(ctx context.Context) (jsonrpc.Message, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if len(c.queue) > 0 {
next := c.queue[0]
c.queue = c.queue[1:]
return next, nil
}
var raw json.RawMessage
select {
case <-ctx.Done():
return nil, ctx.Err()
case v := <-c.incoming:
if v.err != nil {
return nil, v.err
}
raw = v.msg
case <-c.closed:
return nil, io.EOF
}
msgs, err := readIsolatedBatch(raw)
if err != nil {
return nil, err
}
c.queue = msgs[1:]
return msgs[0], nil
}
func readIsolatedBatch(data []byte) ([]jsonrpc.Message, error) {
var rawBatch []json.RawMessage
if err := json.Unmarshal(data, &rawBatch); err == nil {
if len(rawBatch) == 0 {
return nil, fmt.Errorf("empty batch")
}
msgs := make([]jsonrpc.Message, 0, len(rawBatch))
for _, raw := range rawBatch {
msg, err := jsonrpc.DecodeMessage(raw)
if err != nil {
return nil, err
}
msgs = append(msgs, msg)
}
return msgs, nil
}
msg, err := jsonrpc.DecodeMessage(data)
if err != nil {
return nil, err
}
return []jsonrpc.Message{msg}, nil
}
func (c *isolatedIOConn) Write(ctx context.Context, msg jsonrpc.Message) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
data, err := jsonrpc.EncodeMessage(msg)
if err != nil {
return fmt.Errorf("marshaling message: %v", err)
}
data = append(data, '\n')
_, err = c.rwc.Write(data)
return err
}
func (c *isolatedIOConn) Close() error {
c.closeOnce.Do(func() {
c.closeErr = c.rwc.Close()
close(c.closed)
})
return c.closeErr
}
var (
_ sdkmcp.Transport = (*isolatedCommandTransport)(nil)
_ sdkmcp.Connection = (*isolatedIOConn)(nil)
)

View file

@ -365,8 +365,7 @@ func (m *Manager) ConnectServer(
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd.Env = env
transport = &mcp.CommandTransport{Command: cmd}
transport = &isolatedCommandTransport{Command: cmd}
default:
return fmt.Errorf(
"unsupported transport type: %s (supported: stdio, sse, http)",

View file

@ -151,6 +151,30 @@ func RemovePidFile(homePath string) {
os.Remove(pidPath)
}
// RemovePidFileIfPID deletes the PID file only when the recorded PID matches
// expectedPID. It returns true when the file is removed successfully.
func RemovePidFileIfPID(homePath string, expectedPID int) bool {
if expectedPID <= 0 {
return false
}
pidMu.Lock()
defer pidMu.Unlock()
pidPath := pidFilePath(homePath)
data, err := readPidFileUnlocked(pidPath)
if err != nil {
return false
}
if data.PID != expectedPID {
return false
}
if err := os.Remove(pidPath); err != nil {
return false
}
return true
}
// readPidFileUnlocked reads the PID file without acquiring the lock.
// Caller must hold pidMu.
func readPidFileUnlocked(pidPath string) (*PidFileData, error) {

View file

@ -244,6 +244,40 @@ func TestRemovePidFileNonexistent(t *testing.T) {
RemovePidFile(dir)
}
func TestRemovePidFileIfPID(t *testing.T) {
dir := tmpDir(t)
other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"}
raw, _ := json.MarshalIndent(other, "", " ")
path := filepath.Join(dir, pidFileName)
os.WriteFile(path, raw, 0o600)
removed := RemovePidFileIfPID(dir, 99999999)
if !removed {
t.Fatal("expected RemovePidFileIfPID to remove matching pid file")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Error("PID file should be removed for matching expected PID")
}
}
func TestRemovePidFileIfPIDMismatch(t *testing.T) {
dir := tmpDir(t)
other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"}
raw, _ := json.MarshalIndent(other, "", " ")
path := filepath.Join(dir, pidFileName)
os.WriteFile(path, raw, 0o600)
removed := RemovePidFileIfPID(dir, 88888888)
if removed {
t.Fatal("expected RemovePidFileIfPID to keep non-matching pid file")
}
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Error("PID file should NOT be removed for mismatching expected PID")
}
}
// TestReadPidFileUnlockedInvalidJSON returns error for malformed content.
func TestReadPidFileUnlockedInvalidJSON(t *testing.T) {
dir := tmpDir(t)

View file

@ -389,6 +389,7 @@ type antigravityJSONResponse struct {
Content struct {
Parts []struct {
Text string `json:"text,omitempty"`
Thought bool `json:"thought,omitempty"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
@ -406,6 +407,7 @@ type antigravityJSONResponse struct {
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
var contentParts []string
var reasoningParts []string
var toolCalls []ToolCall
var usage *UsageInfo
var finishReason string
@ -433,7 +435,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
for _, candidate := range resp.Candidates {
for _, part := range candidate.Content.Parts {
if part.Text != "" {
contentParts = append(contentParts, part.Text)
if part.Thought {
reasoningParts = append(reasoningParts, part.Text)
} else {
contentParts = append(contentParts, part.Text)
}
}
if part.FunctionCall != nil {
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
@ -475,10 +481,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
}
return &LLMResponse{
Content: strings.Join(contentParts, ""),
ToolCalls: toolCalls,
FinishReason: mappedFinish,
Usage: usage,
Content: strings.Join(contentParts, ""),
ReasoningContent: strings.Join(reasoningParts, ""),
ToolCalls: toolCalls,
FinishReason: mappedFinish,
Usage: usage,
}, nil
}

View file

@ -54,3 +54,27 @@ func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {
t.Fatalf("expected inferred tool name search_docs, got %q", got)
}
}
func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) {
p := &AntigravityProvider{}
body := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hidden reasoning\",\"thought\":true},{\"text\":\"visible answer\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":17,\"totalTokenCount\":216}}}\n" +
"data: [DONE]\n"
resp, err := p.parseSSEResponse(body)
if err != nil {
t.Fatalf("parseSSEResponse() error = %v", err)
}
if resp.Content != "visible answer" {
t.Fatalf("Content = %q, want %q", resp.Content, "visible answer")
}
if resp.ReasoningContent != "hidden reasoning" {
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden reasoning")
}
if resp.FinishReason != "stop" {
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "stop")
}
if resp.Usage == nil || resp.Usage.TotalTokens != 216 {
t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216)
}
}

View file

@ -7,6 +7,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/sipeed/picoclaw/pkg/isolation"
)
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
@ -49,7 +51,9 @@ func (p *ClaudeCliProvider) Chat(
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Execute the CLI through the shared isolation wrapper so external provider
// processes honor the configured isolation policy.
if err := isolation.Run(cmd); err != nil {
stderrStr := strings.TrimSpace(stderr.String())
stdoutStr := strings.TrimSpace(stdout.String())
switch {

View file

@ -8,6 +8,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/sipeed/picoclaw/pkg/isolation"
)
// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
@ -56,7 +58,9 @@ func (p *CodexCliProvider) Chat(
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
// Execute the CLI through the shared isolation wrapper so external provider
// processes honor the configured isolation policy.
err := isolation.Run(cmd)
// Parse JSONL from stdout even if exit code is non-zero,
// because codex writes diagnostic noise to stderr (e.g. rollout errors)

View file

@ -254,6 +254,22 @@ func TestDecodeToolCallArguments_ObjectJSON(t *testing.T) {
}
}
func TestDecodeToolCallArguments_ObjectJSON_NewlineEscape(t *testing.T) {
raw := json.RawMessage(`{"content":"line1\nline2"}`)
args := DecodeToolCallArguments(raw, "write_file")
if args["content"] != "line1\nline2" {
t.Errorf("content = %q, want newline-expanded string", args["content"])
}
}
func TestDecodeToolCallArguments_ObjectJSON_LiteralBackslashN(t *testing.T) {
raw := json.RawMessage(`{"content":"line1\\nline2"}`)
args := DecodeToolCallArguments(raw, "write_file")
if args["content"] != `line1\nline2` {
t.Errorf("content = %q, want literal backslash-n", args["content"])
}
}
func TestDecodeToolCallArguments_StringJSON(t *testing.T) {
raw := json.RawMessage(`"{\"city\":\"SF\"}"`)
args := DecodeToolCallArguments(raw, "test")

View file

@ -114,7 +114,7 @@ func ResolveAPIBase(cfg *config.ModelConfig) string {
// CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create.
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini),
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq),
// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims.
// See the switch on protocol in this function for the authoritative list.
// Returns the provider, the model ID (without protocol prefix), and any error.
@ -218,7 +218,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
return provider, modelID, nil
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice",
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
@ -242,6 +242,24 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.CustomHeaders,
), modelID, nil
case "gemini":
if cfg.APIKey() == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for gemini protocol (model: %s)", cfg.Model)
}
apiBase := cfg.APIBase
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
return NewGeminiProvider(
cfg.APIKey(),
apiBase,
cfg.Proxy,
userAgent,
cfg.RequestTimeout,
cfg.ExtraBody,
cfg.CustomHeaders,
), modelID, nil
case "minimax":
// Minimax requires reasoning_split: true in the request body
if cfg.APIKey() == "" && cfg.APIBase == "" {

View file

@ -434,6 +434,62 @@ func TestCreateProviderFromConfig_Antigravity(t *testing.T) {
}
}
func TestCreateProviderFromConfig_Gemini(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-gemini",
Model: "gemini/gemini-2.5-flash",
}
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 != "gemini-2.5-flash" {
t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash")
}
if _, ok := provider.(*GeminiProvider); !ok {
t.Fatalf("expected *GeminiProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_GeminiMissingAPIKey(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-gemini-no-key",
Model: "gemini/gemini-2.5-flash",
}
_, _, err := CreateProviderFromConfig(cfg)
if err == nil {
t.Fatal("CreateProviderFromConfig() expected error for missing gemini API key")
}
}
func TestCreateProviderFromConfig_GeminiCustomAPIBaseWithoutKey(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-gemini-custom-base",
Model: "gemini/gemini-2.5-flash",
APIBase: "https://proxy.example.com/v1beta",
}
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 != "gemini-2.5-flash" {
t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash")
}
if _, ok := provider.(*GeminiProvider); !ok {
t.Fatalf("expected *GeminiProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-claude-cli",

View file

@ -0,0 +1,796 @@
package providers
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
const (
geminiDefaultAPIBase = "https://generativelanguage.googleapis.com/v1beta"
geminiDefaultModel = "gemini-2.0-flash"
)
type GeminiProvider struct {
apiKey string
apiBase string
httpClient *http.Client
extraBody map[string]any
customHeaders map[string]string
userAgent string
}
func NewGeminiProvider(
apiKey string,
apiBase string,
proxy string,
userAgent string,
requestTimeoutSeconds int,
extraBody map[string]any,
customHeaders map[string]string,
) *GeminiProvider {
if strings.TrimSpace(apiBase) == "" {
apiBase = geminiDefaultAPIBase
}
client := common.NewHTTPClient(proxy)
if requestTimeoutSeconds > 0 {
client.Timeout = time.Duration(requestTimeoutSeconds) * time.Second
}
return &GeminiProvider{
apiKey: strings.TrimSpace(apiKey),
apiBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"),
httpClient: client,
extraBody: cloneAnyMap(extraBody),
customHeaders: cloneStringMap(customHeaders),
userAgent: strings.TrimSpace(userAgent),
}
}
func (p *GeminiProvider) GetDefaultModel() string {
return geminiDefaultModel
}
func (p *GeminiProvider) SupportsThinking() bool {
return true
}
func (p *GeminiProvider) Chat(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (*LLMResponse, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
model = normalizeGeminiModel(model)
requestBody := p.buildRequestBody(messages, tools, model, options)
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
url := fmt.Sprintf("%s/models/%s:generateContent", p.apiBase, model)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
p.applyHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, common.HandleErrorResponse(resp, p.apiBase)
}
var apiResp geminiGenerateContentResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return parseGeminiResponse(&apiResp), nil
}
func (p *GeminiProvider) ChatStream(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
onChunk func(accumulated string),
) (*LLMResponse, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
model = normalizeGeminiModel(model)
requestBody := p.buildRequestBody(messages, tools, model, options)
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
url := fmt.Sprintf("%s/models/%s:streamGenerateContent?alt=sse", p.apiBase, model)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
p.applyHeaders(req)
req.Header.Set("Accept", "text/event-stream")
// Streaming should not use a whole-request timeout; context cancellation is the guard.
streamClient := &http.Client{Transport: p.httpClient.Transport}
resp, err := streamClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, common.HandleErrorResponse(resp, p.apiBase)
}
return parseGeminiStreamResponse(ctx, resp.Body, onChunk)
}
func (p *GeminiProvider) applyHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
req.Header.Set("X-Goog-Api-Key", p.apiKey)
}
if p.userAgent != "" {
req.Header.Set("User-Agent", p.userAgent)
}
for k, v := range p.customHeaders {
if strings.TrimSpace(k) == "" {
continue
}
req.Header.Set(k, v)
}
}
func (p *GeminiProvider) buildRequestBody(
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) map[string]any {
contents := make([]geminiContent, 0, len(messages))
toolCallNames := make(map[string]string)
systemPrompts := make([]string, 0, 1)
for _, msg := range messages {
switch msg.Role {
case "system":
if strings.TrimSpace(msg.Content) != "" {
systemPrompts = append(systemPrompts, msg.Content)
}
case "user":
if msg.ToolCallID != "" {
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
contents = append(contents, geminiContent{
Role: "user",
Parts: []geminiPart{{
FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media),
}},
})
continue
}
parts := make([]geminiPart, 0, 1+len(msg.Media))
if strings.TrimSpace(msg.Content) != "" {
parts = append(parts, geminiPart{Text: msg.Content})
}
parts = append(parts, buildInlineMediaParts(msg.Media)...)
if len(parts) > 0 {
contents = append(contents, geminiContent{Role: "user", Parts: parts})
}
case "assistant":
content := geminiContent{Role: "model"}
if strings.TrimSpace(msg.Content) != "" {
content.Parts = append(content.Parts, geminiPart{Text: msg.Content})
}
for _, tc := range msg.ToolCalls {
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
if toolName == "" {
continue
}
if tc.ID != "" {
toolCallNames[tc.ID] = toolName
}
part := geminiPart{
FunctionCall: &geminiFunctionCall{
Name: toolName,
Args: toolArgs,
ID: tc.ID,
},
}
if thoughtSignature != "" {
part.ThoughtSignature = thoughtSignature
}
content.Parts = append(content.Parts, part)
}
if len(content.Parts) > 0 {
contents = append(contents, content)
}
case "tool":
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
contents = append(contents, geminiContent{
Role: "user",
Parts: []geminiPart{{
FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media),
}},
})
}
}
body := map[string]any{
"contents": contents,
}
if len(systemPrompts) > 0 {
systemParts := make([]geminiPart, 0, len(systemPrompts))
for _, prompt := range systemPrompts {
systemParts = append(systemParts, geminiPart{Text: prompt})
}
body["systemInstruction"] = &geminiContent{Parts: systemParts}
}
if len(tools) > 0 {
funcDecls := make([]geminiFunctionDeclaration, 0, len(tools))
for _, t := range tools {
if t.Type != "function" {
continue
}
funcDecls = append(funcDecls, geminiFunctionDeclaration{
Name: t.Function.Name,
Description: t.Function.Description,
Parameters: sanitizeSchemaForGemini(t.Function.Parameters),
})
}
if len(funcDecls) > 0 {
body["tools"] = []geminiTool{{FunctionDeclarations: funcDecls}}
}
}
generationConfig := make(map[string]any)
if val, ok := options["max_tokens"]; ok {
if maxTokens, ok := val.(int); ok && maxTokens > 0 {
generationConfig["maxOutputTokens"] = maxTokens
} else if maxTokens, ok := val.(float64); ok && maxTokens > 0 {
generationConfig["maxOutputTokens"] = int(maxTokens)
}
}
if temp, ok := options["temperature"].(float64); ok {
generationConfig["temperature"] = temp
}
if thinkingConfig := buildGeminiThinkingConfig(model, options); len(thinkingConfig) > 0 {
generationConfig["thinkingConfig"] = thinkingConfig
}
if len(generationConfig) > 0 {
body["generationConfig"] = generationConfig
}
for k, v := range p.extraBody {
body[k] = v
}
return body
}
func normalizeGeminiModel(model string) string {
model = strings.TrimSpace(model)
model = strings.TrimPrefix(model, "models/")
if strings.Contains(model, "/") {
_, modelID := ExtractProtocol(model)
if modelID != "" {
return modelID
}
}
if model == "" {
return geminiDefaultModel
}
return model
}
func mapGeminiThinkingLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "minimal", "off":
return "minimal"
case "low":
return "low"
case "medium":
return "medium"
case "high", "xhigh", "adaptive":
return "high"
default:
return ""
}
}
func buildGeminiThinkingConfig(model string, options map[string]any) map[string]any {
if !geminiModelSupportsThinkingConfig(model) {
return nil
}
config := map[string]any{}
rawLevel, _ := options["thinking_level"].(string)
rawLevel = strings.ToLower(strings.TrimSpace(rawLevel))
if rawLevel == "" {
// Align with agent-level default: unset means ThinkingOff.
rawLevel = "off"
}
includeThoughts := rawLevel != "off" && rawLevel != "minimal"
config["includeThoughts"] = includeThoughts
if isGemini25Model(model) {
if isGemini25ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") {
// Gemini 2.5 Pro cannot disable thinking; keep model-default thinking.
return config
}
if budget, ok := mapGeminiThinkingBudget(rawLevel); ok {
config["thinkingBudget"] = budget
}
return config
}
if isGemini3ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") {
// Gemini 3.x Pro does not support minimal thinking level.
return config
}
if thinkingLevel := mapGeminiThinkingLevel(rawLevel); thinkingLevel != "" {
config["thinkingLevel"] = thinkingLevel
}
return config
}
func geminiModelSupportsThinkingConfig(model string) bool {
lowerModel := strings.ToLower(strings.TrimSpace(model))
return strings.Contains(lowerModel, "gemini-3") || isGemini25Model(lowerModel)
}
func isGemini25Model(model string) bool {
lowerModel := strings.ToLower(strings.TrimSpace(model))
return strings.Contains(lowerModel, "gemini-2.5") || strings.Contains(lowerModel, "gemini-25")
}
func isGemini25ProModel(model string) bool {
lowerModel := strings.ToLower(strings.TrimSpace(model))
return isGemini25Model(lowerModel) && strings.Contains(lowerModel, "pro")
}
func isGemini3ProModel(model string) bool {
lowerModel := strings.ToLower(strings.TrimSpace(model))
return strings.Contains(lowerModel, "gemini-3") && strings.Contains(lowerModel, "pro")
}
func mapGeminiThinkingBudget(level string) (int, bool) {
level = strings.ToLower(strings.TrimSpace(level))
if level == "" {
return 0, false
}
switch level {
case "adaptive":
return -1, true
case "minimal":
return 0, true
case "off":
return 0, true
case "low":
return 1024, true
case "medium":
return 4096, true
case "high":
return 8192, true
case "xhigh":
return 16384, true
default:
return 0, false
}
}
func parseGeminiResponse(resp *geminiGenerateContentResponse) *LLMResponse {
contentParts := make([]string, 0)
reasoningParts := make([]string, 0)
toolCalls := make([]ToolCall, 0)
finishReason := ""
for _, candidate := range resp.Candidates {
for _, part := range candidate.Content.Parts {
if part.Text != "" {
if part.Thought {
reasoningParts = append(reasoningParts, part.Text)
} else {
contentParts = append(contentParts, part.Text)
}
}
if part.FunctionCall != nil {
toolCalls = append(toolCalls, buildGeminiToolCall(part))
}
}
if candidate.FinishReason != "" {
finishReason = candidate.FinishReason
}
}
var usage *UsageInfo
if resp.UsageMetadata.TotalTokenCount > 0 {
usage = &UsageInfo{
PromptTokens: resp.UsageMetadata.PromptTokenCount,
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
TotalTokens: resp.UsageMetadata.TotalTokenCount,
}
}
return &LLMResponse{
Content: strings.Join(contentParts, ""),
ReasoningContent: strings.Join(reasoningParts, ""),
ToolCalls: toolCalls,
FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)),
Usage: usage,
}
}
func parseGeminiStreamResponse(
ctx context.Context,
reader io.Reader,
onChunk func(accumulated string),
) (*LLMResponse, error) {
var contentBuilder strings.Builder
var reasoningBuilder strings.Builder
var finishReason string
var usage *UsageInfo
toolCallsByID := make(map[string]ToolCall)
toolCallOrder := make([]string, 0)
fallbackIndex := 0
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
if err := ctx.Err(); err != nil {
return nil, err
}
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
if data == "" {
continue
}
if data == "[DONE]" {
break
}
var chunk geminiGenerateContentResponse
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("invalid gemini stream chunk: %w", err)
}
for _, candidate := range chunk.Candidates {
for _, part := range candidate.Content.Parts {
if part.Text != "" {
if part.Thought {
reasoningBuilder.WriteString(part.Text)
} else {
contentBuilder.WriteString(part.Text)
if onChunk != nil {
onChunk(contentBuilder.String())
}
}
}
if part.FunctionCall != nil {
tc := buildGeminiToolCall(part)
if strings.TrimSpace(tc.Name) == "" {
continue
}
key := strings.TrimSpace(part.FunctionCall.ID)
if key == "" {
if len(toolCallOrder) > 0 {
lastKey := toolCallOrder[len(toolCallOrder)-1]
if lastTC, exists := toolCallsByID[lastKey]; exists && lastTC.Name == tc.Name {
key = lastKey
}
}
if key == "" {
fallbackIndex++
key = fmt.Sprintf("%s#%d", tc.Name, fallbackIndex)
}
}
tc.ID = key
if _, exists := toolCallsByID[key]; !exists {
toolCallOrder = append(toolCallOrder, key)
}
toolCallsByID[key] = tc
}
}
if candidate.FinishReason != "" {
finishReason = candidate.FinishReason
}
}
if chunk.UsageMetadata.TotalTokenCount > 0 {
usage = &UsageInfo{
PromptTokens: chunk.UsageMetadata.PromptTokenCount,
CompletionTokens: chunk.UsageMetadata.CandidatesTokenCount,
TotalTokens: chunk.UsageMetadata.TotalTokenCount,
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("streaming read error: %w", err)
}
toolCalls := make([]ToolCall, 0, len(toolCallOrder))
for _, key := range toolCallOrder {
toolCalls = append(toolCalls, toolCallsByID[key])
}
return &LLMResponse{
Content: contentBuilder.String(),
ReasoningContent: reasoningBuilder.String(),
ToolCalls: toolCalls,
FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)),
Usage: usage,
}, nil
}
func normalizeGeminiFinishReason(reason string, toolCalls int) string {
if toolCalls > 0 {
return "tool_calls"
}
switch strings.ToUpper(strings.TrimSpace(reason)) {
case "MAX_TOKENS":
return "length"
case "", "STOP":
return "stop"
default:
return strings.ToLower(strings.TrimSpace(reason))
}
}
func buildGeminiToolCall(part geminiPart) ToolCall {
if part.FunctionCall == nil {
return ToolCall{}
}
args := part.FunctionCall.Args
if args == nil {
args = make(map[string]any)
}
argsJSON, _ := json.Marshal(args)
thoughtSignature := extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake)
toolCall := ToolCall{
ID: part.FunctionCall.ID,
Name: part.FunctionCall.Name,
Arguments: args,
ThoughtSignature: thoughtSignature,
Function: &FunctionCall{
Name: part.FunctionCall.Name,
Arguments: string(argsJSON),
ThoughtSignature: thoughtSignature,
},
}
if thoughtSignature != "" {
toolCall.ExtraContent = &ExtraContent{
Google: &GoogleExtra{ThoughtSignature: thoughtSignature},
}
}
if strings.TrimSpace(toolCall.ID) == "" {
toolCall.ID = fmt.Sprintf("call_%s_%d", toolCall.Name, time.Now().UnixNano())
}
return toolCall
}
func buildInlineMediaParts(media []string) []geminiPart {
parts := make([]geminiPart, 0, len(media))
for _, mediaURL := range media {
mimeType, data, ok := parseBase64DataURL(mediaURL)
if !ok {
continue
}
parts = append(parts, geminiPart{
InlineData: &geminiInlineData{
MIMEType: mimeType,
Data: data,
},
})
}
return parts
}
func buildGeminiFunctionResponse(
toolName string,
toolCallID string,
result string,
media []string,
) *geminiFunctionResponse {
response := &geminiFunctionResponse{
ID: toolCallID,
Name: toolName,
Response: map[string]any{
"result": result,
},
}
if parts := buildFunctionResponseMediaParts(media); len(parts) > 0 {
response.Parts = parts
}
return response
}
func buildFunctionResponseMediaParts(media []string) []geminiFunctionResponsePart {
parts := make([]geminiFunctionResponsePart, 0, len(media))
for i, mediaURL := range media {
mimeType, data, ok := parseBase64DataURL(mediaURL)
if !ok {
continue
}
parts = append(parts, geminiFunctionResponsePart{
InlineData: &geminiInlineData{
MIMEType: mimeType,
Data: data,
DisplayName: defaultFunctionResponseDisplayName(mimeType, i+1),
},
})
}
return parts
}
func defaultFunctionResponseDisplayName(mimeType string, index int) string {
suffix := "bin"
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "image/png":
suffix = "png"
case "image/jpeg":
suffix = "jpg"
case "image/webp":
suffix = "webp"
case "application/pdf":
suffix = "pdf"
case "text/plain":
suffix = "txt"
}
return fmt.Sprintf("attachment-%d.%s", index, suffix)
}
func parseBase64DataURL(mediaURL string) (mimeType string, data string, ok bool) {
if !strings.HasPrefix(mediaURL, "data:") {
return "", "", false
}
payload := strings.TrimPrefix(mediaURL, "data:")
header, data, found := strings.Cut(payload, ",")
if !found {
return "", "", false
}
mimeType, params, _ := strings.Cut(header, ";")
mimeType = strings.TrimSpace(mimeType)
data = strings.TrimSpace(data)
if mimeType == "" || data == "" {
return "", "", false
}
if !strings.Contains(strings.ToLower(params), "base64") {
return "", "", false
}
return mimeType, data, true
}
func cloneAnyMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
type geminiGenerateContentResponse struct {
Candidates []struct {
Content struct {
Role string `json:"role"`
Parts []geminiPart `json:"parts"`
} `json:"content"`
FinishReason string `json:"finishReason"`
} `json:"candidates"`
UsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
} `json:"usageMetadata"`
}
type geminiContent struct {
Role string `json:"role,omitempty"`
Parts []geminiPart `json:"parts"`
}
type geminiPart struct {
Text string `json:"text,omitempty"`
Thought bool `json:"thought,omitempty"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
InlineData *geminiInlineData `json:"inlineData,omitempty"`
FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"`
}
type geminiInlineData struct {
MIMEType string `json:"mimeType"`
Data string `json:"data"`
DisplayName string `json:"displayName,omitempty"`
}
type geminiFunctionCall struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Args map[string]any `json:"args,omitempty"`
}
type geminiFunctionResponse struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Response map[string]any `json:"response"`
Parts []geminiFunctionResponsePart `json:"parts,omitempty"`
}
type geminiFunctionResponsePart struct {
InlineData *geminiInlineData `json:"inlineData,omitempty"`
}
type geminiTool struct {
FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations"`
}
type geminiFunctionDeclaration struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters any `json:"parameters,omitempty"`
}

View file

@ -0,0 +1,763 @@
package providers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGeminiProvider_ChatSeparatesThoughtAndToolCall(t *testing.T) {
var capturedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
if !strings.Contains(r.URL.Path, ":generateContent") {
t.Fatalf("path = %s, expected generateContent endpoint", r.URL.Path)
}
if got := r.Header.Get("X-Goog-Api-Key"); got != "test-key" {
t.Fatalf("X-Goog-Api-Key = %q, want %q", got, "test-key")
}
if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []any{
map[string]any{
"content": map[string]any{
"role": "model",
"parts": []any{
map[string]any{"text": "hidden", "thought": true},
map[string]any{"text": "visible"},
map[string]any{
"functionCall": map[string]any{
"id": "call_1",
"name": "search",
"args": map[string]any{"q": "hi"},
},
"thoughtSignature": "sig-1",
},
},
},
"finishReason": "STOP",
},
},
"usageMetadata": map[string]any{
"promptTokenCount": 2,
"candidatesTokenCount": 3,
"totalTokenCount": 5,
},
})
}))
defer server.Close()
provider := NewGeminiProvider("test-key", server.URL, "", "picoclaw-test", 0, nil, nil)
resp, err := provider.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-3-flash-preview",
map[string]any{"thinking_level": "high"},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if resp.Content != "visible" {
t.Fatalf("Content = %q, want %q", resp.Content, "visible")
}
if resp.ReasoningContent != "hidden" {
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden")
}
if resp.FinishReason != "tool_calls" {
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
}
if resp.Usage == nil || resp.Usage.TotalTokens != 5 {
t.Fatalf("Usage = %#v, expected total tokens = 5", resp.Usage)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
}
if resp.ToolCalls[0].ID != "call_1" {
t.Fatalf("ToolCall ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
}
if resp.ToolCalls[0].Name != "search" {
t.Fatalf("ToolCall Name = %q, want %q", resp.ToolCalls[0].Name, "search")
}
if resp.ToolCalls[0].ThoughtSignature != "sig-1" {
t.Fatalf("ToolCall ThoughtSignature = %q, want %q", resp.ToolCalls[0].ThoughtSignature, "sig-1")
}
if resp.ToolCalls[0].Function == nil || !strings.Contains(resp.ToolCalls[0].Function.Arguments, `"q":"hi"`) {
t.Fatalf("ToolCall Function arguments = %#v, want q=hi", resp.ToolCalls[0].Function)
}
generationConfig, ok := capturedBody["generationConfig"].(map[string]any)
if !ok {
t.Fatalf("request missing generationConfig: %#v", capturedBody)
}
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
if !ok {
t.Fatalf("request missing thinkingConfig: %#v", generationConfig)
}
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
t.Fatalf("thinkingConfig.includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
}
if got := thinkingConfig["thinkingLevel"]; got != "high" {
t.Fatalf("thinkingConfig.thinkingLevel = %#v, want %q", got, "high")
}
}
func TestGeminiProvider_ChatStreamParsesThoughtTextAndToolCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, ":streamGenerateContent") {
t.Fatalf("path = %s, expected streamGenerateContent endpoint", r.URL.Path)
}
if got := r.URL.Query().Get("alt"); got != "sse" {
t.Fatalf("alt query = %q, want %q", got, "sse")
}
w.Header().Set("Content-Type", "text/event-stream")
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("response writer is not flushable")
}
chunks := []map[string]any{
{
"candidates": []any{map[string]any{
"content": map[string]any{
"parts": []any{
map[string]any{"text": "think ", "thought": true},
map[string]any{"text": "Hello "},
},
},
}},
},
{
"candidates": []any{map[string]any{
"content": map[string]any{
"parts": []any{
map[string]any{"text": "World"},
map[string]any{
"functionCall": map[string]any{
"id": "call_stream",
"name": "search",
"args": map[string]any{"q": "stream"},
},
},
},
},
"finishReason": "STOP",
}},
"usageMetadata": map[string]any{
"promptTokenCount": 1,
"candidatesTokenCount": 2,
"totalTokenCount": 3,
},
},
}
for _, chunk := range chunks {
raw, err := json.Marshal(chunk)
if err != nil {
t.Fatalf("marshal chunk: %v", err)
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
t.Fatalf("write chunk: %v", err)
}
flusher.Flush()
}
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}))
defer server.Close()
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
updates := make([]string, 0)
resp, err := provider.ChatStream(
t.Context(),
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.5-flash",
nil,
func(accumulated string) {
updates = append(updates, accumulated)
},
)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
if resp.Content != "Hello World" {
t.Fatalf("Content = %q, want %q", resp.Content, "Hello World")
}
if resp.ReasoningContent != "think " {
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "think ")
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].ID != "call_stream" {
t.Fatalf("ToolCalls = %#v, want single call_stream", resp.ToolCalls)
}
if resp.FinishReason != "tool_calls" {
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
}
if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
t.Fatalf("Usage = %#v, expected total tokens = 3", resp.Usage)
}
if len(updates) < 2 || updates[len(updates)-1] != "Hello World" {
t.Fatalf("stream updates = %#v, expected final accumulated text", updates)
}
}
func TestGeminiProvider_ChatStreamSkipsEmptyDataFrames(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("response writer is not flushable")
}
_, _ = fmt.Fprint(w, "data: \n\n")
flusher.Flush()
chunk := map[string]any{
"candidates": []any{map[string]any{
"content": map[string]any{
"parts": []any{map[string]any{"text": "ok"}},
},
"finishReason": "STOP",
}},
}
raw, err := json.Marshal(chunk)
if err != nil {
t.Fatalf("marshal chunk: %v", err)
}
_, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
flusher.Flush()
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}))
defer server.Close()
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
resp, err := provider.ChatStream(
t.Context(),
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.5-flash",
nil,
nil,
)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
if resp.Content != "ok" {
t.Fatalf("Content = %q, want %q", resp.Content, "ok")
}
}
func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("response writer is not flushable")
}
_, _ = fmt.Fprint(w, "data: {invalid-json}\n\n")
flusher.Flush()
}))
defer server.Close()
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
_, err := provider.ChatStream(
t.Context(),
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.5-flash",
nil,
nil,
)
if err == nil {
t.Fatal("ChatStream() expected error for invalid SSE data frame")
}
if !strings.Contains(err.Error(), "invalid gemini stream chunk") {
t.Fatalf("error = %v, want contains %q", err, "invalid gemini stream chunk")
}
}
func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{{
Role: "assistant",
ToolCalls: []ToolCall{{
ID: "call_1",
Name: "search",
Arguments: map[string]any{"q": "hello"},
Function: &FunctionCall{
Name: "search",
Arguments: `{"q":"hello"}`,
ThoughtSignature: "sig-1",
},
}},
}},
nil,
"gemini-2.5-flash",
nil,
)
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body: %v", err)
}
jsonBody := string(raw)
if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) {
t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody)
}
if strings.Contains(jsonBody, `"thought_signature"`) {
t.Fatalf("request body = %s, unexpected snake_case thought_signature", jsonBody)
}
}
func TestGeminiProvider_ChatStreamCoalescesToolCallWithoutWireID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("response writer is not flushable")
}
chunks := []map[string]any{
{
"candidates": []any{map[string]any{
"content": map[string]any{
"parts": []any{
map[string]any{
"functionCall": map[string]any{
"name": "search",
"args": map[string]any{"q": "first"},
},
},
},
},
}},
},
{
"candidates": []any{map[string]any{
"content": map[string]any{
"parts": []any{
map[string]any{
"functionCall": map[string]any{
"name": "search",
"args": map[string]any{"q": "second"},
},
},
},
},
"finishReason": "STOP",
}},
},
}
for _, chunk := range chunks {
raw, err := json.Marshal(chunk)
if err != nil {
t.Fatalf("marshal chunk: %v", err)
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
t.Fatalf("write chunk: %v", err)
}
flusher.Flush()
}
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}))
defer server.Close()
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
resp, err := provider.ChatStream(
t.Context(),
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.5-flash",
nil,
nil,
)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
}
tc := resp.ToolCalls[0]
if tc.ID != "search#1" {
t.Fatalf("ToolCall ID = %q, want %q", tc.ID, "search#1")
}
if tc.Name != "search" {
t.Fatalf("ToolCall Name = %q, want %q", tc.Name, "search")
}
if argQ, ok := tc.Arguments["q"].(string); !ok || argQ != "second" {
t.Fatalf("ToolCall Arguments = %#v, want q=second", tc.Arguments)
}
if resp.FinishReason != "tool_calls" {
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
}
}
func TestGeminiProvider_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{{
Role: "user",
Content: "analyze attachments",
Media: []string{
"data:application/pdf;base64,UEZERGF0YQ==",
"data:image/png;base64,aW1hZ2VEYXRh",
},
}},
nil,
"gemini-3-flash-preview",
map[string]any{
"thinking_level": "low",
"max_tokens": 128,
"temperature": 0.2,
},
)
contents, ok := body["contents"].([]geminiContent)
if !ok || len(contents) != 1 {
t.Fatalf("contents = %#v, want one gemini content", body["contents"])
}
parts := contents[0].Parts
mimeSet := map[string]bool{}
for _, part := range parts {
if part.InlineData != nil {
mimeSet[part.InlineData.MIMEType] = true
}
}
if !mimeSet["application/pdf"] {
t.Fatalf("inline media missing application/pdf: %#v", parts)
}
if !mimeSet["image/png"] {
t.Fatalf("inline media missing image/png: %#v", parts)
}
generationConfig, ok := body["generationConfig"].(map[string]any)
if !ok {
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
}
if got := generationConfig["maxOutputTokens"]; got != 128 {
t.Fatalf("maxOutputTokens = %#v, want 128", got)
}
if got := generationConfig["temperature"]; got != 0.2 {
t.Fatalf("temperature = %#v, want 0.2", got)
}
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
if !ok {
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
}
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
t.Fatalf("includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
}
if got := thinkingConfig["thinkingLevel"]; got != "low" {
t.Fatalf("thinkingLevel = %#v, want %q", got, "low")
}
}
func TestGeminiProvider_BuildRequestBody_UsesThinkingBudgetForGemini25(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.5-flash",
map[string]any{"thinking_level": "medium"},
)
generationConfig, ok := body["generationConfig"].(map[string]any)
if !ok {
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
}
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
if !ok {
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
}
if got := thinkingConfig["thinkingBudget"]; got != 4096 {
t.Fatalf("thinkingBudget = %#v, want 4096", got)
}
if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
t.Fatalf("thinkingLevel should not be set for Gemini 2.5: %#v", thinkingConfig)
}
}
func TestGeminiProvider_BuildRequestBody_OmitsThinkingConfigForGemini20(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.0-flash-exp",
map[string]any{"thinking_level": "high"},
)
if _, ok := body["generationConfig"]; ok {
t.Fatalf("generationConfig should be omitted for Gemini 2.0 when only thinking_level is set: %#v", body)
}
}
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.5-flash",
nil,
)
generationConfig, ok := body["generationConfig"].(map[string]any)
if !ok {
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
}
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
if !ok {
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
}
if got := thinkingConfig["thinkingBudget"]; got != 0 {
t.Fatalf("thinkingBudget = %#v, want 0 for default/off", got)
}
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
}
}
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini3(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-3-flash-preview",
nil,
)
generationConfig, ok := body["generationConfig"].(map[string]any)
if !ok {
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
}
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
if !ok {
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
}
if got := thinkingConfig["thinkingLevel"]; got != "minimal" {
t.Fatalf("thinkingLevel = %#v, want minimal for default/off", got)
}
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
}
}
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25Pro(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.5-pro",
nil,
)
generationConfig, ok := body["generationConfig"].(map[string]any)
if !ok {
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
}
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
if !ok {
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
}
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
}
if _, hasBudget := thinkingConfig["thinkingBudget"]; hasBudget {
t.Fatalf("thinkingBudget should be omitted for Gemini 2.5 Pro default/off: %#v", thinkingConfig)
}
}
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini31Pro(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-3.1-pro",
nil,
)
generationConfig, ok := body["generationConfig"].(map[string]any)
if !ok {
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
}
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
if !ok {
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
}
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
}
if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
t.Fatalf("thinkingLevel should be omitted for Gemini 3.1 Pro default/off: %#v", thinkingConfig)
}
}
func TestGeminiProvider_BuildRequestBody_PreservesMultipleSystemMessages(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{
{Role: "system", Content: "You are helpful."},
{Role: "system", Content: "Be concise."},
{Role: "user", Content: "hello"},
},
nil,
"gemini-3-flash-preview",
nil,
)
systemInstruction, ok := body["systemInstruction"].(*geminiContent)
if !ok || systemInstruction == nil {
t.Fatalf("systemInstruction = %#v, want *geminiContent", body["systemInstruction"])
}
if len(systemInstruction.Parts) != 2 {
t.Fatalf("systemInstruction.Parts len = %d, want 2", len(systemInstruction.Parts))
}
if systemInstruction.Parts[0].Text != "You are helpful." || systemInstruction.Parts[1].Text != "Be concise." {
t.Fatalf("systemInstruction.Parts = %#v, want ordered system prompts", systemInstruction.Parts)
}
}
func TestGeminiProvider_BuildRequestBody_PreservesToolResponseMedia(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
body := provider.buildRequestBody(
[]Message{
{
Role: "assistant",
ToolCalls: []ToolCall{{
ID: "call_1",
Name: "load_image",
Arguments: map[string]any{"path": "demo.png"},
}},
},
{
Role: "tool",
ToolCallID: "call_1",
Content: "tool result",
Media: []string{
"data:image/png;base64,aW1hZ2VEYXRh",
"data:application/pdf;base64,UEZERGF0YQ==",
},
},
},
nil,
"gemini-3-flash-preview",
nil,
)
contents, ok := body["contents"].([]geminiContent)
if !ok || len(contents) != 2 {
t.Fatalf("contents = %#v, want two content entries", body["contents"])
}
parts := contents[1].Parts
if len(parts) != 1 || parts[0].FunctionResponse == nil {
t.Fatalf("tool response part = %#v, want functionResponse", parts)
}
response := parts[0].FunctionResponse
if response.Name != "load_image" {
t.Fatalf("functionResponse.Name = %q, want %q", response.Name, "load_image")
}
if response.Response["result"] != "tool result" {
t.Fatalf("functionResponse.Response = %#v, want result=tool result", response.Response)
}
if len(response.Parts) != 2 {
t.Fatalf("functionResponse.Parts len = %d, want 2", len(response.Parts))
}
}
func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token")
}
if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []any{
map[string]any{
"content": map[string]any{
"parts": []any{map[string]any{"text": "ok"}},
},
"finishReason": "STOP",
},
},
})
}))
defer server.Close()
provider := NewGeminiProvider(
"",
server.URL,
"",
"",
0,
nil,
map[string]string{"Authorization": "Bearer test-token"},
)
resp, err := provider.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.5-flash",
nil,
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if resp.Content != "ok" {
t.Fatalf("Content = %q, want %q", resp.Content, "ok")
}
}
func TestGeminiProvider_ChatAllowsMissingAPIKeyForCustomAPIBase(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []any{
map[string]any{
"content": map[string]any{"parts": []any{map[string]any{"text": "ok"}}},
"finishReason": "STOP",
},
},
})
}))
defer server.Close()
provider := NewGeminiProvider("", server.URL, "", "", 0, nil, nil)
resp, err := provider.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hello"}},
nil,
"gemini-2.5-flash",
nil,
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if resp.Content != "ok" {
t.Fatalf("Content = %q, want %q", resp.Content, "ok")
}
}

View file

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log"
"maps"
"net/http"
"net/url"
"strings"
@ -181,9 +182,7 @@ func (p *Provider) buildRequestBody(
// Merge extra body fields configured per-provider/model.
// These are injected last so they take precedence over defaults.
for k, v := range p.extraBody {
requestBody[k] = v
}
maps.Copy(requestBody, p.extraBody)
return requestBody
}

View file

@ -0,0 +1,70 @@
package seahorse
import (
"regexp"
"strings"
)
// phraseRegex matches complete quoted phrases like "exact phrase".
// Compiled once at package level to avoid per-call overhead.
var phraseRegex = regexp.MustCompile(`"([^"]+)"`)
// SanitizeFTS5Query escapes user input for safe use in an FTS5 MATCH expression.
//
// FTS5 treats certain characters as operators:
// - `-` (NOT), `+` (required), `*` (prefix), `^` (initial token)
// - `OR`, `AND`, `NOT`, `NEAR` (boolean/proximity operators)
// - `:` (column filter — e.g. `agent:foo` means "search column agent")
// - `"` (phrase query), `(` `)` (grouping)
//
// Strategy: wrap each whitespace-delimited token in double quotes so FTS5
// treats it as a literal phrase token. User-quoted phrases ("...") are
// preserved as-is. Internal double quotes are stripped. Empty tokens are
// dropped. Tokens are joined with spaces (implicit AND).
//
// Returns empty string for blank input so callers can skip the MATCH query.
//
// Examples:
//
// "sub-agent restrict" → `"sub-agent" "restrict"`
// "lcm_expand OR crash" → `"lcm_expand" "OR" "crash"`
// `hello "world"` → `"hello" "world"`
func SanitizeFTS5Query(raw string) string {
if strings.TrimSpace(raw) == "" {
return ""
}
// Preserve user-quoted phrases: extract "..." groups first, then tokenize the rest.
var parts []string
lastIndex := 0
for _, loc := range phraseRegex.FindAllStringIndex(raw, -1) {
// Process unquoted text before this phrase
before := raw[lastIndex:loc[0]]
for _, t := range strings.Fields(before) {
t = strings.ReplaceAll(t, `"`, "")
if t != "" {
parts = append(parts, `"`+t+`"`)
}
}
// Preserve the phrase as-is (strip internal quotes for safety)
phrase := strings.TrimSpace(strings.ReplaceAll(raw[loc[0]+1:loc[1]-1], `"`, ""))
if phrase != "" {
parts = append(parts, `"`+phrase+`"`)
}
lastIndex = loc[1]
}
// Process unquoted text after last phrase
for _, t := range strings.Fields(raw[lastIndex:]) {
t = strings.ReplaceAll(t, `"`, "")
if t != "" {
parts = append(parts, `"`+t+`"`)
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, " ")
}

View file

@ -0,0 +1,237 @@
package seahorse
import (
"context"
"testing"
)
func TestSanitizeFTS5Query(t *testing.T) {
tests := []struct {
input string
want string
}{
// Basic tokens
{"hello world", `"hello" "world"`},
{"database", `"database"`},
// FTS5 operators neutralized
{"sub-agent", `"sub-agent"`},
{"agent:main", `"agent:main"`},
{"+required", `"+required"`},
{"prefix*", `"prefix*"`},
{"^initial", `"^initial"`},
{"crash OR restart", `"crash" "OR" "restart"`},
{"NOT excluded", `"NOT" "excluded"`},
{"(grouped)", `"(grouped)"`},
// User-quoted phrases preserved
{`"exact phrase" other`, `"exact phrase" "other"`},
{`before "middle phrase" after`, `"before" "middle phrase" "after"`},
// Unmatched quotes stripped
{`"unmatched`, `"unmatched"`},
{`hello"world`, `"helloworld"`},
// NEAR operator neutralized
{"NEAR/2 agent", `"NEAR/2" "agent"`},
// Empty input
{"", ""},
{" ", ""},
// CJK unaffected
{"数据库连接", `"数据库连接"`},
{"数据库 连接", `"数据库" "连接"`},
{"sub-agent重启", `"sub-agent重启"`},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := SanitizeFTS5Query(tt.input)
if got != tt.want {
t.Errorf("SanitizeFTS5Query(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// TestFTS5SpecialCharsShouldNotError verifies that user input containing
// FTS5 special characters does not cause errors when searching.
func TestFTS5SpecialCharsShouldNotError(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-sanitize")
re := &RetrievalEngine{store: s}
// Seed data with content containing special characters
s.AddMessage(ctx, conv.ConversationID, "user", "the sub-agent restarted after crash", 10)
s.AddMessage(ctx, conv.ConversationID, "assistant", "agent:main session restored successfully", 10)
s.AddMessage(ctx, conv.ConversationID, "user", "use NOT operator in the query filter", 10)
s.CreateSummary(ctx, CreateSummaryInput{
ConversationID: conv.ConversationID,
Kind: SummaryKindLeaf,
Depth: 0,
Content: "sub-agent crashed and was restarted by the orchestrator",
TokenCount: 50,
})
s.CreateSummary(ctx, CreateSummaryInput{
ConversationID: conv.ConversationID,
Kind: SummaryKindLeaf,
Depth: 0,
Content: "agent:main handled the restart procedure",
TokenCount: 50,
})
tests := []struct {
name string
pattern string
wantSummaryMin int
wantMessageMin int
}{
{
name: "hyphen in search term",
pattern: "sub-agent",
wantSummaryMin: 1,
wantMessageMin: 1,
},
{
name: "colon in search term",
pattern: "agent:main",
wantSummaryMin: 1,
wantMessageMin: 1,
},
{
name: "unmatched double quote",
pattern: `"sub-agent`,
wantSummaryMin: 1,
wantMessageMin: 1,
},
{
name: "plus sign",
pattern: "+agent",
wantSummaryMin: 0,
wantMessageMin: 0,
},
{
name: "parentheses",
pattern: "(agent)",
wantSummaryMin: 0,
wantMessageMin: 0,
},
{
name: "NOT keyword",
pattern: "NOT operator",
wantSummaryMin: 0,
wantMessageMin: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := re.Grep(ctx, GrepInput{
Pattern: tt.pattern,
Scope: "both",
})
if err != nil {
t.Fatalf("Grep(%q) returned error: %v", tt.pattern, err)
}
if len(result.Summaries) < tt.wantSummaryMin {
t.Errorf("Grep(%q) summaries = %d, want >= %d",
tt.pattern, len(result.Summaries), tt.wantSummaryMin)
}
if len(result.Messages) < tt.wantMessageMin {
t.Errorf("Grep(%q) messages = %d, want >= %d",
tt.pattern, len(result.Messages), tt.wantMessageMin)
}
})
}
}
// TestFTS5OperatorsNotInterpreted verifies that FTS5 operators are treated
// as literal text, not as query syntax. Each case constructs data where
// boolean interpretation would produce different results than literal matching.
func TestFTS5OperatorsNotInterpreted(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-operators")
re := &RetrievalEngine{store: s}
// "restart only" — contains "restart" but NOT "crash".
// If OR is treated as boolean, "crash OR restart" would match this.
// With sanitization (literal AND), it should NOT match.
s.AddMessage(ctx, conv.ConversationID, "user", "restart the service now please", 10)
// "subcommand" — starts with "sub" but is not "sub-agent".
// If * is treated as prefix wildcard, "sub*" would match this.
// With sanitization (literal "sub*"), it should NOT match.
s.AddMessage(ctx, conv.ConversationID, "user", "run the subcommand to deploy", 10)
// "agent grouped" — contains "agent" but not "(agent)".
// If () is treated as grouping, "(agent)" would match this.
// With sanitization (literal "(agent)"), it should NOT match.
s.AddMessage(ctx, conv.ConversationID, "user", "the agent processed the request", 10)
// Same patterns in summaries
s.CreateSummary(ctx, CreateSummaryInput{
ConversationID: conv.ConversationID,
Kind: SummaryKindLeaf,
Depth: 0,
Content: "restart procedure completed without any crash involvement",
TokenCount: 50,
})
s.CreateSummary(ctx, CreateSummaryInput{
ConversationID: conv.ConversationID,
Kind: SummaryKindLeaf,
Depth: 0,
Content: "subprocess and subcommand management overview",
TokenCount: 50,
})
t.Run("OR must not be boolean", func(t *testing.T) {
// "crash OR restart" as literal means all three tokens must appear.
// The message "restart the service now please" has "restart" but not "crash" or "OR".
// Boolean OR would match it; literal AND should not.
result, err := re.Grep(ctx, GrepInput{Pattern: "crash OR restart", Scope: "message"})
if err != nil {
t.Fatalf("Grep returned error: %v", err)
}
if len(result.Messages) != 0 {
t.Errorf(
"OR treated as boolean: got %d messages, want 0 (only-restart message should not match literal AND of 'crash','OR','restart')",
len(result.Messages),
)
}
})
t.Run("asterisk must not be prefix wildcard", func(t *testing.T) {
// "sub*" as literal means exact trigram match on "sub*".
// The message "run the subcommand to deploy" contains "sub" as prefix.
// Prefix wildcard would match it; literal should not.
result, err := re.Grep(ctx, GrepInput{Pattern: "sub*", Scope: "message"})
if err != nil {
t.Fatalf("Grep returned error: %v", err)
}
if len(result.Messages) != 0 {
t.Errorf(
"asterisk treated as prefix wildcard: got %d messages, want 0 (literal 'sub*' does not appear in any message)",
len(result.Messages),
)
}
})
t.Run("parentheses must not be grouping", func(t *testing.T) {
// "(agent)" as literal means exact trigram match on "(agent)".
// The message "the agent processed the request" contains "agent" without parens.
// Grouping would match it; literal should not.
result, err := re.Grep(ctx, GrepInput{Pattern: "(agent)", Scope: "message"})
if err != nil {
t.Fatalf("Grep returned error: %v", err)
}
if len(result.Messages) != 0 {
t.Errorf(
"parentheses treated as grouping: got %d messages, want 0 (literal '(agent)' does not appear in any message)",
len(result.Messages),
)
}
})
}

View file

@ -2,14 +2,26 @@ package seahorse
import (
"database/sql"
"fmt"
"strings"
"sync/atomic"
"testing"
_ "modernc.org/sqlite"
)
var testDBCounter uint64
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
n := atomic.AddUint64(&testDBCounter, 1)
testName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
// Use a shared in-memory database so concurrent goroutines/connections in tests
// observe the same schema/data.
dsn := fmt.Sprintf("file:seahorse_test_%s_%d?mode=memory&cache=shared", testName, n)
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open test db: %v", err)
}

View file

@ -1178,9 +1178,14 @@ func (s *Store) SearchSummaries(ctx context.Context, input SearchInput) ([]Searc
}
func (s *Store) searchSummariesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
sanitized := SanitizeFTS5Query(input.Pattern)
if sanitized == "" {
return nil, nil
}
// Build WHERE clause for filters (used in both count and data queries)
whereClauses := []string{"summaries_fts MATCH ?"}
args := []any{input.Pattern}
args := []any{sanitized}
if input.ConversationID > 0 && !input.AllConversations {
whereClauses = append(whereClauses, "s.conversation_id = ?")
@ -1326,9 +1331,14 @@ func (s *Store) SearchMessages(ctx context.Context, input SearchInput) ([]Search
}
func (s *Store) searchMessagesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
sanitized := SanitizeFTS5Query(input.Pattern)
if sanitized == "" {
return nil, nil
}
// Build WHERE clause for filters (used in both count and data queries)
whereClauses := []string{"messages_fts MATCH ?"}
args := []any{input.Pattern}
args := []any{sanitized}
if input.ConversationID > 0 && !input.AllConversations {
whereClauses = append(whereClauses, "m.conversation_id = ?")

View file

@ -29,7 +29,7 @@ func (t *EditFileTool) Name() string {
}
func (t *EditFileTool) Description() string {
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n."
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
}
func (t *EditFileTool) Parameters() map[string]any {
@ -42,11 +42,11 @@ func (t *EditFileTool) Parameters() map[string]any {
},
"old_text": map[string]any{
"type": "string",
"description": "The exact text to find and replace. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
"description": "The exact text to find and replace. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
"new_text": map[string]any{
"type": "string",
"description": "The text to replace with. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
"description": "The text to replace with. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
},
"required": []string{"path", "old_text", "new_text"},
@ -92,7 +92,7 @@ func (t *AppendFileTool) Name() string {
}
func (t *AppendFileTool) Description() string {
return "Append content to the end of a file. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n."
return "Append content to the end of a file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
}
func (t *AppendFileTool) Parameters() map[string]any {
@ -105,7 +105,7 @@ func (t *AppendFileTool) Parameters() map[string]any {
},
"content": map[string]any{
"type": "string",
"description": "The content to append. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
"description": "The content to append. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
},
"required": []string{"path", "content"},

View file

@ -870,7 +870,7 @@ func (t *WriteFileTool) Name() string {
}
func (t *WriteFileTool) Description() string {
return "Write content to a file. In `function.arguments`, use \\n for a newline and \\\\n for a literal backslash-n sequence. Content is written byte-for-byte after argument decoding. If the file already exists, you must set overwrite=true to replace it."
return "Write content to a file. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists, you must set overwrite=true to replace it."
}
func (t *WriteFileTool) Parameters() map[string]any {
@ -883,7 +883,7 @@ func (t *WriteFileTool) Parameters() map[string]any {
},
"content": map[string]any{
"type": "string",
"description": "Content to write to the file. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
"description": "Content to write to the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
"overwrite": map[string]any{
"type": "boolean",

View file

@ -3,14 +3,21 @@ package tools
import (
"context"
"fmt"
"sync/atomic"
"sync"
)
type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error
// sentTarget records the channel+chatID that the message tool sent to.
type sentTarget struct {
Channel string
ChatID string
}
type MessageTool struct {
sendCallback SendCallbackWithContext
sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
mu sync.Mutex
sentTargets []sentTarget // Tracks all targets sent to in the current round
}
func NewMessageTool() *MessageTool {
@ -53,12 +60,30 @@ func (t *MessageTool) Parameters() map[string]any {
// ResetSentInRound resets the per-round send tracker.
// Called by the agent loop at the start of each inbound message processing round.
func (t *MessageTool) ResetSentInRound() {
t.sentInRound.Store(false)
t.mu.Lock()
t.sentTargets = t.sentTargets[:0]
t.mu.Unlock()
}
// HasSentInRound returns true if the message tool sent a message during the current round.
func (t *MessageTool) HasSentInRound() bool {
return t.sentInRound.Load()
t.mu.Lock()
defer t.mu.Unlock()
return len(t.sentTargets) > 0
}
// HasSentTo returns true if the message tool sent to the specific channel+chatID
// during the current round. Used by PublishResponseIfNeeded to avoid suppressing
// the final response when the message tool only sent to a different conversation.
func (t *MessageTool) HasSentTo(channel, chatID string) bool {
t.mu.Lock()
defer t.mu.Unlock()
for _, st := range t.sentTargets {
if st.Channel == channel && st.ChatID == chatID {
return true
}
}
return false
}
func (t *MessageTool) SetSendCallback(callback SendCallbackWithContext) {
@ -98,7 +123,10 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
}
}
t.sentInRound.Store(true)
t.mu.Lock()
t.sentTargets = append(t.sentTargets, sentTarget{Channel: channel, ChatID: chatID})
t.mu.Unlock()
// Silent: user already received the message directly
return &ToolResult{
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),

View file

@ -20,6 +20,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/isolation"
)
var (
@ -120,7 +121,7 @@ func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regex
func NewExecToolWithConfig(
workingDir string,
restrict bool,
config *config.Config,
cfg *config.Config,
allowPaths ...[]*regexp.Regexp,
) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0)
@ -131,8 +132,8 @@ func NewExecToolWithConfig(
allowedPathPatterns = allowPaths[0]
}
if config != nil {
execConfig := config.Tools.Exec
if cfg != nil {
execConfig := cfg.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns
allowRemote = execConfig.AllowRemote
if enableDenyPatterns {
@ -163,8 +164,8 @@ func NewExecToolWithConfig(
}
var timeout time.Duration
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 {
timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second
}
return &ExecTool{
@ -378,7 +379,9 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
// Route shell execution through the shared isolation entry point so exec tool
// subprocesses receive the same isolation policy as other integrations.
if err := isolation.Start(cmd); err != nil {
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
}
@ -521,7 +524,9 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
session.stdinWriter = stdinWriter
}
if err := cmd.Start(); err != nil {
// Background sessions use the same startup path so isolation stays consistent
// with synchronous exec runs.
if err := isolation.Start(cmd); err != nil {
if session.ptyMaster != nil {
session.ptyMaster.Close()
}

View file

@ -0,0 +1,9 @@
package utils
import "fmt"
// FormatToolFeedbackMessage renders the tool name and arguments preview in the
// same markdown shape used by live tool feedback and session reconstruction.
func FormatToolFeedbackMessage(toolName, argsPreview string) string {
return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, argsPreview)
}

View file

@ -0,0 +1,11 @@
package utils
import "testing"
func TestFormatToolFeedbackMessage(t *testing.T) {
got := FormatToolFeedbackMessage("read_file", "{\"path\":\"README.md\"}")
want := "\U0001f527 `read_file`\n```\n{\"path\":\"README.md\"}\n```"
if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
}
}

View file

@ -1,4 +1,5 @@
.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean
.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \
build-android-arm64 build-android-bundle
# Go variables
GO?=CGO_ENABLED=0 go
@ -9,6 +10,7 @@ GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
# Build variables
BUILD_DIR=build
OUTPUT?=$(BUILD_DIR)/picoclaw-launcher
OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64
FRONTEND_DIR=frontend
BACKEND_DIR=backend
BACKEND_DIST=$(BACKEND_DIR)/dist
@ -91,6 +93,17 @@ build: build-frontend
@mkdir -p "$$(dirname "$(OUTPUT)")"
${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
# Build launcher for Android ARM64 (frontend must already be built)
build-android-arm64: build-frontend
@mkdir -p $(BUILD_DIR)
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
# Build launcher for all Android architectures
build-android-bundle: build-frontend
@mkdir -p $(BUILD_DIR)
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/
@echo "All Android launcher builds complete"
build-frontend:
@if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
[ $(FRONTEND_DIR)/package.json -nt $(FRONTEND_DIR)/node_modules ] || \

View file

@ -1,8 +1,10 @@
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
@ -10,34 +12,47 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
// LauncherAuthRouteOpts configures dashboard token login handlers.
// PasswordStore is the interface for bcrypt-backed dashboard password persistence.
// Implemented by dashboardauth.Store; a nil value falls back to the legacy
// static-token comparison.
type PasswordStore interface {
IsInitialized(ctx context.Context) (bool, error)
SetPassword(ctx context.Context, plain string) error
VerifyPassword(ctx context.Context, plain string) (bool, error)
}
// LauncherAuthRouteOpts configures dashboard auth handlers.
type LauncherAuthRouteOpts struct {
// DashboardToken is the fallback plaintext token used when PasswordStore is
// nil or not yet initialized (env-var / config-file source, and ?token= auto-login).
DashboardToken string
SessionCookie string
SecureCookie func(*http.Request) bool
// TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets).
TokenHelp LauncherAuthTokenHelp
}
// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token.
type LauncherAuthTokenHelp struct {
EnvVarName string `json:"env_var_name"`
LogFileAbs string `json:"log_file,omitempty"`
ConfigFileAbs string `json:"config_file,omitempty"`
TrayCopyMenu bool `json:"tray_copy_menu"`
ConsoleStdout bool `json:"console_stdout"`
// PasswordStore enables bcrypt-backed password persistence. When non-nil and
// initialized, web-form login verifies against the stored hash instead of
// the plaintext DashboardToken.
PasswordStore PasswordStore
// StoreError holds the error returned when opening the password store. When
// non-nil and PasswordStore is nil, the auth endpoints surface a recovery
// message instead of an opaque 501/503.
StoreError error
}
type launcherAuthLoginBody struct {
Token string `json:"token"`
Password string `json:"password"`
}
type launcherAuthSetupBody struct {
Password string `json:"password"`
Confirm string `json:"confirm"`
}
type launcherAuthStatusResponse struct {
Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"`
Authenticated bool `json:"authenticated"`
Initialized bool `json:"initialized"`
}
// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status.
// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status|setup.
func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
secure := opts.SecureCookie
if secure == nil {
@ -47,22 +62,52 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
tokenHelp: opts.TokenHelp,
store: opts.PasswordStore,
storeErr: opts.StoreError,
loginLimit: newLoginRateLimiter(),
}
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
}
type launcherAuthHandlers struct {
token string
sessionCookie string
secureCookie func(*http.Request) bool
tokenHelp LauncherAuthTokenHelp
store PasswordStore
storeErr error // set when the store failed to open; drives recovery messages
loginLimit *loginRateLimiter
}
func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool {
return h.store == nil && h.storeErr == nil && h.token != ""
}
// isStoreInitialized safely queries the store.
// Returns (true, nil) when legacy token auth is active without a password store.
// Returns (false, nil) when no store/token fallback is configured.
// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
// "uninitialized", to keep auth fail-closed.
// Exception: handleLogin swallows storeErr and falls back to token auth so
// that a corrupt DB does not lock out all access.
func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
if h.store == nil {
if h.storeErr != nil {
return false, fmt.Errorf(
"password store unavailable (%w); "+
"to recover, stop the application, delete the database file and restart ",
h.storeErr)
}
if h.usesLegacyTokenAuth() {
return true, nil
}
return false, nil
}
return h.store.IsInitialized(ctx)
}
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body launcherAuthLoginBody
@ -77,10 +122,39 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
_, _ = w.Write([]byte(`{"error":"too many login attempts"}`))
return
}
in := strings.TrimSpace(body.Token)
if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 {
in := strings.TrimSpace(body.Password)
var ok bool
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
if h.storeErr != nil {
// Store failed to open at startup — token login remains available.
initialized = false
} else {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "%v", initErr)
return
}
}
if initialized && h.store != nil {
// Bcrypt path: verify against the stored hash.
var err error
ok, err = h.store.VerifyPassword(r.Context(), in)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "password verification failed: %v", err)
return
}
} else {
// Fallback: constant-time compare against the plaintext token.
ok = len(in) == len(h.token) &&
subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1
}
if !ok {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
_, _ = w.Write([]byte(`{"error":"invalid password"}`))
return
}
@ -121,23 +195,108 @@ func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Reque
func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ok := false
authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
if ok {
_, _ = w.Write([]byte(`{"authenticated":true}`))
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
w.WriteHeader(http.StatusServiceUnavailable)
writeErrorf(w, "%v", initErr)
return
}
resp := launcherAuthStatusResponse{
Authenticated: false,
TokenHelp: &h.tokenHelp,
Authenticated: authed,
Initialized: initialized,
}
enc, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal error"}`))
writeErrorf(w, "marshal response failed: %v", err)
return
}
_, _ = w.Write(enc)
}
// handleSetup sets or changes the dashboard password.
//
// Rules:
// - If the store has no password yet, the endpoint is open (no session required).
// - If a password is already set, the caller must hold a valid session cookie.
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if h.usesLegacyTokenAuth() {
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write(
[]byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`),
)
return
}
if h.store == nil {
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
return
}
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
w.WriteHeader(http.StatusServiceUnavailable)
writeErrorf(w, "%v", initErr)
return
}
// If already initialized, require an active session (change-password flow).
if initialized {
authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
if !authed {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"must be authenticated to change password"}`))
return
}
}
var body launcherAuthSetupBody
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid JSON"}`))
return
}
pw := strings.TrimSpace(body.Password)
if pw == "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"password must not be empty"}`))
return
}
if pw != strings.TrimSpace(body.Confirm) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"passwords do not match"}`))
return
}
if len([]rune(pw)) < 8 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"password must be at least 8 characters"}`))
return
}
if err := h.store.SetPassword(r.Context(), pw); err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "failed to save password: %v", err)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
// writeErrorf writes a JSON error response with a formatted message.
// json.Marshal is used to safely escape the message string.
func writeErrorf(w http.ResponseWriter, format string, args ...any) {
msg, _ := json.Marshal(fmt.Sprintf(format, args...))
_, _ = w.Write([]byte(`{"error":` + string(msg) + `}`))
}

View file

@ -23,12 +23,6 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: "/tmp/launcher.log",
TrayCopyMenu: true,
ConsoleStdout: false,
},
})
t.Run("status_unauthenticated", func(t *testing.T) {
@ -38,23 +32,20 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
t.Fatalf("status code = %d", rec.Code)
}
var body struct {
Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help"`
Authenticated bool `json:"authenticated"`
Initialized bool `json:"initialized"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Authenticated || body.TokenHelp == nil {
t.Fatalf("unexpected body: %+v", body)
}
if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" {
t.Fatalf("token_help = %+v", body.TokenHelp)
if body.Authenticated {
t.Fatalf("unexpected authenticated=true: %+v", body)
}
})
t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`))
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
@ -84,6 +75,67 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
})
}
func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
key := make([]byte, 32)
const tok = "legacy-fallback-token"
sess := middleware.SessionCookieValue(key, tok)
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
})
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status code = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Authenticated bool `json:"authenticated"`
Initialized bool `json:"initialized"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if !body.Initialized {
t.Fatalf("initialized = false, want true in legacy token fallback mode")
}
if body.Authenticated {
t.Fatalf("unexpected authenticated=true: %+v", body)
}
rec = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
}
}
func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "legacy-token")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "legacy-token",
SessionCookie: sess,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/auth/setup",
strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
}
}
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
@ -91,7 +143,6 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
})
rec := httptest.NewRecorder()
@ -125,11 +176,10 @@ func TestLauncherAuthLoginRateLimit(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
wrongBody := `{"token":"wrong"}`
wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
@ -187,7 +237,6 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@ -206,7 +255,6 @@ func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))

View file

@ -108,6 +108,8 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response,
return client.Get(url)
}
var gatewayProcessMatcher = isLikelyGatewayProcess
// getGatewayHealth checks the gateway health endpoint and returns the status response.
// Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid.
func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*health.StatusResponse, int, error) {
@ -117,7 +119,7 @@ func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*
gateway.mu.Lock()
if d := gateway.pidData; d != nil && d.Port > 0 {
port = d.Port
host = d.Host
host = gatewayProbeHost(d.Host)
}
gateway.mu.Unlock()
if port == 0 {
@ -150,6 +152,150 @@ func getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusRes
return &healthResponse, resp.StatusCode, nil
}
// isLikelyGatewayProcess returns whether PID appears to be a picoclaw gateway
// process plus whether inspection was conclusive on this platform/environment.
func isLikelyGatewayProcess(pid int) (bool, bool) {
if pid <= 0 {
return false, true
}
if runtime.GOOS == "windows" {
psCmd := fmt.Sprintf(
`$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`,
pid,
)
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output()
if err == nil {
cmdline := strings.TrimSpace(string(out))
if cmdline != "" {
return looksLikeGatewayCommandLine(cmdline), true
}
}
// Fallback: determine only whether the process still exists.
out, err = exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output()
if err != nil {
return false, false
}
line := strings.ToLower(strings.TrimSpace(string(out)))
if line == "" {
return false, true
}
// A CSV row means the process exists, but may have a custom executable
// name we cannot classify here.
if strings.HasPrefix(line, "\"") {
if strings.Contains(line, "\"picoclaw.exe\"") {
return true, true
}
return false, false
}
if strings.Contains(line, "no tasks are running") {
return false, true
}
return false, true
}
out, err := exec.Command("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return false, false
}
cmdline := strings.ToLower(strings.TrimSpace(string(out)))
if cmdline == "" {
return false, true
}
return looksLikeGatewayCommandLine(cmdline), true
}
// looksLikeGatewayCommandLine checks whether a process command line likely
// represents "picoclaw gateway ..." regardless of executable filename.
func looksLikeGatewayCommandLine(cmdline string) bool {
fields := strings.Fields(strings.ToLower(strings.TrimSpace(cmdline)))
if len(fields) == 0 {
return false
}
for _, f := range fields {
token := strings.Trim(f, `"'`)
if token == "gateway" || strings.HasSuffix(token, "/gateway") || strings.HasSuffix(token, `\gateway`) {
return true
}
}
return false
}
func (h *Handler) getGatewayHealthForPidData(
pidData *ppid.PidFileData,
cfg *config.Config,
timeout time.Duration,
) (*health.StatusResponse, int, error) {
if pidData == nil {
return nil, 0, errors.New("nil pid data")
}
port := pidData.Port
if port == 0 {
port = 18790
if cfg != nil && cfg.Gateway.Port != 0 {
port = cfg.Gateway.Port
}
}
host := gatewayProbeHost(strings.TrimSpace(pidData.Host))
if host == "" {
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
}
if host == "" {
host = "127.0.0.1"
}
url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health"
return getGatewayHealthByURL(url, timeout)
}
func (h *Handler) validateGatewayPidData(
pidData *ppid.PidFileData,
cfg *config.Config,
) (ok bool, decisive bool, reason string) {
if pidData == nil || pidData.PID <= 0 {
return false, true, "invalid pid data"
}
if gatewayProcess, inspected := gatewayProcessMatcher(pidData.PID); inspected {
if !gatewayProcess {
return false, true, "pid process command is not picoclaw gateway"
}
return true, true, ""
}
healthResp, statusCode, err := h.getGatewayHealthForPidData(pidData, cfg, 800*time.Millisecond)
if err != nil {
return false, false, fmt.Sprintf("health probe failed: %v", err)
}
if statusCode != http.StatusOK {
return false, false, fmt.Sprintf("health endpoint returned status %d", statusCode)
}
if healthResp.PID > 0 && healthResp.PID != pidData.PID {
return false, true, fmt.Sprintf("health pid mismatch: pidFile=%d, health=%d", pidData.PID, healthResp.PID)
}
return true, true, ""
}
func (h *Handler) sanitizeGatewayPidData(pidData *ppid.PidFileData, cfg *config.Config) *ppid.PidFileData {
if pidData == nil {
return nil
}
ok, decisive, reason := h.validateGatewayPidData(pidData, cfg)
if ok {
return pidData
}
logger.Warnf("ignore pid file for PID %d: %s", pidData.PID, reason)
if decisive && ppid.RemovePidFileIfPID(globalConfigDir(), pidData.PID) {
logger.Warnf("removed stale pid file for PID %d", pidData.PID)
}
return nil
}
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
@ -164,7 +310,7 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
// starts it when possible. Intended to be called by the backend at startup.
func (h *Handler) TryAutoStartGateway() {
// Check PID file first to detect an already-running gateway.
pidData := ppid.ReadPidFileWithCheck(globalConfigDir())
pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil)
if pidData != nil {
gateway.mu.Lock()
ready, reason, err := h.gatewayStartReady()
@ -472,6 +618,11 @@ func stopGatewayLocked() (int, error) {
}
pid := gateway.cmd.Process.Pid
if !gateway.owned {
if isGateway, inspected := gatewayProcessMatcher(pid); inspected && !isGateway {
return pid, fmt.Errorf("refuse to stop non-gateway process (PID %d)", pid)
}
}
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
var sigErr error
@ -681,7 +832,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
// POST /api/gateway/start
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
// Check PID file first to detect an already-running gateway.
pidData := ppid.ReadPidFileWithCheck(globalConfigDir())
pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil)
if pidData != nil {
pid := pidData.PID
gateway.mu.Lock()
@ -807,9 +958,22 @@ func (h *Handler) RestartGateway() (int, error) {
gateway.mu.Lock()
previousCmd := gateway.cmd
previousOwned := gateway.owned
setGatewayRuntimeStatusLocked("restarting")
gateway.mu.Unlock()
if previousCmd != nil && previousCmd.Process != nil && !previousOwned {
if isGateway, inspected := gatewayProcessMatcher(previousCmd.Process.Pid); inspected && !isGateway {
logger.Warnf("refuse restarting non-gateway process (PID: %d)", previousCmd.Process.Pid)
gateway.mu.Lock()
if gateway.cmd == previousCmd {
setGatewayRuntimeStatusLocked("running")
}
gateway.mu.Unlock()
return 0, fmt.Errorf("refuse to restart non-gateway process (PID %d)", previousCmd.Process.Pid)
}
}
if err = stopGatewayProcessForRestart(previousCmd); err != nil {
gateway.mu.Lock()
if gateway.cmd == previousCmd {
@ -921,7 +1085,7 @@ func (h *Handler) gatewayStatusData() map[string]any {
}
// Primary detection: read PID file and check if process is alive.
pidData := ppid.ReadPidFileWithCheck(globalConfigDir())
pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), cfg)
if pidData != nil {
gateway.mu.Lock()
gateway.pidData = pidData

View file

@ -15,8 +15,6 @@ import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
ppid "github.com/sipeed/picoclaw/pkg/pid"
@ -40,6 +38,36 @@ func startLongRunningProcess(t *testing.T) *exec.Cmd {
return cmd
}
func startGatewayLikeProcess(t *testing.T) *exec.Cmd {
t.Helper()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
t.Skip("gateway-like process commandline check is not deterministic on Windows tests")
}
cmd = exec.Command("sh", "-c", "sleep 30 # picoclaw gateway")
if err := cmd.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
return cmd
}
func writeTestPidFile(t *testing.T, data ppid.PidFileData) string {
t.Helper()
path := filepath.Join(globalConfigDir(), ".picoclaw.pid")
raw, err := json.MarshalIndent(data, "", " ")
if err != nil {
t.Fatalf("marshal pid file: %v", err)
}
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatalf("write pid file: %v", err)
}
return path
}
func mockGatewayHealthResponse(statusCode, pid int) *http.Response {
return &http.Response{
StatusCode: statusCode,
@ -68,12 +96,14 @@ func resetGatewayTestState(t *testing.T) {
t.Helper()
originalHealthGet := gatewayHealthGet
originalProcessMatcher := gatewayProcessMatcher
originalRestartGracePeriod := gatewayRestartGracePeriod
originalRestartForceKillWindow := gatewayRestartForceKillWindow
originalRestartPollInterval := gatewayRestartPollInterval
t.Setenv("PICOCLAW_HOME", t.TempDir())
t.Cleanup(func() {
gatewayHealthGet = originalHealthGet
gatewayProcessMatcher = originalProcessMatcher
gatewayRestartGracePeriod = originalRestartGracePeriod
gatewayRestartForceKillWindow = originalRestartForceKillWindow
gatewayRestartPollInterval = originalRestartPollInterval
@ -105,6 +135,105 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
}
}
func TestLooksLikeGatewayCommandLine(t *testing.T) {
cases := []struct {
name string
cmdline string
want bool
}{
{
name: "default picoclaw gateway",
cmdline: "/usr/local/bin/picoclaw gateway -E",
want: true,
},
{
name: "renamed binary with gateway subcommand",
cmdline: "/opt/bin/custom-claw gateway -E -d",
want: true,
},
{
name: "standalone gateway binary path",
cmdline: "/opt/bin/gateway -E",
want: true,
},
{
name: "non gateway process",
cmdline: "/bin/sleep 30",
want: false,
},
{
name: "gateway substring only",
cmdline: "/opt/bin/gatewayd --serve",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := looksLikeGatewayCommandLine(tc.cmdline)
if got != tc.want {
t.Fatalf("looksLikeGatewayCommandLine(%q) = %v, want %v", tc.cmdline, got, tc.want)
}
})
}
}
func TestValidateGatewayPidDataAcceptsHealthWhenMatcherInconclusive(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
const testPID = 34567
pidData := &ppid.PidFileData{
PID: testPID,
Host: "127.0.0.1",
Port: 18790,
}
gatewayProcessMatcher = func(int) (bool, bool) { return false, false }
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return mockGatewayHealthResponse(http.StatusOK, testPID), nil
}
ok, decisive, reason := h.validateGatewayPidData(pidData, nil)
if !ok {
t.Fatalf("validateGatewayPidData() ok = false, want true (reason=%q)", reason)
}
if !decisive {
t.Fatalf("validateGatewayPidData() decisive = false, want true")
}
}
func TestValidateGatewayPidDataRejectsHealthPidMismatchWhenMatcherInconclusive(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
pidData := &ppid.PidFileData{
PID: 34567,
Host: "127.0.0.1",
Port: 18790,
}
gatewayProcessMatcher = func(int) (bool, bool) { return false, false }
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return mockGatewayHealthResponse(http.StatusOK, 99999), nil
}
ok, decisive, reason := h.validateGatewayPidData(pidData, nil)
if ok {
t.Fatalf("validateGatewayPidData() ok = true, want false")
}
if !decisive {
t.Fatalf("validateGatewayPidData() decisive = false, want true")
}
if !strings.Contains(reason, "health pid mismatch") {
t.Fatalf("validateGatewayPidData() reason = %q, want contains %q", reason, "health pid mismatch")
}
}
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
@ -533,7 +662,7 @@ func TestGatewayStatusDowngradesRunningWhenTrackedProcessExitedAndPidFileMissing
}
}
func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
func TestGatewayStatusIgnoresAndRemovesPidFileForNonGatewayProcess(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
@ -549,6 +678,87 @@ func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
_ = cmd.Wait()
})
pidPath := writeTestPidFile(t, ppid.PidFileData{
PID: cmd.Process.Pid,
Token: "stale-token",
Host: "127.0.0.1",
Port: 18790,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "stopped" {
t.Fatalf("gateway_status = %#v, want %q", got, "stopped")
}
if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
t.Fatal("stale pid file should be removed for non-gateway process")
}
}
func TestGatewayStopRefusesNonGatewayAttachedProcess(t *testing.T) {
resetGatewayTestState(t)
if runtime.GOOS == "windows" {
t.Skip("commandline-based process type check is best-effort on Windows")
}
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startLongRunningProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
gateway.cmd = cmd
gateway.owned = false
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/gateway/stop", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
if !isCmdProcessAliveLocked(cmd) {
t.Fatal("non-gateway process should not be terminated by /api/gateway/stop")
}
}
func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
resetGatewayTestState(t)
gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startGatewayLikeProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("stopped")
gateway.mu.Unlock()
@ -557,8 +767,12 @@ func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil
}
_, err := ppid.WritePidFile(globalConfigDir(), "localhost", 0)
require.NoError(t, err)
writeTestPidFile(t, ppid.PidFileData{
PID: cmd.Process.Pid,
Token: "test-token",
Host: "127.0.0.1",
Port: 18790,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
@ -583,6 +797,7 @@ func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
resetGatewayTestState(t)
gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
@ -601,16 +816,23 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
mux := http.NewServeMux()
h.RegisterRoutes(mux)
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("FindProcess() error = %v", err)
}
_, err = ppid.WritePidFile(globalConfigDir(), "localhost", 0)
require.NoError(t, err)
cmd := startGatewayLikeProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
writeTestPidFile(t, ppid.PidFileData{
PID: cmd.Process.Pid,
Token: "test-token",
Host: "127.0.0.1",
Port: 18790,
})
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock()
gateway.cmd = &exec.Cmd{Process: process}
gateway.cmd = cmd
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
gateway.bootConfigSignature = bootSignature
setGatewayRuntimeStatusLocked("running")

View file

@ -64,7 +64,7 @@ func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
gatewayAvailable := false
// Prefer fresh PID file data when available.
if pidData := ppid.ReadPidFileWithCheck(globalConfigDir()); pidData != nil {
if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
gateway.mu.Lock()
gateway.pidData = pidData
setGatewayRuntimeStatusLocked("running")

Some files were not shown because too many files have changed in this diff Show more