From 8e9e2d2f4e6900401d71ed741218c7bb5f83aacd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Mar 2026 10:01:46 -0700 Subject: [PATCH] refactor(auth): unify external CLI credential sync --- .../auth-profiles.external-cli-sync.test.ts | 275 ++++++++++++------ src/agents/auth-profiles/constants.ts | 1 + src/agents/auth-profiles/external-cli-sync.ts | 107 +++---- src/agents/cli-credentials.test.ts | 64 ++++ src/agents/cli-credentials.ts | 170 +++++------ 5 files changed, 401 insertions(+), 216 deletions(-) diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 34f15d50a62..5883352a8d0 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -1,21 +1,70 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "./auth-profiles/types.js"; +import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js"; const mocks = vi.hoisted(() => ({ - readCodexCliCredentialsCached: vi.fn(), - readQwenCliCredentialsCached: vi.fn(() => null), - readMiniMaxCliCredentialsCached: vi.fn(() => null), + readCodexCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), + readQwenCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), + readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), })); let syncExternalCliCredentials: typeof import("./auth-profiles/external-cli-sync.js").syncExternalCliCredentials; +let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential; let CODEX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CODEX_CLI_PROFILE_ID; +let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID; +let QWEN_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").QWEN_CLI_PROFILE_ID; +let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID; -const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default"; +function makeOAuthCredential( + overrides: Partial & Pick, +) { + return { + type: "oauth" as const, + provider: overrides.provider, + access: overrides.access ?? `${overrides.provider}-access`, + refresh: overrides.refresh ?? `${overrides.provider}-refresh`, + expires: overrides.expires ?? Date.now() + 60_000, + accountId: overrides.accountId, + email: overrides.email, + enterpriseUrl: overrides.enterpriseUrl, + projectId: overrides.projectId, + }; +} + +function makeStore(profileId?: string, credential?: OAuthCredential): AuthProfileStore { + return { + version: 1, + profiles: profileId && credential ? { [profileId]: credential } : {}, + }; +} + +function getProviderCases() { + return [ + { + label: "Codex", + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + provider: "openai-codex" as const, + readMock: mocks.readCodexCliCredentialsCached, + legacyProfileId: CODEX_CLI_PROFILE_ID, + }, + { + label: "Qwen", + profileId: QWEN_CLI_PROFILE_ID, + provider: "qwen-portal" as const, + readMock: mocks.readQwenCliCredentialsCached, + }, + { + label: "MiniMax", + profileId: MINIMAX_CLI_PROFILE_ID, + provider: "minimax-portal" as const, + readMock: mocks.readMiniMaxCliCredentialsCached, + }, + ]; +} describe("syncExternalCliCredentials", () => { beforeEach(async () => { vi.resetModules(); - mocks.readCodexCliCredentialsCached.mockReset(); + mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); mocks.readQwenCliCredentialsCached.mockReset().mockReturnValue(null); mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); vi.doMock("./cli-credentials.js", () => ({ @@ -23,68 +72,118 @@ describe("syncExternalCliCredentials", () => { readQwenCliCredentialsCached: mocks.readQwenCliCredentialsCached, readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached, })); - ({ syncExternalCliCredentials } = await import("./auth-profiles/external-cli-sync.js")); - ({ CODEX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js")); + ({ syncExternalCliCredentials, shouldReplaceStoredOAuthCredential } = + await import("./auth-profiles/external-cli-sync.js")); + ({ + CODEX_CLI_PROFILE_ID, + OPENAI_CODEX_DEFAULT_PROFILE_ID, + QWEN_CLI_PROFILE_ID, + MINIMAX_CLI_PROFILE_ID, + } = await import("./auth-profiles/constants.js")); }); - it("syncs Codex CLI credentials into the supported default auth profile", () => { - const expires = Date.now() + 60_000; - mocks.readCodexCliCredentialsCached.mockReturnValue({ - type: "oauth", - provider: "openai-codex", - access: "access-token", - refresh: "refresh-token", - expires, - accountId: "acct_123", + describe("shouldReplaceStoredOAuthCredential", () => { + it("keeps equivalent stored credentials", () => { + const stored = makeOAuthCredential({ provider: "openai-codex", access: "a", refresh: "r" }); + const incoming = makeOAuthCredential({ provider: "openai-codex", access: "a", refresh: "r" }); + + expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(false); }); - const store: AuthProfileStore = { - version: 1, - profiles: {}, - }; + it("keeps the newer stored credential", () => { + const incoming = makeOAuthCredential({ + provider: "openai-codex", + expires: Date.now() + 60_000, + }); + const stored = makeOAuthCredential({ + provider: "openai-codex", + access: "fresh-access", + refresh: "fresh-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }); - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(true); - expect(mocks.readCodexCliCredentialsCached).toHaveBeenCalledWith( - expect.objectContaining({ ttlMs: expect.any(Number) }), - ); - expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ - type: "oauth", - provider: "openai-codex", - access: "access-token", - refresh: "refresh-token", - expires, - accountId: "acct_123", + expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(false); + }); + + it("replaces when incoming credentials are fresher", () => { + const stored = makeOAuthCredential({ + provider: "openai-codex", + expires: Date.now() + 60_000, + }); + const incoming = makeOAuthCredential({ + provider: "openai-codex", + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }); + + expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(true); + expect(shouldReplaceStoredOAuthCredential(undefined, incoming)).toBe(true); }); - expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined(); }); + it.each([{ providerLabel: "Codex" }, { providerLabel: "Qwen" }, { providerLabel: "MiniMax" }])( + "syncs $providerLabel CLI credentials into the target auth profile", + ({ providerLabel }) => { + const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); + expect(providerCase).toBeDefined(); + const current = providerCase!; + const expires = Date.now() + 60_000; + current.readMock.mockReturnValue( + makeOAuthCredential({ + provider: current.provider, + access: `${current.provider}-access-token`, + refresh: `${current.provider}-refresh-token`, + expires, + accountId: "acct_123", + }), + ); + + const store = makeStore(); + + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(true); + expect(current.readMock).toHaveBeenCalledWith( + expect.objectContaining({ ttlMs: expect.any(Number) }), + ); + expect(store.profiles[current.profileId]).toMatchObject({ + type: "oauth", + provider: current.provider, + access: `${current.provider}-access-token`, + refresh: `${current.provider}-refresh-token`, + expires, + accountId: "acct_123", + }); + if (current.legacyProfileId) { + expect(store.profiles[current.legacyProfileId]).toBeUndefined(); + } + }, + ); + it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => { const staleExpiry = Date.now() + 30 * 60_000; const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; - mocks.readCodexCliCredentialsCached.mockReturnValue({ - type: "oauth", - provider: "openai-codex", - access: "new-access-token", - refresh: "new-refresh-token", - expires: freshExpiry, - accountId: "acct_456", - }); + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai-codex", + access: "new-access-token", + refresh: "new-refresh-token", + expires: freshExpiry, + accountId: "acct_456", + }), + ); - const store: AuthProfileStore = { - version: 1, - profiles: { - [OPENAI_CODEX_DEFAULT_PROFILE_ID]: { - type: "oauth", - provider: "openai-codex", - access: "old-access-token", - refresh: "old-refresh-token", - expires: staleExpiry, - accountId: "acct_456", - }, - }, - }; + const store = makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "old-access-token", + refresh: "old-refresh-token", + expires: staleExpiry, + accountId: "acct_456", + }), + ); const mutated = syncExternalCliCredentials(store); @@ -96,39 +195,43 @@ describe("syncExternalCliCredentials", () => { }); }); - it("does not overwrite newer stored Codex credentials with older external CLI credentials", () => { - const staleExpiry = Date.now() + 30 * 60_000; - const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; - mocks.readCodexCliCredentialsCached.mockReturnValue({ - type: "oauth", - provider: "openai-codex", - access: "stale-access-token", - refresh: "stale-refresh-token", - expires: staleExpiry, - accountId: "acct_789", - }); + it.each([{ providerLabel: "Codex" }, { providerLabel: "Qwen" }, { providerLabel: "MiniMax" }])( + "does not overwrite newer stored $providerLabel credentials", + ({ providerLabel }) => { + const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); + expect(providerCase).toBeDefined(); + const current = providerCase!; + const staleExpiry = Date.now() + 30 * 60_000; + const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; + current.readMock.mockReturnValue( + makeOAuthCredential({ + provider: current.provider, + access: `stale-${current.provider}-access-token`, + refresh: `stale-${current.provider}-refresh-token`, + expires: staleExpiry, + accountId: "acct_789", + }), + ); - const store: AuthProfileStore = { - version: 1, - profiles: { - [OPENAI_CODEX_DEFAULT_PROFILE_ID]: { - type: "oauth", - provider: "openai-codex", - access: "fresh-access-token", - refresh: "fresh-refresh-token", + const store = makeStore( + current.profileId, + makeOAuthCredential({ + provider: current.provider, + access: `fresh-${current.provider}-access-token`, + refresh: `fresh-${current.provider}-refresh-token`, expires: freshExpiry, accountId: "acct_789", - }, - }, - }; + }), + ); - const mutated = syncExternalCliCredentials(store); + const mutated = syncExternalCliCredentials(store); - expect(mutated).toBe(false); - expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ - access: "fresh-access-token", - refresh: "fresh-refresh-token", - expires: freshExpiry, - }); - }); + expect(mutated).toBe(false); + expect(store.profiles[current.profileId]).toMatchObject({ + access: `fresh-${current.provider}-access-token`, + refresh: `fresh-${current.provider}-refresh-token`, + expires: freshExpiry, + }); + }, + ); }); diff --git a/src/agents/auth-profiles/constants.ts b/src/agents/auth-profiles/constants.ts index 83946ac7ae4..ace2c98dc81 100644 --- a/src/agents/auth-profiles/constants.ts +++ b/src/agents/auth-profiles/constants.ts @@ -6,6 +6,7 @@ export const LEGACY_AUTH_FILENAME = "auth.json"; export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli"; export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli"; +export const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default"; export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli"; export const MINIMAX_CLI_PROFILE_ID = "minimax-portal:minimax-cli"; diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 717df9f31d9..3551c33b71f 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -5,19 +5,27 @@ import { } from "../cli-credentials.js"; import { EXTERNAL_CLI_SYNC_TTL_MS, + OPENAI_CODEX_DEFAULT_PROFILE_ID, QWEN_CLI_PROFILE_ID, MINIMAX_CLI_PROFILE_ID, log, } from "./constants.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; -const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default"; - type ExternalCliSyncOptions = { log?: boolean; }; -function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean { +type ExternalCliSyncProvider = { + profileId: string; + provider: string; + readCredentials: () => OAuthCredential | null; +}; + +function areOAuthCredentialsEquivalent( + a: OAuthCredential | undefined, + b: OAuthCredential, +): boolean { if (!a) { return false; } @@ -48,14 +56,44 @@ function hasNewerStoredOAuthCredential( ); } +export function shouldReplaceStoredOAuthCredential( + existing: OAuthCredential | undefined, + incoming: OAuthCredential, +): boolean { + if (!existing || existing.type !== "oauth") { + return true; + } + if (areOAuthCredentialsEquivalent(existing, incoming)) { + return false; + } + return !hasNewerStoredOAuthCredential(existing, incoming); +} + +const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [ + { + profileId: QWEN_CLI_PROFILE_ID, + provider: "qwen-portal", + readCredentials: () => readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), + }, + { + profileId: MINIMAX_CLI_PROFILE_ID, + provider: "minimax-portal", + readCredentials: () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), + }, + { + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + provider: "openai-codex", + readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), + }, +]; + /** Sync external CLI credentials into the store for a given provider. */ function syncExternalCliCredentialsForProvider( store: AuthProfileStore, - profileId: string, - provider: string, - readCredentials: () => OAuthCredential | null, + providerConfig: ExternalCliSyncProvider, options: ExternalCliSyncOptions, ): boolean { + const { profileId, provider, readCredentials } = providerConfig; const existing = store.profiles[profileId]; const creds = readCredentials(); if (!creds) { @@ -63,18 +101,17 @@ function syncExternalCliCredentialsForProvider( } const existingOAuth = existing?.type === "oauth" ? existing : undefined; - if (shallowEqualOAuthCredentials(existingOAuth, creds)) { - return false; - } - if (hasNewerStoredOAuthCredential(existingOAuth, creds)) { + if (!shouldReplaceStoredOAuthCredential(existingOAuth, creds)) { if (options.log !== false) { - log.debug(`kept newer stored ${provider} credentials over external cli sync`, { - profileId, - storedExpires: new Date(existingOAuth!.expires).toISOString(), - externalExpires: Number.isFinite(creds.expires) - ? new Date(creds.expires).toISOString() - : null, - }); + if (!areOAuthCredentialsEquivalent(existingOAuth, creds) && existingOAuth) { + log.debug(`kept newer stored ${provider} credentials over external cli sync`, { + profileId, + storedExpires: new Date(existingOAuth.expires).toISOString(), + externalExpires: Number.isFinite(creds.expires) + ? new Date(creds.expires).toISOString() + : null, + }); + } } return false; } @@ -101,38 +138,10 @@ export function syncExternalCliCredentials( ): boolean { let mutated = false; - if ( - syncExternalCliCredentialsForProvider( - store, - QWEN_CLI_PROFILE_ID, - "qwen-portal", - () => readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), - options, - ) - ) { - mutated = true; - } - if ( - syncExternalCliCredentialsForProvider( - store, - MINIMAX_CLI_PROFILE_ID, - "minimax-portal", - () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), - options, - ) - ) { - mutated = true; - } - if ( - syncExternalCliCredentialsForProvider( - store, - OPENAI_CODEX_DEFAULT_PROFILE_ID, - "openai-codex", - () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), - options, - ) - ) { - mutated = true; + for (const provider of EXTERNAL_CLI_SYNC_PROVIDERS) { + if (syncExternalCliCredentialsForProvider(store, provider, options)) { + mutated = true; + } } return mutated; diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index 51f94f4d953..3e4b396f058 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -8,6 +8,7 @@ const execFileSyncMock = vi.fn(); const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000; let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached; let readCodexCliCredentialsCached: typeof import("./cli-credentials.js").readCodexCliCredentialsCached; +let readQwenCliCredentialsCached: typeof import("./cli-credentials.js").readQwenCliCredentialsCached; let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").resetCliCredentialCachesForTest; let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials; let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials; @@ -53,11 +54,28 @@ function createJwtWithExp(expSeconds: number): string { return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`; } +function writePortalCliCredentialFile( + filePath: string, + options: { access: string; refresh: string; expires: number }, +) { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + filePath, + JSON.stringify({ + access_token: options.access, + refresh_token: options.refresh, + expiry_date: options.expires, + }), + "utf8", + ); +} + describe("cli credentials", () => { beforeAll(async () => { ({ readClaudeCliCredentialsCached, readCodexCliCredentialsCached, + readQwenCliCredentialsCached, resetCliCredentialCachesForTest, writeClaudeCliKeychainCredentials, writeClaudeCliCredentials, @@ -354,4 +372,50 @@ describe("cli credentials", () => { fs.rmSync(tempHome, { recursive: true, force: true }); } }); + + it("invalidates cached Qwen credentials when oauth_creds.json changes within the TTL window", () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qwen-cache-")); + const credPath = path.join(tempHome, ".qwen", "oauth_creds.json"); + try { + writePortalCliCredentialFile(credPath, { + access: "stale-access", + refresh: "stale-refresh", + expires: 1_000, + }); + fs.utimesSync(credPath, new Date("2026-03-24T10:00:00Z"), new Date("2026-03-24T10:00:00Z")); + vi.setSystemTime(new Date("2026-03-24T10:00:00Z")); + + const first = readQwenCliCredentialsCached({ + ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS, + homeDir: tempHome, + }); + + expect(first).toMatchObject({ + access: "stale-access", + refresh: "stale-refresh", + expires: 1_000, + }); + + writePortalCliCredentialFile(credPath, { + access: "fresh-access", + refresh: "fresh-refresh", + expires: 2_000, + }); + fs.utimesSync(credPath, new Date("2026-03-24T10:05:00Z"), new Date("2026-03-24T10:05:00Z")); + vi.advanceTimersByTime(60_000); + + const second = readQwenCliCredentialsCached({ + ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS, + homeDir: tempHome, + }); + + expect(second).toMatchObject({ + access: "fresh-access", + refresh: "fresh-refresh", + expires: 2_000, + }); + } finally { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 2f667ed3b2e..44bf708085d 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -21,7 +21,7 @@ type CachedValue = { value: T | null; readAt: number; cacheKey: string; - sourceMtimeMs?: number | null; + sourceFingerprint?: number | string | null; }; let claudeCliCache: CachedValue | null = null; @@ -157,6 +157,45 @@ function readFileMtimeMs(filePath: string): number | null { } } +function readCachedCliCredential(options: { + ttlMs: number; + cache: CachedValue | null; + cacheKey: string; + read: () => T | null; + setCache: (next: CachedValue | null) => void; + readSourceFingerprint?: () => number | string | null; +}): T | null { + const { ttlMs, cache, cacheKey, read, setCache, readSourceFingerprint } = options; + if (ttlMs <= 0) { + return read(); + } + + const now = Date.now(); + const sourceFingerprint = readSourceFingerprint?.(); + if ( + cache && + cache.cacheKey === cacheKey && + cache.sourceFingerprint === sourceFingerprint && + now - cache.readAt < ttlMs + ) { + return cache.value; + } + + const value = read(); + const cachedSourceFingerprint = readSourceFingerprint?.(); + if (!readSourceFingerprint || cachedSourceFingerprint === sourceFingerprint) { + setCache({ + value, + readAt: now, + cacheKey, + sourceFingerprint: cachedSourceFingerprint, + }); + } else { + setCache(null); + } + return value; +} + function computeCodexKeychainAccount(codexHome: string) { const hash = createHash("sha256").update(codexHome).digest("hex"); return `cli|${hash.slice(0, 16)}`; @@ -334,27 +373,21 @@ export function readClaudeCliCredentialsCached(options?: { homeDir?: string; execSync?: ExecSyncFn; }): ClaudeCliCredential | null { - const ttlMs = options?.ttlMs ?? 0; - const now = Date.now(); - const cacheKey = resolveClaudeCliCredentialsPath(options?.homeDir); - if ( - ttlMs > 0 && - claudeCliCache && - claudeCliCache.cacheKey === cacheKey && - now - claudeCliCache.readAt < ttlMs - ) { - return claudeCliCache.value; - } - const value = readClaudeCliCredentials({ - allowKeychainPrompt: options?.allowKeychainPrompt, - platform: options?.platform, - homeDir: options?.homeDir, - execSync: options?.execSync, + return readCachedCliCredential({ + ttlMs: options?.ttlMs ?? 0, + cache: claudeCliCache, + cacheKey: resolveClaudeCliCredentialsPath(options?.homeDir), + read: () => + readClaudeCliCredentials({ + allowKeychainPrompt: options?.allowKeychainPrompt, + platform: options?.platform, + homeDir: options?.homeDir, + execSync: options?.execSync, + }), + setCache: (next) => { + claudeCliCache = next; + }, }); - if (ttlMs > 0) { - claudeCliCache = { value, readAt: now, cacheKey }; - } - return value; } export function writeClaudeCliKeychainCredentials( @@ -533,78 +566,53 @@ export function readCodexCliCredentialsCached(options?: { platform?: NodeJS.Platform; execSync?: ExecSyncFn; }): CodexCliCredential | null { - const ttlMs = options?.ttlMs ?? 0; - const now = Date.now(); const authPath = resolveCodexCliAuthPath(); - const cacheKey = `${options?.platform ?? process.platform}|${authPath}`; - const sourceMtimeMs = readFileMtimeMs(authPath); - if ( - ttlMs > 0 && - codexCliCache && - codexCliCache.cacheKey === cacheKey && - codexCliCache.sourceMtimeMs === sourceMtimeMs && - now - codexCliCache.readAt < ttlMs - ) { - return codexCliCache.value; - } - const value = readCodexCliCredentials({ - platform: options?.platform, - execSync: options?.execSync, + return readCachedCliCredential({ + ttlMs: options?.ttlMs ?? 0, + cache: codexCliCache, + cacheKey: `${options?.platform ?? process.platform}|${authPath}`, + read: () => + readCodexCliCredentials({ + platform: options?.platform, + execSync: options?.execSync, + }), + setCache: (next) => { + codexCliCache = next; + }, + readSourceFingerprint: () => readFileMtimeMs(authPath), }); - const cachedSourceMtimeMs = readFileMtimeMs(authPath); - if (ttlMs > 0 && cachedSourceMtimeMs === sourceMtimeMs) { - codexCliCache = { - value, - readAt: now, - cacheKey, - sourceMtimeMs: cachedSourceMtimeMs, - }; - } else if (ttlMs > 0) { - codexCliCache = null; - } - return value; } export function readQwenCliCredentialsCached(options?: { ttlMs?: number; homeDir?: string; }): QwenCliCredential | null { - const ttlMs = options?.ttlMs ?? 0; - const now = Date.now(); - const cacheKey = resolveQwenCliCredentialsPath(options?.homeDir); - if ( - ttlMs > 0 && - qwenCliCache && - qwenCliCache.cacheKey === cacheKey && - now - qwenCliCache.readAt < ttlMs - ) { - return qwenCliCache.value; - } - const value = readQwenCliCredentials({ homeDir: options?.homeDir }); - if (ttlMs > 0) { - qwenCliCache = { value, readAt: now, cacheKey }; - } - return value; + const credPath = resolveQwenCliCredentialsPath(options?.homeDir); + return readCachedCliCredential({ + ttlMs: options?.ttlMs ?? 0, + cache: qwenCliCache, + cacheKey: credPath, + read: () => readQwenCliCredentials({ homeDir: options?.homeDir }), + setCache: (next) => { + qwenCliCache = next; + }, + readSourceFingerprint: () => readFileMtimeMs(credPath), + }); } export function readMiniMaxCliCredentialsCached(options?: { ttlMs?: number; homeDir?: string; }): MiniMaxCliCredential | null { - const ttlMs = options?.ttlMs ?? 0; - const now = Date.now(); - const cacheKey = resolveMiniMaxCliCredentialsPath(options?.homeDir); - if ( - ttlMs > 0 && - minimaxCliCache && - minimaxCliCache.cacheKey === cacheKey && - now - minimaxCliCache.readAt < ttlMs - ) { - return minimaxCliCache.value; - } - const value = readMiniMaxCliCredentials({ homeDir: options?.homeDir }); - if (ttlMs > 0) { - minimaxCliCache = { value, readAt: now, cacheKey }; - } - return value; + const credPath = resolveMiniMaxCliCredentialsPath(options?.homeDir); + return readCachedCliCredential({ + ttlMs: options?.ttlMs ?? 0, + cache: minimaxCliCache, + cacheKey: credPath, + read: () => readMiniMaxCliCredentials({ homeDir: options?.homeDir }), + setCache: (next) => { + minimaxCliCache = next; + }, + readSourceFingerprint: () => readFileMtimeMs(credPath), + }); }