fix(auth): clear stale lockout state when user re-authenticates

Fixes #43057

* fix(auth): clear stale lockout on re-login

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.

Uses the agent-scoped store (`loadAuthProfileStoreForRuntime`) for
correct multi-agent profile resolution and wraps the housekeeping in
try/catch so corrupt store files never block re-authentication.

Fixes #43057

* test(auth): remove unnecessary non-null assertions

oxlint no-unnecessary-type-assertion: invocationCallOrder[0]
already returns number, not number | undefined.
This commit is contained in:
Andrew Demczuk 2026-03-14 19:20:12 +01:00 committed by GitHub
parent 9bffa3422c
commit e490f450f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 95 additions and 5 deletions

View File

@ -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

View File

@ -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();

View File

@ -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<void> {
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 <id> to pick a provider plugin.");
}
await clearStaleProfileLockouts(selectedProvider.id, agentDir);
const chosenMethod =
pickAuthMethod(selectedProvider, opts.method) ??
(selectedProvider.auth.length === 1