mirror of https://github.com/openclaw/openclaw.git
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:
parent
9bffa3422c
commit
e490f450f3
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue