Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/api/handlers/management/api_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/gin-gonic/gin"
xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
Expand Down Expand Up @@ -103,7 +104,7 @@ type apiCallResponse struct {
func (h *Handler) APICall(c *gin.Context) {
var body apiCallRequest
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
c.JSON(clienterror.HTTPStatusFromErrorOr(errBindJSON, http.StatusBadRequest), gin.H{"error": "invalid body"})
return
}

Expand Down
77 changes: 46 additions & 31 deletions internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/antigravity"
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/credentialweight"
Expand All @@ -38,13 +39,15 @@ type diskAuthFileCandidate struct {
}

var (
callbackForwardersMu sync.Mutex
callbackForwarders = make(map[int]*callbackForwarder)
authFileEntryMu sync.Mutex
errAuthFileMustBeJSON = errors.New("auth file must be .json")
errAuthFileNotFound = errors.New("auth file not found")
errPluginVirtualAuth = errors.New("plugin virtual auth cannot be modified directly; edit or delete the source auth file")
newCodexOAuthService = func(cfg *config.Config) codexOAuthService { return codex.NewCodexAuth(cfg) }
callbackForwardersMu sync.Mutex
callbackForwarders = make(map[int]*callbackForwarder)
authFileEntryMu sync.Mutex
errAuthFileMustBeJSON = errors.New("auth file must be .json")
errInvalidAuthFile = errors.New("invalid auth file")
errAuthFileNotFound = errors.New("auth file not found")
errPluginVirtualAuth = errors.New("plugin virtual auth cannot be modified directly; edit or delete the source auth file")
newCodexOAuthService = func(cfg *config.Config) codexOAuthService { return codex.NewCodexAuth(cfg) }
newAntigravityOAuthService = func(cfg *config.Config) antigravityOAuthService { return antigravity.NewAntigravityAuth(cfg, nil) }
)

func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
Expand Down Expand Up @@ -119,9 +122,9 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
h.mu.Lock()
host := h.pluginHost
h.mu.Unlock()
var quotaSupportedProviders map[string]struct{}
quota := authFileQuotaContext{v8: c.GetBool(ConfigV8ContextKey)}
if host != nil {
quotaSupportedProviders = host.QuotaSupportedProvidersSet(c.Request.Context())
quota.pluginIDs = host.QuotaProviderPluginIDs(c.Request.Context())
}
auths := h.authManager.List()
observedAt := time.Now().UTC()
Expand All @@ -141,7 +144,7 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
start, end := pagination.bounds(total)
files := make([]gin.H, 0, end-start)
for _, auth := range matching[start:end] {
if entry := h.buildAuthFileEntry(auth, quotaSupportedProviders); entry != nil {
if entry := h.buildAuthFileEntry(auth, quota); entry != nil {
entry["cooldowns"] = nil
if cooldownsKnown {
entry["cooldowns"] = coreauth.CooldownSnapshotForAuth(auth, observedAt)
Expand All @@ -157,7 +160,7 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
if !matchesAuthFileLookup(auth, nameFilter, authIndexFilter) {
continue
}
if entry := h.buildAuthFileEntry(auth, quotaSupportedProviders); entry != nil {
if entry := h.buildAuthFileEntry(auth, quota); entry != nil {
entry["cooldowns"] = nil
if cooldownsKnown {
entry["cooldowns"] = coreauth.CooldownSnapshotForAuth(auth, observedAt)
Expand Down Expand Up @@ -478,10 +481,19 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context, pagination authFilesPagi
c.JSON(200, authFilesListResponse(observedAt, files, pagination, total, end))
}

func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth, quotaSupported ...map[string]struct{}) gin.H {
// authFileQuotaContext carries request-scoped quota capability data for auth file entries.
type authFileQuotaContext struct {
// pluginIDs maps normalized provider keys to the quota plugin selected for them.
// A nil map means no quota plugins are available.
pluginIDs map[string]string
// v8 hides declarative quota_probe support because v8 only serves plugin quota routes.
v8 bool
}

func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth, quota authFileQuotaContext) gin.H {
authFileEntryMu.Lock()
defer authFileEntryMu.Unlock()
return h.buildAuthFileEntryLocked(auth, quotaSupported...)
return h.buildAuthFileEntryLocked(auth, quota)
}

func isPersistentAuthFailure(auth *coreauth.Auth, now time.Time) bool {
Expand Down Expand Up @@ -634,7 +646,7 @@ func reconcileAuthFileCooldownState(auth *coreauth.Auth, now time.Time) (unavail
return unavailable, status, statusMessage, nextRetry
}

func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth, quotaSupported ...map[string]struct{}) gin.H {
func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth, quota authFileQuotaContext) gin.H {
if auth == nil {
return nil
}
Expand Down Expand Up @@ -674,25 +686,15 @@ func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth, quotaSupported .
if modelQuotas := modelQuotaObservationPayload(auth.Provider, auth.ModelStates); len(modelQuotas) > 0 {
entry["model_quotas"] = modelQuotas
}
var quotaSupportedMap map[string]struct{}
if len(quotaSupported) > 0 {
quotaSupportedMap = quotaSupported[0]
}
if quotaSupportedMap != nil {
if _, ok := quotaSupportedMap[strings.ToLower(strings.TrimSpace(auth.Provider))]; ok {
entry["supports_quota"] = true
entry["quota_provider"] = auth.Provider
}
} else {
h.mu.Lock()
host := h.pluginHost
h.mu.Unlock()
if host != nil && host.HasQuotaProvider(auth.Provider) {
entry["supports_quota"] = true
entry["quota_provider"] = auth.Provider
if pluginID := quota.pluginIDs[strings.ToLower(strings.TrimSpace(auth.Provider))]; pluginID != "" {
entry["supports_quota"] = true
entry["quota_provider"] = auth.Provider
if quota.v8 {
entry["quota_plugin_id"] = pluginID
}
}
if auth.Metadata != nil {
// Declarative quota_probe is only executed by the v0 quota/fetch route.
if !quota.v8 && auth.Metadata != nil {
if probe, okProbe := auth.Metadata["quota_probe"]; okProbe && probe != nil {
entry["supports_quota"] = true
entry["quota_probe"] = probe
Expand Down Expand Up @@ -784,6 +786,19 @@ func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth, quotaSupported .
if requestRetry, ok := auth.RequestRetryOverride(); ok {
entry["request_retry"] = requestRetry
}
if quota.v8 && strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
entry["siwc_bind_supported"] = siwcBindEligible(auth)
entry["use_siwc"] = coreauth.IsCodexUseSIWC(auth)
entry["siwc_status"] = string(auth.CodexSIWCStatus(time.Now().UTC()))
section, _ := auth.Metadata["siwc"].(map[string]any)
accessToken, _ := section["access_token"].(string)
refreshToken, _ := section["refresh_token"].(string)
clientID, _ := section["client_id"].(string)
entry["siwc_bound"] = strings.TrimSpace(accessToken) != "" && strings.TrimSpace(refreshToken) != "" && strings.TrimSpace(clientID) != ""
if expiry, okExpiry := auth.SIWCExpirationTime(); okExpiry {
entry["siwc_expires_at"] = expiry.UTC().Format(time.RFC3339)
}
}
return entry
}

Expand Down
72 changes: 62 additions & 10 deletions internal/api/handlers/management/auth_files_crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"time"

"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
Expand Down Expand Up @@ -57,7 +59,7 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {

fileHeaders, errMultipart := h.multipartAuthFileHeaders(c)
if errMultipart != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid multipart form: %v", errMultipart)})
c.JSON(clienterror.HTTPStatusFromErrorOr(errMultipart, http.StatusBadRequest), gin.H{"error": fmt.Sprintf("invalid multipart form: %v", errMultipart)})
return
}
if len(fileHeaders) == 1 {
Expand All @@ -66,6 +68,10 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be .json"})
return
}
if errors.Is(errUpload, errInvalidAuthFile) {
c.JSON(http.StatusBadRequest, gin.H{"error": errUpload.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errUpload.Error()})
return
}
Expand Down Expand Up @@ -118,11 +124,15 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {
}
data, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(400, gin.H{"error": "failed to read body"})
c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadRequest), gin.H{"error": "failed to read body"})
return
}
if err = h.writeAuthFile(ctx, filepath.Base(name), data); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
status := http.StatusInternalServerError
if errors.Is(err, errInvalidAuthFile) {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"status": "ok"})
Expand Down Expand Up @@ -156,13 +166,22 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
full = abs
}
}
if err = os.Remove(full); err == nil {
errDelete := func() error {
release := h.beginSIWCFileMutation(ctx, full)
defer release()
if errRemove := os.Remove(full); errRemove != nil {
return nil
}
if errDel := h.deleteTokenRecord(ctx, full); errDel != nil {
c.JSON(500, gin.H{"error": errDel.Error()})
return
return errDel
}
deleted++
h.removeAuth(ctx, full)
return nil
}()
if errDelete != nil {
c.JSON(500, gin.H{"error": errDelete.Error()})
return
}
}
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
Expand All @@ -171,7 +190,7 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {

names, errNames := requestedAuthFileNamesForDelete(c)
if errNames != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": errNames.Error()})
c.JSON(clienterror.HTTPStatusFromErrorOr(errNames, http.StatusBadRequest), gin.H{"error": errNames.Error()})
return
}
if len(names) == 0 {
Expand Down Expand Up @@ -269,7 +288,9 @@ func (h *Handler) writeAuthFile(ctx context.Context, name string, data []byte) e
if err != nil {
return err
}
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
release := h.beginSIWCFileMutation(ctx, dst)
defer release()
if errWrite := h.writeAuthFileBytes(dst, data, auth); errWrite != nil {
return fmt.Errorf("failed to write file: %w", errWrite)
}
if err := h.upsertAuthRecord(ctx, auth); err != nil {
Expand All @@ -283,6 +304,27 @@ func (h *Handler) writeAuthFile(ctx context.Context, name string, data []byte) e
return nil
}

func (h *Handler) writeAuthFileBytes(dst string, data []byte, auth *coreauth.Auth) error {
if h.shouldLockCodexAuthFile(auth) {
return codex.WithFileLock(dst, func() error {
return os.WriteFile(dst, data, 0o600)
})
}
return os.WriteFile(dst, data, 0o600)
}

func (h *Handler) shouldLockCodexAuthFile(auth *coreauth.Auth) bool {
if coreauth.IsCodexAuthRecord(auth) {
return true
}
if h != nil && h.authManager != nil && auth != nil && auth.ID != "" {
if existing, ok := h.authManager.GetByID(auth.ID); ok && coreauth.IsCodexAuthRecord(existing) {
return true
}
}
return false
}

func requestedAuthFileNamesForDelete(c *gin.Context) ([]string, error) {
if c == nil {
return nil, nil
Expand All @@ -294,6 +336,10 @@ func requestedAuthFileNamesForDelete(c *gin.Context) ([]string, error) {

body, err := io.ReadAll(c.Request.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
return nil, err
}
return nil, fmt.Errorf("failed to read body")
}
body = bytes.TrimSpace(body)
Expand Down Expand Up @@ -366,6 +412,8 @@ func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string
targetPath = abs
}
}
release := h.beginSIWCFileMutation(ctx, targetPath)
defer release()
if errRemove := os.Remove(targetPath); errRemove != nil {
if os.IsNotExist(errRemove) {
return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound
Expand Down Expand Up @@ -479,9 +527,13 @@ func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Aut
}
metadata := make(map[string]any)
if err := json.Unmarshal(data, &metadata); err != nil {
return nil, fmt.Errorf("invalid auth file: %w", err)
return nil, fmt.Errorf("%w: %w", errInvalidAuthFile, err)
}
coreauth.NormalizeCredentialMetadata(metadata)
// Loading tolerates invalid exclusion rules, so reject them here before anything is persisted.
if _, errExcluded := synthesizer.ExtractExcludedModelsFromMetadata(metadata); errExcluded != nil {
return nil, fmt.Errorf("%w: %w", errInvalidAuthFile, errExcluded)
}
provider, _ := metadata["type"].(string)
if provider == "" {
provider = "unknown"
Expand All @@ -506,7 +558,7 @@ func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Aut
}
generated, errSynthesize := synthesizer.SynthesizeAuthFile(sctx, path, data)
if errSynthesize != nil {
return nil, fmt.Errorf("invalid auth file: %w", errSynthesize)
return nil, fmt.Errorf("%w: %w", errInvalidAuthFile, errSynthesize)
}
if len(generated) > 0 && generated[0] != nil {
auth = generated[0].Clone()
Expand Down
22 changes: 15 additions & 7 deletions internal/api/handlers/management/auth_files_devin_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ var newDevinOAuthService = func(cfg *config.Config) devinOAuthService {
// Devin's authorization page strictly validates that redirect_uri matches
// http://127.0.0.1:<port>/callback (http protocol, 127.0.0.1 host, and /callback path).
func (h *Handler) devinCallbackURL() (string, error) {
if h == nil || h.cfg == nil || h.cfg.Port <= 0 {
cfg := h.configSnapshot()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical · Nil Devin handler now panics before its guard

When RequestDevinToken is invoked with a nil *Handler, devinCallbackURL now calls h.configSnapshot() before checking h == nil. Since configSnapshot dereferences h.mu, this crashes instead of returning the existing "server port is not configured" error.

if h == nil || cfg == nil || cfg.Port <= 0 {
return "", fmt.Errorf("server port is not configured")
}
return fmt.Sprintf("http://127.0.0.1:%d/callback", h.cfg.Port), nil
return fmt.Sprintf("http://127.0.0.1:%d/callback", cfg.Port), nil
}

// RequestDevinToken starts the same callback/status flow used by the other WebUI providers.
Expand All @@ -59,17 +60,23 @@ func (h *Handler) RequestDevinToken(c *gin.Context) {
return
}

authSvc := newDevinOAuthService(h.cfg)
sessionProxyURL := oauthSessionProxyURLFromContext(c)
cfg := h.oauthConfigWithProxy(sessionProxyURL)
authSvc := newDevinOAuthService(cfg)
authURL := authSvc.BuildAuthorizationURL(redirectURI, pkceCodes.CodeChallenge, state)
RegisterOAuthSession(state, "devin")
RegisterOAuthSessionWithProxy(state, "devin", sessionProxyURL)
// Do not inherit the HTTP request cancellation: login continues after returning the URL.
ctx := PopulateAuthContext(context.Background(), c)
authDir := h.cfg.AuthDir
authDir := ""
if cfg != nil {
authDir = cfg.AuthDir
}
go h.completeDevinOAuth(ctx, authDir, state, pkceCodes.CodeVerifier, authSvc)
c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state})
}

func (h *Handler) completeDevinOAuth(ctx context.Context, authDir, state, codeVerifier string, authSvc devinOAuthService) {
sessionProxyURL := GetOAuthSessionProxyURL(state)
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
go watchOAuthSessionCancel(ctx, cancel, state, "devin")
Expand Down Expand Up @@ -121,11 +128,12 @@ func (h *Handler) completeDevinOAuth(ctx context.Context, authDir, state, codeVe
}
return
}
if errGuard := guardOAuthSessionPendingForSave(state, "devin"); errGuard != nil {
applySessionProxyToAuthRecord(record, sessionProxyURL)
if errGuard := claimOAuthSessionCommit(state, "devin"); errGuard != nil {
return
}
if _, errSave := h.saveTokenRecord(ctx, record); errSave != nil {
SetOAuthSessionError(state, "Failed to save authentication tokens")
FailOAuthSessionCommit(state, "Failed to save authentication tokens")
return
}
CompleteOAuthSession(state)
Expand Down
Loading
Loading