Merge branch 'main' of github.com:lahuman/picoclaw into codex/issue-2478-pending-skills

This commit is contained in:
k 2026-04-13 13:46:01 +09:00
commit f9848bdd3e
120 changed files with 10029 additions and 1326 deletions

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

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

36
go.mod
View file

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

@ -12,6 +12,7 @@ import (
"sync/atomic"
"time"
"github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/tools"
)
@ -122,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)
}

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

@ -515,7 +515,6 @@ type respondWithMediaHook struct {
media []string
responseHandled bool
forLLM string
sendMediaErr error
}
func (h *respondWithMediaHook) BeforeTool(

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

@ -88,6 +88,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)
@ -104,6 +105,8 @@ const (
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"
@ -671,21 +674,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
}
@ -1398,18 +1401,19 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
})
opts := processOptions{
SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
MessageID: msg.MessageID,
ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
SenderID: msg.SenderID,
SenderDisplayName: msg.Sender.DisplayName,
UserMessage: msg.Content,
Media: msg.Media,
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
MessageID: msg.MessageID,
ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
SenderID: msg.SenderID,
SenderDisplayName: msg.Sender.DisplayName,
UserMessage: msg.Content,
Media: msg.Media,
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
AllowInterimPicoPublish: true,
}
// context-dependent commands check their own Runtime fields and report
@ -1620,6 +1624,41 @@ 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{
Channel: "pico",
ChatID: chatID,
Content: reasoningContent,
Metadata: map[string]string{
metadataKeyMessageKind: messageKindThought,
},
}); 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,
@ -2221,12 +2260,16 @@ turnLoop:
if reasoningContent == "" {
reasoningContent = response.ReasoningContent
}
go al.handleReasoning(
turnCtx,
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"),
@ -2253,9 +2296,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 {
@ -2390,7 +2453,7 @@ turnLoop:
string(argsJSON),
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
)
feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, feedbackPreview)
feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview)
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
Channel: ts.channel,
@ -2672,7 +2735,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, bus.OutboundMessage{
Channel: ts.channel,

View file

@ -1123,6 +1123,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(
@ -1941,7 +1975,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,
@ -2680,6 +2714,62 @@ 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.Metadata[metadataKeyMessageKind] != messageKindThought {
t.Fatalf("thought metadata kind = %q, want %q", thoughtMsg.Metadata[metadataKeyMessageKind], messageKindThought)
}
}
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
tmpDir := t.TempDir()
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
@ -2786,6 +2876,133 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
}
}
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

@ -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),
@ -436,24 +443,8 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
// Append media tags to content (like Telegram does)
content = appendMediaTags(content, messageType, mediaRefs)
if content == "" {
content = "[empty message]"
}
metadata := map[string]string{}
if messageID != "" {
metadata["message_id"] = messageID
}
if messageType != "" {
metadata["message_type"] = messageType
}
chatType := stringValue(message.ChatType)
if chatType != "" {
metadata["chat_type"] = chatType
}
if sender != nil && sender.TenantKey != nil {
metadata["tenant_key"] = *sender.TenantKey
}
metadata := buildInboundMetadata(message, sender)
var peer bus.Peer
if chatType == "p2p" {
@ -477,12 +468,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),
})
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
return nil

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
}
@ -285,7 +289,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(metadata map[string]string) bool {
if len(metadata) == 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(metadata["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.Metadata)
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,20 +24,21 @@ 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 {
Version int `json:"version" yaml:"-"` // Config schema version for migration
Agents AgentsConfig `json:"agents" yaml:"-"`
Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"`
Session SessionConfig `json:"session,omitempty" yaml:"-"`
Channels ChannelsConfig `json:"channels" yaml:"channels"`
ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway" yaml:"-"`
Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"`
Tools ToolsConfig `json:"tools" yaml:",inline"`
Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"`
Devices DevicesConfig `json:"devices" yaml:"-"`
Voice VoiceConfig `json:"voice" yaml:"-"`
Version int `json:"version" yaml:"-"` // Config schema version for migration
Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"`
Agents AgentsConfig `json:"agents" yaml:"-"`
Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"`
Session SessionConfig `json:"session,omitempty" yaml:"-"`
Channels ChannelsConfig `json:"channels" yaml:"channels"`
ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway" yaml:"-"`
Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"`
Tools ToolsConfig `json:"tools" yaml:",inline"`
Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"`
Devices DevicesConfig `json:"devices" yaml:"-"`
Voice VoiceConfig `json:"voice" yaml:"-"`
// BuildInfo contains build-time version information
BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"`
@ -45,6 +46,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

@ -852,6 +852,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 SendCallback func(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 SendCallback
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 SendCallback) {
@ -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")

View file

@ -308,6 +308,10 @@ func TestHandlePicoSetup_Response(t *testing.T) {
}
func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
origMatcher := gatewayProcessMatcher
gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
@ -339,9 +343,19 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
if _, err := ppid.WritePidFile(globalConfigDir(), cfg.Gateway.Host, cfg.Gateway.Port); err != nil {
t.Fatalf("WritePidFile() error = %v", 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: cfg.Gateway.Host,
Port: cfg.Gateway.Port,
})
origPidData := gateway.pidData
origPicoToken := gateway.picoToken
t.Cleanup(func() {
@ -392,6 +406,10 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
}
func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) {
origMatcher := gatewayProcessMatcher
gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
@ -416,9 +434,19 @@ func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) {
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
if _, err := ppid.WritePidFile(globalConfigDir(), cfg.Gateway.Host, cfg.Gateway.Port); err != nil {
t.Fatalf("WritePidFile() error = %v", 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: cfg.Gateway.Host,
Port: cfg.Gateway.Port,
})
t.Cleanup(func() {
ppid.RemovePidFile(globalConfigDir())
})
@ -450,6 +478,10 @@ func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) {
}
func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
origMatcher := gatewayProcessMatcher
gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
@ -475,10 +507,20 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
t.Fatalf("SaveConfig() error = %v", err)
}
pidData, err := ppid.WritePidFile(globalConfigDir(), cfg.Gateway.Host, cfg.Gateway.Port)
if err != nil {
t.Fatalf("WritePidFile() error = %v", err)
cmd := startGatewayLikeProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
pidData := ppid.PidFileData{
PID: cmd.Process.Pid,
Token: "test-token",
Host: cfg.Gateway.Host,
Port: cfg.Gateway.Port,
}
writeTestPidFile(t, pidData)
t.Cleanup(func() {
ppid.RemovePidFile(globalConfigDir())
})

View file

@ -14,6 +14,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/utils"
)
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
@ -76,6 +77,11 @@ const (
handledToolResponseSummaryText = "Requested output delivered via tool attachment."
)
func defaultToolFeedbackMaxArgsLength() int {
defaults := config.AgentDefaults{}
return defaults.GetToolFeedbackMaxArgsLength()
}
// extractPicoSessionID extracts the session UUID from a full session key.
// Returns the UUID and true if the key matches the Pico session pattern.
func extractPicoSessionID(key string) (string, bool) {
@ -202,7 +208,7 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
}, nil
}
func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem {
preview := ""
for _, msg := range sess.Messages {
if msg.Role == "user" {
@ -219,7 +225,7 @@ func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
}
title := preview
validMessageCount := len(visibleSessionMessages(sess.Messages))
validMessageCount := len(visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength))
return sessionListItem{
ID: sessionID,
@ -260,7 +266,7 @@ func sessionMessagePreview(msg providers.Message) string {
return ""
}
func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
transcript := make([]sessionChatMessage, 0, len(messages))
for _, msg := range messages {
@ -275,6 +281,17 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
}
case "assistant":
// Reasoning-only assistant messages are transient display artifacts and
// should not be restored from session history.
if assistantMessageTransientThought(msg) {
continue
}
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
if len(toolSummaryMessages) > 0 {
transcript = append(transcript, toolSummaryMessages...)
}
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
if len(visibleToolMessages) > 0 {
transcript = append(transcript, visibleToolMessages...)
@ -283,7 +300,7 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
// Pico web chat can persist both visible `message` tool output and a
// later plain assistant reply in the same turn. Hide only the fixed
// internal summary that marks handled tool delivery.
if len(visibleToolMessages) > 0 || !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
if !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
continue
}
@ -298,10 +315,63 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
return transcript
}
func assistantMessageTransientThought(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == "" &&
strings.TrimSpace(msg.ReasoningContent) != "" &&
len(msg.ToolCalls) == 0 &&
len(msg.Media) == 0
}
func assistantMessageInternalOnly(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
}
func visibleAssistantToolSummaryMessages(
toolCalls []providers.ToolCall,
toolFeedbackMaxArgsLength int,
) []sessionChatMessage {
if len(toolCalls) == 0 {
return nil
}
if toolFeedbackMaxArgsLength <= 0 {
toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength()
}
messages := make([]sessionChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls {
name := tc.Name
argsJSON := ""
if tc.Function != nil {
if name == "" {
name = tc.Function.Name
}
argsJSON = tc.Function.Arguments
}
if strings.TrimSpace(name) == "" {
continue
}
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
argsJSON = string(encodedArgs)
}
}
argsPreview := strings.TrimSpace(argsJSON)
if argsPreview == "" {
argsPreview = "{}"
}
messages = append(messages, sessionChatMessage{
Role: "assistant",
Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)),
})
}
return messages
}
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
if len(toolCalls) == 0 {
return nil
@ -347,7 +417,19 @@ func (h *Handler) sessionsDir() (string, error) {
return "", err
}
workspace := cfg.Agents.Defaults.Workspace
return resolveSessionsDir(cfg.Agents.Defaults.Workspace), nil
}
func (h *Handler) sessionRuntimeSettings() (string, int, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return "", 0, err
}
return resolveSessionsDir(cfg.Agents.Defaults.Workspace), cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), nil
}
func resolveSessionsDir(workspace string) string {
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
@ -363,14 +445,14 @@ func (h *Handler) sessionsDir() (string, error) {
}
}
return filepath.Join(workspace, "sessions"), nil
return filepath.Join(workspace, "sessions")
}
// handleListSessions returns a list of Pico session summaries.
//
// GET /api/sessions
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
dir, err := h.sessionsDir()
dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
@ -454,7 +536,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
}
seen[sessionID] = struct{}{}
items = append(items, buildSessionListItem(sessionID, sess))
items = append(items, buildSessionListItem(sessionID, sess, toolFeedbackMaxArgsLength))
}
// Sort by updated descending (most recent first)
@ -502,7 +584,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
return
}
dir, err := h.sessionsDir()
dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
@ -529,7 +611,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
}
}
messages := visibleSessionMessages(sess.Messages)
messages := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{

View file

@ -13,6 +13,7 @@ import (
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/utils"
)
func sessionsTestDir(t *testing.T, configPath string) string {
@ -217,6 +218,59 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
}
}
func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "detail-transient-thought"
for _, msg := range []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", ReasoningContent: "internal chain of thought"},
{Role: "assistant", Content: "final visible answer"},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-transient-thought", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 2 {
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
}
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" {
t.Fatalf("first message = %#v, want user/hello", resp.Messages[0])
}
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "final visible answer" {
t.Fatalf("second message = %#v, want assistant/final visible answer", resp.Messages[1])
}
}
func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@ -273,11 +327,14 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 2 {
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
if len(resp.Messages) != 3 {
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
}
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1])
if !strings.Contains(resp.Messages[1].Content, "`message`") {
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
}
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2])
}
}
@ -336,14 +393,17 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 3 {
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
if len(resp.Messages) != 4 {
t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages))
}
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1])
if !strings.Contains(resp.Messages[1].Content, "`message`") {
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
}
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" {
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2])
}
if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" {
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3])
}
}
@ -400,8 +460,152 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
if items[0].MessageCount != 2 {
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
if items[0].MessageCount != 3 {
t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount)
}
}
func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "detail-tool-summary-and-content"
for _, msg := range []providers.Message{
{Role: "user", Content: "check file"},
{
Role: "assistant",
Content: "model final reply",
ToolCalls: []providers.ToolCall{
{
ID: "call_1",
Type: "function",
Function: &providers.FunctionCall{
Name: "read_file",
Arguments: `{"path":"README.md","start_line":1,"end_line":10}`,
},
},
},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-and-content", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 3 {
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
}
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
}
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
}
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" {
t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2])
}
}
func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
sessionKey := picoSessionPrefix + "detail-tool-summary-max-args"
err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"})
if err != nil {
t.Fatalf("AddFullMessage(user) error = %v", err)
}
err = store.AddFullMessage(nil, sessionKey, providers.Message{
Role: "assistant",
ToolCalls: []providers.ToolCall{{
ID: "call_1",
Type: "function",
Function: &providers.FunctionCall{
Name: "read_file",
Arguments: argsJSON,
},
}},
})
if err != nil {
t.Fatalf("AddFullMessage(assistant) error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-max-args", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
err = json.Unmarshal(rec.Body.Bytes(), &resp)
if err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) < 2 {
t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
}
wantPreview := utils.Truncate(argsJSON, 20)
if !strings.Contains(resp.Messages[1].Content, wantPreview) {
t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview)
}
if strings.Contains(resp.Messages[1].Content, argsJSON) {
t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content)
}
}

View file

@ -0,0 +1,7 @@
package dashboardauth
import "errors"
// ErrUnsupportedPlatform reports that the SQLite-backed password store is not
// available for the current target platform.
var ErrUnsupportedPlatform = errors.New("dashboard password store is unavailable on this platform")

View file

@ -0,0 +1,24 @@
package dashboardauth
const (
// DBFilename is the SQLite database file stored under the PicoClaw home directory.
DBFilename = "launcher-auth.db"
sqliteDriver = "sqlite"
// bcryptCost is deliberately high enough to slow brute-force attempts.
bcryptCost = 12
sqlCreateTable = `
CREATE TABLE IF NOT EXISTS dashboard_credentials (
id INTEGER PRIMARY KEY CHECK (id = 1),
bcrypt_hash TEXT NOT NULL
)`
sqlCountCredentials = `SELECT COUNT(*) FROM dashboard_credentials WHERE id = 1`
sqlUpsertHash = `
INSERT INTO dashboard_credentials (id, bcrypt_hash) VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET bcrypt_hash = excluded.bcrypt_hash`
sqlSelectHash = `SELECT bcrypt_hash FROM dashboard_credentials WHERE id = 1`
)

View file

@ -0,0 +1,96 @@
//go:build !mipsle && !netbsd && !(freebsd && arm)
// Package dashboardauth provides a bcrypt-backed SQLite store for the
// launcher dashboard password. The database contains a single row (id=1)
// with the bcrypt hash; no plaintext is ever persisted.
package dashboardauth
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite" // register "sqlite" driver
)
// Store holds a handle to the SQLite database that stores the bcrypt hash.
type Store struct {
db *sql.DB
path string // absolute path to the SQLite file
}
// New opens (or creates) the database inside dir, using the package's
// canonical filename. This is the preferred constructor for most callers.
// Any error is wrapped with the resolved path so callers get actionable output.
func New(dir string) (*Store, error) {
path := filepath.Join(dir, DBFilename)
s, err := Open(path)
if err != nil {
return nil, fmt.Errorf("open %q: %w", path, err)
}
return s, nil
}
// Open opens (or creates) the SQLite database at path and migrates the schema.
func Open(path string) (*Store, error) {
db, err := sql.Open(sqliteDriver, path)
if err != nil {
return nil, err
}
if _, err = db.Exec(sqlCreateTable); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db, path: path}, nil
}
// Close releases the database handle.
func (s *Store) Close() error { return s.db.Close() }
// DBPath returns the absolute path to the SQLite database file.
func (s *Store) DBPath() string { return s.path }
// IsInitialized reports whether a password hash has been stored.
func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx, sqlCountCredentials).Scan(&n)
if err != nil {
return false, err
}
return n > 0, nil
}
// SetPassword hashes plain with bcrypt (cost 12) and stores (or replaces) it.
// The plaintext is never written to disk.
func (s *Store) SetPassword(ctx context.Context, plain string) error {
if len([]rune(plain)) == 0 {
return errors.New("password must not be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, sqlUpsertHash, string(hash))
return err
}
// VerifyPassword returns true iff plain matches the stored bcrypt hash.
// Returns (false, nil) when no password has been set yet.
func (s *Store) VerifyPassword(ctx context.Context, plain string) (bool, error) {
var hash string
err := s.db.QueryRowContext(ctx, sqlSelectHash).Scan(&hash)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, nil
}
return err == nil, err
}

View file

@ -0,0 +1,60 @@
//go:build mipsle || netbsd || (freebsd && arm)
package dashboardauth
import (
"context"
"fmt"
"path/filepath"
"runtime"
)
// Store is unavailable on platforms where modernc sqlite/libc does not build.
type Store struct {
path string
}
// New reports that the password store is unavailable on this platform.
func New(dir string) (*Store, error) {
path := filepath.Join(dir, DBFilename)
s, err := Open(path)
if err != nil {
return nil, fmt.Errorf("open %q: %w", path, err)
}
return s, nil
}
// Open reports that the password store is unavailable on this platform.
func Open(path string) (*Store, error) {
return nil, unsupportedPlatformError()
}
// Close is a no-op for unsupported platforms.
func (s *Store) Close() error { return nil }
// DBPath returns the configured path, if any.
func (s *Store) DBPath() string {
if s == nil {
return ""
}
return s.path
}
// IsInitialized reports that the store is unavailable on this platform.
func (s *Store) IsInitialized(context.Context) (bool, error) {
return false, unsupportedPlatformError()
}
// SetPassword reports that the store is unavailable on this platform.
func (s *Store) SetPassword(context.Context, string) error {
return unsupportedPlatformError()
}
// VerifyPassword reports that the store is unavailable on this platform.
func (s *Store) VerifyPassword(context.Context, string) (bool, error) {
return false, unsupportedPlatformError()
}
func unsupportedPlatformError() error {
return fmt.Errorf("%w (%s/%s)", ErrUnsupportedPlatform, runtime.GOOS, runtime.GOARCH)
}

View file

@ -24,8 +24,6 @@ const (
AppTooltip TranslationKey = "AppTooltip"
MenuOpen TranslationKey = "MenuOpen"
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
MenuCopyToken TranslationKey = "MenuCopyToken"
MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint"
MenuAbout TranslationKey = "MenuAbout"
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
MenuVersion TranslationKey = "MenuVersion"
@ -49,8 +47,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "Open Console",
MenuOpenTooltip: "Open PicoClaw console in browser",
MenuCopyToken: "Copy dashboard token",
MenuCopyTokenHint: "Copy the current web console access token to the clipboard",
MenuAbout: "About",
MenuAboutTooltip: "About PicoClaw",
MenuVersion: "Version: %s",
@ -68,8 +64,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "打开控制台",
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
MenuCopyToken: "复制控制台口令",
MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板",
MenuAbout: "关于",
MenuAboutTooltip: "关于 PicoClaw",
MenuVersion: "版本: %s",

View file

@ -27,6 +27,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/utils"
@ -49,8 +50,6 @@ var (
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
browserLaunchURL string
apiHandler *api.Handler
// launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode).
launcherDashboardTokenForClipboard string
noBrowser *bool
)
@ -66,6 +65,24 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la
return launcherPath
}
// maskSecret masks a secret for display. It always shows up to the first 3
// runes. The last 4 runes are only appended when at least 5 runes remain
// hidden in the middle (i.e. string length >= 12), so an 8-char minimum
// password never exposes its tail. Strings of 3 chars or fewer are fully
// masked.
func maskSecret(s string) string {
runes := []rune(s)
n := len(runes)
const prefixLen, suffixLen, minHidden = 3, 4, 5
if n < prefixLen+suffixLen+minHidden {
if n <= prefixLen {
return "**********"
}
return string(runes[:prefixLen]) + "**********"
}
return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:])
}
func main() {
port := flag.String("port", "18800", "Port to listen on")
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
@ -209,7 +226,25 @@ func main() {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
}
dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
launcherDashboardTokenForClipboard = dashboardToken
// Open the bcrypt password store (creates the DB file on first run).
authStore, authStoreErr := dashboardauth.New(picoHome)
var passwordStore api.PasswordStore
if authStoreErr == nil {
passwordStore = authStore
defer authStore.Close()
} else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) {
logger.InfoC(
"web",
fmt.Sprintf(
"Dashboard password store unavailable on this platform; falling back to token login: %v",
authStoreErr,
),
)
authStoreErr = nil
} else {
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
}
// Determine listen address
var addr string
@ -222,20 +257,11 @@ func main() {
// Initialize Server components
mux := http.NewServeMux()
tokenLogFileAbs := ""
if fileLoggingEnabled {
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
}
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
DashboardToken: dashboardToken,
SessionCookie: dashboardSessionCookie,
TokenHelp: api.LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: tokenLogFileAbs,
ConfigFileAbs: dashboardTokenConfigHelpPath(dashboardTokenSource, launcherPath),
TrayCopyMenu: trayOffersDashboardTokenCopy(),
ConsoleStdout: enableConsole,
},
PasswordStore: passwordStore,
StoreError: authStoreErr,
})
// API Routes (e.g. /api/status)
@ -284,23 +310,23 @@ func main() {
fmt.Println()
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceRandom:
fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken)
fmt.Printf(" Dashboard password (this run): %s\n", maskSecret(dashboardToken))
case launcherconfig.DashboardTokenSourceEnv:
fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken)
fmt.Printf(" Dashboard password: from environment variable PICOCLAW_LAUNCHER_TOKEN\n")
case launcherconfig.DashboardTokenSourceConfig:
fmt.Printf(" Dashboard token: %s (from %s)\n", dashboardToken, launcherPath)
fmt.Printf(" Dashboard password: configured in %s\n", launcherPath)
}
fmt.Println()
}
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceEnv:
logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
logger.InfoC("web", "Dashboard password: environment PICOCLAW_LAUNCHER_TOKEN")
case launcherconfig.DashboardTokenSourceConfig:
logger.InfoC("web", fmt.Sprintf("Dashboard token: configured in %s", launcherPath))
logger.InfoC("web", fmt.Sprintf("Dashboard password: configured in %s", launcherPath))
case launcherconfig.DashboardTokenSourceRandom:
if !enableConsole {
logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
logger.InfoC("web", "Dashboard password (this run): "+maskSecret(dashboardToken))
}
}

View file

@ -67,3 +67,31 @@ func TestDashboardTokenConfigHelpPath(t *testing.T) {
})
}
}
func TestMaskSecret(t *testing.T) {
tests := []struct {
input string
want string
}{
// Long token (>=12 chars): first 3 + 10 stars + last 4
{"sdhjflsjdflksdf", "sdh**********ksdf"},
{"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"},
// Exactly 12 chars (3+4+5 hidden): suffix shown
{"abcdefghijkl", "abc**********ijkl"},
// 8 chars (minimum password length): suffix NOT shown — only prefix+stars
{"abcdefgh", "abc**********"},
// 11 chars (one below threshold): suffix NOT shown
{"abcdefghijk", "abc**********"},
// 4..3 chars: prefix shown, no suffix
{"abcdefg", "abc**********"},
{"abcd", "abc**********"},
// <=3 chars: fully masked
{"abc", "**********"},
{"", "**********"},
}
for _, tt := range tests {
if got := maskSecret(tt.input); got != tt.want {
t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

View file

@ -173,6 +173,8 @@ func isPublicLauncherDashboardPath(method, p string) bool {
return method == http.MethodPost
case "/api/auth/status":
return method == http.MethodGet
case "/api/auth/setup":
return method == http.MethodPost
}
return false
}
@ -183,7 +185,7 @@ func isPublicLauncherDashboardStatic(method, p string) bool {
if method != http.MethodGet && method != http.MethodHead {
return false
}
if p == "/launcher-login" {
if p == "/launcher-login" || p == "/launcher-setup" {
return true
}
if strings.HasPrefix(p, "/assets/") {

View file

@ -1,4 +1,4 @@
//go:build (!darwin && !freebsd) || cgo
//go:build !android && ((!darwin && !freebsd) || cgo)
package main
@ -6,7 +6,6 @@ import (
"fmt"
"fyne.io/systray"
"github.com/atotto/clipboard"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/utils"
@ -24,7 +23,6 @@ func onReady() {
// Create menu items
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint))
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
// Add version info under About menu
@ -52,17 +50,6 @@ func onReady() {
logger.Errorf("Failed to open browser: %v", err)
}
case <-mCopyTok.ClickedCh:
if launcherDashboardTokenForClipboard == "" {
logger.WarnC("web", "Dashboard token is empty; cannot copy")
continue
}
if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil {
logger.Errorf("Failed to copy dashboard token: %v", err)
} else {
logger.InfoC("web", "Dashboard token copied to clipboard")
}
case <-mVersion.ClickedCh:
// Version info - do nothing, just shows current version

View file

@ -1,4 +1,4 @@
//go:build (darwin || freebsd) && !cgo
//go:build (darwin || freebsd || android) && !cgo
package main

View file

@ -1,5 +0,0 @@
//go:build (!darwin && !freebsd) || cgo
package main
func trayOffersDashboardTokenCopy() bool { return true }

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