diff --git a/CHANGELOG.md b/CHANGELOG.md index 544049021f3..2a03b9d2c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Plugins/install: remove the previous managed plugin directory when a reinstall switches sources, so stale ClawHub and npm copies no longer keep duplicate plugin ids in discovery after the new install wins. Thanks @vincentkoc. - Plugins/install: let official plugin reinstall recovery repair source-only installed runtime shadows, so `openclaw plugins install npm:@openclaw/discord --force` can replace the bad package instead of stopping at stale config validation. Thanks @vincentkoc. - Plugins/commands: allow the official ClawHub Codex plugin package to keep reserved `/codex` command ownership, matching the existing npm-managed Codex package behavior. Thanks @vincentkoc. +- Auth/OpenAI Codex: rewrite invalidated per-agent Codex auth-order and session profile overrides toward a healthy relogin profile, so revoked OAuth accounts do not stay pinned after signing in again. Thanks @BunsDev. - Plugins/commands: scope QQBot framework slash commands to the QQBot channel so `/bot-*` command handlers and native specs do not leak onto unrelated chat surfaces. Thanks @vincentkoc. - fix: harden backend message action gateway routing [AI]. (#76374) Thanks @pgondhi987. - Gate QQBot streaming command auth [AI]. (#76375) Thanks @pgondhi987. diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index 91ac1021732..b19d83a7fcc 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -573,6 +573,94 @@ describe("ensureAuthProfileStore", () => { } }); + it("rewrites invalidated per-agent Codex order to the main agent's healthy relogin profile", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-codex-relogin-")); + const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + try { + const mainDir = path.join(root, "main-agent"); + const agentDir = path.join(root, "agent-x"); + fs.mkdirSync(mainDir, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + process.env.OPENCLAW_AGENT_DIR = mainDir; + process.env.PI_CODING_AGENT_DIR = mainDir; + + const now = Date.now(); + const healthyProfileId = "openai-codex:bunsthedev@gmail.com"; + const staleProfileId = "openai-codex:val@viewdue.ai"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [healthyProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "healthy-access", + refresh: "healthy-refresh", + expires: now + 60 * 60 * 1000, + email: "bunsthedev@gmail.com", + }, + }, + order: { + "openai-codex": [healthyProfileId], + }, + lastGood: { + "openai-codex": healthyProfileId, + }, + }, + mainDir, + ); + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [staleProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "stale-access", + refresh: "stale-refresh", + expires: now + 30 * 60 * 1000, + email: "val@viewdue.ai", + }, + }, + order: { + "openai-codex": [staleProfileId], + }, + lastGood: { + "openai-codex": staleProfileId, + }, + usageStats: { + [staleProfileId]: { + cooldownUntil: now + 60_000, + cooldownReason: "auth", + failureCounts: { auth: 1 }, + errorCount: 1, + lastFailureAt: now - 1_000, + }, + }, + }, + agentDir, + ); + clearRuntimeAuthProfileStoreSnapshots(); + + const store = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true }); + + expect(store.profiles[healthyProfileId]).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "healthy-access", + }); + expect(store.profiles[staleProfileId]).toBeUndefined(); + expect(store.order?.["openai-codex"]).toEqual([healthyProfileId]); + expect(store.lastGood?.["openai-codex"]).toBe(healthyProfileId); + expect(store.usageStats?.[staleProfileId]).toBeUndefined(); + } finally { + restoreAgentDirEnv({ previousAgentDir, previousPiAgentDir }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it.each([ { name: "mode/apiKey aliases map to type/key", diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 5b44626816a..1276a5aa725 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -18,10 +18,12 @@ import { } from "./state.js"; import type { AuthProfileCredential, + AuthProfileFailureReason, AuthProfileSecretsStore, AuthProfileStore, OAuthCredential, OAuthCredentials, + ProfileUsageStats, } from "./types.js"; export type LegacyAuthStore = Record; @@ -213,6 +215,107 @@ function isNewerUsableOAuthCredential( ); } +const AUTH_INVALIDATION_REASONS = new Set([ + "auth", + "auth_permanent", + "session_expired", +]); + +function hasAuthInvalidationSignal(stats: ProfileUsageStats | undefined): boolean { + if (!stats) { + return false; + } + if ( + (stats.cooldownReason && AUTH_INVALIDATION_REASONS.has(stats.cooldownReason)) || + (stats.disabledReason && AUTH_INVALIDATION_REASONS.has(stats.disabledReason)) + ) { + return true; + } + return Object.entries(stats.failureCounts ?? {}).some( + ([reason, count]) => + AUTH_INVALIDATION_REASONS.has(reason as AuthProfileFailureReason) && + typeof count === "number" && + count > 0, + ); +} + +function isProfileReferencedByAuthState(store: AuthProfileStore, profileId: string): boolean { + if (Object.values(store.order ?? {}).some((profileIds) => profileIds.includes(profileId))) { + return true; + } + return Object.values(store.lastGood ?? {}).some((value) => value === profileId); +} + +function resolveProviderAuthStateValue( + values: Record | undefined, + providerKey: string, +): T | undefined { + if (!values) { + return undefined; + } + for (const [key, value] of Object.entries(values)) { + if (normalizeProviderId(key) === providerKey) { + return value; + } + } + return undefined; +} + +function findMainStoreOAuthReplacementForInvalidatedProfile(params: { + base: AuthProfileStore; + override: AuthProfileStore; + profileId: string; + credential: OAuthCredential; +}): string | undefined { + const providerKey = normalizeProviderId(params.credential.provider); + if ( + providerKey !== "openai-codex" || + !isProfileReferencedByAuthState(params.override, params.profileId) || + !hasAuthInvalidationSignal(params.override.usageStats?.[params.profileId]) + ) { + return undefined; + } + + const candidates = Object.entries(params.base.profiles) + .flatMap(([profileId, credential]): Array<[string, OAuthCredential]> => { + if ( + profileId === params.profileId || + credential.type !== "oauth" || + normalizeProviderId(credential.provider) !== providerKey || + !hasUsableOAuthCredential(credential) + ) { + return []; + } + return [[profileId, credential]]; + }) + .toSorted(([leftId, leftCredential], [rightId, rightCredential]) => { + const leftExpires = Number.isFinite(leftCredential.expires) ? leftCredential.expires : 0; + const rightExpires = Number.isFinite(rightCredential.expires) ? rightCredential.expires : 0; + if (rightExpires !== leftExpires) { + return rightExpires - leftExpires; + } + return leftId.localeCompare(rightId); + }); + if (candidates.length === 0) { + return undefined; + } + + const candidateIds = new Set(candidates.map(([profileId]) => profileId)); + const orderedProfileId = resolveProviderAuthStateValue(params.base.order, providerKey)?.find( + (profileId) => candidateIds.has(profileId), + ); + if (orderedProfileId) { + return orderedProfileId; + } + + const lastGoodProfileId = resolveProviderAuthStateValue(params.base.lastGood, providerKey); + if (lastGoodProfileId && candidateIds.has(lastGoodProfileId)) { + return lastGoodProfileId; + } + + return candidates.length === 1 ? candidates[0]?.[0] : undefined; +} + function findMainStoreOAuthReplacement(params: { base: AuthProfileStore; legacyProfileId: string; @@ -343,14 +446,21 @@ function reconcileMainStoreOAuthProfileDrift(params: { }): AuthProfileStore { const replacements = new Map(); for (const [profileId, credential] of Object.entries(params.override.profiles)) { - if (credential.type !== "oauth" || !isLegacyDefaultOAuthProfile(profileId, credential)) { + if (credential.type !== "oauth") { continue; } - const replacementProfileId = findMainStoreOAuthReplacement({ - base: params.base, - legacyProfileId: profileId, - legacyCredential: credential, - }); + const replacementProfileId = isLegacyDefaultOAuthProfile(profileId, credential) + ? findMainStoreOAuthReplacement({ + base: params.base, + legacyProfileId: profileId, + legacyCredential: credential, + }) + : findMainStoreOAuthReplacementForInvalidatedProfile({ + base: params.base, + override: params.override, + profileId, + credential, + }); if (replacementProfileId) { replacements.set(profileId, replacementProfileId); } diff --git a/src/agents/auth-profiles/profiles.test.ts b/src/agents/auth-profiles/profiles.test.ts new file mode 100644 index 00000000000..a6e17794618 --- /dev/null +++ b/src/agents/auth-profiles/profiles.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { AUTH_STORE_VERSION } from "./constants.js"; +import { promoteAuthProfileInOrder } from "./profiles.js"; +import { loadAuthProfileStoreForRuntime, saveAuthProfileStore } from "./store.js"; + +describe("promoteAuthProfileInOrder", () => { + it("moves a relogin profile to the front of an existing per-agent provider order", async () => { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-order-promote-")); + try { + const newProfileId = "openai-codex:bunsthedev@gmail.com"; + const staleProfileId = "openai-codex:val@viewdue.ai"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [newProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + [staleProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "stale-access", + refresh: "stale-refresh", + expires: Date.now() + 30 * 60 * 1000, + }, + }, + order: { + "openai-codex": [staleProfileId], + }, + }, + agentDir, + ); + + const updated = await promoteAuthProfileInOrder({ + agentDir, + provider: "openai-codex", + profileId: newProfileId, + }); + + expect(updated?.order?.["openai-codex"]).toEqual([newProfileId, staleProfileId]); + expect(loadAuthProfileStoreForRuntime(agentDir).order?.["openai-codex"]).toEqual([ + newProfileId, + staleProfileId, + ]); + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index ad06751566a..8860f8ca848 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -1,7 +1,7 @@ import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; -import { normalizeProviderId } from "../provider-id.js"; +import { findNormalizedProviderKey, normalizeProviderId } from "../provider-id.js"; import { dedupeProfileIds, listProfilesForProvider } from "./profile-list.js"; import { ensureAuthProfileStoreForLocalUpdate, @@ -41,6 +41,41 @@ export async function setAuthProfileOrder(params: { }); } +export async function promoteAuthProfileInOrder(params: { + agentDir?: string; + provider: string; + profileId: string; +}): Promise { + const providerKey = resolveProviderIdForAuth(params.provider); + return await updateAuthProfileStoreWithLock({ + agentDir: params.agentDir, + updater: (store) => { + const profile = store.profiles[params.profileId]; + if (!profile || resolveProviderIdForAuth(profile.provider) !== providerKey) { + return false; + } + const orderKey = + findNormalizedProviderKey(store.order, providerKey) ?? normalizeProviderId(providerKey); + const existing = store.order?.[orderKey]; + if (!existing || existing.length === 0) { + return false; + } + const next = dedupeProfileIds([ + params.profileId, + ...existing.filter((profileId) => profileId !== params.profileId), + ]); + if ( + next.length === existing.length && + next.every((profileId, idx) => profileId === existing[idx]) + ) { + return false; + } + store.order = { ...store.order, [orderKey]: next }; + return true; + }, + }); +} + export function upsertAuthProfile(params: { profileId: string; credential: AuthProfileCredential; diff --git a/src/agents/auth-profiles/session-override.test.ts b/src/agents/auth-profiles/session-override.test.ts index b135d9706c0..42d6874bf7e 100644 --- a/src/agents/auth-profiles/session-override.test.ts +++ b/src/agents/auth-profiles/session-override.test.ts @@ -19,7 +19,7 @@ const authStoreMocks = vi.hoisted(() => { state, ensureAuthProfileStore: vi.fn(() => state.store), hasAnyAuthProfileStoreSource: vi.fn(() => state.hasSource), - isProfileInCooldown: vi.fn(() => false), + isProfileInCooldown: vi.fn((_store: AuthProfileStore, _profileId: string) => false), reset() { state.hasSource = false; state.store = { version: 1, profiles: {} }; @@ -246,4 +246,55 @@ describe("resolveSessionAuthProfileOverride", () => { expect(sessionEntry.authProfileOverride).toBe(TEST_PRIMARY_PROFILE_ID); }); }); + + it("re-resolves a stale user session override when the selected profile becomes unusable", async () => { + await withAuthState(async (state) => { + const agentDir = state.agentDir(); + await fs.mkdir(agentDir, { recursive: true }); + authStoreMocks.state.hasSource = true; + authStoreMocks.state.store = createAuthStoreWithProfiles({ + profiles: { + [TEST_PRIMARY_PROFILE_ID]: { + type: "api_key", + provider: "openai-codex", + key: "sk-stale", + }, + [TEST_SECONDARY_PROFILE_ID]: { + type: "api_key", + provider: "openai-codex", + key: "sk-healthy", + }, + }, + order: { + "openai-codex": [TEST_SECONDARY_PROFILE_ID, TEST_PRIMARY_PROFILE_ID], + }, + }); + authStoreMocks.isProfileInCooldown.mockImplementation( + (_store: AuthProfileStore, profileId: string) => profileId === TEST_PRIMARY_PROFILE_ID, + ); + + const sessionEntry: SessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + authProfileOverride: TEST_PRIMARY_PROFILE_ID, + authProfileOverrideSource: "user", + }; + const sessionStore = { "agent:main:main": sessionEntry }; + + const resolved = await resolveSessionAuthProfileOverride({ + cfg: {} as OpenClawConfig, + provider: "openai-codex", + agentDir, + sessionEntry, + sessionStore, + sessionKey: "agent:main:main", + storePath: undefined, + isNewSession: false, + }); + + expect(resolved).toBe(TEST_SECONDARY_PROFILE_ID); + expect(sessionEntry.authProfileOverride).toBe(TEST_SECONDARY_PROFILE_ID); + expect(sessionEntry.authProfileOverrideSource).toBe("auto"); + }); + }); }); diff --git a/src/agents/auth-profiles/session-override.ts b/src/agents/auth-profiles/session-override.ts index ef7e8e9eeb5..653ca48342d 100644 --- a/src/agents/auth-profiles/session-override.ts +++ b/src/agents/auth-profiles/session-override.ts @@ -136,12 +136,21 @@ export async function resolveSessionAuthProfileOverride(params: { typeof sessionEntry.authProfileOverrideCompactionCount === "number" ? sessionEntry.authProfileOverrideCompactionCount : compactionCount; + const replacementForUnusableCurrent = + current && isProfileInCooldown(store, current) + ? order.find((profileId) => profileId !== current && !isProfileInCooldown(store, profileId)) + : undefined; + if (replacementForUnusableCurrent) { + current = undefined; + } if (source === "user" && current && !isNewSession) { return current; } let next = current; - if (isNewSession) { + if (replacementForUnusableCurrent) { + next = replacementForUnusableCurrent; + } else if (isNewSession) { next = current ? pickNextAvailable(current) : pickFirstAvailable(); } else if (current && compactionCount > storedCompaction) { next = pickNextAvailable(current); diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index fa3879b112c..5c5dccba2ce 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -23,11 +23,13 @@ const mocks = vi.hoisted(() => ({ isRemoteEnvironment: vi.fn(() => false), loadAuthProfileStoreForRuntime: vi.fn(), listProfilesForProvider: vi.fn(), + promoteAuthProfileInOrder: vi.fn(), clearAuthProfileCooldown: vi.fn(), })); vi.mock("../../agents/auth-profiles/profiles.js", () => ({ listProfilesForProvider: mocks.listProfilesForProvider, + promoteAuthProfileInOrder: mocks.promoteAuthProfileInOrder, upsertAuthProfile: mocks.upsertAuthProfile, })); @@ -278,6 +280,7 @@ describe("modelsAuthLoginCommand", () => { mocks.clackSelect.mockReset(); mocks.clackText.mockReset(); mocks.upsertAuthProfile.mockReset(); + mocks.promoteAuthProfileInOrder.mockReset(); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw/agents/main"); @@ -391,6 +394,11 @@ describe("modelsAuthLoginCommand", () => { }), agentDir: "/tmp/openclaw/agents/main", }); + expect(mocks.promoteAuthProfileInOrder).toHaveBeenCalledWith({ + agentDir: "/tmp/openclaw/agents/main", + provider: "openai-codex", + profileId: "openai-codex:user@example.com", + }); expect(lastUpdatedConfig?.auth?.profiles?.["openai-codex:user@example.com"]).toMatchObject({ provider: "openai-codex", mode: "oauth", diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 1d985185c1c..9968591613f 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -11,7 +11,11 @@ import { resolveDefaultAgentId, } from "../../agents/agent-scope.js"; import { externalCliDiscoveryForProviderAuth } from "../../agents/auth-profiles.js"; -import { listProfilesForProvider, upsertAuthProfile } from "../../agents/auth-profiles/profiles.js"; +import { + listProfilesForProvider, + promoteAuthProfileInOrder, + upsertAuthProfile, +} from "../../agents/auth-profiles/profiles.js"; import { loadAuthProfileStoreForRuntime } from "../../agents/auth-profiles/store.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; import { clearAuthProfileCooldown } from "../../agents/auth-profiles/usage.js"; @@ -247,6 +251,11 @@ async function persistProviderAuthResult(params: { credential: profile.credential, agentDir: params.agentDir, }); + await promoteAuthProfileInOrder({ + agentDir: params.agentDir, + provider: profile.credential.provider, + profileId: profile.profileId, + }); } await updateConfig((cfg) => {