diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b6fdf42d0..546b5b9e30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman. - Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin. - Control UI/usage: truncate long context skill, tool, and file names in the usage panel while keeping the full name available on hover. (#42197) Thanks @Rain120. +- Codex: respect explicit `models auth order set` and `config.auth.order` precedence over stale `lastGood` in `/codex account`, and show `no working credential` when every explicit-order profile is ineligible instead of marking a lower-ranked profile as active. Fixes #84386. (#84412) Thanks @openperf. ## 2026.5.19 diff --git a/extensions/codex/src/command-account.ts b/extensions/codex/src/command-account.ts index 26248c5ff92..4141c1e9ce0 100644 --- a/extensions/codex/src/command-account.ts +++ b/extensions/codex/src/command-account.ts @@ -62,7 +62,7 @@ export async function readCodexAccountAuthOverview(params: { allowKeychainPrompt: false, config, }); - const order = resolveDisplayAuthOrder({ config, store }); + const { order, explicit: explicitOrder } = resolveDisplayAuthOrder({ config, store }); if (order.length === 0) { return undefined; } @@ -71,6 +71,7 @@ export async function readCodexAccountAuthOverview(params: { const activeProfileId = resolveActiveProfileId({ store, order, + explicitOrder, config, account: params.account, limits: params.limits, @@ -135,21 +136,45 @@ export async function readCodexAccountAuthOverview(params: { }; } +type DisplayAuthOrder = { + readonly order: string[]; + readonly explicit: boolean; +}; + function resolveDisplayAuthOrder(params: { config: AuthProfileOrderConfig; store: AuthProfileStore; -}): string[] { +}): DisplayAuthOrder { const codexOrder = resolveOrder(params.store.order, OPENAI_CODEX_PROVIDER_ID) ?? resolveOrder(params.config?.auth?.order, OPENAI_CODEX_PROVIDER_ID); if (codexOrder && codexOrder.length > 0) { - return dedupe(codexOrder); + return { order: dedupe(codexOrder), explicit: true }; } - return resolveAuthProfileOrder({ + const order = resolveAuthProfileOrder({ cfg: params.config, store: params.store, provider: OPENAI_CODEX_PROVIDER_ID, }); + return { order, explicit: hasExplicitOpenAiAuthOrder(params) }; +} + +function hasExplicitOpenAiAuthOrder(params: { + config: AuthProfileOrderConfig; + store: AuthProfileStore; +}): boolean { + const sources = [params.store.order, params.config?.auth?.order]; + for (const source of sources) { + const codex = resolveOrder(source, OPENAI_CODEX_PROVIDER_ID); + if (codex && codex.length > 0) { + return true; + } + const openai = resolveOrder(source, OPENAI_PROVIDER_ID); + if (openai && openai.length > 0) { + return true; + } + } + return false; } function resolveOrder( @@ -162,6 +187,7 @@ function resolveOrder( function resolveActiveProfileId(params: { store: AuthProfileStore; order: string[]; + explicitOrder: boolean; config: AuthProfileOrderConfig; account: SafeValue; limits: SafeValue; @@ -175,6 +201,25 @@ function resolveActiveProfileId(params: { if (liveProfileId) { return liveProfileId; } + // Explicit auth order (`models auth order set` or `config.auth.order`) is + // authoritative for the status display and overrides `lastGood`/usage + // heuristics, matching the core `resolveAuthProfileOrder` precedence so the + // display does not silently disagree with the runtime resolver. When no + // fully-usable candidate exists return undefined — marking an ineligible + // profile as active would misrepresent what the runtime resolver can use. + if (params.explicitOrder) { + return params.order.find( + (profileId) => + isActiveProfileCandidate(params, profileId) && + resolveAuthProfileEligibility({ + cfg: params.config, + store: params.store, + provider: OPENAI_CODEX_PROVIDER_ID, + profileId, + now: params.now, + }).eligible, + ); + } const lastGood = [ params.store.lastGood?.[OPENAI_PROVIDER_ID], params.store.lastGood?.[OPENAI_CODEX_PROVIDER_ID], diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index d767e6b91fc..ef6e0b026a0 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -1327,6 +1327,192 @@ describe("codex command", () => { ); }); + it("respects explicit Codex auth order over stale lastGood after OAuth re-login", async () => { + const config = {}; + const now = Date.now(); + installAuthProfileStore( + { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "stale-access-token", + refresh: "stale-refresh-token", + expires: now + 2 * 24 * 60 * 60 * 1000, + email: "previous@example.com", + }, + "openai-codex:fresh-email@example.com": { + type: "oauth", + provider: "openai-codex", + access: "fresh-access-token", + refresh: "fresh-refresh-token", + expires: now + 9 * 24 * 60 * 60 * 1000, + email: "fresh-email@example.com", + }, + }, + order: { + "openai-codex": ["openai-codex:fresh-email@example.com", "openai-codex:default"], + }, + lastGood: { + "openai-codex": "openai-codex:default", + }, + }, + config, + ); + + const safeCodexControlRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + value: { account: { type: "unknown" }, requiresOpenaiAuth: true }, + }) + .mockResolvedValueOnce({ + ok: true, + value: codexRateLimitPayload({ + primaryUsedPercent: 5, + secondaryUsedPercent: 10, + primaryResetSeconds: Math.ceil(now / 1000) + 60 * 60, + secondaryResetSeconds: Math.ceil(now / 1000) + 6 * 60 * 60, + }), + }); + + const result = await handleCodexCommand(createContext("account", undefined, { config }), { + deps: createDeps({ safeCodexControlRequest }), + }); + + expect(result.text).toContain( + "\n 1. fresh-email@example.com ChatGPT subscription — active now", + ); + expect(result.text).toContain( + "\n 2. previous@example.com ChatGPT subscription — available if needed", + ); + expect(result.text).not.toContain("previous@example.com ChatGPT subscription — active now"); + expect(result.text).not.toContain("openai-codex:"); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(2); + }); + + it("respects openai-alias explicit order over stale lastGood for API key profiles", async () => { + const config = {}; + const now = Date.now(); + installAuthProfileStore( + { + version: 1, + profiles: { + "openai:fresh-key": { + type: "api_key", + provider: "openai", + key: "sk-fresh-111", + }, + "openai:stale-key": { + type: "api_key", + provider: "openai", + key: "sk-stale-222", + }, + }, + order: { + openai: ["openai:fresh-key", "openai:stale-key"], + }, + lastGood: { + openai: "openai:stale-key", + }, + }, + config, + ); + + const safeCodexControlRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + value: { account: { type: "unknown" }, requiresOpenaiAuth: false }, + }) + .mockResolvedValueOnce({ + ok: false, + error: "usage data unavailable", + }); + + const result = await handleCodexCommand(createContext("account", undefined, { config }), { + deps: createDeps({ safeCodexControlRequest }), + }); + + expect(result.text).toContain("\n 1. fresh-key API key — active now"); + expect(result.text).not.toContain("stale-key API key — active now"); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(2); + }); + + it("does not mark any profile active when all explicit-order token credentials are expired", async () => { + // Both profiles use type:"token" with expired expiry, so resolveAuthProfileEligibility + // returns eligible=false for both. resolveActiveProfileId must return undefined rather + // than marking an ineligible profile as active; the display shows "no working credential". + const config = {}; + const now = Date.now(); + installAuthProfileStore( + { + version: 1, + profiles: { + "openai-codex:fresh@example.com": { + type: "token", + provider: "openai-codex", + token: "fresh-token", + expires: now - 1000, + email: "fresh@example.com", + }, + "openai-codex:stale@example.com": { + type: "token", + provider: "openai-codex", + token: "stale-token", + expires: now - 2000, + email: "stale@example.com", + }, + }, + order: { + "openai-codex": ["openai-codex:fresh@example.com", "openai-codex:stale@example.com"], + }, + lastGood: { + "openai-codex": "openai-codex:stale@example.com", + }, + }, + config, + ); + + const safeCodexControlRequest = vi + .fn() + // call 1: account info for the active/first profile + .mockResolvedValueOnce({ + ok: true, + value: { account: { type: "unknown" }, requiresOpenaiAuth: true }, + }) + // call 2: rate limits for the active profile + .mockResolvedValueOnce({ + ok: false, + error: "rate limits unavailable", + }) + // call 3: readSubscriptionUsage — no activeProfileId means the subscription + // profile (fresh, type:"token") is fetched separately + .mockResolvedValueOnce({ + ok: false, + error: "subscription limits unavailable", + }); + + const result = await handleCodexCommand(createContext("account", undefined, { config }), { + deps: createDeps({ safeCodexControlRequest }), + }); + + // With all credentials expired, no profile is active — the display shows + // "no working credential" and both profiles are labelled "sign-in expired". + // lastGood (stale) must not override the stated operator rank, and the + // first explicit-order entry must not be falsely marked active when ineligible. + expect(result.text).toContain("no working credential"); + expect(result.text).toContain( + "\n 1. fresh@example.com ChatGPT subscription — sign-in expired", + ); + expect(result.text).toContain( + "\n 2. stale@example.com ChatGPT subscription — sign-in expired", + ); + expect(result.text).not.toContain("active now"); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(3); + }); + it("escapes successful Codex account fallback summaries before chat display", async () => { const unsafe = "<@U123> [trusted](https://evil) @here"; const safeCodexControlRequest = vi