mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-20 17:16:00 +08:00
feat(auth): add runtime auth removal and unscheduling logic
- Introduced `Manager.Remove` to delete runtime auth and unschedule associated tasks. - Updated handler logic to directly remove auth instead of marking as disabled. - Added tests to validate removal, unscheduling, and runtime state handling. - Added a test to validate `skipPersist` behavior during registration. - Enhanced `Remove` test to verify auto-refresh loop state before and after removal. Closes: #3690
This commit is contained in:
@@ -770,7 +770,7 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
deleted++
|
||||
h.disableAuth(ctx, full)
|
||||
h.removeAuth(ctx, full)
|
||||
}
|
||||
}
|
||||
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
|
||||
@@ -976,9 +976,9 @@ func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string
|
||||
return filepath.Base(name), http.StatusInternalServerError, errDeleteRecord
|
||||
}
|
||||
if targetID != "" {
|
||||
h.disableAuth(ctx, targetID)
|
||||
h.removeAuth(ctx, targetID)
|
||||
} else {
|
||||
h.disableAuth(ctx, targetPath)
|
||||
h.removeAuth(ctx, targetPath)
|
||||
}
|
||||
return filepath.Base(name), http.StatusOK, nil
|
||||
}
|
||||
@@ -1558,7 +1558,7 @@ func syncAuthFileDisabledState(auth *coreauth.Auth) {
|
||||
auth.StatusMessage = ""
|
||||
}
|
||||
|
||||
func (h *Handler) disableAuth(ctx context.Context, id string) {
|
||||
func (h *Handler) removeAuth(ctx context.Context, id string) {
|
||||
if h == nil || h.authManager == nil {
|
||||
return
|
||||
}
|
||||
@@ -1566,25 +1566,15 @@ func (h *Handler) disableAuth(ctx context.Context, id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if auth, ok := h.authManager.GetByID(id); ok {
|
||||
auth.Disabled = true
|
||||
auth.Status = coreauth.StatusDisabled
|
||||
auth.StatusMessage = "removed via management API"
|
||||
auth.UpdatedAt = time.Now()
|
||||
_, _ = h.authManager.Update(ctx, auth)
|
||||
if _, ok := h.authManager.GetByID(id); ok {
|
||||
h.authManager.Remove(ctx, id)
|
||||
return
|
||||
}
|
||||
authID := h.authIDForPath(id)
|
||||
if authID == "" {
|
||||
return
|
||||
}
|
||||
if auth, ok := h.authManager.GetByID(authID); ok {
|
||||
auth.Disabled = true
|
||||
auth.Status = coreauth.StatusDisabled
|
||||
auth.StatusMessage = "removed via management API"
|
||||
auth.UpdatedAt = time.Now()
|
||||
_, _ = h.authManager.Update(ctx, auth)
|
||||
}
|
||||
h.authManager.Remove(ctx, authID)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error {
|
||||
|
||||
@@ -127,3 +127,49 @@ func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) {
|
||||
t.Fatalf("expected auth file to be removed from auth dir, stat err: %v", errStat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAuthFile_RemovesRuntimeAuth(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
authDir := t.TempDir()
|
||||
fileName := "runtime-remove-user.json"
|
||||
filePath := filepath.Join(authDir, fileName)
|
||||
if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex","email":"runtime@example.com"}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("failed to write auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
record := &coreauth.Auth{
|
||||
ID: "runtime-remove-auth",
|
||||
FileName: fileName,
|
||||
Provider: "codex",
|
||||
Status: coreauth.StatusActive,
|
||||
Attributes: map[string]string{
|
||||
"path": filePath,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "codex",
|
||||
"email": "runtime@example.com",
|
||||
},
|
||||
}
|
||||
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
|
||||
t.Fatalf("failed to register auth record: %v", errRegister)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||
h.tokenStore = &memoryAuthStore{}
|
||||
|
||||
deleteRec := httptest.NewRecorder()
|
||||
deleteCtx, _ := gin.CreateTestContext(deleteRec)
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
|
||||
deleteCtx.Request = deleteReq
|
||||
h.DeleteAuthFile(deleteCtx)
|
||||
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
if _, ok := manager.GetByID(record.ID); ok {
|
||||
t.Fatalf("expected runtime auth %q to be removed", record.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,18 +1164,21 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
|
||||
return nil, nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.auths[auth.ID]; ok && existing != nil {
|
||||
if !auth.indexAssigned && auth.Index == "" {
|
||||
auth.Index = existing.Index
|
||||
auth.indexAssigned = existing.indexAssigned
|
||||
}
|
||||
auth.Success = existing.Success
|
||||
auth.Failed = existing.Failed
|
||||
auth.recentRequests = existing.recentRequests
|
||||
if !existing.Disabled && existing.Status != StatusDisabled && !auth.Disabled && auth.Status != StatusDisabled {
|
||||
if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {
|
||||
auth.ModelStates = existing.ModelStates
|
||||
}
|
||||
existing, ok := m.auths[auth.ID]
|
||||
if !ok || existing == nil {
|
||||
m.mu.Unlock()
|
||||
return nil, nil
|
||||
}
|
||||
if !auth.indexAssigned && auth.Index == "" {
|
||||
auth.Index = existing.Index
|
||||
auth.indexAssigned = existing.indexAssigned
|
||||
}
|
||||
auth.Success = existing.Success
|
||||
auth.Failed = existing.Failed
|
||||
auth.recentRequests = existing.recentRequests
|
||||
if !existing.Disabled && existing.Status != StatusDisabled && !auth.Disabled && auth.Status != StatusDisabled {
|
||||
if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {
|
||||
auth.ModelStates = existing.ModelStates
|
||||
}
|
||||
}
|
||||
auth.EnsureIndex()
|
||||
@@ -1192,6 +1195,65 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
|
||||
return auth.Clone(), nil
|
||||
}
|
||||
|
||||
// Remove deletes an auth from runtime state without persisting.
|
||||
// Disk and token-store deletion must be handled by the caller.
|
||||
func (m *Manager) Remove(ctx context.Context, id string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
_ = ctx
|
||||
|
||||
m.mu.Lock()
|
||||
existing := m.auths[id]
|
||||
if existing == nil {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
provider := strings.TrimSpace(existing.Provider)
|
||||
delete(m.auths, id)
|
||||
if m.modelPoolOffsets != nil {
|
||||
delete(m.modelPoolOffsets, id)
|
||||
}
|
||||
for sessionID, sessionAuths := range m.homeRuntimeAuths {
|
||||
if sessionAuths == nil {
|
||||
continue
|
||||
}
|
||||
delete(sessionAuths, id)
|
||||
if len(sessionAuths) == 0 {
|
||||
delete(m.homeRuntimeAuths, sessionID)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.rebuildAPIKeyModelAliasFromRuntimeConfig()
|
||||
if m.scheduler != nil {
|
||||
m.scheduler.removeAuth(id)
|
||||
}
|
||||
m.queueRefreshUnschedule(id)
|
||||
m.invalidateSessionAffinity(id)
|
||||
|
||||
if provider != "" {
|
||||
if exec, ok := m.Executor(provider); ok && exec != nil {
|
||||
if closer, okCloser := exec.(ExecutionSessionCloser); okCloser {
|
||||
closer.CloseExecutionSession(CloseAllExecutionSessionsID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) invalidateSessionAffinity(authID string) {
|
||||
if m == nil || authID == "" {
|
||||
return
|
||||
}
|
||||
if invalidator, ok := m.selector.(interface{ InvalidateAuth(string) }); ok && invalidator != nil {
|
||||
invalidator.InvalidateAuth(authID)
|
||||
}
|
||||
}
|
||||
|
||||
// Load resets manager state from the backing store.
|
||||
func (m *Manager) Load(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
@@ -4041,6 +4103,19 @@ func (m *Manager) queueRefreshReschedule(authID string) {
|
||||
loop.queueReschedule(authID)
|
||||
}
|
||||
|
||||
func (m *Manager) queueRefreshUnschedule(authID string) {
|
||||
if m == nil || authID == "" {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
loop := m.refreshLoop
|
||||
m.mu.RUnlock()
|
||||
if loop == nil {
|
||||
return
|
||||
}
|
||||
loop.remove(authID)
|
||||
}
|
||||
|
||||
func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
|
||||
111
sdk/cliproxy/auth/conductor_remove_test.go
Normal file
111
sdk/cliproxy/auth/conductor_remove_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManager_Remove_DeletesRuntimeAuth(t *testing.T) {
|
||||
manager := NewManager(nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
auth := &Auth{
|
||||
ID: "remove-runtime-auth",
|
||||
Provider: "claude",
|
||||
Status: StatusActive,
|
||||
Metadata: map[string]any{"email": "x@example.com"},
|
||||
}
|
||||
if _, errRegister := manager.Register(ctx, auth); errRegister != nil {
|
||||
t.Fatalf("register auth: %v", errRegister)
|
||||
}
|
||||
|
||||
manager.Remove(ctx, auth.ID)
|
||||
|
||||
if _, ok := manager.GetByID(auth.ID); ok {
|
||||
t.Fatalf("expected auth %q to be removed", auth.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Update_MissingAuthIsNoOp(t *testing.T) {
|
||||
manager := NewManager(nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
auth := &Auth{
|
||||
ID: "missing-update-auth",
|
||||
Provider: "claude",
|
||||
Status: StatusActive,
|
||||
}
|
||||
if _, errRegister := manager.Register(ctx, auth); errRegister != nil {
|
||||
t.Fatalf("register auth: %v", errRegister)
|
||||
}
|
||||
manager.Remove(ctx, auth.ID)
|
||||
|
||||
updated, errUpdate := manager.Update(ctx, &Auth{
|
||||
ID: auth.ID,
|
||||
Provider: "claude",
|
||||
Status: StatusDisabled,
|
||||
Disabled: true,
|
||||
})
|
||||
if errUpdate != nil {
|
||||
t.Fatalf("update removed auth: %v", errUpdate)
|
||||
}
|
||||
if updated != nil {
|
||||
t.Fatalf("expected update on removed auth to be no-op, got %#v", updated)
|
||||
}
|
||||
if _, ok := manager.GetByID(auth.ID); ok {
|
||||
t.Fatalf("expected removed auth to stay absent after late update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Remove_UnschedulesAutoRefresh(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
manager := NewManager(nil, nil, nil)
|
||||
loop := newAuthAutoRefreshLoop(manager, time.Second, 1)
|
||||
manager.mu.Lock()
|
||||
manager.refreshLoop = loop
|
||||
manager.mu.Unlock()
|
||||
|
||||
lead := 10 * time.Minute
|
||||
setRefreshLeadFactory(t, "provider-lead-expiry", func() *time.Duration {
|
||||
d := lead
|
||||
return &d
|
||||
})
|
||||
|
||||
auth := &Auth{
|
||||
ID: "remove-refresh-auth",
|
||||
Provider: "provider-lead-expiry",
|
||||
Metadata: map[string]any{
|
||||
"email": "x@example.com",
|
||||
"expires_at": time.Now().Add(time.Hour).Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
if _, errRegister := manager.Register(ctx, auth); errRegister != nil {
|
||||
t.Fatalf("register auth: %v", errRegister)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if _, ok := nextRefreshCheckAt(now, auth, time.Second); !ok {
|
||||
t.Fatalf("expected auth to be scheduled before removal")
|
||||
}
|
||||
loop.applyDirty(now)
|
||||
loop.mu.Lock()
|
||||
if _, ok := loop.index[auth.ID]; !ok {
|
||||
loop.mu.Unlock()
|
||||
t.Fatalf("expected auth %q to be present in auto-refresh index before removal", auth.ID)
|
||||
}
|
||||
loop.mu.Unlock()
|
||||
|
||||
manager.Remove(ctx, auth.ID)
|
||||
|
||||
if _, ok := manager.GetByID(auth.ID); ok {
|
||||
t.Fatalf("expected auth to be removed")
|
||||
}
|
||||
loop.mu.Lock()
|
||||
if _, ok := loop.index[auth.ID]; ok {
|
||||
loop.mu.Unlock()
|
||||
t.Fatalf("expected auth %q to be removed from auto-refresh index", auth.ID)
|
||||
}
|
||||
loop.mu.Unlock()
|
||||
}
|
||||
@@ -28,6 +28,13 @@ func TestWithSkipPersist_DisablesUpdatePersistence(t *testing.T) {
|
||||
Metadata: map[string]any{"type": "antigravity"},
|
||||
}
|
||||
|
||||
if _, err := mgr.Register(WithSkipPersist(context.Background()), auth); err != nil {
|
||||
t.Fatalf("Register(skipPersist) returned error: %v", err)
|
||||
}
|
||||
if got := store.saveCount.Load(); got != 0 {
|
||||
t.Fatalf("expected 0 Save calls, got %d", got)
|
||||
}
|
||||
|
||||
if _, err := mgr.Update(context.Background(), auth); err != nil {
|
||||
t.Fatalf("Update returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -339,17 +339,15 @@ func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
|
||||
if s.coreManager == nil {
|
||||
return
|
||||
}
|
||||
GlobalModelRegistry().UnregisterClient(id)
|
||||
id = strings.TrimSpace(id)
|
||||
var provider string
|
||||
if existing, ok := s.coreManager.GetByID(id); ok && existing != nil {
|
||||
existing.Disabled = true
|
||||
existing.Status = coreauth.StatusDisabled
|
||||
if _, err := s.coreManager.Update(ctx, existing); err != nil {
|
||||
log.Errorf("failed to disable auth %s: %v", id, err)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(existing.Provider), "codex") {
|
||||
executor.CloseCodexWebsocketSessionsForAuthID(existing.ID, "auth_removed")
|
||||
s.ensureExecutorsForAuth(existing)
|
||||
}
|
||||
provider = strings.TrimSpace(existing.Provider)
|
||||
}
|
||||
GlobalModelRegistry().UnregisterClient(id)
|
||||
s.coreManager.Remove(ctx, id)
|
||||
if strings.EqualFold(provider, "codex") {
|
||||
executor.CloseCodexWebsocketSessionsForAuthID(id, "auth_removed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,37 +40,8 @@ func TestServiceApplyCoreAuthAddOrUpdate_DeleteReAddDoesNotInheritStaleRuntimeSt
|
||||
|
||||
service.applyCoreAuthRemoval(context.Background(), authID)
|
||||
|
||||
disabled, ok := service.coreManager.GetByID(authID)
|
||||
if !ok || disabled == nil {
|
||||
t.Fatalf("expected disabled auth after removal")
|
||||
}
|
||||
if !disabled.Disabled || disabled.Status != coreauth.StatusDisabled {
|
||||
t.Fatalf("expected disabled auth after removal, got disabled=%v status=%v", disabled.Disabled, disabled.Status)
|
||||
}
|
||||
if disabled.LastRefreshedAt.IsZero() {
|
||||
t.Fatalf("expected disabled auth to still carry prior LastRefreshedAt for regression setup")
|
||||
}
|
||||
if disabled.NextRefreshAfter.IsZero() {
|
||||
t.Fatalf("expected disabled auth to still carry prior NextRefreshAfter for regression setup")
|
||||
}
|
||||
|
||||
// Reconcile prunes unsupported model state during registration, so seed the
|
||||
// disabled snapshot explicitly before exercising delete -> re-add behavior.
|
||||
disabled.ModelStates = map[string]*coreauth.ModelState{
|
||||
modelID: {
|
||||
Quota: coreauth.QuotaState{BackoffLevel: 7},
|
||||
},
|
||||
}
|
||||
if _, err := service.coreManager.Update(context.Background(), disabled); err != nil {
|
||||
t.Fatalf("seed disabled auth stale ModelStates: %v", err)
|
||||
}
|
||||
|
||||
disabled, ok = service.coreManager.GetByID(authID)
|
||||
if !ok || disabled == nil {
|
||||
t.Fatalf("expected disabled auth after stale state seeding")
|
||||
}
|
||||
if len(disabled.ModelStates) == 0 {
|
||||
t.Fatalf("expected disabled auth to carry seeded ModelStates for regression setup")
|
||||
if _, ok := service.coreManager.GetByID(authID); ok {
|
||||
t.Fatalf("expected auth %q to be removed from runtime state", authID)
|
||||
}
|
||||
|
||||
service.applyCoreAuthAddOrUpdate(context.Background(), &coreauth.Auth{
|
||||
|
||||
Reference in New Issue
Block a user