refactor: remove the legacy picoclaw-launcher
This commit is contained in:
parent
6f5930624b
commit
5e90e4cafb
15 changed files with 0 additions and 4324 deletions
|
|
@ -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/
|
|
||||||
```
|
|
||||||
|
|
@ -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/
|
|
||||||
```
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
|
|
@ -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/")
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
|
||||||
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,
|
|
||||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
|
||||||
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, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, 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, `<html><body>
|
|
||||||
<h2>Authentication successful!</h2>
|
|
||||||
<p>Redirecting back to Config Editor...</p>
|
|
||||||
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
|
|
||||||
</body></html>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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())
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 ""
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue