fix(codex/command-account): respect explicit auth order over lastGood (#84412)

Fixes openclaw#84386. resolveActiveProfileId in extensions/codex/src/command-account.ts returned store.lastGood whenever that profile was still in the resolved order, ignoring rank, so /codex account marked the stale openai-codex:default profile as active after models auth login + models auth order set. Tracks whether the order came from an explicit operator source (store.order / config.auth.order, including the openai alias key), picks the first usable explicit-order profile, and returns undefined when no candidate is eligible so the display surfaces "no working credential" instead of marking a lower-ranked profile active. Runtime selection via resolveCodexAppServerAuthProfileId is unchanged.
This commit is contained in:
Chunyue Wang
2026-05-20 20:02:28 +08:00
committed by GitHub
parent 99c88629c3
commit 5d775122c1
3 changed files with 236 additions and 4 deletions

View File

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

View File

@@ -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<JsonValue | undefined>;
limits: SafeValue<JsonValue | undefined>;
@@ -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],

View File

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