Skip to content

Commit 5d77512

Browse files
authored
fix(codex/command-account): respect explicit auth order over lastGood (#84412)
Fixes #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.
1 parent 99c8862 commit 5d77512

3 files changed

Lines changed: 236 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai
6666
- iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman.
6767
- 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.
6868
- 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.
69+
- 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.
6970

7071
## 2026.5.19
7172

extensions/codex/src/command-account.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export async function readCodexAccountAuthOverview(params: {
6262
allowKeychainPrompt: false,
6363
config,
6464
});
65-
const order = resolveDisplayAuthOrder({ config, store });
65+
const { order, explicit: explicitOrder } = resolveDisplayAuthOrder({ config, store });
6666
if (order.length === 0) {
6767
return undefined;
6868
}
@@ -71,6 +71,7 @@ export async function readCodexAccountAuthOverview(params: {
7171
const activeProfileId = resolveActiveProfileId({
7272
store,
7373
order,
74+
explicitOrder,
7475
config,
7576
account: params.account,
7677
limits: params.limits,
@@ -135,21 +136,45 @@ export async function readCodexAccountAuthOverview(params: {
135136
};
136137
}
137138

139+
type DisplayAuthOrder = {
140+
readonly order: string[];
141+
readonly explicit: boolean;
142+
};
143+
138144
function resolveDisplayAuthOrder(params: {
139145
config: AuthProfileOrderConfig;
140146
store: AuthProfileStore;
141-
}): string[] {
147+
}): DisplayAuthOrder {
142148
const codexOrder =
143149
resolveOrder(params.store.order, OPENAI_CODEX_PROVIDER_ID) ??
144150
resolveOrder(params.config?.auth?.order, OPENAI_CODEX_PROVIDER_ID);
145151
if (codexOrder && codexOrder.length > 0) {
146-
return dedupe(codexOrder);
152+
return { order: dedupe(codexOrder), explicit: true };
147153
}
148-
return resolveAuthProfileOrder({
154+
const order = resolveAuthProfileOrder({
149155
cfg: params.config,
150156
store: params.store,
151157
provider: OPENAI_CODEX_PROVIDER_ID,
152158
});
159+
return { order, explicit: hasExplicitOpenAiAuthOrder(params) };
160+
}
161+
162+
function hasExplicitOpenAiAuthOrder(params: {
163+
config: AuthProfileOrderConfig;
164+
store: AuthProfileStore;
165+
}): boolean {
166+
const sources = [params.store.order, params.config?.auth?.order];
167+
for (const source of sources) {
168+
const codex = resolveOrder(source, OPENAI_CODEX_PROVIDER_ID);
169+
if (codex && codex.length > 0) {
170+
return true;
171+
}
172+
const openai = resolveOrder(source, OPENAI_PROVIDER_ID);
173+
if (openai && openai.length > 0) {
174+
return true;
175+
}
176+
}
177+
return false;
153178
}
154179

155180
function resolveOrder(
@@ -162,6 +187,7 @@ function resolveOrder(
162187
function resolveActiveProfileId(params: {
163188
store: AuthProfileStore;
164189
order: string[];
190+
explicitOrder: boolean;
165191
config: AuthProfileOrderConfig;
166192
account: SafeValue<JsonValue | undefined>;
167193
limits: SafeValue<JsonValue | undefined>;
@@ -175,6 +201,25 @@ function resolveActiveProfileId(params: {
175201
if (liveProfileId) {
176202
return liveProfileId;
177203
}
204+
// Explicit auth order (`models auth order set` or `config.auth.order`) is
205+
// authoritative for the status display and overrides `lastGood`/usage
206+
// heuristics, matching the core `resolveAuthProfileOrder` precedence so the
207+
// display does not silently disagree with the runtime resolver. When no
208+
// fully-usable candidate exists return undefined — marking an ineligible
209+
// profile as active would misrepresent what the runtime resolver can use.
210+
if (params.explicitOrder) {
211+
return params.order.find(
212+
(profileId) =>
213+
isActiveProfileCandidate(params, profileId) &&
214+
resolveAuthProfileEligibility({
215+
cfg: params.config,
216+
store: params.store,
217+
provider: OPENAI_CODEX_PROVIDER_ID,
218+
profileId,
219+
now: params.now,
220+
}).eligible,
221+
);
222+
}
178223
const lastGood = [
179224
params.store.lastGood?.[OPENAI_PROVIDER_ID],
180225
params.store.lastGood?.[OPENAI_CODEX_PROVIDER_ID],

extensions/codex/src/commands.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1327,6 +1327,192 @@ describe("codex command", () => {
13271327
);
13281328
});
13291329

1330+
it("respects explicit Codex auth order over stale lastGood after OAuth re-login", async () => {
1331+
const config = {};
1332+
const now = Date.now();
1333+
installAuthProfileStore(
1334+
{
1335+
version: 1,
1336+
profiles: {
1337+
"openai-codex:default": {
1338+
type: "oauth",
1339+
provider: "openai-codex",
1340+
access: "stale-access-token",
1341+
refresh: "stale-refresh-token",
1342+
expires: now + 2 * 24 * 60 * 60 * 1000,
1343+
email: "previous@example.com",
1344+
},
1345+
"openai-codex:fresh-email@example.com": {
1346+
type: "oauth",
1347+
provider: "openai-codex",
1348+
access: "fresh-access-token",
1349+
refresh: "fresh-refresh-token",
1350+
expires: now + 9 * 24 * 60 * 60 * 1000,
1351+
email: "fresh-email@example.com",
1352+
},
1353+
},
1354+
order: {
1355+
"openai-codex": ["openai-codex:fresh-email@example.com", "openai-codex:default"],
1356+
},
1357+
lastGood: {
1358+
"openai-codex": "openai-codex:default",
1359+
},
1360+
},
1361+
config,
1362+
);
1363+
1364+
const safeCodexControlRequest = vi
1365+
.fn()
1366+
.mockResolvedValueOnce({
1367+
ok: true,
1368+
value: { account: { type: "unknown" }, requiresOpenaiAuth: true },
1369+
})
1370+
.mockResolvedValueOnce({
1371+
ok: true,
1372+
value: codexRateLimitPayload({
1373+
primaryUsedPercent: 5,
1374+
secondaryUsedPercent: 10,
1375+
primaryResetSeconds: Math.ceil(now / 1000) + 60 * 60,
1376+
secondaryResetSeconds: Math.ceil(now / 1000) + 6 * 60 * 60,
1377+
}),
1378+
});
1379+
1380+
const result = await handleCodexCommand(createContext("account", undefined, { config }), {
1381+
deps: createDeps({ safeCodexControlRequest }),
1382+
});
1383+
1384+
expect(result.text).toContain(
1385+
"\n 1. fresh-email@example.com ChatGPT subscription — active now",
1386+
);
1387+
expect(result.text).toContain(
1388+
"\n 2. previous@example.com ChatGPT subscription — available if needed",
1389+
);
1390+
expect(result.text).not.toContain("previous@example.com ChatGPT subscription — active now");
1391+
expect(result.text).not.toContain("openai-codex:");
1392+
expect(safeCodexControlRequest).toHaveBeenCalledTimes(2);
1393+
});
1394+
1395+
it("respects openai-alias explicit order over stale lastGood for API key profiles", async () => {
1396+
const config = {};
1397+
const now = Date.now();
1398+
installAuthProfileStore(
1399+
{
1400+
version: 1,
1401+
profiles: {
1402+
"openai:fresh-key": {
1403+
type: "api_key",
1404+
provider: "openai",
1405+
key: "sk-fresh-111",
1406+
},
1407+
"openai:stale-key": {
1408+
type: "api_key",
1409+
provider: "openai",
1410+
key: "sk-stale-222",
1411+
},
1412+
},
1413+
order: {
1414+
openai: ["openai:fresh-key", "openai:stale-key"],
1415+
},
1416+
lastGood: {
1417+
openai: "openai:stale-key",
1418+
},
1419+
},
1420+
config,
1421+
);
1422+
1423+
const safeCodexControlRequest = vi
1424+
.fn()
1425+
.mockResolvedValueOnce({
1426+
ok: true,
1427+
value: { account: { type: "unknown" }, requiresOpenaiAuth: false },
1428+
})
1429+
.mockResolvedValueOnce({
1430+
ok: false,
1431+
error: "usage data unavailable",
1432+
});
1433+
1434+
const result = await handleCodexCommand(createContext("account", undefined, { config }), {
1435+
deps: createDeps({ safeCodexControlRequest }),
1436+
});
1437+
1438+
expect(result.text).toContain("\n 1. fresh-key API key — active now");
1439+
expect(result.text).not.toContain("stale-key API key — active now");
1440+
expect(safeCodexControlRequest).toHaveBeenCalledTimes(2);
1441+
});
1442+
1443+
it("does not mark any profile active when all explicit-order token credentials are expired", async () => {
1444+
// Both profiles use type:"token" with expired expiry, so resolveAuthProfileEligibility
1445+
// returns eligible=false for both. resolveActiveProfileId must return undefined rather
1446+
// than marking an ineligible profile as active; the display shows "no working credential".
1447+
const config = {};
1448+
const now = Date.now();
1449+
installAuthProfileStore(
1450+
{
1451+
version: 1,
1452+
profiles: {
1453+
"openai-codex:fresh@example.com": {
1454+
type: "token",
1455+
provider: "openai-codex",
1456+
token: "fresh-token",
1457+
expires: now - 1000,
1458+
email: "fresh@example.com",
1459+
},
1460+
"openai-codex:stale@example.com": {
1461+
type: "token",
1462+
provider: "openai-codex",
1463+
token: "stale-token",
1464+
expires: now - 2000,
1465+
email: "stale@example.com",
1466+
},
1467+
},
1468+
order: {
1469+
"openai-codex": ["openai-codex:fresh@example.com", "openai-codex:stale@example.com"],
1470+
},
1471+
lastGood: {
1472+
"openai-codex": "openai-codex:stale@example.com",
1473+
},
1474+
},
1475+
config,
1476+
);
1477+
1478+
const safeCodexControlRequest = vi
1479+
.fn()
1480+
// call 1: account info for the active/first profile
1481+
.mockResolvedValueOnce({
1482+
ok: true,
1483+
value: { account: { type: "unknown" }, requiresOpenaiAuth: true },
1484+
})
1485+
// call 2: rate limits for the active profile
1486+
.mockResolvedValueOnce({
1487+
ok: false,
1488+
error: "rate limits unavailable",
1489+
})
1490+
// call 3: readSubscriptionUsage — no activeProfileId means the subscription
1491+
// profile (fresh, type:"token") is fetched separately
1492+
.mockResolvedValueOnce({
1493+
ok: false,
1494+
error: "subscription limits unavailable",
1495+
});
1496+
1497+
const result = await handleCodexCommand(createContext("account", undefined, { config }), {
1498+
deps: createDeps({ safeCodexControlRequest }),
1499+
});
1500+
1501+
// With all credentials expired, no profile is active — the display shows
1502+
// "no working credential" and both profiles are labelled "sign-in expired".
1503+
// lastGood (stale) must not override the stated operator rank, and the
1504+
// first explicit-order entry must not be falsely marked active when ineligible.
1505+
expect(result.text).toContain("no working credential");
1506+
expect(result.text).toContain(
1507+
"\n 1. fresh@example.com ChatGPT subscription — sign-in expired",
1508+
);
1509+
expect(result.text).toContain(
1510+
"\n 2. stale@example.com ChatGPT subscription — sign-in expired",
1511+
);
1512+
expect(result.text).not.toContain("active now");
1513+
expect(safeCodexControlRequest).toHaveBeenCalledTimes(3);
1514+
});
1515+
13301516
it("escapes successful Codex account fallback summaries before chat display", async () => {
13311517
const unsafe = "<@U123> [trusted](https://evil) @here";
13321518
const safeCodexControlRequest = vi

0 commit comments

Comments
 (0)