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:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Windows Application Icon/Resource
|
||||||
|
*.syso
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ version: 2
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- 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:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
|
@ -37,6 +39,32 @@ builds:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
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:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: docker/Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
|
|
@ -72,6 +100,9 @@ archives:
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
builds:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
package_name: picoclaw
|
package_name: picoclaw
|
||||||
file_name_template: >-
|
file_name_template: >-
|
||||||
{{ .PackageName }}_
|
{{ .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)
|
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)
|
buf := make([]byte, 32)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := generateState()
|
state, err := GenerateState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generating state: %w", err)
|
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)
|
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)
|
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 {
|
if result.err != nil {
|
||||||
return nil, result.err
|
return nil, result.err
|
||||||
}
|
}
|
||||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||||
case manualInput := <-manualCh:
|
case manualInput := <-manualCh:
|
||||||
if manualInput == "" {
|
if manualInput == "" {
|
||||||
return nil, fmt.Errorf("manual input canceled")
|
return nil, fmt.Errorf("manual input canceled")
|
||||||
|
|
@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("could not find authorization code in input")
|
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):
|
case <-time.After(5 * time.Minute):
|
||||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||||
}
|
}
|
||||||
|
|
@ -186,6 +187,59 @@ type deviceCodeResponse struct {
|
||||||
Interval int
|
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) {
|
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
||||||
var raw struct {
|
var raw struct {
|
||||||
DeviceAuthID string `json:"device_auth_id"`
|
DeviceAuthID string `json:"device_auth_id"`
|
||||||
|
|
@ -318,7 +372,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
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) {
|
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()
|
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{
|
data := url.Values{
|
||||||
"grant_type": {"authorization_code"},
|
"grant_type": {"authorization_code"},
|
||||||
"code": {code},
|
"code": {code},
|
||||||
|
|
@ -552,7 +607,8 @@ func base64URLDecode(s string) ([]byte, error) {
|
||||||
return base64.StdEncoding.DecodeString(s)
|
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 {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return exec.Command("open", url).Start()
|
return exec.Command("open", url).Start()
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
||||||
Port: 1455,
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("exchangeCodeForTokens() error: %v", err)
|
t.Fatalf("ExchangeCodeForTokens() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cred.AccessToken != "mock-access-token" {
|
if cred.AccessToken != "mock-access-token" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue