From 5c1b972e8b98149fa013adae7944fb2a018cfc9d Mon Sep 17 00:00:00 2001 From: wenjie Date: Tue, 3 Mar 2026 17:06:09 +0800 Subject: [PATCH] refactor: remove the legacy picoclaw-launcher --- cmd/picoclaw-launcher/README.md | 290 --- cmd/picoclaw-launcher/README.zh.md | 287 --- cmd/picoclaw-launcher/icon.ico | Bin 44671 -> 0 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 - 15 files changed, 4324 deletions(-) delete mode 100644 cmd/picoclaw-launcher/README.md delete mode 100644 cmd/picoclaw-launcher/README.zh.md delete mode 100644 cmd/picoclaw-launcher/icon.ico delete mode 100644 cmd/picoclaw-launcher/internal/server/auth_config.go delete mode 100644 cmd/picoclaw-launcher/internal/server/auth_config_test.go delete mode 100644 cmd/picoclaw-launcher/internal/server/auth_handlers.go delete mode 100644 cmd/picoclaw-launcher/internal/server/logbuffer.go delete mode 100644 cmd/picoclaw-launcher/internal/server/logbuffer_test.go delete mode 100644 cmd/picoclaw-launcher/internal/server/process.go delete mode 100644 cmd/picoclaw-launcher/internal/server/server.go delete mode 100644 cmd/picoclaw-launcher/internal/server/server_test.go delete mode 100644 cmd/picoclaw-launcher/internal/server/utils.go delete mode 100644 cmd/picoclaw-launcher/internal/ui/index.html delete mode 100644 cmd/picoclaw-launcher/main.go delete mode 100644 cmd/picoclaw-launcher/winres/winres.json diff --git a/cmd/picoclaw-launcher/README.md b/cmd/picoclaw-launcher/README.md deleted file mode 100644 index 641279bb1..000000000 --- a/cmd/picoclaw-launcher/README.md +++ /dev/null @@ -1,290 +0,0 @@ -# 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 deleted file mode 100644 index 320de75a5..000000000 --- a/cmd/picoclaw-launcher/README.zh.md +++ /dev/null @@ -1,287 +0,0 @@ -# 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 deleted file mode 100644 index 4f65394147f95a863892e39c48d3386170e4f245..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44671 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 Kl9l2{!T$$h!av>s diff --git a/cmd/picoclaw-launcher/internal/server/auth_config.go b/cmd/picoclaw-launcher/internal/server/auth_config.go deleted file mode 100644 index f75e8fff0..000000000 --- a/cmd/picoclaw-launcher/internal/server/auth_config.go +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index 92158d011..000000000 --- a/cmd/picoclaw-launcher/internal/server/auth_config_test.go +++ /dev/null @@ -1,222 +0,0 @@ -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 deleted file mode 100644 index 1e9b8be0a..000000000 --- a/cmd/picoclaw-launcher/internal/server/auth_handlers.go +++ /dev/null @@ -1,312 +0,0 @@ -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 deleted file mode 100644 index 4d70f6466..000000000 --- a/cmd/picoclaw-launcher/internal/server/logbuffer.go +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index dc525be16..000000000 --- a/cmd/picoclaw-launcher/internal/server/logbuffer_test.go +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index bc2129bf5..000000000 --- a/cmd/picoclaw-launcher/internal/server/process.go +++ /dev/null @@ -1,232 +0,0 @@ -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 deleted file mode 100644 index 4fc68f04c..000000000 --- a/cmd/picoclaw-launcher/internal/server/server.go +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index c87e93d8c..000000000 --- a/cmd/picoclaw-launcher/internal/server/server_test.go +++ /dev/null @@ -1,247 +0,0 @@ -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 deleted file mode 100644 index a46adbece..000000000 --- a/cmd/picoclaw-launcher/internal/server/utils.go +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 93893fd75..000000000 --- a/cmd/picoclaw-launcher/internal/ui/index.html +++ /dev/null @@ -1,1999 +0,0 @@ - - - - - - - - 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 deleted file mode 100644 index 3323c31a8..000000000 --- a/cmd/picoclaw-launcher/main.go +++ /dev/null @@ -1,127 +0,0 @@ -// 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 deleted file mode 100644 index 01ea7364c..000000000 --- a/cmd/picoclaw-launcher/winres/winres.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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 - } - } - } -}