feat: add picoclaw-launcher with web UI for configuration and gateway management (#904)
A standalone web-based tool for managing picoclaw configuration, OAuth authentication providers, and gateway process lifecycle. Features include a sidebar layout with i18n (en/zh) and theme support, real-time gateway log streaming, startup prerequisites checks, and Windows icon embedding. Co-authored-by: wj-xiao <meetwenjie@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
172e6ebe5f
commit
5e028a847c
19 changed files with 4425 additions and 11 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -44,3 +44,6 @@ tasks/
|
|||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ version: 2
|
|||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./cmd/picoclaw/...
|
||||
- go generate ./...
|
||||
- go install github.com/tc-hib/go-winres@latest
|
||||
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||
|
||||
builds:
|
||||
- id: picoclaw
|
||||
|
|
@ -37,6 +39,32 @@ builds:
|
|||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher
|
||||
binary: picoclaw-launcher
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
goarm:
|
||||
- "7"
|
||||
main: ./cmd/picoclaw-launcher
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
dockers_v2:
|
||||
- id: picoclaw
|
||||
dockerfile: docker/Dockerfile.goreleaser
|
||||
|
|
@ -72,6 +100,9 @@ archives:
|
|||
|
||||
nfpms:
|
||||
- id: picoclaw
|
||||
builds:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
package_name: picoclaw
|
||||
file_name_template: >-
|
||||
{{ .PackageName }}_
|
||||
|
|
|
|||
290
cmd/picoclaw-launcher/README.md
Normal file
290
cmd/picoclaw-launcher/README.md
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# PicoClaw Launcher
|
||||
|
||||
> [!WARNING]
|
||||
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
|
||||
|
||||
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
|
||||
|
||||
## Features
|
||||
|
||||
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
||||
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
||||
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
||||
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
||||
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
||||
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
||||
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
|
||||
# Run with default config path (~/.picoclaw/config.json)
|
||||
./picoclaw-launcher
|
||||
|
||||
# Specify a config file
|
||||
./picoclaw-launcher ./config.json
|
||||
|
||||
# Allow LAN access
|
||||
./picoclaw-launcher -public
|
||||
```
|
||||
|
||||
Open `http://localhost:18800` in your browser.
|
||||
|
||||
## CLI Options
|
||||
|
||||
```
|
||||
Usage: picoclaw-config [options] [config.json]
|
||||
|
||||
Arguments:
|
||||
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
|
||||
|
||||
Options:
|
||||
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Base URL: `http://localhost:18800`
|
||||
|
||||
---
|
||||
|
||||
### Static Files
|
||||
|
||||
#### GET /
|
||||
|
||||
Serves the embedded frontend (`index.html`).
|
||||
|
||||
---
|
||||
|
||||
### Config API
|
||||
|
||||
#### GET /api/config
|
||||
|
||||
Reads the current configuration file.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { ... },
|
||||
"path": "/Users/xiao/.picoclaw/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/config
|
||||
|
||||
Saves the configuration. The request body must be a complete Config JSON object.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
**Error** `400 Bad Request` — Invalid JSON
|
||||
|
||||
---
|
||||
|
||||
### Auth API
|
||||
|
||||
#### GET /api/auth/status
|
||||
|
||||
Returns the authentication status of all providers and any in-progress device code login.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"provider": "openai",
|
||||
"auth_method": "oauth",
|
||||
"status": "active",
|
||||
"account_id": "user-xxx",
|
||||
"expires_at": "2026-03-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"pending_device": {
|
||||
"provider": "openai",
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` values: `active` | `expired` | `needs_refresh`
|
||||
|
||||
`pending_device` is only present when a device code login is in progress.
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/login
|
||||
|
||||
Initiates a provider login.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
|
||||
|
||||
##### OpenAI (Device Code Flow)
|
||||
|
||||
Returns device code info. The server polls for completion in the background.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234",
|
||||
"message": "Open the URL and enter the code to authenticate."
|
||||
}
|
||||
```
|
||||
|
||||
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
|
||||
|
||||
##### Anthropic (API Token)
|
||||
|
||||
Requires a `token` field in the request:
|
||||
|
||||
```json
|
||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "status": "success", "message": "Anthropic token saved" }
|
||||
```
|
||||
|
||||
##### Google Antigravity (Browser OAuth)
|
||||
|
||||
Returns an authorization URL for the frontend to open in a new tab:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "redirect",
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||
"message": "Open the URL to authenticate with Google."
|
||||
}
|
||||
```
|
||||
|
||||
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/logout
|
||||
|
||||
Logs out from a provider.
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
Omit or leave `provider` empty to log out from all providers.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /auth/callback
|
||||
|
||||
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
|
||||
|
||||
**Query Parameters:**
|
||||
- `state` — OAuth state for CSRF validation
|
||||
- `code` — Authorization code
|
||||
|
||||
On success, redirects to `/#auth`.
|
||||
|
||||
|
||||
### Process API
|
||||
|
||||
#### GET /api/process/status
|
||||
|
||||
Gets the running status of the `picoclaw gateway` process.
|
||||
|
||||
**Response** `200 OK` (Running)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "running",
|
||||
"status": "ok",
|
||||
"uptime": "1.010814s"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK` (Stopped)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "stopped",
|
||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/start
|
||||
|
||||
Starts the `picoclaw gateway` process in the background.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/stop
|
||||
|
||||
Stops the running `picoclaw gateway` process.
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test -v ./cmd/picoclaw-launcher/
|
||||
```
|
||||
287
cmd/picoclaw-launcher/README.zh.md
Normal file
287
cmd/picoclaw-launcher/README.zh.md
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# PicoClaw Launcher
|
||||
|
||||
> [!WARNING]
|
||||
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
|
||||
|
||||
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
|
||||
|
||||
## 功能
|
||||
|
||||
- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器
|
||||
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
|
||||
- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
|
||||
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
|
||||
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
|
||||
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
|
||||
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
|
||||
# 运行(使用默认配置路径 ~/.picoclaw/config.json)
|
||||
./picoclaw-launcher
|
||||
|
||||
# 指定配置文件
|
||||
./picoclaw-launcher ./config.json
|
||||
|
||||
# 允许局域网访问
|
||||
./picoclaw-launcher -public
|
||||
```
|
||||
|
||||
启动后在浏览器中打开 `http://localhost:18800`。
|
||||
|
||||
## 命令行参数
|
||||
|
||||
```
|
||||
Usage: picoclaw-launcher [options] [config.json]
|
||||
|
||||
Arguments:
|
||||
config.json 配置文件路径(默认: ~/.picoclaw/config.json)
|
||||
|
||||
Options:
|
||||
-public 监听所有网络接口(0.0.0.0),允许局域网设备访问
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
Base URL: `http://localhost:18800`
|
||||
|
||||
### 静态文件
|
||||
|
||||
#### GET /
|
||||
|
||||
提供嵌入式前端页面(`index.html`)。
|
||||
|
||||
---
|
||||
|
||||
### Config API
|
||||
|
||||
#### GET /api/config
|
||||
|
||||
读取当前配置文件内容。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { ... },
|
||||
"path": "/Users/xiao/.picoclaw/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/config
|
||||
|
||||
保存配置。请求体为完整的 Config JSON。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
**Error** `400 Bad Request` — 无效 JSON
|
||||
|
||||
---
|
||||
|
||||
### Auth API
|
||||
|
||||
#### GET /api/auth/status
|
||||
|
||||
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"provider": "openai",
|
||||
"auth_method": "oauth",
|
||||
"status": "active",
|
||||
"account_id": "user-xxx",
|
||||
"expires_at": "2026-03-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"pending_device": {
|
||||
"provider": "openai",
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可选值: `active` | `expired` | `needs_refresh`
|
||||
|
||||
`pending_device` 仅在有进行中的 Device Code 登录时返回。
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/login
|
||||
|
||||
发起 Provider 登录。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
|
||||
|
||||
##### OpenAI (Device Code Flow)
|
||||
|
||||
返回 Device Code 信息,后台自动轮询认证结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending",
|
||||
"device_url": "https://auth.openai.com/activate",
|
||||
"user_code": "ABCD-1234",
|
||||
"message": "Open the URL and enter the code to authenticate."
|
||||
}
|
||||
```
|
||||
|
||||
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。
|
||||
|
||||
##### Anthropic (API Token)
|
||||
|
||||
需在请求中附带 token:
|
||||
|
||||
```json
|
||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "status": "success", "message": "Anthropic token saved" }
|
||||
```
|
||||
|
||||
##### Google Antigravity (Browser OAuth)
|
||||
|
||||
返回授权 URL,前端打开新标签页:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "redirect",
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||
"message": "Open the URL to authenticate with Google."
|
||||
}
|
||||
```
|
||||
|
||||
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/auth/logout
|
||||
|
||||
登出 Provider。
|
||||
|
||||
**Request Body** — `application/json`
|
||||
|
||||
```json
|
||||
{ "provider": "openai" }
|
||||
```
|
||||
|
||||
传空字符串或省略 `provider` 则登出所有 Provider。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /auth/callback
|
||||
|
||||
OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
|
||||
|
||||
**Query Parameters:**
|
||||
- `state` — OAuth state 校验
|
||||
- `code` — 授权码
|
||||
|
||||
认证成功后重定向到 `/#auth`。
|
||||
|
||||
### Process API
|
||||
|
||||
#### GET /api/process/status
|
||||
|
||||
获取 `picoclaw gateway` 进程的运行状态。
|
||||
|
||||
**Response** `200 OK` (运行中)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "running",
|
||||
"status": "ok",
|
||||
"uptime": "1.010814s"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** `200 OK` (未运行)
|
||||
|
||||
```json
|
||||
{
|
||||
"process_status": "stopped",
|
||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/start
|
||||
|
||||
在后台启动 `picoclaw gateway` 进程。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/process/stop
|
||||
|
||||
停止正在运行的 `picoclaw gateway` 进程。
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test -v ./cmd/picoclaw-launcher/
|
||||
```
|
||||
BIN
cmd/picoclaw-launcher/icon.ico
Normal file
BIN
cmd/picoclaw-launcher/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
147
cmd/picoclaw-launcher/internal/server/auth_config.go
Normal file
147
cmd/picoclaw-launcher/internal/server/auth_config.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// updateConfigAfterLogin updates config.json after a successful provider login.
|
||||
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not load config to update auth_method: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic.AuthMethod = "token"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
AuthMethod: "token",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||
|
||||
case "google-antigravity":
|
||||
cfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
cfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
log.Printf("Warning: could not update config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
|
||||
func clearAuthMethodInConfig(configPath, provider string) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range cfg.ModelList {
|
||||
switch provider {
|
||||
case "openai":
|
||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "anthropic":
|
||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI.AuthMethod = ""
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic.AuthMethod = ""
|
||||
case "google-antigravity", "antigravity":
|
||||
cfg.Providers.Antigravity.AuthMethod = ""
|
||||
}
|
||||
|
||||
config.SaveConfig(configPath, cfg)
|
||||
}
|
||||
|
||||
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
|
||||
func clearAllAuthMethodsInConfig(configPath string) {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := range cfg.ModelList {
|
||||
cfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
cfg.Providers.OpenAI.AuthMethod = ""
|
||||
cfg.Providers.Anthropic.AuthMethod = ""
|
||||
cfg.Providers.Antigravity.AuthMethod = ""
|
||||
config.SaveConfig(configPath, cfg)
|
||||
}
|
||||
|
||||
// ── Model identification helpers ─────────────────────────────────
|
||||
|
||||
func isOpenAIModel(model string) bool {
|
||||
return model == "openai" || strings.HasPrefix(model, "openai/")
|
||||
}
|
||||
|
||||
func isAnthropicModel(model string) bool {
|
||||
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
|
||||
}
|
||||
|
||||
func isAntigravityModel(model string) bool {
|
||||
return model == "antigravity" || model == "google-antigravity" ||
|
||||
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
|
||||
}
|
||||
222
cmd/picoclaw-launcher/internal/server/auth_config_test.go
Normal file
222
cmd/picoclaw-launcher/internal/server/auth_config_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ── Model identification helpers ─────────────────────────────────
|
||||
|
||||
func TestIsOpenAIModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"openai", true},
|
||||
{"openai/gpt-4o", true},
|
||||
{"openai/gpt-5.2", true},
|
||||
{"anthropic", false},
|
||||
{"anthropic/claude-sonnet-4.6", false},
|
||||
{"openai-compatible", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isOpenAIModel(tt.model); got != tt.want {
|
||||
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAnthropicModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"anthropic", true},
|
||||
{"anthropic/claude-sonnet-4.6", true},
|
||||
{"openai", false},
|
||||
{"openai/gpt-4o", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isAnthropicModel(tt.model); got != tt.want {
|
||||
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAntigravityModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"antigravity", true},
|
||||
{"google-antigravity", true},
|
||||
{"antigravity/gemini-3-flash", true},
|
||||
{"google-antigravity/gemini-3-flash", true},
|
||||
{"openai", false},
|
||||
{"antigravity-custom", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isAntigravityModel(tt.model); got != tt.want {
|
||||
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config update helpers ────────────────────────────────────────
|
||||
|
||||
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := config.SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func loadTempConfig(t *testing.T, path string) *config.Config {
|
||||
t.Helper()
|
||||
cfg, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "openai", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model-level auth_method persists through serialization
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "oauth" {
|
||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "openai", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
if len(result.ModelList) != 2 {
|
||||
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[1].Model != "openai/gpt-5.2" {
|
||||
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
|
||||
}
|
||||
if result.Agents.Defaults.ModelName != "gpt-5.2" {
|
||||
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "token"}
|
||||
updateConfigAfterLogin(path, "anthropic", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model should be added with correct auth_method
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "token" {
|
||||
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||
updateConfigAfterLogin(path, "google-antigravity", cred)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Model should be added with correct auth_method
|
||||
if len(result.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||
}
|
||||
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
|
||||
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
|
||||
}
|
||||
if result.ModelList[0].AuthMethod != "oauth" {
|
||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAuthMethodInConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
clearAuthMethodInConfig(path, "openai")
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
// Openai model auth_method should be cleared
|
||||
if result.ModelList[0].AuthMethod != "" {
|
||||
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
|
||||
}
|
||||
// Anthropic model should be unchanged
|
||||
if result.ModelList[1].AuthMethod != "token" {
|
||||
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAllAuthMethodsInConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
|
||||
},
|
||||
}
|
||||
path := writeTempConfigViaSave(t, cfg)
|
||||
|
||||
clearAllAuthMethodsInConfig(path)
|
||||
|
||||
result := loadTempConfig(t, path)
|
||||
|
||||
for i, m := range result.ModelList {
|
||||
if m.AuthMethod != "" {
|
||||
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
312
cmd/picoclaw-launcher/internal/server/auth_handlers.go
Normal file
312
cmd/picoclaw-launcher/internal/server/auth_handlers.go
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// oauthSession stores in-flight OAuth state for browser-based flows.
|
||||
type oauthSession struct {
|
||||
Provider string
|
||||
PKCE auth.PKCECodes
|
||||
State string
|
||||
RedirectURI string
|
||||
OAuthCfg auth.OAuthProviderConfig
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// deviceCodeSession stores in-flight device code flow state.
|
||||
type deviceCodeSession struct {
|
||||
mu sync.Mutex
|
||||
Provider string
|
||||
Info *auth.DeviceCodeInfo
|
||||
OAuthCfg auth.OAuthProviderConfig
|
||||
ConfigPath string
|
||||
Status string // "pending", "success", "error"
|
||||
Error string
|
||||
Done bool
|
||||
}
|
||||
|
||||
var (
|
||||
oauthSessions = map[string]*oauthSession{} // keyed by state
|
||||
oauthSessionsMu sync.Mutex
|
||||
|
||||
activeDeviceSession *deviceCodeSession
|
||||
activeDeviceSessionMu sync.Mutex
|
||||
)
|
||||
|
||||
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
|
||||
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
|
||||
// Check if there's already a pending device code session
|
||||
activeDeviceSessionMu.Lock()
|
||||
if activeDeviceSession != nil {
|
||||
activeDeviceSession.mu.Lock()
|
||||
if !activeDeviceSession.Done {
|
||||
resp := map[string]any{
|
||||
"status": "pending",
|
||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||
"user_code": activeDeviceSession.Info.UserCode,
|
||||
"message": "Device code flow already in progress. Enter the code in your browser.",
|
||||
}
|
||||
activeDeviceSession.mu.Unlock()
|
||||
activeDeviceSessionMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
activeDeviceSession.mu.Unlock()
|
||||
}
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
// Request a device code
|
||||
oauthCfg := auth.OpenAIOAuthConfig()
|
||||
info, err := auth.RequestDeviceCode(oauthCfg)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session := &deviceCodeSession{
|
||||
Provider: "openai",
|
||||
Info: info,
|
||||
OAuthCfg: oauthCfg,
|
||||
ConfigPath: configPath,
|
||||
Status: "pending",
|
||||
}
|
||||
|
||||
activeDeviceSessionMu.Lock()
|
||||
activeDeviceSession = session
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
// Start background polling
|
||||
go func() {
|
||||
deadline := time.After(15 * time.Minute)
|
||||
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
session.mu.Lock()
|
||||
session.Status = "error"
|
||||
session.Error = "Authentication timed out after 15 minutes"
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
|
||||
if err != nil {
|
||||
continue // Still pending
|
||||
}
|
||||
if cred != nil {
|
||||
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
|
||||
session.mu.Lock()
|
||||
session.Status = "error"
|
||||
session.Error = saveErr.Error()
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
return
|
||||
}
|
||||
updateConfigAfterLogin(configPath, "openai", cred)
|
||||
session.mu.Lock()
|
||||
session.Status = "success"
|
||||
session.Done = true
|
||||
session.mu.Unlock()
|
||||
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Return device code info to frontend
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "pending",
|
||||
"device_url": info.VerifyURL,
|
||||
"user_code": info.UserCode,
|
||||
"message": "Open the URL and enter the code to authenticate.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleAnthropicLogin saves a pasted API token for Anthropic.
|
||||
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
|
||||
if token == "" {
|
||||
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cred := &auth.AuthCredential{
|
||||
AccessToken: token,
|
||||
Provider: "anthropic",
|
||||
AuthMethod: "token",
|
||||
}
|
||||
|
||||
if err := auth.SetCredential("anthropic", cred); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
updateConfigAfterLogin(configPath, "anthropic", cred)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "success",
|
||||
"message": "Anthropic token saved",
|
||||
})
|
||||
}
|
||||
|
||||
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
|
||||
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
|
||||
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||
|
||||
pkce, err := auth.GeneratePKCE()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := auth.GenerateState()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build redirect URI pointing to picoclaw-launcher's own callback
|
||||
scheme := "http"
|
||||
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
|
||||
|
||||
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
|
||||
|
||||
// Store session for callback
|
||||
oauthSessionsMu.Lock()
|
||||
oauthSessions[state] = &oauthSession{
|
||||
Provider: "google-antigravity",
|
||||
PKCE: pkce,
|
||||
State: state,
|
||||
RedirectURI: redirectURI,
|
||||
OAuthCfg: oauthCfg,
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
oauthSessionsMu.Unlock()
|
||||
|
||||
// Clean up stale sessions after 10 minutes
|
||||
go func() {
|
||||
time.Sleep(10 * time.Minute)
|
||||
oauthSessionsMu.Lock()
|
||||
delete(oauthSessions, state)
|
||||
oauthSessionsMu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "redirect",
|
||||
"auth_url": authURL,
|
||||
"message": "Open the URL to authenticate with Google.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
|
||||
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
oauthSessionsMu.Lock()
|
||||
session, ok := oauthSessions[state]
|
||||
if ok {
|
||||
delete(oauthSessions, state)
|
||||
}
|
||||
oauthSessionsMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
errMsg := r.URL.Query().Get("error")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
`<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
|
||||
}
|
||||
99
cmd/picoclaw-launcher/internal/server/logbuffer.go
Normal file
99
cmd/picoclaw-launcher/internal/server/logbuffer.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package server
|
||||
|
||||
import "sync"
|
||||
|
||||
// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
|
||||
// It supports incremental reads via LinesSince and tracks a runID that increments
|
||||
// on each Reset (used to detect gateway restarts).
|
||||
type LogBuffer struct {
|
||||
mu sync.RWMutex
|
||||
lines []string
|
||||
cap int
|
||||
total int // total lines ever appended in current run
|
||||
runID int
|
||||
}
|
||||
|
||||
// NewLogBuffer creates a LogBuffer with the given capacity.
|
||||
func NewLogBuffer(capacity int) *LogBuffer {
|
||||
return &LogBuffer{
|
||||
lines: make([]string, 0, capacity),
|
||||
cap: capacity,
|
||||
}
|
||||
}
|
||||
|
||||
// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted.
|
||||
func (b *LogBuffer) Append(line string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if len(b.lines) < b.cap {
|
||||
b.lines = append(b.lines, line)
|
||||
} else {
|
||||
b.lines[b.total%b.cap] = line
|
||||
}
|
||||
|
||||
b.total++
|
||||
}
|
||||
|
||||
// Reset clears the buffer and increments the runID. Call this when starting a new gateway process.
|
||||
func (b *LogBuffer) Reset() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.lines = b.lines[:0]
|
||||
b.total = 0
|
||||
b.runID++
|
||||
}
|
||||
|
||||
// LinesSince returns lines appended after the given offset, the current total count, and the runID.
|
||||
// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned.
|
||||
func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
total = b.total
|
||||
runID = b.runID
|
||||
|
||||
if offset >= b.total {
|
||||
return nil, total, runID
|
||||
}
|
||||
|
||||
buffered := len(b.lines)
|
||||
|
||||
// How many new lines since offset
|
||||
newCount := b.total - offset
|
||||
if newCount > buffered {
|
||||
newCount = buffered
|
||||
}
|
||||
|
||||
result := make([]string, newCount)
|
||||
|
||||
if b.total <= b.cap {
|
||||
// Buffer hasn't wrapped yet — simple slice
|
||||
copy(result, b.lines[buffered-newCount:])
|
||||
} else {
|
||||
// Buffer has wrapped — read from ring
|
||||
start := (b.total - newCount) % b.cap
|
||||
for i := range newCount {
|
||||
result[i] = b.lines[(start+i)%b.cap]
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, runID
|
||||
}
|
||||
|
||||
// RunID returns the current run identifier.
|
||||
func (b *LogBuffer) RunID() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
return b.runID
|
||||
}
|
||||
|
||||
// Total returns the total number of lines appended in the current run.
|
||||
func (b *LogBuffer) Total() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
return b.total
|
||||
}
|
||||
116
cmd/picoclaw-launcher/internal/server/logbuffer_test.go
Normal file
116
cmd/picoclaw-launcher/internal/server/logbuffer_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLogBuffer_Basic(t *testing.T) {
|
||||
buf := NewLogBuffer(5)
|
||||
|
||||
// Empty buffer
|
||||
lines, total, runID := buf.LinesSince(0)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 0, total)
|
||||
assert.Equal(t, 0, runID)
|
||||
|
||||
// Append some lines
|
||||
buf.Append("line1")
|
||||
buf.Append("line2")
|
||||
buf.Append("line3")
|
||||
|
||||
lines, total, runID = buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
assert.Equal(t, 0, runID)
|
||||
|
||||
// Incremental read
|
||||
lines, total, _ = buf.LinesSince(2)
|
||||
assert.Equal(t, []string{"line3"}, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
|
||||
// No new lines
|
||||
lines, total, _ = buf.LinesSince(3)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 3, total)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Wrap(t *testing.T) {
|
||||
buf := NewLogBuffer(3)
|
||||
|
||||
buf.Append("a")
|
||||
buf.Append("b")
|
||||
buf.Append("c")
|
||||
buf.Append("d") // evicts "a"
|
||||
buf.Append("e") // evicts "b"
|
||||
|
||||
lines, total, _ := buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
|
||||
// Incremental after wrap
|
||||
lines, total, _ = buf.LinesSince(3)
|
||||
assert.Equal(t, []string{"d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
|
||||
// Offset too old (before buffer start), get all buffered
|
||||
lines, total, _ = buf.LinesSince(1)
|
||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||
assert.Equal(t, 5, total)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Reset(t *testing.T) {
|
||||
buf := NewLogBuffer(5)
|
||||
|
||||
buf.Append("before")
|
||||
assert.Equal(t, 0, buf.RunID())
|
||||
|
||||
buf.Reset()
|
||||
assert.Equal(t, 1, buf.RunID())
|
||||
assert.Equal(t, 0, buf.Total())
|
||||
|
||||
lines, total, runID := buf.LinesSince(0)
|
||||
assert.Nil(t, lines)
|
||||
assert.Equal(t, 0, total)
|
||||
assert.Equal(t, 1, runID)
|
||||
|
||||
buf.Append("after")
|
||||
lines, total, runID = buf.LinesSince(0)
|
||||
assert.Equal(t, []string{"after"}, lines)
|
||||
assert.Equal(t, 1, total)
|
||||
assert.Equal(t, 1, runID)
|
||||
}
|
||||
|
||||
func TestLogBuffer_Concurrent(t *testing.T) {
|
||||
buf := NewLogBuffer(100)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 10 writers
|
||||
for i := range 10 {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := range 50 {
|
||||
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 5 readers
|
||||
for range 5 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 100 {
|
||||
buf.LinesSince(0)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, 500, buf.Total())
|
||||
}
|
||||
232
cmd/picoclaw-launcher/internal/server/process.go
Normal file
232
cmd/picoclaw-launcher/internal/server/process.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
|
||||
var gatewayLogs = NewLogBuffer(200)
|
||||
|
||||
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
|
||||
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
|
||||
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleStatusGateway(w, r, absPath)
|
||||
})
|
||||
mux.HandleFunc("POST /api/process/start", handleStartGateway)
|
||||
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
|
||||
}
|
||||
|
||||
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
|
||||
// Locate picoclaw executable:
|
||||
// 1. Try same directory as current executable
|
||||
// 2. Fallback to just "picoclaw" (relies on $PATH)
|
||||
execPath := "picoclaw"
|
||||
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidate := filepath.Join(dir, "picoclaw")
|
||||
if runtime.GOOS == "windows" {
|
||||
candidate += ".exe"
|
||||
}
|
||||
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
execPath = candidate
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(execPath, "gateway")
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Printf("Failed to create stdout pipe: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
log.Printf("Failed to create stderr pipe: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear old logs and increment runID before starting
|
||||
gatewayLogs.Reset()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("Failed to start picoclaw gateway: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Read stdout and stderr into the log buffer
|
||||
go scanPipe(stdoutPipe, gatewayLogs)
|
||||
go scanPipe(stderrPipe, gatewayLogs)
|
||||
|
||||
// Wait for the process to exit in the background to avoid zombies
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Printf("Gateway process exited: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"pid": cmd.Process.Pid,
|
||||
})
|
||||
}
|
||||
|
||||
// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF.
|
||||
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line
|
||||
|
||||
for scanner.Scan() {
|
||||
buf.Append(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
|
||||
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
|
||||
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
|
||||
err = exec.Command("powershell", "-Command", psCmd).Run()
|
||||
} else {
|
||||
// Linux/macOS
|
||||
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
|
||||
// We still return 200 OK because pkill returns an error if no process was found
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok", // or "not_found"
|
||||
"msg": "Stop command executed, but returned error (process might not be running).",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Stopped picoclaw gateway processes.\n")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
|
||||
cfg, cfgErr := config.LoadConfig(absPath)
|
||||
host := "127.0.0.1"
|
||||
port := 18790
|
||||
if cfgErr == nil && cfg != nil {
|
||||
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
||||
host = cfg.Gateway.Host
|
||||
}
|
||||
if cfg.Gateway.Port != 0 {
|
||||
port = cfg.Gateway.Port
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
client := http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
|
||||
// Build the response data map
|
||||
data := map[string]any{}
|
||||
|
||||
if err != nil {
|
||||
data["process_status"] = "stopped"
|
||||
data["error"] = err.Error()
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data["process_status"] = "error"
|
||||
data["status_code"] = resp.StatusCode
|
||||
} else {
|
||||
var healthData map[string]any
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||
data["process_status"] = "error"
|
||||
data["error"] = "invalid response from gateway"
|
||||
} else {
|
||||
// Gateway is running and responded properly — merge health data
|
||||
for k, v := range healthData {
|
||||
data[k] = v
|
||||
}
|
||||
data["process_status"] = "running"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append log data from the buffer
|
||||
appendLogData(r, data)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// appendLogData reads log_offset and log_run_id query params from the request and
|
||||
// populates the response data map with incremental log lines.
|
||||
func appendLogData(r *http.Request, data map[string]any) {
|
||||
clientOffset := 0
|
||||
clientRunID := -1
|
||||
|
||||
if v := r.URL.Query().Get("log_offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientOffset = n
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("log_run_id"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
clientRunID = n
|
||||
}
|
||||
}
|
||||
|
||||
runID := gatewayLogs.RunID()
|
||||
|
||||
// If runID is 0 (never reset = never launched from this launcher), report no source
|
||||
if runID == 0 {
|
||||
data["logs"] = []string{}
|
||||
data["log_total"] = 0
|
||||
data["log_run_id"] = 0
|
||||
data["log_source"] = "none"
|
||||
return
|
||||
}
|
||||
|
||||
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
|
||||
offset := clientOffset
|
||||
if clientRunID != runID {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
lines, total, runID := gatewayLogs.LinesSince(offset)
|
||||
if lines == nil {
|
||||
lines = []string{}
|
||||
}
|
||||
|
||||
data["logs"] = lines
|
||||
data["log_total"] = total
|
||||
data["log_run_id"] = runID
|
||||
data["log_source"] = "launcher"
|
||||
}
|
||||
196
cmd/picoclaw-launcher/internal/server/server.go
Normal file
196
cmd/picoclaw-launcher/internal/server/server.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const DefaultPort = "18800"
|
||||
|
||||
// providerStatus represents the auth status of a single provider in API responses.
|
||||
type providerStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
Status string `json:"status"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// ── Route registration ───────────────────────────────────────────
|
||||
|
||||
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
|
||||
// GET /api/config — read config
|
||||
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(absPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]any{
|
||||
"config": cfg,
|
||||
"path": absPath,
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(resp); err != nil {
|
||||
log.Printf("Failed to encode response: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config — save config
|
||||
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(absPath, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
|
||||
// GET /api/auth/status — all authenticated providers + pending login state
|
||||
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
store, err := auth.LoadStore()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := []providerStatus{}
|
||||
for name, cred := range store.Credentials {
|
||||
status := "active"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs_refresh"
|
||||
}
|
||||
ps := providerStatus{
|
||||
Provider: name,
|
||||
AuthMethod: cred.AuthMethod,
|
||||
Status: status,
|
||||
AccountID: cred.AccountID,
|
||||
Email: cred.Email,
|
||||
ProjectID: cred.ProjectID,
|
||||
}
|
||||
if !cred.ExpiresAt.IsZero() {
|
||||
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
result = append(result, ps)
|
||||
}
|
||||
|
||||
// Include pending device code state
|
||||
var pendingDevice map[string]any
|
||||
activeDeviceSessionMu.Lock()
|
||||
if activeDeviceSession != nil {
|
||||
activeDeviceSession.mu.Lock()
|
||||
pendingDevice = map[string]any{
|
||||
"provider": activeDeviceSession.Provider,
|
||||
"status": activeDeviceSession.Status,
|
||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||
"user_code": activeDeviceSession.Info.UserCode,
|
||||
}
|
||||
if activeDeviceSession.Error != "" {
|
||||
pendingDevice["error"] = activeDeviceSession.Error
|
||||
}
|
||||
if activeDeviceSession.Done {
|
||||
activeDeviceSession.mu.Unlock()
|
||||
activeDeviceSession = nil
|
||||
} else {
|
||||
activeDeviceSession.mu.Unlock()
|
||||
}
|
||||
}
|
||||
activeDeviceSessionMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"providers": result,
|
||||
"pending_device": pendingDevice,
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/auth/login — initiate provider login
|
||||
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Provider {
|
||||
case "openai":
|
||||
handleOpenAILogin(w, absPath)
|
||||
case "anthropic":
|
||||
handleAnthropicLogin(w, req.Token, absPath)
|
||||
case "google-antigravity", "antigravity":
|
||||
handleGoogleAntigravityLogin(w, r, absPath)
|
||||
default:
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf(
|
||||
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
|
||||
req.Provider,
|
||||
),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/auth/logout — logout a provider
|
||||
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Provider == "" {
|
||||
if err := auth.DeleteAllCredentials(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clearAllAuthMethodsInConfig(absPath)
|
||||
} else {
|
||||
if err := auth.DeleteCredential(req.Provider); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clearAuthMethodInConfig(absPath, req.Provider)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// GET /auth/callback — OAuth browser callback for Google Antigravity
|
||||
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
|
||||
}
|
||||
247
cmd/picoclaw-launcher/internal/server/server_test.go
Normal file
247
cmd/picoclaw-launcher/internal/server/server_test.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ── Config API tests ─────────────────────────────────────────────
|
||||
|
||||
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterConfigAPI(mux, path)
|
||||
RegisterAuthAPI(mux, path)
|
||||
return mux, path
|
||||
}
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||
},
|
||||
}
|
||||
mux, path := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Config config.Config `json:"config"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Path != path {
|
||||
t.Errorf("expected path %q, got %q", path, resp.Path)
|
||||
}
|
||||
if len(resp.Config.ModelList) != 1 {
|
||||
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// LoadConfig returns a default empty config when file is missing
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, path := setupConfigMux(t, cfg)
|
||||
|
||||
newCfg := config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(newCfg)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
saved, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load saved config: %v", err)
|
||||
}
|
||||
if len(saved.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
|
||||
}
|
||||
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutConfig_InvalidJSON(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth API tests ───────────────────────────────────────────────
|
||||
|
||||
func TestAuthStatus(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/auth/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Providers []providerStatus `json:"providers"`
|
||||
PendingDevice map[string]any `json:"pending_device"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
// providers should be a non-nil list (could be empty)
|
||||
if resp.Providers == nil {
|
||||
t.Error("providers should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
body := `{"provider": "unsupported"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
body := `{"provider": "anthropic"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_InvalidBody(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogout_InvalidBody(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid body, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallback_InvalidState(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mux, _ := setupConfigMux(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid state, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility tests ────────────────────────────────────────────────
|
||||
|
||||
func TestDefaultConfigPath(t *testing.T) {
|
||||
path := DefaultConfigPath()
|
||||
if path == "" {
|
||||
t.Error("defaultConfigPath should not return empty")
|
||||
}
|
||||
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
|
||||
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLocalIP(t *testing.T) {
|
||||
// Just ensure it doesn't panic; IP may or may not be available
|
||||
ip := GetLocalIP()
|
||||
if ip != "" {
|
||||
// If returned, should look like an IP
|
||||
if !strings.Contains(ip, ".") {
|
||||
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
28
cmd/picoclaw-launcher/internal/server/utils.go
Normal file
28
cmd/picoclaw-launcher/internal/server/utils.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "config.json"
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
func GetLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
1999
cmd/picoclaw-launcher/internal/ui/index.html
Normal file
1999
cmd/picoclaw-launcher/internal/ui/index.html
Normal file
File diff suppressed because it is too large
Load diff
127
cmd/picoclaw-launcher/main.go
Normal file
127
cmd/picoclaw-launcher/main.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// PicoClaw Launcher - Standalone HTTP service
|
||||
//
|
||||
// Provides a web-based JSON editor for picoclaw config files,
|
||||
// with OAuth provider authentication support.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||
// ./picoclaw-launcher [config.json]
|
||||
// ./picoclaw-launcher -public config.json
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
|
||||
)
|
||||
|
||||
//go:embed internal/ui/index.html
|
||||
var staticFiles embed.FS
|
||||
|
||||
func main() {
|
||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
" %s -public ./config.json Allow access from other devices on the network\n",
|
||||
os.Args[0],
|
||||
)
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
configPath := server.DefaultConfigPath()
|
||||
if flag.NArg() > 0 {
|
||||
configPath = flag.Arg(0)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve config path: %v", err)
|
||||
}
|
||||
|
||||
var addr string
|
||||
if *public {
|
||||
addr = "0.0.0.0:" + server.DefaultPort
|
||||
} else {
|
||||
addr = "127.0.0.1:" + server.DefaultPort
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.RegisterConfigAPI(mux, absPath)
|
||||
server.RegisterAuthAPI(mux, absPath)
|
||||
server.RegisterProcessAPI(mux, absPath)
|
||||
|
||||
staticFS, err := fs.Sub(staticFiles, "internal/ui")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
// Print startup banner
|
||||
fmt.Println("=============================================")
|
||||
fmt.Println(" PicoClaw Launcher")
|
||||
fmt.Println("=============================================")
|
||||
fmt.Printf(" Config file : %s\n", absPath)
|
||||
fmt.Printf(" Listen addr : %s\n\n", addr)
|
||||
fmt.Println(" Open the following URL in your browser")
|
||||
fmt.Println(" to view and edit the configuration:")
|
||||
fmt.Println()
|
||||
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
|
||||
if *public {
|
||||
if ip := server.GetLocalIP(); ip != "" {
|
||||
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
// fmt.Println("=============================================")
|
||||
|
||||
go func() {
|
||||
// Wait briefly to ensure the server is ready before opening the browser
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
url := "http://localhost:" + server.DefaultPort
|
||||
if err := openBrowser(url); err != nil {
|
||||
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// openBrowser automatically opens the given URL in the default browser.
|
||||
func openBrowser(url string) error {
|
||||
var err error
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
err = exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
err = exec.Command("open", url).Start()
|
||||
default:
|
||||
err = fmt.Errorf("unsupported platform")
|
||||
}
|
||||
return err
|
||||
}
|
||||
22
cmd/picoclaw-launcher/winres/winres.json
Normal file
22
cmd/picoclaw-launcher/winres/winres.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"RT_GROUP_ICON": {
|
||||
"APP": {
|
||||
"0000": "../icon.ico"
|
||||
}
|
||||
},
|
||||
"RT_MANIFEST": {
|
||||
"#1": {
|
||||
"0409": {
|
||||
"identity": {
|
||||
"name": "PicoClaw Launcher",
|
||||
"version": "0.0.0.0"
|
||||
},
|
||||
"description": "PicoClaw Launcher - Web-based configuration editor",
|
||||
"minimum-os": "win7",
|
||||
"execution-level": "asInvoker",
|
||||
"dpi-awareness": "system",
|
||||
"use-common-controls-v6": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,8 @@ func decodeBase64(s string) string {
|
|||
return string(data)
|
||||
}
|
||||
|
||||
func generateState() (string, error) {
|
||||
// GenerateState generates a random state string for OAuth CSRF protection.
|
||||
func GenerateState() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
|
|
@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
state, err := GenerateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating state: %w", err)
|
||||
}
|
||||
|
|
@ -127,7 +128,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
|
||||
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
||||
|
||||
if err := openBrowser(authURL); err != nil {
|
||||
if err := OpenBrowser(authURL); err != nil {
|
||||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +154,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
if result.err != nil {
|
||||
return nil, result.err
|
||||
}
|
||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||
return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||
case manualInput := <-manualCh:
|
||||
if manualInput == "" {
|
||||
return nil, fmt.Errorf("manual input canceled")
|
||||
|
|
@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
if code == "" {
|
||||
return nil, fmt.Errorf("could not find authorization code in input")
|
||||
}
|
||||
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||
return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||
case <-time.After(5 * time.Minute):
|
||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||
}
|
||||
|
|
@ -186,6 +187,59 @@ type deviceCodeResponse struct {
|
|||
Interval int
|
||||
}
|
||||
|
||||
// DeviceCodeInfo holds the device code information returned by the OAuth provider.
|
||||
type DeviceCodeInfo struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerifyURL string `json:"verify_url"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// RequestDeviceCode requests a device code from the OAuth provider.
|
||||
// Returns the info needed for the user to authenticate in a browser.
|
||||
func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
||||
reqBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
})
|
||||
|
||||
resp, err := http.Post(
|
||||
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
||||
"application/json",
|
||||
strings.NewReader(string(reqBody)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("requesting device code: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||
}
|
||||
|
||||
deviceResp, err := parseDeviceCodeResponse(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing device code response: %w", err)
|
||||
}
|
||||
|
||||
if deviceResp.Interval < 1 {
|
||||
deviceResp.Interval = 5
|
||||
}
|
||||
|
||||
return &DeviceCodeInfo{
|
||||
DeviceAuthID: deviceResp.DeviceAuthID,
|
||||
UserCode: deviceResp.UserCode,
|
||||
VerifyURL: cfg.Issuer + "/codex/device",
|
||||
Interval: deviceResp.Interval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated.
|
||||
// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure.
|
||||
func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
||||
return pollDeviceCode(cfg, deviceAuthID, userCode)
|
||||
}
|
||||
|
||||
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
||||
var raw struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
|
|
@ -318,7 +372,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
|||
}
|
||||
|
||||
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
||||
return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||
return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||
}
|
||||
|
||||
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
|
|
@ -410,7 +464,8 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
|
|||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||
}
|
||||
|
||||
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||
// ExchangeCodeForTokens exchanges an authorization code for tokens.
|
||||
func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
|
|
@ -552,7 +607,8 @@ func base64URLDecode(s string) ([]byte, error) {
|
|||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
// OpenBrowser opens the given URL in the user's default browser.
|
||||
func OpenBrowser(url string) error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
|
|
|
|||
|
|
@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
|||
Port: 1455,
|
||||
}
|
||||
|
||||
cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||
cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("exchangeCodeForTokens() error: %v", err)
|
||||
t.Fatalf("ExchangeCodeForTokens() error: %v", err)
|
||||
}
|
||||
|
||||
if cred.AccessToken != "mock-access-token" {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue