From 5e028a847c5ee1fa957d94cd20e1b7acadb83a27 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:38:38 +0800 Subject: [PATCH] feat: add picoclaw-launcher with web UI for configuration and gateway management (#904) A standalone web-based tool for managing picoclaw configuration, OAuth authentication providers, and gateway process lifecycle. Features include a sidebar layout with i18n (en/zh) and theme support, real-time gateway log streaming, startup prerequisites checks, and Windows icon embedding. Co-authored-by: wj-xiao Co-authored-by: Claude Opus 4.6 --- .gitignore | 3 + .goreleaser.yaml | 33 +- cmd/picoclaw-launcher/README.md | 290 +++ cmd/picoclaw-launcher/README.zh.md | 287 +++ cmd/picoclaw-launcher/icon.ico | Bin 0 -> 44671 bytes .../internal/server/auth_config.go | 147 ++ .../internal/server/auth_config_test.go | 222 ++ .../internal/server/auth_handlers.go | 312 +++ .../internal/server/logbuffer.go | 99 + .../internal/server/logbuffer_test.go | 116 + .../internal/server/process.go | 232 ++ .../internal/server/server.go | 196 ++ .../internal/server/server_test.go | 247 ++ .../internal/server/utils.go | 28 + cmd/picoclaw-launcher/internal/ui/index.html | 1999 +++++++++++++++++ cmd/picoclaw-launcher/main.go | 127 ++ cmd/picoclaw-launcher/winres/winres.json | 22 + pkg/auth/oauth.go | 72 +- pkg/auth/oauth_test.go | 4 +- 19 files changed, 4425 insertions(+), 11 deletions(-) create mode 100644 cmd/picoclaw-launcher/README.md create mode 100644 cmd/picoclaw-launcher/README.zh.md create mode 100644 cmd/picoclaw-launcher/icon.ico create mode 100644 cmd/picoclaw-launcher/internal/server/auth_config.go create mode 100644 cmd/picoclaw-launcher/internal/server/auth_config_test.go create mode 100644 cmd/picoclaw-launcher/internal/server/auth_handlers.go create mode 100644 cmd/picoclaw-launcher/internal/server/logbuffer.go create mode 100644 cmd/picoclaw-launcher/internal/server/logbuffer_test.go create mode 100644 cmd/picoclaw-launcher/internal/server/process.go create mode 100644 cmd/picoclaw-launcher/internal/server/server.go create mode 100644 cmd/picoclaw-launcher/internal/server/server_test.go create mode 100644 cmd/picoclaw-launcher/internal/server/utils.go create mode 100644 cmd/picoclaw-launcher/internal/ui/index.html create mode 100644 cmd/picoclaw-launcher/main.go create mode 100644 cmd/picoclaw-launcher/winres/winres.json diff --git a/.gitignore b/.gitignore index 3ff195fbf..02ef18d1f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ tasks/ # Added by goreleaser init: dist/ + +# Windows Application Icon/Resource +*.syso diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 90bdc8437..d893b07a8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -5,7 +5,9 @@ version: 2 before: hooks: - go mod tidy - - go generate ./cmd/picoclaw/... + - go generate ./... + - go install github.com/tc-hib/go-winres@latest + - go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }} builds: - id: picoclaw @@ -37,6 +39,32 @@ builds: - goos: windows goarch: arm + - id: picoclaw-launcher + binary: picoclaw-launcher + env: + - CGO_ENABLED=0 + tags: + - stdjson + ldflags: + - -s -w + goos: + - linux + - windows + - darwin + - freebsd + goarch: + - amd64 + - arm64 + - riscv64 + - loong64 + - arm + goarm: + - "7" + main: ./cmd/picoclaw-launcher + ignore: + - goos: windows + goarch: arm + dockers_v2: - id: picoclaw dockerfile: docker/Dockerfile.goreleaser @@ -72,6 +100,9 @@ archives: nfpms: - id: picoclaw + builds: + - picoclaw + - picoclaw-launcher package_name: picoclaw file_name_template: >- {{ .PackageName }}_ diff --git a/cmd/picoclaw-launcher/README.md b/cmd/picoclaw-launcher/README.md new file mode 100644 index 000000000..641279bb1 --- /dev/null +++ b/cmd/picoclaw-launcher/README.md @@ -0,0 +1,290 @@ +# PicoClaw Launcher + +> [!WARNING] +> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable. + +A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management. + +## Features + +- ๐Ÿ“ **Config Editor** โ€” Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor +- ๐Ÿค– **Model Management** โ€” Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation +- ๐Ÿ“ก **Channel Configuration** โ€” Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links +- ๐Ÿ” **Provider Auth** โ€” Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth) +- ๐ŸŒ **Embedded Frontend** โ€” Compiles to a single binary with no external dependencies +- ๐ŸŒ **i18n** โ€” Chinese/English language switching with browser auto-detection +- ๐ŸŽจ **Theme** โ€” Light / Dark / System theme toggle with localStorage persistence + +## Quick Start + +```bash +# Build +go build -o picoclaw-launcher ./cmd/picoclaw-launcher/ + +# Run with default config path (~/.picoclaw/config.json) +./picoclaw-launcher + +# Specify a config file +./picoclaw-launcher ./config.json + +# Allow LAN access +./picoclaw-launcher -public +``` + +Open `http://localhost:18800` in your browser. + +## CLI Options + +``` +Usage: picoclaw-config [options] [config.json] + +Arguments: + config.json Path to the configuration file (default: ~/.picoclaw/config.json) + +Options: + -public Listen on all interfaces (0.0.0.0), allowing access from other devices +``` + +## API Reference + +Base URL: `http://localhost:18800` + +--- + +### Static Files + +#### GET / + +Serves the embedded frontend (`index.html`). + +--- + +### Config API + +#### GET /api/config + +Reads the current configuration file. + +**Response** `200 OK` + +```json +{ + "config": { ... }, + "path": "/Users/xiao/.picoclaw/config.json" +} +``` + +--- + +#### PUT /api/config + +Saves the configuration. The request body must be a complete Config JSON object. + +**Request Body** โ€” `application/json` + +```json +{ + "agents": { "defaults": { "model_name": "gpt-5.2" } }, + "model_list": [ + { + "model_name": "gpt-5.2", + "model": "openai/gpt-5.2", + "auth_method": "oauth" + } + ] +} +``` + +**Response** `200 OK` + +```json +{ "status": "ok" } +``` + +**Error** `400 Bad Request` โ€” Invalid JSON + +--- + +### Auth API + +#### GET /api/auth/status + +Returns the authentication status of all providers and any in-progress device code login. + +**Response** `200 OK` + +```json +{ + "providers": [ + { + "provider": "openai", + "auth_method": "oauth", + "status": "active", + "account_id": "user-xxx", + "expires_at": "2026-03-01T00:00:00Z" + } + ], + "pending_device": { + "provider": "openai", + "status": "pending", + "device_url": "https://auth.openai.com/activate", + "user_code": "ABCD-1234" + } +} +``` + +`status` values: `active` | `expired` | `needs_refresh` + +`pending_device` is only present when a device code login is in progress. + +--- + +#### POST /api/auth/login + +Initiates a provider login. + +**Request Body** โ€” `application/json` + +```json +{ "provider": "openai" } +``` + +Supported `provider` values: `openai` | `anthropic` | `google-antigravity` + +##### OpenAI (Device Code Flow) + +Returns device code info. The server polls for completion in the background. + +```json +{ + "status": "pending", + "device_url": "https://auth.openai.com/activate", + "user_code": "ABCD-1234", + "message": "Open the URL and enter the code to authenticate." +} +``` + +The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`. + +##### Anthropic (API Token) + +Requires a `token` field in the request: + +```json +{ "provider": "anthropic", "token": "sk-ant-xxx" } +``` + +**Response:** + +```json +{ "status": "success", "message": "Anthropic token saved" } +``` + +##### Google Antigravity (Browser OAuth) + +Returns an authorization URL for the frontend to open in a new tab: + +```json +{ + "status": "redirect", + "auth_url": "https://accounts.google.com/o/oauth2/auth?...", + "message": "Open the URL to authenticate with Google." +} +``` + +After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI. + +--- + +#### POST /api/auth/logout + +Logs out from a provider. + +**Request Body** โ€” `application/json` + +```json +{ "provider": "openai" } +``` + +Omit or leave `provider` empty to log out from all providers. + +**Response** `200 OK` + +```json +{ "status": "ok" } +``` + +--- + +#### GET /auth/callback + +OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect โ€” **not invoked directly by the frontend**. + +**Query Parameters:** +- `state` โ€” OAuth state for CSRF validation +- `code` โ€” Authorization code + +On success, redirects to `/#auth`. + + +### Process API + +#### GET /api/process/status + +Gets the running status of the `picoclaw gateway` process. + +**Response** `200 OK` (Running) + +```json +{ + "process_status": "running", + "status": "ok", + "uptime": "1.010814s" +} +``` + +**Response** `200 OK` (Stopped) + +```json +{ + "process_status": "stopped", + "error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused" +} +``` + +--- + +#### POST /api/process/start + +Starts the `picoclaw gateway` process in the background. + +**Response** `200 OK` + +```json +{ + "status": "ok", + "pid": 12345 +} +``` + +--- + +#### POST /api/process/stop + +Stops the running `picoclaw gateway` process. + +**Response** `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## Testing + +```bash +go test -v ./cmd/picoclaw-launcher/ +``` diff --git a/cmd/picoclaw-launcher/README.zh.md b/cmd/picoclaw-launcher/README.zh.md new file mode 100644 index 000000000..320de75a5 --- /dev/null +++ b/cmd/picoclaw-launcher/README.zh.md @@ -0,0 +1,287 @@ +# PicoClaw Launcher + +> [!WARNING] +> ่ฏฅ้กน็›ฎๅฑžไบŽไธดๆ—ถ่งฃๅ†ณๆ–นๆกˆ๏ผŒๅŽ็ปญไผš้‡ๆž„ๅนถๆไพ›ๅฎŒๆ•ด็š„ Web ๆœๅŠก๏ผŒๅ› ๆญค่ฏฅ็›ฎๅฝ•ไธ‹็š„ๆŽฅๅฃๅนถไธ็จณๅฎšใ€‚ + +PicoClaw ็š„็‹ฌ็ซ‹ๅฏๅŠจๅ™จ๏ผŒๆไพ›ๅฏ่ง†ๅŒ– JSON ้…็ฝฎ็ผ–่พ‘ๅ’Œ OAuth Provider ่ฎค่ฏ็ฎก็†ใ€‚ + +## ๅŠŸ่ƒฝ + +- ๐Ÿ“ **้…็ฝฎ็ผ–่พ‘** โ€” ไพง่พนๆ ๅผ่ฎพ็ฝฎ UI๏ผŒๆ”ฏๆŒๆจกๅž‹็ฎก็†ใ€้€š้“้…็ฝฎ่กจๅ•ๅ’ŒๅŽŸๅง‹ JSON ็ผ–่พ‘ๅ™จ +- ๐Ÿค– **ๆจกๅž‹็ฎก็†** โ€” ๆจกๅž‹ๅก็‰‡็ฝ‘ๆ ผ๏ผŒๅฏ็”จๆ€ง็Šถๆ€ๆ˜พ็คบ๏ผˆๆ—  API Key ๆ—ถ็ฐ่‰ฒ๏ผ‰๏ผŒไธปๆจกๅž‹้€‰ๆ‹ฉ๏ผŒๅขžๅˆ ๆ”นๆŸฅ๏ผŒๅฟ…ๅกซ/้€‰ๅกซๅญ—ๆฎตๅˆ†็ฆป +- ๐Ÿ“ก **้€š้“้…็ฝฎ** โ€” 12 ็ง้€š้“็ฑปๅž‹๏ผˆTelegramใ€Discordใ€Slackใ€ไผไธšๅพฎไฟกใ€้’‰้’‰ใ€้ฃžไนฆใ€LINEใ€WhatsAppใ€QQใ€OneBotใ€MaixCAM ็ญ‰๏ผ‰็š„่กจๅ•ๅŒ–้…็ฝฎ๏ผŒ้™„ๅธฆๆ–‡ๆกฃ้“พๆŽฅ +- ๐Ÿ” **Provider ่ฎค่ฏ** โ€” ๆ”ฏๆŒ OpenAI (Device Code)ใ€Anthropic (API Token)ใ€Google Antigravity (Browser OAuth) ็™ปๅฝ• +- ๐ŸŒ **ๅตŒๅ…ฅๅผๅ‰็ซฏ** โ€” ็ผ–่ฏ‘ไธบๅ•ไธ€ไบŒ่ฟ›ๅˆถๆ–‡ไปถ๏ผŒๆ— ้œ€้ขๅค–ไพ่ต– +- ๐ŸŒ **ๅ›ฝ้™…ๅŒ–** โ€” ไธญ่‹ฑๆ–‡ๅˆ‡ๆข๏ผŒ้ฆ–ๆฌก่ฎฟ้—ฎ่‡ชๅŠจๆฃ€ๆต‹ๆต่งˆๅ™จ่ฏญ่จ€ +- ๐ŸŽจ **ไธป้ข˜** โ€” ไบฎ่‰ฒ / ๆš—่‰ฒ / ่ทŸ้š็ณป็ปŸ๏ผŒๅๅฅฝไฟๅญ˜ๅœจ localStorage + +## ๅฟซ้€Ÿๅผ€ๅง‹ + +```bash +# ็ผ–่ฏ‘ +go build -o picoclaw-launcher ./cmd/picoclaw-launcher/ + +# ่ฟ่กŒ๏ผˆไฝฟ็”จ้ป˜่ฎค้…็ฝฎ่ทฏๅพ„ ~/.picoclaw/config.json๏ผ‰ +./picoclaw-launcher + +# ๆŒ‡ๅฎš้…็ฝฎๆ–‡ไปถ +./picoclaw-launcher ./config.json + +# ๅ…่ฎธๅฑ€ๅŸŸ็ฝ‘่ฎฟ้—ฎ +./picoclaw-launcher -public +``` + +ๅฏๅŠจๅŽๅœจๆต่งˆๅ™จไธญๆ‰“ๅผ€ `http://localhost:18800`ใ€‚ + +## ๅ‘ฝไปค่กŒๅ‚ๆ•ฐ + +``` +Usage: picoclaw-launcher [options] [config.json] + +Arguments: + config.json ้…็ฝฎๆ–‡ไปถ่ทฏๅพ„๏ผˆ้ป˜่ฎค: ~/.picoclaw/config.json๏ผ‰ + +Options: + -public ็›‘ๅฌๆ‰€ๆœ‰็ฝ‘็ปœๆŽฅๅฃ๏ผˆ0.0.0.0๏ผ‰๏ผŒๅ…่ฎธๅฑ€ๅŸŸ็ฝ‘่ฎพๅค‡่ฎฟ้—ฎ +``` + +## API ๆ–‡ๆกฃ + +Base URL: `http://localhost:18800` + +### ้™ๆ€ๆ–‡ไปถ + +#### GET / + +ๆไพ›ๅตŒๅ…ฅๅผๅ‰็ซฏ้กต้ข๏ผˆ`index.html`๏ผ‰ใ€‚ + +--- + +### Config API + +#### GET /api/config + +่ฏปๅ–ๅฝ“ๅ‰้…็ฝฎๆ–‡ไปถๅ†…ๅฎนใ€‚ + +**Response** `200 OK` + +```json +{ + "config": { ... }, + "path": "/Users/xiao/.picoclaw/config.json" +} +``` + +--- + +#### PUT /api/config + +ไฟๅญ˜้…็ฝฎใ€‚่ฏทๆฑ‚ไฝ“ไธบๅฎŒๆ•ด็š„ Config JSONใ€‚ + +**Request Body** โ€” `application/json` + +```json +{ + "agents": { "defaults": { "model_name": "gpt-5.2" } }, + "model_list": [ + { + "model_name": "gpt-5.2", + "model": "openai/gpt-5.2", + "auth_method": "oauth" + } + ] +} +``` + +**Response** `200 OK` + +```json +{ "status": "ok" } +``` + +**Error** `400 Bad Request` โ€” ๆ— ๆ•ˆ JSON + +--- + +### Auth API + +#### GET /api/auth/status + +่Žทๅ–ๆ‰€ๆœ‰ Provider ็š„่ฎค่ฏ็Šถๆ€ๅ’Œ่ฟ›่กŒไธญ็š„ Device Code ็™ปๅฝ•ไฟกๆฏใ€‚ + +**Response** `200 OK` + +```json +{ + "providers": [ + { + "provider": "openai", + "auth_method": "oauth", + "status": "active", + "account_id": "user-xxx", + "expires_at": "2026-03-01T00:00:00Z" + } + ], + "pending_device": { + "provider": "openai", + "status": "pending", + "device_url": "https://auth.openai.com/activate", + "user_code": "ABCD-1234" + } +} +``` + +`status` ๅฏ้€‰ๅ€ผ: `active` | `expired` | `needs_refresh` + +`pending_device` ไป…ๅœจๆœ‰่ฟ›่กŒไธญ็š„ Device Code ็™ปๅฝ•ๆ—ถ่ฟ”ๅ›žใ€‚ + +--- + +#### POST /api/auth/login + +ๅ‘่ตท Provider ็™ปๅฝ•ใ€‚ + +**Request Body** โ€” `application/json` + +```json +{ "provider": "openai" } +``` + +ๆ”ฏๆŒ็š„ `provider` ๅ€ผ: `openai` | `anthropic` | `google-antigravity` + +##### OpenAI (Device Code Flow) + +่ฟ”ๅ›ž Device Code ไฟกๆฏ๏ผŒๅŽๅฐ่‡ชๅŠจ่ฝฎ่ฏข่ฎค่ฏ็ป“ๆžœ๏ผš + +```json +{ + "status": "pending", + "device_url": "https://auth.openai.com/activate", + "user_code": "ABCD-1234", + "message": "Open the URL and enter the code to authenticate." +} +``` + +็”จๆˆทๅœจๆต่งˆๅ™จไธญๆ‰“ๅผ€ `device_url` ๅนถ่พ“ๅ…ฅ `user_code`ใ€‚่ฎค่ฏๅฎŒๆˆๅŽ้€š่ฟ‡ `GET /api/auth/status` ็š„ `pending_device.status` ๅ˜ไธบ `success` ้€š็Ÿฅๅ‰็ซฏใ€‚ + +##### Anthropic (API Token) + +้œ€ๅœจ่ฏทๆฑ‚ไธญ้™„ๅธฆ token๏ผš + +```json +{ "provider": "anthropic", "token": "sk-ant-xxx" } +``` + +**Response:** + +```json +{ "status": "success", "message": "Anthropic token saved" } +``` + +##### Google Antigravity (Browser OAuth) + +่ฟ”ๅ›žๆŽˆๆƒ URL๏ผŒๅ‰็ซฏๆ‰“ๅผ€ๆ–ฐๆ ‡็ญพ้กต๏ผš + +```json +{ + "status": "redirect", + "auth_url": "https://accounts.google.com/o/oauth2/auth?...", + "message": "Open the URL to authenticate with Google." +} +``` + +่ฎค่ฏๅฎŒๆˆๅŽ Google ๅ›ž่ฐƒ่‡ณ `GET /auth/callback`๏ผŒ่‡ชๅŠจไฟๅญ˜ๅ‡ญๆฎๅนถ้‡ๅฎšๅ‘ๅ›ž picoclaw-config ้กต้ขใ€‚ + +--- + +#### POST /api/auth/logout + +็™ปๅ‡บ Providerใ€‚ + +**Request Body** โ€” `application/json` + +```json +{ "provider": "openai" } +``` + +ไผ ็ฉบๅญ—็ฌฆไธฒๆˆ–็œ็•ฅ `provider` ๅˆ™็™ปๅ‡บๆ‰€ๆœ‰ Providerใ€‚ + +**Response** `200 OK` + +```json +{ "status": "ok" } +``` + +--- + +#### GET /auth/callback + +OAuth Browser ๅ›ž่ฐƒ็ซฏ็‚น๏ผˆGoogle Antigravity ไธ“็”จ๏ผ‰๏ผŒ็”ฑ OAuth Provider ้‡ๅฎšๅ‘่ฐƒ็”จ๏ผŒ**้žๅ‰็ซฏ็›ดๆŽฅไฝฟ็”จ**ใ€‚ + +**Query Parameters:** +- `state` โ€” OAuth state ๆ ก้ชŒ +- `code` โ€” ๆŽˆๆƒ็  + +่ฎค่ฏๆˆๅŠŸๅŽ้‡ๅฎšๅ‘ๅˆฐ `/#auth`ใ€‚ + +### Process API + +#### GET /api/process/status + +่Žทๅ– `picoclaw gateway` ่ฟ›็จ‹็š„่ฟ่กŒ็Šถๆ€ใ€‚ + +**Response** `200 OK` (่ฟ่กŒไธญ) + +```json +{ + "process_status": "running", + "status": "ok", + "uptime": "1.010814s" +} +``` + +**Response** `200 OK` (ๆœช่ฟ่กŒ) + +```json +{ + "process_status": "stopped", + "error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused" +} +``` + +--- + +#### POST /api/process/start + +ๅœจๅŽๅฐๅฏๅŠจ `picoclaw gateway` ่ฟ›็จ‹ใ€‚ + +**Response** `200 OK` + +```json +{ + "status": "ok", + "pid": 12345 +} +``` + +--- + +#### POST /api/process/stop + +ๅœๆญขๆญฃๅœจ่ฟ่กŒ็š„ `picoclaw gateway` ่ฟ›็จ‹ใ€‚ + +**Response** `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## ๆต‹่ฏ• + +```bash +go test -v ./cmd/picoclaw-launcher/ +``` diff --git a/cmd/picoclaw-launcher/icon.ico b/cmd/picoclaw-launcher/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4f65394147f95a863892e39c48d3386170e4f245 GIT binary patch literal 44671 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 Kl9l2{!T$$h!av>s literal 0 HcmV?d00001 diff --git a/cmd/picoclaw-launcher/internal/server/auth_config.go b/cmd/picoclaw-launcher/internal/server/auth_config.go new file mode 100644 index 000000000..f75e8fff0 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/auth_config.go @@ -0,0 +1,147 @@ +package server + +import ( + "log" + "strings" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +// updateConfigAfterLogin updates config.json after a successful provider login. +func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + log.Printf("Warning: could not load config to update auth_method: %v", err) + return + } + + switch provider { + case "openai": + cfg.Providers.OpenAI.AuthMethod = "oauth" + found := false + for i := range cfg.ModelList { + if isOpenAIModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "oauth" + found = true + break + } + } + if !found { + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "gpt-5.2", + Model: "openai/gpt-5.2", + AuthMethod: "oauth", + }) + } + cfg.Agents.Defaults.ModelName = "gpt-5.2" + + case "anthropic": + cfg.Providers.Anthropic.AuthMethod = "token" + found := false + for i := range cfg.ModelList { + if isAnthropicModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "token" + found = true + break + } + } + if !found { + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "claude-sonnet-4.6", + Model: "anthropic/claude-sonnet-4.6", + AuthMethod: "token", + }) + } + cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" + + case "google-antigravity": + cfg.Providers.Antigravity.AuthMethod = "oauth" + found := false + for i := range cfg.ModelList { + if isAntigravityModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "oauth" + found = true + break + } + } + if !found { + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "gemini-flash", + Model: "antigravity/gemini-3-flash", + AuthMethod: "oauth", + }) + } + cfg.Agents.Defaults.ModelName = "gemini-flash" + } + + if err := config.SaveConfig(configPath, cfg); err != nil { + log.Printf("Warning: could not update config: %v", err) + } +} + +// clearAuthMethodInConfig clears auth_method for a specific provider in config.json. +func clearAuthMethodInConfig(configPath, provider string) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return + } + + for i := range cfg.ModelList { + switch provider { + case "openai": + if isOpenAIModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "" + } + case "anthropic": + if isAnthropicModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "" + } + case "google-antigravity", "antigravity": + if isAntigravityModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "" + } + } + } + + switch provider { + case "openai": + cfg.Providers.OpenAI.AuthMethod = "" + case "anthropic": + cfg.Providers.Anthropic.AuthMethod = "" + case "google-antigravity", "antigravity": + cfg.Providers.Antigravity.AuthMethod = "" + } + + config.SaveConfig(configPath, cfg) +} + +// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json. +func clearAllAuthMethodsInConfig(configPath string) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return + } + for i := range cfg.ModelList { + cfg.ModelList[i].AuthMethod = "" + } + cfg.Providers.OpenAI.AuthMethod = "" + cfg.Providers.Anthropic.AuthMethod = "" + cfg.Providers.Antigravity.AuthMethod = "" + config.SaveConfig(configPath, cfg) +} + +// โ”€โ”€ Model identification helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func isOpenAIModel(model string) bool { + return model == "openai" || strings.HasPrefix(model, "openai/") +} + +func isAnthropicModel(model string) bool { + return model == "anthropic" || strings.HasPrefix(model, "anthropic/") +} + +func isAntigravityModel(model string) bool { + return model == "antigravity" || model == "google-antigravity" || + strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/") +} diff --git a/cmd/picoclaw-launcher/internal/server/auth_config_test.go b/cmd/picoclaw-launcher/internal/server/auth_config_test.go new file mode 100644 index 000000000..92158d011 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/auth_config_test.go @@ -0,0 +1,222 @@ +package server + +import ( + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +// โ”€โ”€ Model identification helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestIsOpenAIModel(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"openai", true}, + {"openai/gpt-4o", true}, + {"openai/gpt-5.2", true}, + {"anthropic", false}, + {"anthropic/claude-sonnet-4.6", false}, + {"openai-compatible", false}, + {"", false}, + } + for _, tt := range tests { + if got := isOpenAIModel(tt.model); got != tt.want { + t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want) + } + } +} + +func TestIsAnthropicModel(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"anthropic", true}, + {"anthropic/claude-sonnet-4.6", true}, + {"openai", false}, + {"openai/gpt-4o", false}, + {"", false}, + } + for _, tt := range tests { + if got := isAnthropicModel(tt.model); got != tt.want { + t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want) + } + } +} + +func TestIsAntigravityModel(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"antigravity", true}, + {"google-antigravity", true}, + {"antigravity/gemini-3-flash", true}, + {"google-antigravity/gemini-3-flash", true}, + {"openai", false}, + {"antigravity-custom", false}, + {"", false}, + } + for _, tt := range tests { + if got := isAntigravityModel(tt.model); got != tt.want { + t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want) + } + } +} + +// โ”€โ”€ Config update helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := config.SaveConfig(path, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + return path +} + +func loadTempConfig(t *testing.T, path string) *config.Config { + t.Helper() + cfg, err := config.LoadConfig(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + return cfg +} + +func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "gpt-4o", Model: "openai/gpt-4o"}, + }, + } + path := writeTempConfigViaSave(t, cfg) + + cred := &auth.AuthCredential{AuthMethod: "oauth"} + updateConfigAfterLogin(path, "openai", cred) + + result := loadTempConfig(t, path) + + // Model-level auth_method persists through serialization + if len(result.ModelList) != 1 { + t.Fatalf("expected 1 model, got %d", len(result.ModelList)) + } + if result.ModelList[0].AuthMethod != "oauth" { + t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod) + } +} + +func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"}, + }, + } + path := writeTempConfigViaSave(t, cfg) + + cred := &auth.AuthCredential{AuthMethod: "oauth"} + updateConfigAfterLogin(path, "openai", cred) + + result := loadTempConfig(t, path) + + if len(result.ModelList) != 2 { + t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList)) + } + if result.ModelList[1].Model != "openai/gpt-5.2" { + t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model) + } + if result.Agents.Defaults.ModelName != "gpt-5.2" { + t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName) + } +} + +func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) { + cfg := &config.Config{} + path := writeTempConfigViaSave(t, cfg) + + cred := &auth.AuthCredential{AuthMethod: "token"} + updateConfigAfterLogin(path, "anthropic", cred) + + result := loadTempConfig(t, path) + + // Model should be added with correct auth_method + if len(result.ModelList) != 1 { + t.Fatalf("expected 1 model added, got %d", len(result.ModelList)) + } + if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" { + t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model) + } + if result.ModelList[0].AuthMethod != "token" { + t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod) + } +} + +func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) { + cfg := &config.Config{} + path := writeTempConfigViaSave(t, cfg) + + cred := &auth.AuthCredential{AuthMethod: "oauth"} + updateConfigAfterLogin(path, "google-antigravity", cred) + + result := loadTempConfig(t, path) + + // Model should be added with correct auth_method + if len(result.ModelList) != 1 { + t.Fatalf("expected 1 model added, got %d", len(result.ModelList)) + } + if result.ModelList[0].Model != "antigravity/gemini-3-flash" { + t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model) + } + if result.ModelList[0].AuthMethod != "oauth" { + t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod) + } +} + +func TestClearAuthMethodInConfig(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"}, + {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"}, + }, + } + path := writeTempConfigViaSave(t, cfg) + + clearAuthMethodInConfig(path, "openai") + + result := loadTempConfig(t, path) + + // Openai model auth_method should be cleared + if result.ModelList[0].AuthMethod != "" { + t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod) + } + // Anthropic model should be unchanged + if result.ModelList[1].AuthMethod != "token" { + t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod) + } +} + +func TestClearAllAuthMethodsInConfig(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"}, + {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"}, + {ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"}, + }, + } + path := writeTempConfigViaSave(t, cfg) + + clearAllAuthMethodsInConfig(path) + + result := loadTempConfig(t, path) + + for i, m := range result.ModelList { + if m.AuthMethod != "" { + t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod) + } + } +} diff --git a/cmd/picoclaw-launcher/internal/server/auth_handlers.go b/cmd/picoclaw-launcher/internal/server/auth_handlers.go new file mode 100644 index 000000000..1e9b8be0a --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/auth_handlers.go @@ -0,0 +1,312 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// oauthSession stores in-flight OAuth state for browser-based flows. +type oauthSession struct { + Provider string + PKCE auth.PKCECodes + State string + RedirectURI string + OAuthCfg auth.OAuthProviderConfig + ConfigPath string +} + +// deviceCodeSession stores in-flight device code flow state. +type deviceCodeSession struct { + mu sync.Mutex + Provider string + Info *auth.DeviceCodeInfo + OAuthCfg auth.OAuthProviderConfig + ConfigPath string + Status string // "pending", "success", "error" + Error string + Done bool +} + +var ( + oauthSessions = map[string]*oauthSession{} // keyed by state + oauthSessionsMu sync.Mutex + + activeDeviceSession *deviceCodeSession + activeDeviceSessionMu sync.Mutex +) + +// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend. +func handleOpenAILogin(w http.ResponseWriter, configPath string) { + // Check if there's already a pending device code session + activeDeviceSessionMu.Lock() + if activeDeviceSession != nil { + activeDeviceSession.mu.Lock() + if !activeDeviceSession.Done { + resp := map[string]any{ + "status": "pending", + "device_url": activeDeviceSession.Info.VerifyURL, + "user_code": activeDeviceSession.Info.UserCode, + "message": "Device code flow already in progress. Enter the code in your browser.", + } + activeDeviceSession.mu.Unlock() + activeDeviceSessionMu.Unlock() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + activeDeviceSession.mu.Unlock() + } + activeDeviceSessionMu.Unlock() + + // Request a device code + oauthCfg := auth.OpenAIOAuthConfig() + info, err := auth.RequestDeviceCode(oauthCfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError) + return + } + + session := &deviceCodeSession{ + Provider: "openai", + Info: info, + OAuthCfg: oauthCfg, + ConfigPath: configPath, + Status: "pending", + } + + activeDeviceSessionMu.Lock() + activeDeviceSession = session + activeDeviceSessionMu.Unlock() + + // Start background polling + go func() { + deadline := time.After(15 * time.Minute) + ticker := time.NewTicker(time.Duration(info.Interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-deadline: + session.mu.Lock() + session.Status = "error" + session.Error = "Authentication timed out after 15 minutes" + session.Done = true + session.mu.Unlock() + return + case <-ticker.C: + cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode) + if err != nil { + continue // Still pending + } + if cred != nil { + if saveErr := auth.SetCredential("openai", cred); saveErr != nil { + session.mu.Lock() + session.Status = "error" + session.Error = saveErr.Error() + session.Done = true + session.mu.Unlock() + return + } + updateConfigAfterLogin(configPath, "openai", cred) + session.mu.Lock() + session.Status = "success" + session.Done = true + session.mu.Unlock() + log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID) + return + } + } + } + }() + + // Return device code info to frontend + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "pending", + "device_url": info.VerifyURL, + "user_code": info.UserCode, + "message": "Open the URL and enter the code to authenticate.", + }) +} + +// handleAnthropicLogin saves a pasted API token for Anthropic. +func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) { + if token == "" { + http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest) + return + } + + cred := &auth.AuthCredential{ + AccessToken: token, + Provider: "anthropic", + AuthMethod: "token", + } + + if err := auth.SetCredential("anthropic", cred); err != nil { + http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError) + return + } + + updateConfigAfterLogin(configPath, "anthropic", cred) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "message": "Anthropic token saved", + }) +} + +// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend. +func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) { + oauthCfg := auth.GoogleAntigravityOAuthConfig() + + pkce, err := auth.GeneratePKCE() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError) + return + } + + state, err := auth.GenerateState() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError) + return + } + + // Build redirect URI pointing to picoclaw-launcher's own callback + scheme := "http" + redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host) + + authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI) + + // Store session for callback + oauthSessionsMu.Lock() + oauthSessions[state] = &oauthSession{ + Provider: "google-antigravity", + PKCE: pkce, + State: state, + RedirectURI: redirectURI, + OAuthCfg: oauthCfg, + ConfigPath: configPath, + } + oauthSessionsMu.Unlock() + + // Clean up stale sessions after 10 minutes + go func() { + time.Sleep(10 * time.Minute) + oauthSessionsMu.Lock() + delete(oauthSessions, state) + oauthSessionsMu.Unlock() + }() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "redirect", + "auth_url": authURL, + "message": "Open the URL to authenticate with Google.", + }) +} + +// handleOAuthCallback processes the OAuth callback from Google Antigravity. +func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + + oauthSessionsMu.Lock() + session, ok := oauthSessions[state] + if ok { + delete(oauthSessions, state) + } + oauthSessionsMu.Unlock() + + if !ok { + http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest) + return + } + + if code == "" { + errMsg := r.URL.Query().Get("error") + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf( + w, + `

Authentication failed

%s

You can close this window.

`, + errMsg, + ) + return + } + + cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI) + if err != nil { + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf( + w, + `

Authentication failed

%s

You can close this window.

`, + err.Error(), + ) + return + } + + cred.Provider = session.Provider + + // Fetch user info for Google Antigravity + if session.Provider == "google-antigravity" { + if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil { + cred.Email = email + } + if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil { + cred.ProjectID = projectID + } + } + + if err := auth.SetCredential(session.Provider, cred); err != nil { + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `

Failed to save credentials

%s

`, err.Error()) + return + } + + updateConfigAfterLogin(session.ConfigPath, session.Provider, cred) + + // Redirect back to picoclaw-launcher UI + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, ` +

Authentication successful!

+

Redirecting back to Config Editor...

+ + `) +} + +// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint. +func fetchGoogleUserEmail(accessToken string) (string, error) { + req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("userinfo request failed: %s", string(body)) + } + + var userInfo struct { + Email string `json:"email"` + } + if err := json.Unmarshal(body, &userInfo); err != nil { + return "", err + } + return userInfo.Email, nil +} diff --git a/cmd/picoclaw-launcher/internal/server/logbuffer.go b/cmd/picoclaw-launcher/internal/server/logbuffer.go new file mode 100644 index 000000000..4d70f6466 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/logbuffer.go @@ -0,0 +1,99 @@ +package server + +import "sync" + +// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines. +// It supports incremental reads via LinesSince and tracks a runID that increments +// on each Reset (used to detect gateway restarts). +type LogBuffer struct { + mu sync.RWMutex + lines []string + cap int + total int // total lines ever appended in current run + runID int +} + +// NewLogBuffer creates a LogBuffer with the given capacity. +func NewLogBuffer(capacity int) *LogBuffer { + return &LogBuffer{ + lines: make([]string, 0, capacity), + cap: capacity, + } +} + +// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted. +func (b *LogBuffer) Append(line string) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.lines) < b.cap { + b.lines = append(b.lines, line) + } else { + b.lines[b.total%b.cap] = line + } + + b.total++ +} + +// Reset clears the buffer and increments the runID. Call this when starting a new gateway process. +func (b *LogBuffer) Reset() { + b.mu.Lock() + defer b.mu.Unlock() + + b.lines = b.lines[:0] + b.total = 0 + b.runID++ +} + +// LinesSince returns lines appended after the given offset, the current total count, and the runID. +// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned. +func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) { + b.mu.RLock() + defer b.mu.RUnlock() + + total = b.total + runID = b.runID + + if offset >= b.total { + return nil, total, runID + } + + buffered := len(b.lines) + + // How many new lines since offset + newCount := b.total - offset + if newCount > buffered { + newCount = buffered + } + + result := make([]string, newCount) + + if b.total <= b.cap { + // Buffer hasn't wrapped yet โ€” simple slice + copy(result, b.lines[buffered-newCount:]) + } else { + // Buffer has wrapped โ€” read from ring + start := (b.total - newCount) % b.cap + for i := range newCount { + result[i] = b.lines[(start+i)%b.cap] + } + } + + return result, total, runID +} + +// RunID returns the current run identifier. +func (b *LogBuffer) RunID() int { + b.mu.RLock() + defer b.mu.RUnlock() + + return b.runID +} + +// Total returns the total number of lines appended in the current run. +func (b *LogBuffer) Total() int { + b.mu.RLock() + defer b.mu.RUnlock() + + return b.total +} diff --git a/cmd/picoclaw-launcher/internal/server/logbuffer_test.go b/cmd/picoclaw-launcher/internal/server/logbuffer_test.go new file mode 100644 index 000000000..dc525be16 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/logbuffer_test.go @@ -0,0 +1,116 @@ +package server + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLogBuffer_Basic(t *testing.T) { + buf := NewLogBuffer(5) + + // Empty buffer + lines, total, runID := buf.LinesSince(0) + assert.Nil(t, lines) + assert.Equal(t, 0, total) + assert.Equal(t, 0, runID) + + // Append some lines + buf.Append("line1") + buf.Append("line2") + buf.Append("line3") + + lines, total, runID = buf.LinesSince(0) + assert.Equal(t, []string{"line1", "line2", "line3"}, lines) + assert.Equal(t, 3, total) + assert.Equal(t, 0, runID) + + // Incremental read + lines, total, _ = buf.LinesSince(2) + assert.Equal(t, []string{"line3"}, lines) + assert.Equal(t, 3, total) + + // No new lines + lines, total, _ = buf.LinesSince(3) + assert.Nil(t, lines) + assert.Equal(t, 3, total) +} + +func TestLogBuffer_Wrap(t *testing.T) { + buf := NewLogBuffer(3) + + buf.Append("a") + buf.Append("b") + buf.Append("c") + buf.Append("d") // evicts "a" + buf.Append("e") // evicts "b" + + lines, total, _ := buf.LinesSince(0) + assert.Equal(t, []string{"c", "d", "e"}, lines) + assert.Equal(t, 5, total) + + // Incremental after wrap + lines, total, _ = buf.LinesSince(3) + assert.Equal(t, []string{"d", "e"}, lines) + assert.Equal(t, 5, total) + + // Offset too old (before buffer start), get all buffered + lines, total, _ = buf.LinesSince(1) + assert.Equal(t, []string{"c", "d", "e"}, lines) + assert.Equal(t, 5, total) +} + +func TestLogBuffer_Reset(t *testing.T) { + buf := NewLogBuffer(5) + + buf.Append("before") + assert.Equal(t, 0, buf.RunID()) + + buf.Reset() + assert.Equal(t, 1, buf.RunID()) + assert.Equal(t, 0, buf.Total()) + + lines, total, runID := buf.LinesSince(0) + assert.Nil(t, lines) + assert.Equal(t, 0, total) + assert.Equal(t, 1, runID) + + buf.Append("after") + lines, total, runID = buf.LinesSince(0) + assert.Equal(t, []string{"after"}, lines) + assert.Equal(t, 1, total) + assert.Equal(t, 1, runID) +} + +func TestLogBuffer_Concurrent(t *testing.T) { + buf := NewLogBuffer(100) + var wg sync.WaitGroup + + // 10 writers + for i := range 10 { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := range 50 { + buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j)) + } + }(i) + } + + // 5 readers + for range 5 { + wg.Add(1) + go func() { + defer wg.Done() + for range 100 { + buf.LinesSince(0) + } + }() + } + + wg.Wait() + + assert.Equal(t, 500, buf.Total()) +} diff --git a/cmd/picoclaw-launcher/internal/server/process.go b/cmd/picoclaw-launcher/internal/server/process.go new file mode 100644 index 000000000..bc2129bf5 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/process.go @@ -0,0 +1,232 @@ +package server + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher. +var gatewayLogs = NewLogBuffer(200) + +// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway. +func RegisterProcessAPI(mux *http.ServeMux, absPath string) { + mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) { + handleStatusGateway(w, r, absPath) + }) + mux.HandleFunc("POST /api/process/start", handleStartGateway) + mux.HandleFunc("POST /api/process/stop", handleStopGateway) +} + +func handleStartGateway(w http.ResponseWriter, r *http.Request) { + // Locate picoclaw executable: + // 1. Try same directory as current executable + // 2. Fallback to just "picoclaw" (relies on $PATH) + execPath := "picoclaw" + + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + candidate := filepath.Join(dir, "picoclaw") + if runtime.GOOS == "windows" { + candidate += ".exe" + } + + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + execPath = candidate + } + } + + cmd := exec.Command(execPath, "gateway") + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + log.Printf("Failed to create stdout pipe: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) + return + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + log.Printf("Failed to create stderr pipe: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) + return + } + + // Clear old logs and increment runID before starting + gatewayLogs.Reset() + + if err := cmd.Start(); err != nil { + log.Printf("Failed to start picoclaw gateway: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) + return + } + + // Read stdout and stderr into the log buffer + go scanPipe(stdoutPipe, gatewayLogs) + go scanPipe(stderrPipe, gatewayLogs) + + // Wait for the process to exit in the background to avoid zombies + go func() { + if err := cmd.Wait(); err != nil { + log.Printf("Gateway process exited: %v\n", err) + } + }() + + log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": cmd.Process.Pid, + }) +} + +// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF. +func scanPipe(r io.Reader, buf *LogBuffer) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line + + for scanner.Scan() { + buf.Append(scanner.Text()) + } +} + +func handleStopGateway(w http.ResponseWriter, r *http.Request) { + var err error + if runtime.GOOS == "windows" { + // Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe) + // Alternatively, we use powershell to kill processes with commandline containing 'gateway' + psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }` + err = exec.Command("powershell", "-Command", psCmd).Run() + } else { + // Linux/macOS + err = exec.Command("pkill", "-f", "picoclaw gateway").Run() + } + + if err != nil { + log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err) + // We still return 200 OK because pkill returns an error if no process was found + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", // or "not_found" + "msg": "Stop command executed, but returned error (process might not be running).", + "error": err.Error(), + }) + return + } + + log.Printf("Stopped picoclaw gateway processes.\n") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + }) +} + +func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) { + cfg, cfgErr := config.LoadConfig(absPath) + host := "127.0.0.1" + port := 18790 + if cfgErr == nil && cfg != nil { + if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" { + host = cfg.Gateway.Host + } + if cfg.Gateway.Port != 0 { + port = cfg.Gateway.Port + } + } + + url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port))) + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) + + // Build the response data map + data := map[string]any{} + + if err != nil { + data["process_status"] = "stopped" + data["error"] = err.Error() + } else { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + data["process_status"] = "error" + data["status_code"] = resp.StatusCode + } else { + var healthData map[string]any + if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil { + data["process_status"] = "error" + data["error"] = "invalid response from gateway" + } else { + // Gateway is running and responded properly โ€” merge health data + for k, v := range healthData { + data[k] = v + } + data["process_status"] = "running" + } + } + } + + // Append log data from the buffer + appendLogData(r, data) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +// appendLogData reads log_offset and log_run_id query params from the request and +// populates the response data map with incremental log lines. +func appendLogData(r *http.Request, data map[string]any) { + clientOffset := 0 + clientRunID := -1 + + if v := r.URL.Query().Get("log_offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + clientOffset = n + } + } + + if v := r.URL.Query().Get("log_run_id"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + clientRunID = n + } + } + + runID := gatewayLogs.RunID() + + // If runID is 0 (never reset = never launched from this launcher), report no source + if runID == 0 { + data["logs"] = []string{} + data["log_total"] = 0 + data["log_run_id"] = 0 + data["log_source"] = "none" + return + } + + // If the client's runID doesn't match, send all buffered lines (gateway restarted) + offset := clientOffset + if clientRunID != runID { + offset = 0 + } + + lines, total, runID := gatewayLogs.LinesSince(offset) + if lines == nil { + lines = []string{} + } + + data["logs"] = lines + data["log_total"] = total + data["log_run_id"] = runID + data["log_source"] = "launcher" +} diff --git a/cmd/picoclaw-launcher/internal/server/server.go b/cmd/picoclaw-launcher/internal/server/server.go new file mode 100644 index 000000000..4fc68f04c --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/server.go @@ -0,0 +1,196 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +const DefaultPort = "18800" + +// providerStatus represents the auth status of a single provider in API responses. +type providerStatus struct { + Provider string `json:"provider"` + AuthMethod string `json:"auth_method"` + Status string `json:"status"` + AccountID string `json:"account_id,omitempty"` + Email string `json:"email,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +// โ”€โ”€ Route registration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func RegisterConfigAPI(mux *http.ServeMux, absPath string) { + // GET /api/config โ€” read config + mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(absPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "config": cfg, + "path": absPath, + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(resp); err != nil { + log.Printf("Failed to encode response: %v", err) + } + }) + + // PUT /api/config โ€” save config + mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var cfg config.Config + if err := json.Unmarshal(body, &cfg); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if err := config.SaveConfig(absPath, &cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) +} + +func RegisterAuthAPI(mux *http.ServeMux, absPath string) { + // GET /api/auth/status โ€” all authenticated providers + pending login state + mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) { + store, err := auth.LoadStore() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError) + return + } + + result := []providerStatus{} + for name, cred := range store.Credentials { + status := "active" + if cred.IsExpired() { + status = "expired" + } else if cred.NeedsRefresh() { + status = "needs_refresh" + } + ps := providerStatus{ + Provider: name, + AuthMethod: cred.AuthMethod, + Status: status, + AccountID: cred.AccountID, + Email: cred.Email, + ProjectID: cred.ProjectID, + } + if !cred.ExpiresAt.IsZero() { + ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339) + } + result = append(result, ps) + } + + // Include pending device code state + var pendingDevice map[string]any + activeDeviceSessionMu.Lock() + if activeDeviceSession != nil { + activeDeviceSession.mu.Lock() + pendingDevice = map[string]any{ + "provider": activeDeviceSession.Provider, + "status": activeDeviceSession.Status, + "device_url": activeDeviceSession.Info.VerifyURL, + "user_code": activeDeviceSession.Info.UserCode, + } + if activeDeviceSession.Error != "" { + pendingDevice["error"] = activeDeviceSession.Error + } + if activeDeviceSession.Done { + activeDeviceSession.mu.Unlock() + activeDeviceSession = nil + } else { + activeDeviceSession.mu.Unlock() + } + } + activeDeviceSessionMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "providers": result, + "pending_device": pendingDevice, + }) + }) + + // POST /api/auth/login โ€” initiate provider login + mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Token string `json:"token,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + switch req.Provider { + case "openai": + handleOpenAILogin(w, absPath) + case "anthropic": + handleAnthropicLogin(w, req.Token, absPath) + case "google-antigravity", "antigravity": + handleGoogleAntigravityLogin(w, r, absPath) + default: + http.Error( + w, + fmt.Sprintf( + "Unsupported provider: %s (supported: openai, anthropic, google-antigravity)", + req.Provider, + ), + http.StatusBadRequest, + ) + } + }) + + // POST /api/auth/logout โ€” logout a provider + mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Provider == "" { + if err := auth.DeleteAllCredentials(); err != nil { + http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError) + return + } + clearAllAuthMethodsInConfig(absPath) + } else { + if err := auth.DeleteCredential(req.Provider); err != nil { + http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError) + return + } + clearAuthMethodInConfig(absPath, req.Provider) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + + // GET /auth/callback โ€” OAuth browser callback for Google Antigravity + mux.HandleFunc("GET /auth/callback", handleOAuthCallback) +} diff --git a/cmd/picoclaw-launcher/internal/server/server_test.go b/cmd/picoclaw-launcher/internal/server/server_test.go new file mode 100644 index 000000000..c87e93d8c --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/server_test.go @@ -0,0 +1,247 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// โ”€โ”€ Config API tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + mux := http.NewServeMux() + RegisterConfigAPI(mux, path) + RegisterAuthAPI(mux, path) + return mux, path +} + +func TestGetConfig(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "gpt-4o", Model: "openai/gpt-4o"}, + }, + } + mux, path := setupConfigMux(t, cfg) + + req := httptest.NewRequest("GET", "/api/config", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Config config.Config `json:"config"` + Path string `json:"path"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.Path != path { + t.Errorf("expected path %q, got %q", path, resp.Path) + } + if len(resp.Config.ModelList) != 1 { + t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList)) + } +} + +func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) { + mux := http.NewServeMux() + RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json") + + req := httptest.NewRequest("GET", "/api/config", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + // LoadConfig returns a default empty config when file is missing + if w.Code != http.StatusOK { + t.Errorf("expected 200 for missing file (default config), got %d", w.Code) + } +} + +func TestPutConfig(t *testing.T) { + cfg := &config.Config{} + mux, path := setupConfigMux(t, cfg) + + newCfg := config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"}, + }, + } + body, _ := json.Marshal(newCfg) + + req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + saved, err := config.LoadConfig(path) + if err != nil { + t.Fatalf("load saved config: %v", err) + } + if len(saved.ModelList) != 1 { + t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList)) + } + if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" { + t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model) + } +} + +func TestPutConfig_InvalidJSON(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid JSON, got %d", w.Code) + } +} + +// โ”€โ”€ Auth API tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestAuthStatus(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("GET", "/api/auth/status", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Providers []providerStatus `json:"providers"` + PendingDevice map[string]any `json:"pending_device"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + // providers should be a non-nil list (could be empty) + if resp.Providers == nil { + t.Error("providers should not be nil") + } +} + +func TestAuthLogin_UnsupportedProvider(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + body := `{"provider": "unsupported"}` + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for unsupported provider, got %d", w.Code) + } +} + +func TestAuthLogin_AnthropicNoToken(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + body := `{"provider": "anthropic"}` + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for anthropic without token, got %d", w.Code) + } +} + +func TestAuthLogin_InvalidBody(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid JSON body, got %d", w.Code) + } +} + +func TestAuthLogout_InvalidBody(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid body, got %d", w.Code) + } +} + +func TestOAuthCallback_InvalidState(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid state, got %d", w.Code) + } +} + +// โ”€โ”€ Utility tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestDefaultConfigPath(t *testing.T) { + path := DefaultConfigPath() + if path == "" { + t.Error("defaultConfigPath should not return empty") + } + if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) { + t.Errorf("expected path ending with .picoclaw/config.json, got %q", path) + } +} + +func TestGetLocalIP(t *testing.T) { + // Just ensure it doesn't panic; IP may or may not be available + ip := GetLocalIP() + if ip != "" { + // If returned, should look like an IP + if !strings.Contains(ip, ".") { + t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip) + } + } +} diff --git a/cmd/picoclaw-launcher/internal/server/utils.go b/cmd/picoclaw-launcher/internal/server/utils.go new file mode 100644 index 000000000..a46adbece --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/utils.go @@ -0,0 +1,28 @@ +package server + +import ( + "net" + "os" + "path/filepath" +) + +func DefaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "config.json" + } + return filepath.Join(home, ".picoclaw", "config.json") +} + +func GetLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + return "" +} diff --git a/cmd/picoclaw-launcher/internal/ui/index.html b/cmd/picoclaw-launcher/internal/ui/index.html new file mode 100644 index 000000000..93893fd75 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/ui/index.html @@ -0,0 +1,1999 @@ + + + + + + + + PicoClaw Config + + + + + + + + + +
+
+ +

PicoClaw Config

+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + + + +
+
+
Models
+
Manage LLM model configurations. Models without an API key are grayed out. Only available models can be set as primary.
+
+ +
+
+
+ +
+
Provider Authentication
+
+
+
+
+ OpenAI + Not logged in +
+
+
+ +
+
+
+
+ Anthropic + Not logged in +
+
+
+ +
+
+
+
+ Google Antigravity + Not logged in +
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
Gateway Logs
+
Real-time output from the gateway process.
+
+
+ +
+
+ +
+
+
+
No logs available. Start the gateway to see output here.
+
+
+ + +
+
Raw JSON
+
Directly edit the configuration file.
+
+ config.json + - +
+
+ + + +
+
+ +
+
+
+
+
+
+
+
+ + + + +
+ PicoClaw Config + - +
+ + + + + diff --git a/cmd/picoclaw-launcher/main.go b/cmd/picoclaw-launcher/main.go new file mode 100644 index 000000000..3323c31a8 --- /dev/null +++ b/cmd/picoclaw-launcher/main.go @@ -0,0 +1,127 @@ +// PicoClaw Launcher - Standalone HTTP service +// +// Provides a web-based JSON editor for picoclaw config files, +// with OAuth provider authentication support. +// +// Usage: +// +// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/ +// ./picoclaw-launcher [config.json] +// ./picoclaw-launcher -public config.json + +package main + +import ( + "embed" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server" +) + +//go:embed internal/ui/index.html +var staticFiles embed.FS + +func main() { + public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n") + fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Arguments:\n") + fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0]) + fmt.Fprintf( + os.Stderr, + " %s -public ./config.json Allow access from other devices on the network\n", + os.Args[0], + ) + } + flag.Parse() + + configPath := server.DefaultConfigPath() + if flag.NArg() > 0 { + configPath = flag.Arg(0) + } + + absPath, err := filepath.Abs(configPath) + if err != nil { + log.Fatalf("Failed to resolve config path: %v", err) + } + + var addr string + if *public { + addr = "0.0.0.0:" + server.DefaultPort + } else { + addr = "127.0.0.1:" + server.DefaultPort + } + + mux := http.NewServeMux() + server.RegisterConfigAPI(mux, absPath) + server.RegisterAuthAPI(mux, absPath) + server.RegisterProcessAPI(mux, absPath) + + staticFS, err := fs.Sub(staticFiles, "internal/ui") + if err != nil { + log.Fatalf("Failed to create sub filesystem: %v", err) + } + mux.Handle("/", http.FileServer(http.FS(staticFS))) + + // Print startup banner + fmt.Println("=============================================") + fmt.Println(" PicoClaw Launcher") + fmt.Println("=============================================") + fmt.Printf(" Config file : %s\n", absPath) + fmt.Printf(" Listen addr : %s\n\n", addr) + fmt.Println(" Open the following URL in your browser") + fmt.Println(" to view and edit the configuration:") + fmt.Println() + fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort) + if *public { + if ip := server.GetLocalIP(); ip != "" { + fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort) + } + } + fmt.Println() + // fmt.Println("=============================================") + + go func() { + // Wait briefly to ensure the server is ready before opening the browser + time.Sleep(500 * time.Millisecond) + url := "http://localhost:" + server.DefaultPort + if err := openBrowser(url); err != nil { + log.Printf("Warning: Failed to auto-open browser: %v\n", err) + } + }() + + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("Server failed: %v", err) + } +} + +// openBrowser automatically opens the given URL in the default browser. +func openBrowser(url string) error { + var err error + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + return err +} diff --git a/cmd/picoclaw-launcher/winres/winres.json b/cmd/picoclaw-launcher/winres/winres.json new file mode 100644 index 000000000..01ea7364c --- /dev/null +++ b/cmd/picoclaw-launcher/winres/winres.json @@ -0,0 +1,22 @@ +{ + "RT_GROUP_ICON": { + "APP": { + "0000": "../icon.ico" + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "PicoClaw Launcher", + "version": "0.0.0.0" + }, + "description": "PicoClaw Launcher - Web-based configuration editor", + "minimum-os": "win7", + "execution-level": "asInvoker", + "dpi-awareness": "system", + "use-common-controls-v6": true + } + } + } +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index ba757ffd4..91c9e25c5 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -66,7 +66,8 @@ func decodeBase64(s string) string { return string(data) } -func generateState() (string, error) { +// GenerateState generates a random state string for OAuth CSRF protection. +func GenerateState() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err @@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { return nil, fmt.Errorf("generating PKCE: %w", err) } - state, err := generateState() + state, err := GenerateState() if err != nil { return nil, fmt.Errorf("generating state: %w", err) } @@ -127,7 +128,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) - if err := openBrowser(authURL); err != nil { + if err := OpenBrowser(authURL); err != nil { fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) } @@ -153,7 +154,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { if result.err != nil { return nil, result.err } - return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI) + return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI) case manualInput := <-manualCh: if manualInput == "" { return nil, fmt.Errorf("manual input canceled") @@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { if code == "" { return nil, fmt.Errorf("could not find authorization code in input") } - return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI) + return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI) case <-time.After(5 * time.Minute): return nil, fmt.Errorf("authentication timed out after 5 minutes") } @@ -186,6 +187,59 @@ type deviceCodeResponse struct { Interval int } +// DeviceCodeInfo holds the device code information returned by the OAuth provider. +type DeviceCodeInfo struct { + DeviceAuthID string `json:"device_auth_id"` + UserCode string `json:"user_code"` + VerifyURL string `json:"verify_url"` + Interval int `json:"interval"` +} + +// RequestDeviceCode requests a device code from the OAuth provider. +// Returns the info needed for the user to authenticate in a browser. +func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) { + reqBody, _ := json.Marshal(map[string]string{ + "client_id": cfg.ClientID, + }) + + resp, err := http.Post( + cfg.Issuer+"/api/accounts/deviceauth/usercode", + "application/json", + strings.NewReader(string(reqBody)), + ) + if err != nil { + return nil, fmt.Errorf("requesting device code: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("device code request failed: %s", string(body)) + } + + deviceResp, err := parseDeviceCodeResponse(body) + if err != nil { + return nil, fmt.Errorf("parsing device code response: %w", err) + } + + if deviceResp.Interval < 1 { + deviceResp.Interval = 5 + } + + return &DeviceCodeInfo{ + DeviceAuthID: deviceResp.DeviceAuthID, + UserCode: deviceResp.UserCode, + VerifyURL: cfg.Issuer + "/codex/device", + Interval: deviceResp.Interval, + }, nil +} + +// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated. +// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure. +func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) { + return pollDeviceCode(cfg, deviceAuthID, userCode) +} + func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) { var raw struct { DeviceAuthID string `json:"device_auth_id"` @@ -318,7 +372,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au } redirectURI := cfg.Issuer + "/deviceauth/callback" - return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI) + return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI) } func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) { @@ -410,7 +464,8 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU return cfg.Issuer + "/oauth/authorize?" + params.Encode() } -func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) { +// ExchangeCodeForTokens exchanges an authorization code for tokens. +func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) { data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, @@ -552,7 +607,8 @@ func base64URLDecode(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) } -func openBrowser(url string) error { +// OpenBrowser opens the given URL in the user's default browser. +func OpenBrowser(url string) error { switch runtime.GOOS { case "darwin": return exec.Command("open", url).Start() diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 0cb589069..230ac7c2a 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) { Port: 1455, } - cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback") + cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback") if err != nil { - t.Fatalf("exchangeCodeForTokens() error: %v", err) + t.Fatalf("ExchangeCodeForTokens() error: %v", err) } if cred.AccessToken != "mock-access-token" {