+
+---
+
+> **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% 저렴합니다!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+> [!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 런처 지원과 함께 출시되었습니다.
+
+
+이전 뉴스...
+
+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!
+
+
+
+## ✨ 기능
+
+🪶 **초경량**: 코어 메모리 사용량이 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 싱글코어 벤치마크를 기준으로 합니다(아래 표 참고)._
+
+
+
+> **[하드웨어 호환 목록](docs/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요!
+
+
+
+
+
+## 🦾 데모
+
+### 🛠️ 표준 어시스턴트 워크플로
+
+
+
+
풀스택 엔지니어 모드
+
로깅 및 계획
+
웹 검색 및 학습
+
+
+
+
+
+
+
+
개발 · 배포 · 확장
+
스케줄링 · 자동화 · 기억
+
탐색 · 인사이트 · 트렌드
+
+
+
+### 🐜 혁신적인 초저사양 배포
+
+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)
+
+
+
+🌟 더 많은 배포 사례가 기다리고 있습니다!
+
+## 📦 설치
+
+### 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
+> ```
+
+
+
+
+
+**시작 방법:**
+
+WebUI를 연 뒤 다음 순서로 진행하세요. **1)** 프로바이더 설정(LLM API 키 추가) -> **2)** 채널 설정(예: Telegram) -> **3)** 게이트웨이 시작 -> **4)** 채팅!
+
+자세한 WebUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요.
+
+
+Docker(대안)
+
+```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
+```
+
+
+
+
+macOS - 첫 실행 보안 경고
+
+macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 거치지 않았기 때문에, 첫 실행 시 `picoclaw-launcher`가 차단될 수 있습니다.
+
+**1단계:** `picoclaw-launcher`를 더블클릭합니다. 그러면 보안 경고가 표시됩니다.
+
+
+
+
+
+> *"picoclaw-launcher"을(를) 열 수 없습니다. Apple에서 이 앱이 악성 소프트웨어가 없으며 Mac이나 개인 정보를 해치지 않는다고 확인할 수 없습니다.*
+
+**2단계:** **시스템 설정** -> **개인정보 보호 및 보안** 으로 이동한 뒤 **보안** 섹션까지 스크롤하여 **그래도 열기(Open Anyway)** 를 클릭하고, 대화상자에서 다시 한 번 **그래도 열기**를 확인합니다.
+
+
+
+
+
+이 과정을 한 번만 거치면 이후에는 `picoclaw-launcher`가 정상적으로 열립니다.
+
+
+
+### 💻 TUI Launcher (헤드리스 / SSH 권장)
+
+TUI(Terminal UI) Launcher는 설정과 관리를 위한 모든 기능을 갖춘 터미널 인터페이스를 제공합니다. 서버, Raspberry Pi, 기타 헤드리스 환경에 적합합니다.
+
+```bash
+picoclaw-launcher-tui
+```
+
+
+
+
+
+**시작 방법:**
+
+TUI 메뉴를 사용해 다음 순서로 진행하세요. **1)** 프로바이더 설정 -> **2)** 채널 설정 -> **3)** 게이트웨이 시작 -> **4)** 채팅!
+
+자세한 TUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요.
+
+### 📱 Android
+
+오래된 스마트폰에 새 생명을 불어넣어 보세요! PicoClaw를 설치하면 스마트 AI 어시스턴트로 바꿀 수 있습니다.
+
+**옵션 1: APK 설치**
+
+미리보기:
+
+
+
+
+
+
+
+
+
+
+[picoclaw.io](https://picoclaw.io/download/)에서 APK를 다운로드해 바로 설치하세요. Termux가 필요 없습니다!
+
+**옵션 2: Termux**
+
+
+터미널 런처 (리소스 제약 환경용)
+
+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 파일시스템 레이아웃을 제공합니다
+```
+
+그다음 아래의 터미널 런처 섹션을 따라 설정을 마무리하세요.
+
+
+
+런처 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
+```
+
+
+
+## 🔌 프로바이더(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`도 함께 설정해야 합니다.
+
+
+로컬 배포(Ollama, vLLM 등)
+
+**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)을 참고하세요.
+
+
+
+## 💬 채널(채팅 앱)
+
+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
+```
+
+**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)를 참고하세요.
+
+## 에이전트 소셜 네트워크 참여하기
+
+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:
+
+WeChat:
+
diff --git a/README.md b/README.md
index a48a53d47..eb0d389d2 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-[中文](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**
diff --git a/README.my.md b/README.my.md
index f00fb438c..f8e602f83 100644
--- a/README.my.md
+++ b/README.my.md
@@ -18,7 +18,7 @@
-[中文](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)
diff --git a/README.pt-br.md b/README.pt-br.md
index db11d4d82..65d23d1d1 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -18,7 +18,7 @@
-[中文](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)
diff --git a/README.vi.md b/README.vi.md
index 78b8a9a59..1d70d0615 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -18,7 +18,7 @@
-[中文](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)
diff --git a/README.zh.md b/README.zh.md
index 2ba0913fc..e61ff7e28 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -18,7 +18,7 @@
-**中文** | [日本語](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)
@@ -620,4 +620,3 @@ WeChat:
-
From 8f7eae8b373b38851232d98f6db1b4bfe9633b1e Mon Sep 17 00:00:00 2001
From: k
Date: Wed, 8 Apr 2026 14:19:11 +0900
Subject: [PATCH 05/34] docs(tool): use provider-agnostic JSON escaping
guidance
---
pkg/providers/common/common_test.go | 16 ++++++++++++++++
pkg/tools/edit.go | 10 +++++-----
pkg/tools/filesystem.go | 4 ++--
3 files changed, 23 insertions(+), 7 deletions(-)
diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go
index 0a4d5f34a..c107bb665 100644
--- a/pkg/providers/common/common_test.go
+++ b/pkg/providers/common/common_test.go
@@ -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")
diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go
index 09d1f545b..c527dab54 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/edit.go
@@ -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"},
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 52d77f665..0f6811f33 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -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",
From 7d167646749b11b54a3d27c42fa2d5bf05e88381 Mon Sep 17 00:00:00 2001
From: wenjie
Date: Wed, 8 Apr 2026 14:23:21 +0800
Subject: [PATCH 06/34] fix(gateway): validate PID ownership and clean stale
pid files (#2422)
* fix(gateway): validate PID ownership and clean stale pid files
- include `pid` in health responses for runtime PID verification
- add `RemovePidFileIfPID` to safely delete PID files only on PID match
- sanitize gateway PID data via process-command checks with health fallback
- ignore and remove stale/non-gateway PID files before gateway operations
- refuse stop/restart actions when the attached process is not a gateway
- update gateway and websocket tests to cover PID validation and safety paths
* test(seahorse): use shared in-memory SQLite DB in tests to fix async compaction failures
* test: remove unused sendMediaErr field from hook test mock
---
pkg/agent/hooks_test.go | 1 -
pkg/health/server.go | 3 +
pkg/pid/pidfile.go | 24 ++++
pkg/pid/pidfile_test.go | 34 +++++
pkg/seahorse/schema_test.go | 14 +-
web/backend/api/gateway.go | 172 +++++++++++++++++++++-
web/backend/api/gateway_test.go | 246 ++++++++++++++++++++++++++++++--
web/backend/api/pico.go | 2 +-
web/backend/api/pico_test.go | 60 ++++++--
9 files changed, 528 insertions(+), 28 deletions(-)
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index 92e9caae9..9049a5c72 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -515,7 +515,6 @@ type respondWithMediaHook struct {
media []string
responseHandled bool
forLLM string
- sendMediaErr error
}
func (h *respondWithMediaHook) BeforeTool(
diff --git a/pkg/health/server.go b/pkg/health/server.go
index 2602cb965..a152d8ab1 100644
--- a/pkg/health/server.go
+++ b/pkg/health/server.go
@@ -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)
diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go
index 0b6d461c2..f7c1f42b2 100644
--- a/pkg/pid/pidfile.go
+++ b/pkg/pid/pidfile.go
@@ -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) {
diff --git a/pkg/pid/pidfile_test.go b/pkg/pid/pidfile_test.go
index e54b93f4f..2da44bbbc 100644
--- a/pkg/pid/pidfile_test.go
+++ b/pkg/pid/pidfile_test.go
@@ -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)
diff --git a/pkg/seahorse/schema_test.go b/pkg/seahorse/schema_test.go
index 17879f66c..e11e6e96e 100644
--- a/pkg/seahorse/schema_test.go
+++ b/pkg/seahorse/schema_test.go
@@ -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)
}
diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go
index 139f2c8c8..8994e9c60 100644
--- a/web/backend/api/gateway.go
+++ b/web/backend/api/gateway.go
@@ -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
diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go
index 1f5f13e27..d300b657c 100644
--- a/web/backend/api/gateway_test.go
+++ b/web/backend/api/gateway_test.go
@@ -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")
diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go
index 95bbfd2c1..1d6b46d32 100644
--- a/web/backend/api/pico.go
+++ b/web/backend/api/pico.go
@@ -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")
diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go
index 04888fde7..af5ba205f 100644
--- a/web/backend/api/pico_test.go
+++ b/web/backend/api/pico_test.go
@@ -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())
})
From 8b3e5026903d4a6c02b3a7f8860d6625c4117fbe Mon Sep 17 00:00:00 2001
From: ywj <138745068+yangwenjie1231@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:26:17 +0800
Subject: [PATCH 07/34] fix(feishu): enrich reply context for card and file
replies (#2144)
* fix(feishu): enrich reply context for card and file replies
* refactor(feishu): extract reply functions to feishu_reply.go
- Move reply-related functions to new feishu_reply.go
- Move corresponding tests to feishu_reply_test.go
- Extract magic number 600 to maxReplyContextLen constant
- Unify replyTargetID/replyTargetFromMessage (prefer parent_id, fallback root_id)
- Add source comment for containsFeishuUpgradePlaceholder
* fix(feishu): skip API fallback for non-thread messages, prepend replied media refs
- resolveReplyTargetMessageID: only call fetchMessageByID fallback when
ThreadId is set, avoiding unnecessary API calls for non-reply messages
- prependReplyContext: prepend replied media refs before current media refs
to maintain correct ordering
* fix(feishu): add message cache for fetchMessageByID to avoid repeated downloads
- Add messageCache (sync.Map) to FeishuChannel struct
- Cache fetched messages with 30s TTL to avoid re-downloading attachments
when multiple users reply to the same parent message in a thread
- Cleanup expired entries on read access (no background goroutine needed)
* fix(feishu): early-return for non-reply messages, add cache and fetchMessageByID comment
* fix: remove duplicate test and fix gci import order
* fix(feishu): remove duplicate prependReplyContext call
---
pkg/channels/feishu/feishu_64.go | 40 +--
pkg/channels/feishu/feishu_reply.go | 298 +++++++++++++++++++++++
pkg/channels/feishu/feishu_reply_test.go | 229 +++++++++++++++++
3 files changed, 549 insertions(+), 18 deletions(-)
create mode 100644 pkg/channels/feishu/feishu_reply.go
create mode 100644 pkg/channels/feishu/feishu_reply_test.go
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index b0b231d09..c12827729 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -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
diff --git a/pkg/channels/feishu/feishu_reply.go b/pkg/channels/feishu/feishu_reply.go
new file mode 100644
index 000000000..22dfe3e87
--- /dev/null
+++ b/pkg/channels/feishu/feishu_reply.go
@@ -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)
+}
diff --git a/pkg/channels/feishu/feishu_reply_test.go b/pkg/channels/feishu/feishu_reply_test.go
new file mode 100644
index 000000000..0efe7bc01
--- /dev/null
+++ b/pkg/channels/feishu/feishu_reply_test.go
@@ -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)
+ }
+ })
+ }
+}
From 51eecde01ed2db893949b88939e3e1965204847c Mon Sep 17 00:00:00 2001
From: lxowalle <83055338+lxowalle@users.noreply.github.com>
Date: Wed, 8 Apr 2026 18:15:42 +0800
Subject: [PATCH 08/34] Feat/support isolation (#2423)
* * completed
* * optimzie
* * fix format
* * fix pr check
* try to fix ci
* * Indicates that Windows does not support expos_paths, adding more mount paths for the Linux platform.
* fix isolation startup lifecycle and MCP transport wrapping
* fix isolation startup cleanup and optional Linux mounts
* fix isolation path handling for relative hooks
Preserve relative command and working-directory semantics when Linux isolation wraps subprocesses, and restore absolute argv path exposure to avoid startup regressions. Add hook coverage and docs updates so isolation-enabled process hooks keep working as configured.
* * fix ci
---
pkg/agent/hook_process.go | 5 +-
pkg/agent/hook_process_test.go | 126 ++++++++
pkg/agent/instance.go | 7 +
pkg/config/config.go | 42 ++-
pkg/config/config_test.go | 31 ++
pkg/config/defaults.go | 5 +
pkg/isolation/README.md | 238 ++++++++++++++
pkg/isolation/README_CN.md | 238 ++++++++++++++
pkg/isolation/platform_linux.go | 264 +++++++++++++++
pkg/isolation/platform_linux_test.go | 148 +++++++++
pkg/isolation/platform_other.go | 22 ++
pkg/isolation/platform_windows.go | 217 +++++++++++++
pkg/isolation/runtime.go | 443 ++++++++++++++++++++++++++
pkg/isolation/runtime_test.go | 245 ++++++++++++++
pkg/mcp/isolated_command_transport.go | 226 +++++++++++++
pkg/mcp/manager.go | 3 +-
pkg/providers/claude_cli_provider.go | 6 +-
pkg/providers/codex_cli_provider.go | 6 +-
pkg/tools/shell.go | 19 +-
19 files changed, 2266 insertions(+), 25 deletions(-)
create mode 100644 pkg/isolation/README.md
create mode 100644 pkg/isolation/README_CN.md
create mode 100644 pkg/isolation/platform_linux.go
create mode 100644 pkg/isolation/platform_linux_test.go
create mode 100644 pkg/isolation/platform_other.go
create mode 100644 pkg/isolation/platform_windows.go
create mode 100644 pkg/isolation/runtime.go
create mode 100644 pkg/isolation/runtime_test.go
create mode 100644 pkg/mcp/isolated_command_transport.go
diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go
index 59dc8ad62..ace95f44d 100644
--- a/pkg/agent/hook_process.go
+++ b/pkg/agent/hook_process.go
@@ -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)
}
diff --git a/pkg/agent/hook_process_test.go b/pkg/agent/hook_process_test.go
index 50f89811f..9e95d105e 100644
--- a/pkg/agent/hook_process_test.go
+++ b/pkg/agent/hook_process_test.go
@@ -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()
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index 48e5aa625..5bcb83087 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -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)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 606f7a095..fd4466b8c 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -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).
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 1c6b784c7..f0449d98f 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -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 {
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index c2e1a31f3..bb073d436 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -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,
diff --git a/pkg/isolation/README.md b/pkg/isolation/README.md
new file mode 100644
index 000000000..de16ce505
--- /dev/null
+++ b/pkg/isolation/README.md
@@ -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.
diff --git a/pkg/isolation/README_CN.md b/pkg/isolation/README_CN.md
new file mode 100644
index 000000000..0529a84bd
--- /dev/null
+++ b/pkg/isolation/README_CN.md
@@ -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`
+
+这样能最快建立对配置模型、运行流程和平台边界的整体理解。
diff --git a/pkg/isolation/platform_linux.go b/pkg/isolation/platform_linux.go
new file mode 100644
index 000000000..9a282a4ad
--- /dev/null
+++ b/pkg/isolation/platform_linux.go
@@ -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
+}
diff --git a/pkg/isolation/platform_linux_test.go b/pkg/isolation/platform_linux_test.go
new file mode 100644
index 000000000..2dcca96ce
--- /dev/null
+++ b/pkg/isolation/platform_linux_test.go
@@ -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))
+ }
+}
diff --git a/pkg/isolation/platform_other.go b/pkg/isolation/platform_other.go
new file mode 100644
index 000000000..d8d06e2ec
--- /dev/null
+++ b/pkg/isolation/platform_other.go
@@ -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) {
+}
diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go
new file mode 100644
index 000000000..9434976f7
--- /dev/null
+++ b/pkg/isolation/platform_windows.go
@@ -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
+}
diff --git a/pkg/isolation/runtime.go b/pkg/isolation/runtime.go
new file mode 100644
index 000000000..b2de98b88
--- /dev/null
+++ b/pkg/isolation/runtime.go
@@ -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
+}
diff --git a/pkg/isolation/runtime_test.go b/pkg/isolation/runtime_test.go
new file mode 100644
index 000000000..213c4b065
--- /dev/null
+++ b/pkg/isolation/runtime_test.go
@@ -0,0 +1,245 @@
+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) {
+ 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")
+ }
+}
diff --git a/pkg/mcp/isolated_command_transport.go b/pkg/mcp/isolated_command_transport.go
new file mode 100644
index 000000000..f54b4af8b
--- /dev/null
+++ b/pkg/mcp/isolated_command_transport.go
@@ -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)
+)
diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go
index 323df0312..f589f82a9 100644
--- a/pkg/mcp/manager.go
+++ b/pkg/mcp/manager.go
@@ -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)",
diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go
index 40b581490..c3d98c555 100644
--- a/pkg/providers/claude_cli_provider.go
+++ b/pkg/providers/claude_cli_provider.go
@@ -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 {
diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go
index 13f53ad9e..a9c8b692a 100644
--- a/pkg/providers/codex_cli_provider.go
+++ b/pkg/providers/codex_cli_provider.go
@@ -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)
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index d2971f3f8..a570ac9ec 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -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()
}
From 1dc25e7cf52e49de1c8d318b800d9b74a5478a12 Mon Sep 17 00:00:00 2001
From: k
Date: Wed, 8 Apr 2026 19:44:07 +0900
Subject: [PATCH 09/34] test(agent): remove unused respondWithMediaHook field
---
pkg/agent/hooks_test.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index 92e9caae9..9049a5c72 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -515,7 +515,6 @@ type respondWithMediaHook struct {
media []string
responseHandled bool
forLLM string
- sendMediaErr error
}
func (h *respondWithMediaHook) BeforeTool(
From 087e35588547e0700fbf03b7022f22b6efb737ef Mon Sep 17 00:00:00 2001
From: k
Date: Wed, 8 Apr 2026 19:44:07 +0900
Subject: [PATCH 10/34] test(agent): remove unused respondWithMediaHook field
---
pkg/agent/hooks_test.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index 92e9caae9..9049a5c72 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -515,7 +515,6 @@ type respondWithMediaHook struct {
media []string
responseHandled bool
forLLM string
- sendMediaErr error
}
func (h *respondWithMediaHook) BeforeTool(
From 06023c79fa8ef485dc17e13074b8f8d292bd981b Mon Sep 17 00:00:00 2001
From: sky5454
Date: Wed, 8 Apr 2026 21:43:51 +0800
Subject: [PATCH 11/34] feat(launcher): standard HTTP login/setup/logout flow
for dashboard, frontend and backend impl. and fix windows pid lock for ws
(#2339)
* feat(launcher): replace token-in-logs auth with standard HTTP login flow
## Problem
Previously users had to find the one-time token from console logs or
log files to access the dashboard - a non-standard, error-prone workflow
with no clear path for changing credentials.
## Solution: standard HTTP API login with bcrypt-backed password store
### Auth flow (new)
1. First run: browser opens, session guard detects uninitialized state,
redirects to /launcher-setup
2. User sets a password (min 8 chars) via POST /api/auth/setup {password, confirm},
bcrypt(cost=12) hash stored in ~/.picoclaw/launcher-auth.db (SQLite)
3. Subsequent logins: POST /api/auth/login {password}, HttpOnly cookie
picoclaw_launcher_auth (HMAC-SHA256 signed, 7-day expiry)
4. 401 on any API call, frontend redirects to /launcher-login
5. Logout: POST /api/auth/logout, cookie cleared, redirect to login
### Backend changes
- web/backend/api/auth.go: renamed Token to Password; added handleSetup;
launcherAuthStatusResponse now includes Initialized bool; PasswordStore
interface wires bcrypt store into handlers
- web/backend/dashboardauth/: new package - Store with New(dir) / Open(path);
SetPassword (bcrypt cost=12), VerifyPassword, IsInitialized
- sql.go: all DB-layer constants (DBFilename, sqliteDriver, bcryptCost,
four SQL query strings) - compile-time constants, zero runtime overhead
- web/backend/middleware/launcher_dashboard_auth.go: /launcher-setup and
/api/auth/setup added to public paths
- web/backend/main.go:
- dashboardauth.New(picoHome) replaces manual path construction
- maskSecret(): suffix only revealed when >=5 chars hidden (length >= 12),
preventing 8-char minimum passwords from leaking their tail
- web/backend/main_test.go: TestMaskSecret updated with boundary cases
### Forward-compatibility: pkg/credential integration
If the dashboard password is later reused as the enc:// passphrase,
the bcrypt hash in launcher-auth.db becomes an offline oracle.
Recommended mitigation (not yet implemented): derive two independent
subkeys via HKDF before use:
bcrypt(HKDF(password, info="picoclaw-dashboard-login-v1")) stored in DB
HKDF(password, info="picoclaw-credential-enc-v1") passed to PassphraseProvider
This isolates the two domains: cracking the bcrypt hash yields only the
login subkey, which is computationally independent of the enc:// subkey.
* fix(auth): replace wastedassign ok := false with var ok bool
* refactor(tray): remove copy-token clipboard feature
Dashboard login now uses standard web auth (bcrypt + session cookie).
The system tray 'Copy dashboard token' menu item is no longer needed.
- Delete tray_offers_copy.go and tray_offers_copy_stub.go
- Remove mCopyTok menu item and clipboard handler from systray.go
- Remove launcherDashboardTokenForClipboard var from main.go
- Remove MenuCopyToken/MenuCopyTokenHint keys from i18n.go
* feat(launcher-ui): standard HTTP login/setup/logout flow for dashboard
Replaces the previous "find token in logs" workflow with a proper
browser-based authentication UI backed by the new /api/auth/* endpoints.
### New pages
- /launcher-setup: first-run password initialization form (password +
confirm, min 8 chars); calls POST /api/auth/setup; redirects to login
on success
- /launcher-login: standard password login form; calls POST /api/auth/login;
sets HttpOnly session cookie on success
### Session guard (src/routes/__root.tsx)
A useEffect on every non-auth page load calls GET /api/auth/status:
- initialized=false -> redirect to /launcher-setup
- authenticated=false -> redirect to /launcher-login
This ensures the setup/login UI is shown even when the ?token= URL
mechanism auto-logs in (first-run case).
### Logout button (src/components/app-header.tsx)
IconLogout button added to the header with a confirm AlertDialog;
calls POST /api/auth/logout then redirects to /launcher-login.
### API layer
- src/api/launcher-auth.ts: LauncherAuthStatus gains initialized bool;
postLauncherDashboardSetup() added; LauncherAuthTokenHelp removed
- src/api/http.ts: 401 guard uses isLauncherAuthPathname() (covers both
/launcher-login and /launcher-setup) to prevent redirect loops
- src/lib/launcher-login-path.ts: isLauncherSetupPathname() and
isLauncherAuthPathname() added
### Routing
- src/routeTree.gen.ts: /launcher-setup route registered throughout
- src/routes/launcher-login.tsx: tokenHelp UI removed; useEffect added
to redirect to setup when initialized=false
### i18n
- en.json / zh.json: launcherSetup block added; launcherLogin keys
updated to use passwordLabel/passwordPlaceholder
* fix(lint): ts lint fixed 1
* fix(auth): detail auth error handle
* fix(login): frontend web auth error handle
* fix(frontend): auth error handler 5xx
---
web/backend/api/auth.go | 199 +++++++++++++++---
web/backend/api/auth_test.go | 25 +--
web/backend/dashboardauth/sql.go | 24 +++
web/backend/dashboardauth/store.go | 94 +++++++++
web/backend/i18n.go | 6 -
web/backend/main.go | 56 +++--
web/backend/main_test.go | 28 +++
.../middleware/launcher_dashboard_auth.go | 4 +-
web/backend/systray.go | 13 --
web/backend/tray_offers_copy.go | 5 -
web/backend/tray_offers_copy_stub.go | 5 -
web/frontend/src/api/http.ts | 12 +-
web/frontend/src/api/launcher-auth.ts | 44 ++--
web/frontend/src/components/app-header.tsx | 114 +++++++---
web/frontend/src/hooks/use-gateway.ts | 11 +-
web/frontend/src/i18n/locales/en.json | 38 ++--
web/frontend/src/i18n/locales/zh.json | 38 ++--
web/frontend/src/lib/launcher-login-path.ts | 9 +
web/frontend/src/routeTree.gen.ts | 21 ++
web/frontend/src/routes/__root.tsx | 76 +++++--
web/frontend/src/routes/launcher-login.tsx | 64 +-----
web/frontend/src/routes/launcher-setup.tsx | 146 +++++++++++++
web/frontend/src/store/gateway.ts | 11 +-
23 files changed, 795 insertions(+), 248 deletions(-)
create mode 100644 web/backend/dashboardauth/sql.go
create mode 100644 web/backend/dashboardauth/store.go
delete mode 100644 web/backend/tray_offers_copy.go
delete mode 100644 web/backend/tray_offers_copy_stub.go
create mode 100644 web/frontend/src/routes/launcher-setup.tsx
diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go
index 22f7ec2c2..0790a6b76 100644
--- a/web/backend/api/auth.go
+++ b/web/backend/api/auth.go
@@ -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,44 @@ 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
}
+// isStoreInitialized safely queries the store.
+// Returns (false, nil) when no store is configured (storeErr also nil).
+// 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)
+ }
+ 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 +114,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 {
+ // 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 +187,100 @@ 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.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) + `}`))
+}
diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go
index d2624a440..58ffb823a 100644
--- a/web/backend/api/auth_test.go
+++ b/web/backend/api/auth_test.go
@@ -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)
@@ -91,7 +82,6 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
})
rec := httptest.NewRecorder()
@@ -125,11 +115,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 +176,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 +194,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(`{}{}`))
diff --git a/web/backend/dashboardauth/sql.go b/web/backend/dashboardauth/sql.go
new file mode 100644
index 000000000..94886072b
--- /dev/null
+++ b/web/backend/dashboardauth/sql.go
@@ -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`
+)
diff --git a/web/backend/dashboardauth/store.go b/web/backend/dashboardauth/store.go
new file mode 100644
index 000000000..44605ba22
--- /dev/null
+++ b/web/backend/dashboardauth/store.go
@@ -0,0 +1,94 @@
+// 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
+}
diff --git a/web/backend/i18n.go b/web/backend/i18n.go
index 106df8506..9cda9e5d5 100644
--- a/web/backend/i18n.go
+++ b/web/backend/i18n.go
@@ -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",
diff --git a/web/backend/main.go b/web/backend/main.go
index 5e9f3315f..d9ea3474c 100644
--- a/web/backend/main.go
+++ b/web/backend/main.go
@@ -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,15 @@ 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)
+ if authStoreErr != nil {
+ logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
+ authStore = nil
+ } else {
+ defer authStore.Close()
+ }
// Determine listen address
var addr string
@@ -222,20 +247,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: authStore,
+ StoreError: authStoreErr,
})
// API Routes (e.g. /api/status)
@@ -284,23 +300,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))
}
}
diff --git a/web/backend/main_test.go b/web/backend/main_test.go
index f69705179..82bf12b40 100644
--- a/web/backend/main_test.go
+++ b/web/backend/main_test.go
@@ -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)
+ }
+ }
+}
diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go
index 7e92fca22..c1c4c19c6 100644
--- a/web/backend/middleware/launcher_dashboard_auth.go
+++ b/web/backend/middleware/launcher_dashboard_auth.go
@@ -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/") {
diff --git a/web/backend/systray.go b/web/backend/systray.go
index 744ea4611..9dcc025df 100644
--- a/web/backend/systray.go
+++ b/web/backend/systray.go
@@ -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
diff --git a/web/backend/tray_offers_copy.go b/web/backend/tray_offers_copy.go
deleted file mode 100644
index 6b7d17412..000000000
--- a/web/backend/tray_offers_copy.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build (!darwin && !freebsd) || cgo
-
-package main
-
-func trayOffersDashboardTokenCopy() bool { return true }
diff --git a/web/backend/tray_offers_copy_stub.go b/web/backend/tray_offers_copy_stub.go
deleted file mode 100644
index 9312700f3..000000000
--- a/web/backend/tray_offers_copy_stub.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build (darwin || freebsd) && !cgo
-
-package main
-
-func trayOffersDashboardTokenCopy() bool { return false }
diff --git a/web/frontend/src/api/http.ts b/web/frontend/src/api/http.ts
index 0eb872f3f..347dd9373 100644
--- a/web/frontend/src/api/http.ts
+++ b/web/frontend/src/api/http.ts
@@ -1,14 +1,14 @@
-import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
+import { isLauncherAuthPathname } from "@/lib/launcher-login-path"
-function isLauncherLoginPath(): boolean {
+function isLauncherAuthPath(): boolean {
if (typeof globalThis.location === "undefined") {
return false
}
- if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
+ if (isLauncherAuthPathname(globalThis.location.pathname || "/")) {
return true
}
try {
- return isLauncherLoginPathname(
+ return isLauncherAuthPathname(
new URL(globalThis.location.href).pathname || "/",
)
} catch {
@@ -18,7 +18,7 @@ function isLauncherLoginPath(): boolean {
/**
* Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses.
- * Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll).
+ * Skips redirect while already on an auth page (login or setup) to avoid reload loops.
*/
export async function launcherFetch(
input: RequestInfo | URL,
@@ -33,7 +33,7 @@ export async function launcherFetch(
if (
ct.includes("application/json") &&
typeof globalThis.location !== "undefined" &&
- !isLauncherLoginPath()
+ !isLauncherAuthPath()
) {
globalThis.location.assign("/launcher-login")
}
diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts
index 4ca51993b..ed2e30687 100644
--- a/web/frontend/src/api/launcher-auth.ts
+++ b/web/frontend/src/api/launcher-auth.ts
@@ -1,30 +1,23 @@
/**
- * Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
- * redirect loops on 401 while on the login page.
+ * Dashboard launcher auth API.
+ * Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages.
*/
export async function postLauncherDashboardLogin(
- token: string,
+ password: string,
): Promise {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
- body: JSON.stringify({ token: token.trim() }),
+ body: JSON.stringify({ password: password.trim() }),
})
return res.ok
}
-export type LauncherAuthTokenHelp = {
- env_var_name: string
- log_file?: string
- config_file?: string
- tray_copy_menu: boolean
- console_stdout: boolean
-}
-
export type LauncherAuthStatus = {
authenticated: boolean
- token_help?: LauncherAuthTokenHelp
+ /** true when a bcrypt password has been stored in the DB */
+ initialized: boolean
}
export async function getLauncherAuthStatus(): Promise {
@@ -47,3 +40,28 @@ export async function postLauncherDashboardLogout(): Promise {
})
return res.ok
}
+
+export type SetupResult =
+ | { ok: true }
+ | { ok: false; error: string }
+
+export async function postLauncherDashboardSetup(
+ password: string,
+ confirm: string,
+): Promise {
+ const res = await fetch("/api/auth/setup", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "same-origin",
+ body: JSON.stringify({ password: password.trim(), confirm: confirm.trim() }),
+ })
+ if (res.ok) return { ok: true }
+ let msg = "Unknown error"
+ try {
+ const j = (await res.json()) as { error?: string }
+ if (j.error) msg = j.error
+ } catch {
+ /* ignore */
+ }
+ return { ok: false, error: msg }
+}
diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx
index fa1b5a488..798ac8ad5 100644
--- a/web/frontend/src/components/app-header.tsx
+++ b/web/frontend/src/components/app-header.tsx
@@ -2,6 +2,7 @@ import {
IconBook,
IconLanguage,
IconLoader2,
+ IconLogout,
IconMenu2,
IconMoon,
IconPlayerPlay,
@@ -39,6 +40,7 @@ import {
} from "@/components/ui/tooltip"
import { useGateway } from "@/hooks/use-gateway.ts"
import { useTheme } from "@/hooks/use-theme.ts"
+import { postLauncherDashboardLogout } from "@/api/launcher-auth"
export function AppHeader() {
const { i18n, t } = useTranslation()
@@ -47,10 +49,12 @@ export function AppHeader() {
state: gwState,
loading: gwLoading,
canStart,
+ startReason,
restartRequired,
start,
restart,
stop,
+ error: gwError,
} = useGateway()
const isRunning = gwState === "running"
@@ -65,6 +69,12 @@ export function AppHeader() {
(gwState === "stopped" || gwState === "error")
const [showStopDialog, setShowStopDialog] = React.useState(false)
+ const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
+
+ const handleLogout = async () => {
+ await postLauncherDashboardLogout()
+ globalThis.location.assign("/launcher-login")
+ }
const handleGatewayToggle = () => {
if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) {
@@ -134,6 +144,23 @@ export function AppHeader() {
+
+
+
+ {t("header.logout.tooltip")}
+
+ {t("header.logout.description")}
+
+
+
+ {t("common.cancel")}
+ void handleLogout()}>
+ {t("header.logout.confirm")}
+
+
+
+
+
{restartRequired && (
@@ -171,38 +198,50 @@ export function AppHeader() {
- {t("header.gateway.action.stop")}
+ {gwError ?? t("header.gateway.action.stop")}
) : (
-
+
+
+ {/* Wrap in span so the tooltip still fires when the button is disabled */}
+
+
+
+
+ {(gwError || (!canStart && startReason)) ? (
+ {gwError ?? startReason}
+ ) : null}
+
)}
{/* Theme Toggle */}
+
+
+
+
+ {t("header.logout.tooltip")}
+
+