diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index 2607efecf97..fe782ec3039 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -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 = { diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts index d685576c4e9..ef3217f6444 100644 --- a/src/agents/auth-health.ts +++ b/src/agents/auth-health.ts @@ -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, diff --git a/src/agents/auth-profiles/effective-oauth.ts b/src/agents/auth-profiles/effective-oauth.ts new file mode 100644 index 00000000000..8768982acd8 --- /dev/null +++ b/src/agents/auth-profiles/effective-oauth.ts @@ -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; +} diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 52226ce60cb..a7c7d9a0a0c 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -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, }); }