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.
|
- 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
|
- 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)
|
- 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
|
## 2026.3.12
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,16 @@ const mocks = vi.hoisted(() => ({
|
||||||
updateConfig: vi.fn(),
|
updateConfig: vi.fn(),
|
||||||
logConfigUpdated: vi.fn(),
|
logConfigUpdated: vi.fn(),
|
||||||
openUrl: 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", () => ({
|
vi.mock("@clack/prompts", () => ({
|
||||||
|
|
@ -41,10 +51,6 @@ vi.mock("../../agents/workspace.js", () => ({
|
||||||
resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir,
|
resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../agents/auth-profiles.js", () => ({
|
|
||||||
upsertAuthProfile: mocks.upsertAuthProfile,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../plugins/providers.js", () => ({
|
vi.mock("../../plugins/providers.js", () => ({
|
||||||
resolvePluginProviders: mocks.resolvePluginProviders,
|
resolvePluginProviders: mocks.resolvePluginProviders,
|
||||||
}));
|
}));
|
||||||
|
|
@ -155,6 +161,9 @@ describe("modelsAuthLoginCommand", () => {
|
||||||
});
|
});
|
||||||
mocks.writeOAuthCredentials.mockResolvedValue("openai-codex:user@example.com");
|
mocks.writeOAuthCredentials.mockResolvedValue("openai-codex:user@example.com");
|
||||||
mocks.resolvePluginProviders.mockReturnValue([]);
|
mocks.resolvePluginProviders.mockReturnValue([]);
|
||||||
|
mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} });
|
||||||
|
mocks.listProfilesForProvider.mockReturnValue([]);
|
||||||
|
mocks.clearAuthProfileCooldown.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -198,6 +207,60 @@ describe("modelsAuthLoginCommand", () => {
|
||||||
expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4");
|
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 () => {
|
it("keeps existing plugin error behavior for non built-in providers", async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@ import {
|
||||||
resolveAgentWorkspaceDir,
|
resolveAgentWorkspaceDir,
|
||||||
resolveDefaultAgentId,
|
resolveDefaultAgentId,
|
||||||
} from "../../agents/agent-scope.js";
|
} 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 type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
|
||||||
import { normalizeProviderId } from "../../agents/model-selection.js";
|
import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
||||||
|
|
@ -265,6 +270,24 @@ type LoginOptions = {
|
||||||
setDefault?: boolean;
|
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(
|
export function resolveRequestedLoginProviderOrThrow(
|
||||||
providers: ProviderPlugin[],
|
providers: ProviderPlugin[],
|
||||||
rawProvider?: string,
|
rawProvider?: string,
|
||||||
|
|
@ -356,6 +379,7 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim
|
||||||
const prompter = createClackPrompter();
|
const prompter = createClackPrompter();
|
||||||
|
|
||||||
if (requestedProviderId === "openai-codex") {
|
if (requestedProviderId === "openai-codex") {
|
||||||
|
await clearStaleProfileLockouts("openai-codex", agentDir);
|
||||||
await runBuiltInOpenAICodexLogin({
|
await runBuiltInOpenAICodexLogin({
|
||||||
opts,
|
opts,
|
||||||
runtime,
|
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.");
|
throw new Error("Unknown provider. Use --provider <id> to pick a provider plugin.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clearStaleProfileLockouts(selectedProvider.id, agentDir);
|
||||||
|
|
||||||
const chosenMethod =
|
const chosenMethod =
|
||||||
pickAuthMethod(selectedProvider, opts.method) ??
|
pickAuthMethod(selectedProvider, opts.method) ??
|
||||||
(selectedProvider.auth.length === 1
|
(selectedProvider.auth.length === 1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue