Merge branch 'main' of github.com:lahuman/picoclaw into codex/issue-2478-pending-skills
This commit is contained in:
commit
f9848bdd3e
120 changed files with 10029 additions and 1326 deletions
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
32
Makefile
32
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 :
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
626
README.ko.md
Normal 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">
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
147
cmd/picoclaw/internal/cliui/cliui.go
Normal file
147
cmd/picoclaw/internal/cliui/cliui.go
Normal 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)
|
||||
}
|
||||
180
cmd/picoclaw/internal/cliui/cliui_test.go
Normal file
180
cmd/picoclaw/internal/cliui/cliui_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
298
cmd/picoclaw/internal/cliui/help_cmd.go
Normal file
298
cmd/picoclaw/internal/cliui/help_cmd.go
Normal 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")
|
||||
}
|
||||
75
cmd/picoclaw/internal/cliui/help_error.go
Normal file
75
cmd/picoclaw/internal/cliui/help_error.go
Normal 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:")
|
||||
}
|
||||
110
cmd/picoclaw/internal/cliui/onboard.go
Normal file
110
cmd/picoclaw/internal/cliui/onboard.go
Normal 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!\""
|
||||
}
|
||||
168
cmd/picoclaw/internal/cliui/status.go
Normal file
168
cmd/picoclaw/internal/cliui/status.go
Normal 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()
|
||||
}
|
||||
61
cmd/picoclaw/internal/cliui/version.go
Normal file
61
cmd/picoclaw/internal/cliui/version.go
Normal 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")))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
36
go.mod
|
|
@ -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
73
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -515,7 +515,6 @@ type respondWithMediaHook struct {
|
|||
media []string
|
||||
responseHandled bool
|
||||
forLLM string
|
||||
sendMediaErr error
|
||||
}
|
||||
|
||||
func (h *respondWithMediaHook) BeforeTool(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
298
pkg/channels/feishu/feishu_reply.go
Normal file
298
pkg/channels/feishu/feishu_reply.go
Normal 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)
|
||||
}
|
||||
229
pkg/channels/feishu/feishu_reply_test.go
Normal file
229
pkg/channels/feishu/feishu_reply_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//go:build !mipsle && !netbsd && !(freebsd && arm)
|
||||
//go:build !mipsle && !netbsd && !(freebsd && arm) && !android
|
||||
|
||||
package gateway
|
||||
|
||||
|
|
|
|||
|
|
@ -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
108
pkg/gateway/gateway_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
238
pkg/isolation/README.md
Normal 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
238
pkg/isolation/README_CN.md
Normal 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`
|
||||
|
||||
这样能最快建立对配置模型、运行流程和平台边界的整体理解。
|
||||
264
pkg/isolation/platform_linux.go
Normal file
264
pkg/isolation/platform_linux.go
Normal 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
|
||||
}
|
||||
148
pkg/isolation/platform_linux_test.go
Normal file
148
pkg/isolation/platform_linux_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
22
pkg/isolation/platform_other.go
Normal file
22
pkg/isolation/platform_other.go
Normal 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) {
|
||||
}
|
||||
217
pkg/isolation/platform_windows.go
Normal file
217
pkg/isolation/platform_windows.go
Normal 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,
|
||||
¤t,
|
||||
); 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
443
pkg/isolation/runtime.go
Normal 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
|
||||
}
|
||||
248
pkg/isolation/runtime_test.go
Normal file
248
pkg/isolation/runtime_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
226
pkg/mcp/isolated_command_transport.go
Normal file
226
pkg/mcp/isolated_command_transport.go
Normal 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)
|
||||
)
|
||||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
796
pkg/providers/gemini_provider.go
Normal file
796
pkg/providers/gemini_provider.go
Normal 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"`
|
||||
}
|
||||
763
pkg/providers/gemini_provider_test.go
Normal file
763
pkg/providers/gemini_provider_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
70
pkg/seahorse/fts5_sanitize.go
Normal file
70
pkg/seahorse/fts5_sanitize.go
Normal 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, " ")
|
||||
}
|
||||
237
pkg/seahorse/fts5_sanitize_test.go
Normal file
237
pkg/seahorse/fts5_sanitize_test.go
Normal 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),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ?")
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
9
pkg/utils/tool_feedback.go
Normal file
9
pkg/utils/tool_feedback.go
Normal 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)
|
||||
}
|
||||
11
pkg/utils/tool_feedback_test.go
Normal file
11
pkg/utils/tool_feedback_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
15
web/Makefile
15
web/Makefile
|
|
@ -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 ] || \
|
||||
|
|
|
|||
|
|
@ -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) + `}`))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`{}{}`))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
7
web/backend/dashboardauth/platform.go
Normal file
7
web/backend/dashboardauth/platform.go
Normal 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")
|
||||
24
web/backend/dashboardauth/sql.go
Normal file
24
web/backend/dashboardauth/sql.go
Normal 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`
|
||||
)
|
||||
96
web/backend/dashboardauth/store.go
Normal file
96
web/backend/dashboardauth/store.go
Normal 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
|
||||
}
|
||||
60
web/backend/dashboardauth/store_unsupported.go
Normal file
60
web/backend/dashboardauth/store_unsupported.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//go:build (darwin || freebsd) && !cgo
|
||||
//go:build (darwin || freebsd || android) && !cgo
|
||||
|
||||
package main
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue