diff --git a/src/main/rate-limits/codex-auth-presence.test.ts b/src/main/rate-limits/codex-auth-presence.test.ts index 95854350f7f..6f49f27bfb4 100644 --- a/src/main/rate-limits/codex-auth-presence.test.ts +++ b/src/main/rate-limits/codex-auth-presence.test.ts @@ -14,9 +14,13 @@ vi.mock('node:os', () => ({ homedir: homedirMock })) -import { codexAuthExists } from './codex-auth-presence' +import { probeCodexAuthPresence } from './codex-auth-presence' -describe('codexAuthExists', () => { +function fsError(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(code), { code }) +} + +describe('probeCodexAuthPresence', () => { const originalCodexHome = process.env.CODEX_HOME beforeEach(() => { @@ -36,7 +40,7 @@ describe('codexAuthExists', () => { it('checks an explicit managed-account home first', async () => { accessMock.mockResolvedValue(undefined) - await expect(codexAuthExists('/managed/home')).resolves.toBe(true) + await expect(probeCodexAuthPresence('/managed/home')).resolves.toBe('present') expect(accessMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json')) }) @@ -44,21 +48,39 @@ describe('codexAuthExists', () => { process.env.CODEX_HOME = '/custom/codex' accessMock.mockResolvedValue(undefined) - await expect(codexAuthExists()).resolves.toBe(true) + await expect(probeCodexAuthPresence()).resolves.toBe('present') expect(accessMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json')) }) it('falls back to ~/.codex when neither home nor CODEX_HOME is set', async () => { - accessMock.mockRejectedValue(new Error('ENOENT')) + accessMock.mockRejectedValue(fsError('ENOENT')) - await expect(codexAuthExists()).resolves.toBe(false) + await expect(probeCodexAuthPresence()).resolves.toBe('absent') expect(accessMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json')) }) - it('returns false instead of throwing when the fs check fails', async () => { - accessMock.mockRejectedValue(new Error('EACCES')) + it('keeps an inaccessible auth path distinct from confirmed absence', async () => { + accessMock.mockRejectedValue(fsError('EACCES')) - await expect(codexAuthExists('/managed/home')).resolves.toBe(false) + await expect(probeCodexAuthPresence('/managed/home')).resolves.toBe('unavailable') + }) + + it('confirms WSL auth absence only when the Codex home is reachable', async () => { + accessMock.mockRejectedValueOnce(fsError('ENOENT')).mockResolvedValueOnce(undefined) + + await expect( + probeCodexAuthPresence('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex') + ).resolves.toBe('absent') + expect(accessMock).toHaveBeenCalledTimes(2) + }) + + it('reports unavailable when WSL cannot resolve either the auth file or its home', async () => { + accessMock.mockRejectedValue(fsError('ENOENT')) + + await expect( + probeCodexAuthPresence('\\\\wsl.localhost\\Missing\\home\\alice\\.codex') + ).resolves.toBe('unavailable') + expect(accessMock).toHaveBeenCalledTimes(2) }) it('stops waiting for a stalled filesystem check when the caller aborts', async () => { @@ -70,12 +92,12 @@ describe('codexAuthExists', () => { ) const controller = new AbortController() - const result = codexAuthExists('/managed/home', { signal: controller.signal }) + const result = probeCodexAuthPresence('/managed/home', { signal: controller.signal }) await Promise.resolve() await Promise.resolve() controller.abort() - await expect(result).resolves.toBe(false) + await expect(result).resolves.toBe('unavailable') resolveAccess() await Promise.resolve() }) @@ -90,13 +112,13 @@ describe('codexAuthExists', () => { const timeoutController = new AbortController() const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutController.signal) - const result = codexAuthExists('/managed/home') + const result = probeCodexAuthPresence('/managed/home') expect(timeout).toHaveBeenCalledWith(5_000) await Promise.resolve() await Promise.resolve() timeoutController.abort() - await expect(result).resolves.toBe(false) + await expect(result).resolves.toBe('timeout') resolveAccess() await Promise.resolve() timeout.mockRestore() @@ -112,10 +134,10 @@ describe('codexAuthExists', () => { const firstController = new AbortController() const secondController = new AbortController() - const first = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { + const first = probeCodexAuthPresence('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { signal: firstController.signal }) - const second = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { + const second = probeCodexAuthPresence('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { signal: secondController.signal }) @@ -124,8 +146,8 @@ describe('codexAuthExists', () => { expect(accessMock).toHaveBeenCalledTimes(1) firstController.abort() secondController.abort() - await expect(first).resolves.toBe(false) - await expect(second).resolves.toBe(false) + await expect(first).resolves.toBe('unavailable') + await expect(second).resolves.toBe('unavailable') resolveAccess() await Promise.resolve() diff --git a/src/main/rate-limits/codex-auth-presence.ts b/src/main/rate-limits/codex-auth-presence.ts index 8ebe81bd9be..833f46a1b6f 100644 --- a/src/main/rate-limits/codex-auth-presence.ts +++ b/src/main/rate-limits/codex-auth-presence.ts @@ -1,20 +1,28 @@ import { access } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' import { createAuthFilesystemOperation, type SharedAuthFilesystemOperation } from './auth-filesystem-operation' const AUTH_PRESENCE_TIMEOUT_MS = 5_000 -const authPresenceProbeByPath = new Map>() +const authPresenceProbeByPath = new Map>() + +export type CodexAuthPresence = 'present' | 'absent' | 'timeout' | 'unavailable' type CodexAuthPresenceOptions = { signal?: AbortSignal timeoutMs?: number } -function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation { +function isMissingPathError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} + +function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation { const existing = authPresenceProbeByPath.get(authPath) if (existing) { return existing @@ -22,12 +30,14 @@ function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation - access(authPath).then( - () => true, - () => false - ) - ) + const probe = createAuthFilesystemOperation(authPath, async () => { + try { + await access(authPath) + return 'present' + } catch (error) { + return isMissingPathError(error) ? 'absent' : 'unavailable' + } + }) authPresenceProbeByPath.set(authPath, probe) const clearProbe = (): void => { if (authPresenceProbeByPath.get(authPath) === probe) { @@ -44,10 +54,10 @@ function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation { +): Promise { // Mirror Codex's own home resolution: an explicit managed-account home wins, // then CODEX_HOME, then the default ~/.codex. const home = codexHomePath ?? process.env.CODEX_HOME ?? join(homedir(), '.codex') @@ -58,8 +68,16 @@ export async function codexAuthExists( // Why: managed WSL homes are UNC paths. A synchronous stat can park // Electron main while Windows wakes or reconnects the distro; the race // also keeps a disconnected distro from serializing all later refreshes. - return await getAuthPresenceProbe(authPath).wait(signal) + const authPresence = await getAuthPresenceProbe(authPath).wait(signal) + if (authPresence !== 'absent' || !parseWslUncPath(home)) { + return authPresence + } + + // Why: ENOENT on a WSL UNC auth path can mean either a missing auth file or + // an unavailable distro. Only an accessible Codex home proves signed-out. + const homePresence = await getAuthPresenceProbe(home).wait(signal) + return homePresence === 'present' ? 'absent' : 'unavailable' } catch { - return false + return timeoutSignal.aborted && !options.signal?.aborted ? 'timeout' : 'unavailable' } } diff --git a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts index 3342b5c608e..43b3bc44e5d 100644 --- a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts +++ b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts @@ -21,7 +21,7 @@ vi.mock('node-pty', () => ({ // Auth gate is covered separately; these tests assume a signed-in Codex. vi.mock('./codex-auth-presence', () => ({ - codexAuthExists: vi.fn(() => true) + probeCodexAuthPresence: vi.fn(() => 'present') })) import { fetchCodexRateLimits } from './codex-fetcher' diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index 8a0e2ee7028..3028bee551e 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -10,7 +10,9 @@ const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) -vi.mock('./codex-auth-presence', () => ({ codexAuthExists: vi.fn(async () => true) })) +vi.mock('./codex-auth-presence', () => ({ + probeCodexAuthPresence: vi.fn(async () => 'present') +})) import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' diff --git a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts index 969ba02ce75..c5088a2ae77 100644 --- a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts +++ b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts @@ -20,7 +20,7 @@ vi.mock('node-pty', () => ({ // Auth gate is covered separately; these tests assume a signed-in Codex. vi.mock('./codex-auth-presence', () => ({ - codexAuthExists: vi.fn(() => true) + probeCodexAuthPresence: vi.fn(() => 'present') })) import { fetchCodexRateLimits } from './codex-fetcher' diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index a2031ac9941..571229188c6 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -28,11 +28,11 @@ vi.mock('node-pty', () => ({ // Default to signed-in so the spawn paths under test still run; the auth gate // itself is covered by codex-auth-presence.test.ts and the no-auth case below. vi.mock('./codex-auth-presence', () => ({ - codexAuthExists: vi.fn(() => true) + probeCodexAuthPresence: vi.fn(() => 'present') })) import { fetchCodexRateLimits } from './codex-fetcher' -import { codexAuthExists } from './codex-auth-presence' +import { probeCodexAuthPresence } from './codex-auth-presence' import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' function makeDisposable() { @@ -77,7 +77,7 @@ describe('fetchCodexRateLimits', () => { vi.useFakeTimers() vi.clearAllMocks() resolveCodexCommandMock.mockReturnValue('codex') - vi.mocked(codexAuthExists).mockResolvedValue(true) + vi.mocked(probeCodexAuthPresence).mockResolvedValue('present') readFileMock.mockRejectedValue(new Error('no auth fixture')) vi.stubGlobal('fetch', vi.fn()) }) @@ -87,7 +87,7 @@ describe('fetchCodexRateLimits', () => { }) it('does not spawn Codex when the user is not signed in', async () => { - vi.mocked(codexAuthExists).mockResolvedValue(false) + vi.mocked(probeCodexAuthPresence).mockResolvedValue('absent') await expect(fetchCodexRateLimits()).resolves.toMatchObject({ provider: 'codex', @@ -102,9 +102,9 @@ describe('fetchCodexRateLimits', () => { }) it('preserves the aborted result when cancellation lands during the auth check', async () => { - let resolveAuth!: (exists: boolean) => void - vi.mocked(codexAuthExists).mockReturnValueOnce( - new Promise((resolve) => { + let resolveAuth!: (presence: 'absent') => void + vi.mocked(probeCodexAuthPresence).mockReturnValueOnce( + new Promise<'absent'>((resolve) => { resolveAuth = resolve }) ) @@ -112,7 +112,7 @@ describe('fetchCodexRateLimits', () => { const result = fetchCodexRateLimits({ signal: controller.signal }) controller.abort() - resolveAuth(false) + resolveAuth('absent') await expect(result).resolves.toMatchObject({ provider: 'codex', @@ -123,6 +123,24 @@ describe('fetchCodexRateLimits', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) + it.each([ + ['timeout', 'Timed out while checking Codex sign-in status'], + ['unavailable', 'Codex sign-in status is unavailable'] + ] as const)( + 'does not report an indeterminate %s probe as signed out', + async (presence, error) => { + vi.mocked(probeCodexAuthPresence).mockResolvedValue(presence) + + await expect(fetchCodexRateLimits()).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error + }) + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + } + ) + it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 8a3ed94bdff..d606a12e850 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -10,7 +10,7 @@ import { spawn } from 'node:child_process' import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' -import { codexAuthExists } from './codex-auth-presence' +import { probeCodexAuthPresence } from './codex-auth-presence' import { resolveCodexCommand } from '../codex-cli/command' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' @@ -1026,13 +1026,13 @@ export async function fetchCodexRateLimits( // Why: never spawn the `codex` binary unless the user has signed in. Without // auth the RPC/PTY paths can only error, and spawning them shows up as an // unexpected background Codex process for users who don't use Codex. - const hasCodexAuth = await codexAuthExists(options?.codexHomePath, { + const authPresence = await probeCodexAuthPresence(options?.codexHomePath, { signal: options?.signal }) if (options?.signal?.aborted) { return abortedCodexRateLimitResult() } - if (!hasCodexAuth) { + if (authPresence === 'absent') { return { provider: 'codex', session: null, @@ -1042,6 +1042,19 @@ export async function fetchCodexRateLimits( status: 'unavailable' } } + if (authPresence !== 'present') { + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + authPresence === 'timeout' + ? 'Timed out while checking Codex sign-in status' + : 'Codex sign-in status is unavailable', + status: 'error' + } + } // Path A (WSL): use Codex's own backend usage contract. Host accounts retain // app-server's token-refresh/custom-CA behavior; WSL avoids starting a login