mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
e55b3b7a8d
* refactor: remove the legacy picoclaw-launcher * feat: create initial web frontend and backend structure * feat(packaging): add desktop entry for PicoClaw Launcher (#1062) - Add .desktop file with Terminal=true, named "PicoClaw Launcher" - Install to /usr/share/applications/ for app menu visibility - Add 512x512 PNG icon to /usr/share/icons/hicolor/ Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * `make dev`: If you haven't built it before, you need to run `build` first. * feat(web): comprehensive web UI and backend refactoring This commit introduces a major overhaul of both the frontend web UI and the Go backend API, transitioning to a highly modular architecture and integrating new core features. Backend: - Refactored monolithic API endpoints into domain-specific modules (config, gateway, log, models, pico, session). - Cleaned up obsolete files (`server.go`, `status.go`, WebSocket handlers) and outdated tests. - Implemented Gateway process lifecycle management (start/stop/restart) and real-time log streaming. Frontend: - Integrated Shadcn UI components to establish a modern, consistent design system. - Introduced a new application layout featuring a responsive sidebar (`app-sidebar`) and header. - Implemented internationalization (i18n) with initial support for English and Chinese. - Restructured API clients, hooks, and Zustand stores into logical domains. - Added new management pages for Settings, Logs, Models, Providers, and Credentials. - Upgraded the Pico chat interface with session history management and dynamic model selection. Build & Config: - Updated frontend dependencies, Vite configuration, and lockfiles. - Refined routing setup and overarching application stylesheets. * feat(web): enhance model management, sorting, and deletion logic - Implement model sorting in UI (default > configured > unconfigured) - Prevent deletion of default models in the frontend - Update backend to clear default settings when a model is deleted - Add existence validation when setting a default model via API - Group models in chat UI by type (API Key, OAuth, Local) - Conditionally display model selector in chat based on configuration status * refactor(web): refactor chat page into modular components/hooks and update i18n - split chat route into dedicated chat components (page, composer, empty state, messages, history, model selector) - extract model/session logic into use-chat-models and use-session-history hooks - update chat locale keys in en/zh and add empty-state/history-related translations * refactor(models): refactor models page into modular components and improve UX - split /models route into dedicated components (page, provider section, card, add/edit sheets, delete dialog) - add provider grouping/sorting, provider labels/icons, and a no-default hint in the models page - add "Set as default model" toggle to add/edit flows with safer defaults - introduce shared form helpers and new UI primitives (field, label, switch) - update i18n strings (en/zh) for models and gateway header text usage - apply minor UI polish (models nav icon, separator client directive) * fix(web): add SPA index fallback for embedded frontend routes Serve existing static assets as-is, keep /api/* and missing asset paths returning 404, and add tests for SPA fallback behavior on refresh. * fix(frontend/chat): normalize message timestamp units to prevent invalid far-future dates * chore: delete TestSPARouteFallsBackToIndex * feat: update build for web-based launcher (#1186) - Makefile: add build-launcher target (builds frontend + Go backend) - GoReleaser: point picoclaw-launcher build to web/backend, add frontend build hook, restore winres hook with updated paths - Restore icon.ico and winres config from main for Windows builds Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(credentials): add multi-provider OAuth credential management - add backend `/api/oauth/*` endpoints for provider status, browser/device-code/token login, flow query/polling, and logout - extend API handler with OAuth flow/state tracking and route registration, plus OAuth unit tests - implement frontend credentials page/components for OpenAI, Anthropic, and Google Antigravity login/logout - add OAuth API client and `useCredentialsPage` hook, with new EN/ZH i18n strings * chore: remove placeholder index.html from dist (#1188) The .gitkeep is sufficient for go:embed to find the dist directory. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): polish model and credential UX; remove Providers nav - remove the Providers item from sidebar navigation and locale keys - simplify chat composer by dropping attach/voice action buttons - support ReactNode titles in credential cards and add provider brand icons - refine sheet header/footer styling and device-code footer button hierarchy - disable “Set default” when a model is unconfigured or already default * feat(web): Update config page (#1173) * feat(web): Update config page * fix(web): useEffect resets editorValue whenever config changes * fix(web): react-hooks/set-state-in-effect error & pnpm lint #1173 * feat(web): add channel management page for web console (#1190) * feat(web): add channel management page for web console Add a complete channel management UI that allows users to configure messaging channels (Telegram, Discord, Slack, Feishu, etc.) directly from the web console instead of manually editing config.json. Backend: GET/PUT/PATCH API endpoints for listing, updating, and toggling channels with secret field masking. Frontend: Channel cards grid with enable/disable toggles, per-channel configuration sheets with dedicated forms for major platforms and a generic fallback for others. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(web/channels): move channels to own sidebar group and fix sheet padding - Channels now has its own navigation group instead of being under Services - Fix edit sheet form content padding (px-1 -> px-4) to match header/footer - Fix naked return lint error in extractChannelInfo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(web): harden channel config updates and resolve frontend lint issues - validate channel PUT/PATCH updates before saving and return structured validation errors - require `enabled` in toggle requests to avoid silent false defaults - support editing `allow_origins` in the generic channel form and parse string/array inputs on backend - replace channel form `any` usage with `ChannelConfig` (`Record<string, unknown>`) and add safe value helpers - add i18n strings for allow-origins fields and apply related frontend formatting cleanups * fix(frontend): prevent false "Invalid JSON" errors in config editor * feat: add startup readiness checks and propagate start availability to UI - add gateway precondition validation for default model and credentials - auto-start gateway on backend boot when conditions are met - include gateway_start_allowed and gateway_start_reason in status updates - prevent frontend start actions when gateway cannot be started * feat(web): revamp channel config UX with catalog-based routing - replace legacy channel management endpoints with a backend channel catalog API - switch frontend channel updates to PATCH /api/config and per-channel config pages - add dynamic channel items in the sidebar with support for expand/collapse - migrate /channels to nested routes (/channels/$name) and remove old card/sheet flow - improve channel forms with clearer hints, required/error states, and reusable switch cards - fix Discord mention-only toggle to read/write group_trigger.mention_only * refactor(frontend): move shared-form to components and unify default-model switch with SwitchCardField * fix(frontend): improve model form validation and unify secret placeholder handling - block duplicate model aliases when adding a model (with localized error messages) - share masked secret placeholder logic across model and channel forms - refresh gateway state after setting the default model - apply minor UI cleanup to provider icon rendering * feat(web): add visual system config and launcher/autostart controls - add launcher config model and persistence (`launcher-config.json`) for port/public/CIDR settings - add system APIs for launch-at-login and launcher parameters - apply CIDR-based access-control middleware to backend HTTP routes - split config routing into visual config and raw JSON config pages - add frontend system API client and visual config sections for runtime/devices/launcher - expand i18n strings (en/zh) for new config UI - improve sidebar active matching and session ID generation fallback * refactor(frontend): remove i18n fallback strings and drop providers route - Replace `t(key, defaultValue)` calls with key-only translations across UI pages - Clean up locale files by pruning unused keys and adding missing shared keys - Remove the obsolete `/providers` page and update generated route tree * fix(backend): correct gateway status detection on Windows * fix(repo): keep web backend dist placeholder tracked --------- Co-authored-by: Guoguo <16666742+imguoguo@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Dihubopen <dihubcn@gmail.com> Co-authored-by: Dihubopen <130813726+Dihubopen@users.noreply.github.com>
845 lines
23 KiB
Go
845 lines
23 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/auth"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
)
|
|
|
|
const (
|
|
oauthProviderOpenAI = "openai"
|
|
oauthProviderAnthropic = "anthropic"
|
|
oauthProviderGoogleAntigravity = "google-antigravity"
|
|
|
|
oauthMethodBrowser = "browser"
|
|
oauthMethodDeviceCode = "device_code"
|
|
oauthMethodToken = "token"
|
|
|
|
oauthFlowPending = "pending"
|
|
oauthFlowSuccess = "success"
|
|
oauthFlowError = "error"
|
|
oauthFlowExpired = "expired"
|
|
)
|
|
|
|
const (
|
|
oauthBrowserFlowTTL = 10 * time.Minute
|
|
oauthDeviceCodeFlowTTL = 15 * time.Minute
|
|
oauthTerminalFlowGC = 30 * time.Minute
|
|
)
|
|
|
|
var oauthProviderOrder = []string{
|
|
oauthProviderOpenAI,
|
|
oauthProviderAnthropic,
|
|
oauthProviderGoogleAntigravity,
|
|
}
|
|
|
|
var oauthProviderMethods = map[string][]string{
|
|
oauthProviderOpenAI: {oauthMethodBrowser, oauthMethodDeviceCode, oauthMethodToken},
|
|
oauthProviderAnthropic: {oauthMethodToken},
|
|
oauthProviderGoogleAntigravity: {oauthMethodBrowser},
|
|
}
|
|
|
|
var oauthProviderLabels = map[string]string{
|
|
oauthProviderOpenAI: "OpenAI",
|
|
oauthProviderAnthropic: "Anthropic",
|
|
oauthProviderGoogleAntigravity: "Google Antigravity",
|
|
}
|
|
|
|
var (
|
|
oauthNow = time.Now
|
|
oauthGeneratePKCE = auth.GeneratePKCE
|
|
oauthGenerateState = auth.GenerateState
|
|
oauthBuildAuthorizeURL = auth.BuildAuthorizeURL
|
|
oauthRequestDeviceCode = auth.RequestDeviceCode
|
|
oauthPollDeviceCodeOnce = auth.PollDeviceCodeOnce
|
|
oauthExchangeCodeForTokens = auth.ExchangeCodeForTokens
|
|
oauthGetCredential = auth.GetCredential
|
|
oauthSetCredential = auth.SetCredential
|
|
oauthDeleteCredential = auth.DeleteCredential
|
|
oauthLoadConfig = config.LoadConfig
|
|
oauthSaveConfig = config.SaveConfig
|
|
oauthFetchAntigravityProject = providers.FetchAntigravityProjectID
|
|
oauthFetchGoogleUserEmailFunc = fetchGoogleUserEmail
|
|
)
|
|
|
|
type oauthFlow struct {
|
|
ID string
|
|
Provider string
|
|
Method string
|
|
Status string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
ExpiresAt time.Time
|
|
Error string
|
|
CodeVerifier string
|
|
OAuthState string
|
|
RedirectURI string
|
|
DeviceAuthID string
|
|
UserCode string
|
|
VerifyURL string
|
|
Interval int
|
|
}
|
|
|
|
type oauthProviderStatus struct {
|
|
Provider string `json:"provider"`
|
|
DisplayName string `json:"display_name"`
|
|
Methods []string `json:"methods"`
|
|
LoggedIn bool `json:"logged_in"`
|
|
Status string `json:"status"`
|
|
AuthMethod string `json:"auth_method,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
AccountID string `json:"account_id,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
}
|
|
|
|
type oauthFlowResponse struct {
|
|
FlowID string `json:"flow_id"`
|
|
Provider string `json:"provider"`
|
|
Method string `json:"method"`
|
|
Status string `json:"status"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
UserCode string `json:"user_code,omitempty"`
|
|
VerifyURL string `json:"verify_url,omitempty"`
|
|
Interval int `json:"interval,omitempty"`
|
|
}
|
|
|
|
// registerOAuthRoutes binds OAuth login/logout endpoints to the ServeMux.
|
|
func (h *Handler) registerOAuthRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /api/oauth/providers", h.handleListOAuthProviders)
|
|
mux.HandleFunc("POST /api/oauth/login", h.handleOAuthLogin)
|
|
mux.HandleFunc("GET /api/oauth/flows/{id}", h.handleGetOAuthFlow)
|
|
mux.HandleFunc("POST /api/oauth/flows/{id}/poll", h.handlePollOAuthFlow)
|
|
mux.HandleFunc("POST /api/oauth/logout", h.handleOAuthLogout)
|
|
mux.HandleFunc("GET /oauth/callback", h.handleOAuthCallback)
|
|
}
|
|
|
|
func (h *Handler) handleListOAuthProviders(w http.ResponseWriter, r *http.Request) {
|
|
providersResp := make([]oauthProviderStatus, 0, len(oauthProviderOrder))
|
|
|
|
for _, provider := range oauthProviderOrder {
|
|
cred, err := oauthGetCredential(provider)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to load credentials: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
item := oauthProviderStatus{
|
|
Provider: provider,
|
|
DisplayName: oauthProviderLabels[provider],
|
|
Methods: oauthProviderMethods[provider],
|
|
Status: "not_logged_in",
|
|
}
|
|
if cred != nil {
|
|
item.LoggedIn = true
|
|
item.AuthMethod = cred.AuthMethod
|
|
item.AccountID = cred.AccountID
|
|
item.Email = cred.Email
|
|
item.ProjectID = cred.ProjectID
|
|
if !cred.ExpiresAt.IsZero() {
|
|
item.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
|
}
|
|
switch {
|
|
case cred.IsExpired():
|
|
item.Status = "expired"
|
|
case cred.NeedsRefresh():
|
|
item.Status = "needs_refresh"
|
|
default:
|
|
item.Status = "connected"
|
|
}
|
|
}
|
|
|
|
providersResp = append(providersResp, item)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"providers": providersResp,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) handleOAuthLogin(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 req struct {
|
|
Provider string `json:"provider"`
|
|
Method string `json:"method"`
|
|
Token string `json:"token"`
|
|
}
|
|
if err = json.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
provider, err := normalizeOAuthProvider(req.Provider)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
method := strings.ToLower(strings.TrimSpace(req.Method))
|
|
if !isOAuthMethodSupported(provider, method) {
|
|
http.Error(
|
|
w,
|
|
fmt.Sprintf("unsupported login method %q for provider %q", method, provider),
|
|
http.StatusBadRequest,
|
|
)
|
|
return
|
|
}
|
|
|
|
switch method {
|
|
case oauthMethodToken:
|
|
token := strings.TrimSpace(req.Token)
|
|
if token == "" {
|
|
http.Error(w, "token is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cred := &auth.AuthCredential{
|
|
AccessToken: token,
|
|
Provider: provider,
|
|
AuthMethod: oauthMethodToken,
|
|
}
|
|
if err := h.persistCredentialAndConfig(provider, oauthMethodToken, cred); err != nil {
|
|
http.Error(w, fmt.Sprintf("token login failed: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "ok",
|
|
"provider": provider,
|
|
"method": method,
|
|
})
|
|
return
|
|
|
|
case oauthMethodDeviceCode:
|
|
cfg := auth.OpenAIOAuthConfig()
|
|
info, err := oauthRequestDeviceCode(cfg)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to request device code: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
now := oauthNow()
|
|
flow := &oauthFlow{
|
|
ID: newOAuthFlowID(),
|
|
Provider: provider,
|
|
Method: method,
|
|
Status: oauthFlowPending,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
ExpiresAt: now.Add(oauthDeviceCodeFlowTTL),
|
|
DeviceAuthID: info.DeviceAuthID,
|
|
UserCode: info.UserCode,
|
|
VerifyURL: info.VerifyURL,
|
|
Interval: info.Interval,
|
|
}
|
|
h.storeOAuthFlow(flow)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "ok",
|
|
"provider": provider,
|
|
"method": method,
|
|
"flow_id": flow.ID,
|
|
"user_code": flow.UserCode,
|
|
"verify_url": flow.VerifyURL,
|
|
"interval": flow.Interval,
|
|
"expires_at": flow.ExpiresAt.Format(time.RFC3339),
|
|
})
|
|
return
|
|
|
|
case oauthMethodBrowser:
|
|
cfg, err := oauthConfigForProvider(provider)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
pkce, err := oauthGeneratePKCE()
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
state, err := oauthGenerateState()
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
redirectURI := buildOAuthRedirectURI(r)
|
|
authURL := oauthBuildAuthorizeURL(cfg, pkce, state, redirectURI)
|
|
|
|
now := oauthNow()
|
|
flow := &oauthFlow{
|
|
ID: newOAuthFlowID(),
|
|
Provider: provider,
|
|
Method: method,
|
|
Status: oauthFlowPending,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
ExpiresAt: now.Add(oauthBrowserFlowTTL),
|
|
CodeVerifier: pkce.CodeVerifier,
|
|
OAuthState: state,
|
|
RedirectURI: redirectURI,
|
|
}
|
|
h.storeOAuthFlow(flow)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "ok",
|
|
"provider": provider,
|
|
"method": method,
|
|
"flow_id": flow.ID,
|
|
"auth_url": authURL,
|
|
"expires_at": flow.ExpiresAt.Format(time.RFC3339),
|
|
})
|
|
return
|
|
default:
|
|
http.Error(w, "unsupported login method", http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) handleGetOAuthFlow(w http.ResponseWriter, r *http.Request) {
|
|
flowID := strings.TrimSpace(r.PathValue("id"))
|
|
if flowID == "" {
|
|
http.Error(w, "missing flow id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
flow, ok := h.getOAuthFlow(flowID)
|
|
if !ok {
|
|
http.Error(w, "flow not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(flowToResponse(flow))
|
|
}
|
|
|
|
func (h *Handler) handlePollOAuthFlow(w http.ResponseWriter, r *http.Request) {
|
|
flowID := strings.TrimSpace(r.PathValue("id"))
|
|
if flowID == "" {
|
|
http.Error(w, "missing flow id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
flow, ok := h.getOAuthFlow(flowID)
|
|
if !ok {
|
|
http.Error(w, "flow not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if flow.Method != oauthMethodDeviceCode {
|
|
http.Error(w, "flow does not support polling", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if flow.Status != oauthFlowPending {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(flowToResponse(flow))
|
|
return
|
|
}
|
|
|
|
cfg := auth.OpenAIOAuthConfig()
|
|
cred, err := oauthPollDeviceCodeOnce(cfg, flow.DeviceAuthID, flow.UserCode)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "pending") {
|
|
updated, _ := h.getOAuthFlow(flowID)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
|
return
|
|
}
|
|
h.setOAuthFlowError(flowID, fmt.Sprintf("device code poll failed: %v", err))
|
|
updated, _ := h.getOAuthFlow(flowID)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
|
return
|
|
}
|
|
if cred == nil {
|
|
updated, _ := h.getOAuthFlow(flowID)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
|
return
|
|
}
|
|
|
|
if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {
|
|
h.setOAuthFlowError(flowID, fmt.Sprintf("failed to save credential: %v", err))
|
|
updated, _ := h.getOAuthFlow(flowID)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
|
return
|
|
}
|
|
|
|
h.setOAuthFlowSuccess(flowID)
|
|
updated, _ := h.getOAuthFlow(flowID)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(flowToResponse(updated))
|
|
}
|
|
|
|
func (h *Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
|
state := strings.TrimSpace(r.URL.Query().Get("state"))
|
|
if state == "" {
|
|
renderOAuthCallbackPage(w, "", oauthFlowError, "Missing state", "missing_state")
|
|
return
|
|
}
|
|
|
|
flow, ok := h.getOAuthFlowByState(state)
|
|
if !ok {
|
|
renderOAuthCallbackPage(w, "", oauthFlowError, "OAuth flow not found", "flow_not_found")
|
|
return
|
|
}
|
|
|
|
if flow.Status != oauthFlowPending {
|
|
renderOAuthCallbackPage(w, flow.ID, flow.Status, "Flow already completed", flow.Error)
|
|
return
|
|
}
|
|
|
|
if errMsg := strings.TrimSpace(r.URL.Query().Get("error")); errMsg != "" {
|
|
if desc := strings.TrimSpace(r.URL.Query().Get("error_description")); desc != "" {
|
|
errMsg += ": " + desc
|
|
}
|
|
h.setOAuthFlowError(flow.ID, errMsg)
|
|
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Authorization failed", errMsg)
|
|
return
|
|
}
|
|
|
|
code := strings.TrimSpace(r.URL.Query().Get("code"))
|
|
if code == "" {
|
|
h.setOAuthFlowError(flow.ID, "missing authorization code")
|
|
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Missing authorization code", "missing_code")
|
|
return
|
|
}
|
|
|
|
cfg, err := oauthConfigForProvider(flow.Provider)
|
|
if err != nil {
|
|
h.setOAuthFlowError(flow.ID, err.Error())
|
|
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Unsupported provider", err.Error())
|
|
return
|
|
}
|
|
|
|
cred, err := oauthExchangeCodeForTokens(cfg, code, flow.CodeVerifier, flow.RedirectURI)
|
|
if err != nil {
|
|
h.setOAuthFlowError(flow.ID, fmt.Sprintf("token exchange failed: %v", err))
|
|
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Token exchange failed", err.Error())
|
|
return
|
|
}
|
|
|
|
if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {
|
|
h.setOAuthFlowError(flow.ID, fmt.Sprintf("failed to save credential: %v", err))
|
|
renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Failed to save credential", err.Error())
|
|
return
|
|
}
|
|
|
|
h.setOAuthFlowSuccess(flow.ID)
|
|
renderOAuthCallbackPage(w, flow.ID, oauthFlowSuccess, "Authentication successful", "")
|
|
}
|
|
|
|
func (h *Handler) handleOAuthLogout(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 req struct {
|
|
Provider string `json:"provider"`
|
|
}
|
|
if err = json.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
provider, err := normalizeOAuthProvider(req.Provider)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := oauthDeleteCredential(provider); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to delete credential: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := h.syncProviderAuthMethod(provider, ""); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to update config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "ok",
|
|
"provider": provider,
|
|
})
|
|
}
|
|
|
|
func renderOAuthCallbackPage(w http.ResponseWriter, flowID, status, title, errMsg string) {
|
|
payload := map[string]string{
|
|
"type": "picoclaw-oauth-result",
|
|
"flowId": flowID,
|
|
"status": status,
|
|
}
|
|
if errMsg != "" {
|
|
payload["error"] = errMsg
|
|
}
|
|
payloadJSON, _ := json.Marshal(payload)
|
|
|
|
message := title
|
|
if errMsg != "" {
|
|
message = fmt.Sprintf("%s: %s", title, errMsg)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if status == oauthFlowSuccess {
|
|
w.WriteHeader(http.StatusOK)
|
|
} else {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(
|
|
w,
|
|
"<!doctype html><html><head><meta charset=\"utf-8\"><title>PicoClaw OAuth</title></head><body><script>(function(){var payload=%s;var hasOpener=false;try{if(window.opener&&!window.opener.closed){window.opener.postMessage(payload,window.location.origin);hasOpener=true}}catch(e){}var target='/credentials?oauth_flow_id='+encodeURIComponent(payload.flowId||'')+'&oauth_status='+encodeURIComponent(payload.status||'');setTimeout(function(){if(hasOpener){window.close();return}window.location.replace(target)},800)})();</script><div style=\"font-family:Inter,system-ui,sans-serif;padding:24px\"><h2>%s</h2><p>%s</p><p>You can close this window.</p></div></body></html>",
|
|
string(payloadJSON),
|
|
html.EscapeString(title),
|
|
html.EscapeString(message),
|
|
)
|
|
}
|
|
|
|
func normalizeOAuthProvider(raw string) (string, error) {
|
|
provider := strings.ToLower(strings.TrimSpace(raw))
|
|
switch provider {
|
|
case "antigravity":
|
|
return oauthProviderGoogleAntigravity, nil
|
|
case oauthProviderOpenAI, oauthProviderAnthropic, oauthProviderGoogleAntigravity:
|
|
return provider, nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported provider %q", raw)
|
|
}
|
|
}
|
|
|
|
func isOAuthMethodSupported(provider, method string) bool {
|
|
methods := oauthProviderMethods[provider]
|
|
for _, m := range methods {
|
|
if m == method {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func oauthConfigForProvider(provider string) (auth.OAuthProviderConfig, error) {
|
|
switch provider {
|
|
case oauthProviderOpenAI:
|
|
return auth.OpenAIOAuthConfig(), nil
|
|
case oauthProviderGoogleAntigravity:
|
|
return auth.GoogleAntigravityOAuthConfig(), nil
|
|
default:
|
|
return auth.OAuthProviderConfig{}, fmt.Errorf("provider %q does not support browser oauth", provider)
|
|
}
|
|
}
|
|
|
|
func oauthMethodTokenOrOAuth(method string) string {
|
|
if method == oauthMethodToken {
|
|
return oauthMethodToken
|
|
}
|
|
return "oauth"
|
|
}
|
|
|
|
func buildOAuthRedirectURI(r *http.Request) string {
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
|
|
scheme = strings.Split(forwarded, ",")[0]
|
|
}
|
|
return fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host)
|
|
}
|
|
|
|
func flowToResponse(flow *oauthFlow) oauthFlowResponse {
|
|
resp := oauthFlowResponse{
|
|
FlowID: flow.ID,
|
|
Provider: flow.Provider,
|
|
Method: flow.Method,
|
|
Status: flow.Status,
|
|
Error: flow.Error,
|
|
}
|
|
if !flow.ExpiresAt.IsZero() {
|
|
resp.ExpiresAt = flow.ExpiresAt.Format(time.RFC3339)
|
|
}
|
|
if flow.Method == oauthMethodDeviceCode {
|
|
resp.UserCode = flow.UserCode
|
|
resp.VerifyURL = flow.VerifyURL
|
|
resp.Interval = flow.Interval
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func newOAuthFlowID() string {
|
|
buf := make([]byte, 16)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return fmt.Sprintf("oauth_%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|
|
|
|
func (h *Handler) storeOAuthFlow(flow *oauthFlow) {
|
|
now := oauthNow()
|
|
h.oauthMu.Lock()
|
|
defer h.oauthMu.Unlock()
|
|
|
|
h.gcOAuthFlowsLocked(now)
|
|
h.oauthFlows[flow.ID] = flow
|
|
if flow.OAuthState != "" {
|
|
h.oauthState[flow.OAuthState] = flow.ID
|
|
}
|
|
}
|
|
|
|
func (h *Handler) getOAuthFlow(flowID string) (*oauthFlow, bool) {
|
|
now := oauthNow()
|
|
h.oauthMu.Lock()
|
|
defer h.oauthMu.Unlock()
|
|
|
|
h.gcOAuthFlowsLocked(now)
|
|
flow, ok := h.oauthFlows[flowID]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
cp := *flow
|
|
return &cp, true
|
|
}
|
|
|
|
func (h *Handler) getOAuthFlowByState(state string) (*oauthFlow, bool) {
|
|
now := oauthNow()
|
|
h.oauthMu.Lock()
|
|
defer h.oauthMu.Unlock()
|
|
|
|
h.gcOAuthFlowsLocked(now)
|
|
flowID, ok := h.oauthState[state]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
flow, ok := h.oauthFlows[flowID]
|
|
if !ok {
|
|
delete(h.oauthState, state)
|
|
return nil, false
|
|
}
|
|
cp := *flow
|
|
return &cp, true
|
|
}
|
|
|
|
func (h *Handler) setOAuthFlowSuccess(flowID string) {
|
|
now := oauthNow()
|
|
h.oauthMu.Lock()
|
|
defer h.oauthMu.Unlock()
|
|
|
|
flow, ok := h.oauthFlows[flowID]
|
|
if !ok {
|
|
return
|
|
}
|
|
flow.Status = oauthFlowSuccess
|
|
flow.Error = ""
|
|
flow.UpdatedAt = now
|
|
if flow.OAuthState != "" {
|
|
delete(h.oauthState, flow.OAuthState)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) setOAuthFlowError(flowID, errMsg string) {
|
|
now := oauthNow()
|
|
h.oauthMu.Lock()
|
|
defer h.oauthMu.Unlock()
|
|
|
|
flow, ok := h.oauthFlows[flowID]
|
|
if !ok {
|
|
return
|
|
}
|
|
flow.Status = oauthFlowError
|
|
flow.Error = errMsg
|
|
flow.UpdatedAt = now
|
|
if flow.OAuthState != "" {
|
|
delete(h.oauthState, flow.OAuthState)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) gcOAuthFlowsLocked(now time.Time) {
|
|
for id, flow := range h.oauthFlows {
|
|
if flow.Status == oauthFlowPending && !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) {
|
|
flow.Status = oauthFlowExpired
|
|
flow.Error = "flow expired"
|
|
flow.UpdatedAt = now
|
|
if flow.OAuthState != "" {
|
|
delete(h.oauthState, flow.OAuthState)
|
|
}
|
|
}
|
|
|
|
if flow.Status != oauthFlowPending && now.Sub(flow.UpdatedAt) > oauthTerminalFlowGC {
|
|
if flow.OAuthState != "" {
|
|
delete(h.oauthState, flow.OAuthState)
|
|
}
|
|
delete(h.oauthFlows, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *auth.AuthCredential) error {
|
|
if cred == nil {
|
|
return fmt.Errorf("empty credential")
|
|
}
|
|
|
|
cp := *cred
|
|
cp.Provider = provider
|
|
if cp.AuthMethod == "" {
|
|
cp.AuthMethod = authMethod
|
|
}
|
|
|
|
if provider == oauthProviderGoogleAntigravity {
|
|
if cp.Email == "" {
|
|
email, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken)
|
|
if err != nil {
|
|
log.Printf("oauth warning: could not fetch google email: %v", err)
|
|
} else {
|
|
cp.Email = email
|
|
}
|
|
}
|
|
if cp.ProjectID == "" {
|
|
projectID, err := oauthFetchAntigravityProject(cp.AccessToken)
|
|
if err != nil {
|
|
log.Printf("oauth warning: could not fetch antigravity project id: %v", err)
|
|
} else {
|
|
cp.ProjectID = projectID
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := oauthSetCredential(provider, &cp); err != nil {
|
|
return fmt.Errorf("saving credential: %w", err)
|
|
}
|
|
if err := h.syncProviderAuthMethod(provider, authMethod); err != nil {
|
|
return fmt.Errorf("syncing provider auth config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
|
|
cfg, err := oauthLoadConfig(h.configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch provider {
|
|
case oauthProviderOpenAI:
|
|
cfg.Providers.OpenAI.AuthMethod = authMethod
|
|
case oauthProviderAnthropic:
|
|
cfg.Providers.Anthropic.AuthMethod = authMethod
|
|
case oauthProviderGoogleAntigravity:
|
|
cfg.Providers.Antigravity.AuthMethod = authMethod
|
|
default:
|
|
return fmt.Errorf("unsupported provider %q", provider)
|
|
}
|
|
|
|
found := false
|
|
for i := range cfg.ModelList {
|
|
if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
|
|
cfg.ModelList[i].AuthMethod = authMethod
|
|
found = true
|
|
}
|
|
}
|
|
|
|
if !found && authMethod != "" {
|
|
cfg.ModelList = append(cfg.ModelList, defaultModelConfigForProvider(provider, authMethod))
|
|
}
|
|
|
|
return oauthSaveConfig(h.configPath, cfg)
|
|
}
|
|
|
|
func modelBelongsToProvider(provider, model string) bool {
|
|
lower := strings.ToLower(strings.TrimSpace(model))
|
|
switch provider {
|
|
case oauthProviderOpenAI:
|
|
return lower == "openai" || strings.HasPrefix(lower, "openai/")
|
|
case oauthProviderAnthropic:
|
|
return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/")
|
|
case oauthProviderGoogleAntigravity:
|
|
return lower == "antigravity" ||
|
|
lower == "google-antigravity" ||
|
|
strings.HasPrefix(lower, "antigravity/") ||
|
|
strings.HasPrefix(lower, "google-antigravity/")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig {
|
|
switch provider {
|
|
case oauthProviderOpenAI:
|
|
return config.ModelConfig{
|
|
ModelName: "gpt-5.2",
|
|
Model: "openai/gpt-5.2",
|
|
AuthMethod: authMethod,
|
|
}
|
|
case oauthProviderAnthropic:
|
|
return config.ModelConfig{
|
|
ModelName: "claude-sonnet-4.6",
|
|
Model: "anthropic/claude-sonnet-4.6",
|
|
AuthMethod: authMethod,
|
|
}
|
|
case oauthProviderGoogleAntigravity:
|
|
return config.ModelConfig{
|
|
ModelName: "gemini-flash",
|
|
Model: "antigravity/gemini-3-flash",
|
|
AuthMethod: authMethod,
|
|
}
|
|
default:
|
|
return config.ModelConfig{}
|
|
}
|
|
}
|
|
|
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
|
req, err := http.NewRequest(http.MethodGet, "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
|
|
}
|
|
if userInfo.Email == "" {
|
|
return "", fmt.Errorf("empty email in userinfo response")
|
|
}
|
|
return userInfo.Email, nil
|
|
}
|