mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 00:59:51 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user