diff --git a/CHANGELOG.md b/CHANGELOG.md index 9431eaedc58..7144f3502eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic. - Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix - Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931) +- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) ## 2026.3.12 diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index e59e7fd021e..bf8195b5284 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -21,6 +21,16 @@ const mocks = vi.hoisted(() => ({ updateConfig: vi.fn(), logConfigUpdated: vi.fn(), openUrl: vi.fn(), + loadAuthProfileStoreForRuntime: vi.fn(), + listProfilesForProvider: vi.fn(), + clearAuthProfileCooldown: vi.fn(), +})); + +vi.mock("../../agents/auth-profiles.js", () => ({ + loadAuthProfileStoreForRuntime: mocks.loadAuthProfileStoreForRuntime, + listProfilesForProvider: mocks.listProfilesForProvider, + clearAuthProfileCooldown: mocks.clearAuthProfileCooldown, + upsertAuthProfile: mocks.upsertAuthProfile, })); vi.mock("@clack/prompts", () => ({ @@ -41,10 +51,6 @@ vi.mock("../../agents/workspace.js", () => ({ resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir, })); -vi.mock("../../agents/auth-profiles.js", () => ({ - upsertAuthProfile: mocks.upsertAuthProfile, -})); - vi.mock("../../plugins/providers.js", () => ({ resolvePluginProviders: mocks.resolvePluginProviders, })); @@ -155,6 +161,9 @@ describe("modelsAuthLoginCommand", () => { }); mocks.writeOAuthCredentials.mockResolvedValue("openai-codex:user@example.com"); mocks.resolvePluginProviders.mockReturnValue([]); + mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} }); + mocks.listProfilesForProvider.mockReturnValue([]); + mocks.clearAuthProfileCooldown.mockResolvedValue(undefined); }); afterEach(() => { @@ -198,6 +207,60 @@ describe("modelsAuthLoginCommand", () => { expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4"); }); + it("clears stale auth lockouts before attempting openai-codex login", async () => { + const runtime = createRuntime(); + const fakeStore = { + profiles: { + "openai-codex:user@example.com": { + type: "oauth", + provider: "openai-codex", + }, + }, + usageStats: { + "openai-codex:user@example.com": { + disabledUntil: Date.now() + 3_600_000, + disabledReason: "auth_permanent", + errorCount: 3, + }, + }, + }; + mocks.loadAuthProfileStoreForRuntime.mockReturnValue(fakeStore); + mocks.listProfilesForProvider.mockReturnValue(["openai-codex:user@example.com"]); + + await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); + + expect(mocks.clearAuthProfileCooldown).toHaveBeenCalledWith({ + store: fakeStore, + profileId: "openai-codex:user@example.com", + agentDir: "/tmp/openclaw/agents/main", + }); + // Verify clearing happens before login attempt + const clearOrder = mocks.clearAuthProfileCooldown.mock.invocationCallOrder[0]; + const loginOrder = mocks.loginOpenAICodexOAuth.mock.invocationCallOrder[0]; + expect(clearOrder).toBeLessThan(loginOrder); + }); + + it("survives lockout clearing failure without blocking login", async () => { + const runtime = createRuntime(); + mocks.loadAuthProfileStoreForRuntime.mockImplementation(() => { + throw new Error("corrupt auth-profiles.json"); + }); + + await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); + + expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledOnce(); + }); + + it("loads lockout state from the agent-scoped store", async () => { + const runtime = createRuntime(); + mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} }); + mocks.listProfilesForProvider.mockReturnValue([]); + + await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); + + expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main"); + }); + it("keeps existing plugin error behavior for non built-in providers", async () => { const runtime = createRuntime(); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 56946d590a7..c9b54b2f753 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -10,7 +10,12 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; -import { upsertAuthProfile } from "../../agents/auth-profiles.js"; +import { + clearAuthProfileCooldown, + listProfilesForProvider, + loadAuthProfileStoreForRuntime, + upsertAuthProfile, +} from "../../agents/auth-profiles.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; @@ -265,6 +270,24 @@ type LoginOptions = { setDefault?: boolean; }; +/** + * Clear stale cooldown/disabled state for all profiles matching a provider. + * When a user explicitly runs `models auth login`, they intend to fix auth — + * stale `auth_permanent` / `billing` lockouts should not persist across + * a deliberate re-authentication attempt. + */ +async function clearStaleProfileLockouts(provider: string, agentDir: string): Promise { + try { + const store = loadAuthProfileStoreForRuntime(agentDir); + const profileIds = listProfilesForProvider(store, provider); + for (const profileId of profileIds) { + await clearAuthProfileCooldown({ store, profileId, agentDir }); + } + } catch { + // Best-effort housekeeping — never block re-authentication. + } +} + export function resolveRequestedLoginProviderOrThrow( providers: ProviderPlugin[], rawProvider?: string, @@ -356,6 +379,7 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim const prompter = createClackPrompter(); if (requestedProviderId === "openai-codex") { + await clearStaleProfileLockouts("openai-codex", agentDir); await runBuiltInOpenAICodexLogin({ opts, runtime, @@ -390,6 +414,8 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim throw new Error("Unknown provider. Use --provider to pick a provider plugin."); } + await clearStaleProfileLockouts(selectedProvider.id, agentDir); + const chosenMethod = pickAuthMethod(selectedProvider, opts.method) ?? (selectedProvider.auth.length === 1