fix(status): align oauth health with runtime

This commit is contained in:
Vincent Koc
2026-04-17 10:35:46 -07:00
parent 5c2f4afcce
commit eed71160ae
4 changed files with 99 additions and 21 deletions

View File

@@ -1,4 +1,15 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({
readCodexCliCredentialsCachedMock: vi.fn(() => null),
}));
vi.mock("./cli-credentials.js", () => ({
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
}));
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
@@ -16,6 +27,11 @@ describe("buildAuthHealthSummary", () => {
vi.restoreAllMocks();
});
beforeEach(() => {
readCodexCliCredentialsCachedMock.mockReset();
readCodexCliCredentialsCachedMock.mockReturnValue(null);
});
it("classifies OAuth and API key profiles", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
const store = {
@@ -58,13 +74,12 @@ describe("buildAuthHealthSummary", () => {
const statuses = profileStatuses(summary);
expect(statuses["anthropic:ok"]).toBe("ok");
// OAuth credentials with refresh tokens are auto-renewable, so they report "ok"
expect(statuses["anthropic:expiring"]).toBe("ok");
expect(statuses["anthropic:expired"]).toBe("ok");
expect(statuses["anthropic:expiring"]).toBe("expiring");
expect(statuses["anthropic:expired"]).toBe("expired");
expect(statuses["anthropic:api"]).toBe("static");
const provider = summary.providers.find((entry) => entry.provider === "anthropic");
expect(provider?.status).toBe("ok");
expect(provider?.status).toBe("expired");
});
it("reports expired for OAuth without a refresh token", () => {
@@ -92,6 +107,38 @@ describe("buildAuthHealthSummary", () => {
expect(statuses["google:no-refresh"]).toBe("expired");
});
it("prefers fresher imported external OAuth credentials when reporting health", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
readCodexCliCredentialsCachedMock.mockReturnValue({
type: "oauth",
provider: "openai-codex",
access: "fresh-cli-access",
refresh: "fresh-cli-refresh",
expires: now + DEFAULT_OAUTH_WARN_MS + 60_000,
accountId: "acct-cli",
});
const store = {
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth" as const,
provider: "openai-codex",
access: "expired-access",
refresh: "expired-refresh",
expires: now - 10_000,
},
},
};
const summary = buildAuthHealthSummary({
store,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
const statuses = profileStatuses(summary);
expect(statuses["openai-codex:default"]).toBe("ok");
});
it("marks token profiles with invalid expires as missing with reason code", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
const store = {

View File

@@ -5,6 +5,7 @@ import {
resolveTokenExpiryState,
} from "./auth-profiles/credential-state.js";
import { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
import { resolveEffectiveOAuthCredential } from "./auth-profiles/effective-oauth.js";
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
import { normalizeProviderId } from "./provider-id.js";
@@ -161,22 +162,21 @@ function buildProfileHealth(params: {
};
}
const hasRefreshToken = typeof credential.refresh === "string" && credential.refresh.length > 0;
const effectiveCredential = resolveEffectiveOAuthCredential({
profileId,
credential,
});
const { status: rawStatus, remainingMs } = resolveOAuthStatus(
credential.expires,
effectiveCredential.expires,
now,
warnAfterMs,
);
// OAuth credentials with a valid refresh token auto-renew on first API call,
// so don't warn about access token expiration.
const status =
hasRefreshToken && (rawStatus === "expired" || rawStatus === "expiring") ? "ok" : rawStatus;
return {
profileId,
provider,
type: "oauth",
status,
expiresAt: credential.expires,
status: rawStatus,
expiresAt: effectiveCredential.expires,
remainingMs,
source,
label,

View File

@@ -0,0 +1,21 @@
import {
readManagedExternalCliCredential,
shouldReplaceStoredOAuthCredential,
} from "./external-cli-sync.js";
import type { OAuthCredential } from "./types.js";
export function resolveEffectiveOAuthCredential(params: {
profileId: string;
credential: OAuthCredential;
}): OAuthCredential {
const imported = readManagedExternalCliCredential({
profileId: params.profileId,
credential: params.credential,
});
if (!imported) {
return params.credential;
}
return shouldReplaceStoredOAuthCredential(params.credential, imported)
? imported
: params.credential;
}

View File

@@ -24,6 +24,7 @@ import {
} from "./constants.js";
import { resolveTokenExpiryState } from "./credential-state.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { resolveEffectiveOAuthCredential } from "./effective-oauth.js";
import {
areOAuthCredentialsEquivalent,
readManagedExternalCliCredential,
@@ -753,11 +754,16 @@ async function tryResolveOAuthProfile(
return null;
}
if (Date.now() < cred.expires) {
const effectiveCred = resolveEffectiveOAuthCredential({
profileId,
credential: cred,
});
if (Date.now() < effectiveCred.expires) {
return await buildOAuthProfileResult({
provider: cred.provider,
credentials: cred,
email: cred.email,
provider: effectiveCred.provider,
credentials: effectiveCred,
email: effectiveCred.email ?? cred.email,
});
}
@@ -904,12 +910,16 @@ export async function resolveApiKeyForProfile(
agentDir: params.agentDir,
cred,
}) ?? cred;
const effectiveOAuthCred = resolveEffectiveOAuthCredential({
profileId,
credential: oauthCred,
});
if (Date.now() < oauthCred.expires) {
if (Date.now() < effectiveOAuthCred.expires) {
return await buildOAuthProfileResult({
provider: oauthCred.provider,
credentials: oauthCred,
email: oauthCred.email,
provider: effectiveOAuthCred.provider,
credentials: effectiveOAuthCred,
email: effectiveOAuthCred.email,
});
}