fix: refresh stale codex auth profile routing

This commit is contained in:
Val Alexander
2026-05-04 06:03:58 -05:00
parent a7c5a04259
commit 1a6f93a372
9 changed files with 377 additions and 10 deletions

View File

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

View File

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

View File

@@ -18,10 +18,12 @@ import {
} from "./state.js";
import type {
AuthProfileCredential,
AuthProfileFailureReason,
AuthProfileSecretsStore,
AuthProfileStore,
OAuthCredential,
OAuthCredentials,
ProfileUsageStats,
} from "./types.js";
export type LegacyAuthStore = Record<string, AuthProfileCredential>;
@@ -213,6 +215,107 @@ function isNewerUsableOAuthCredential(
);
}
const AUTH_INVALIDATION_REASONS = new Set<AuthProfileFailureReason>([
"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<T>(
values: Record<string, T> | 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<string, string>();
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);
}

View File

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

View File

@@ -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<AuthProfileStore | null> {
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;

View File

@@ -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");
});
});
});

View File

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

View File

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

View File

@@ -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) => {