diff --git a/src/main/rate-limits/codex-auth-presence.test.ts b/src/main/rate-limits/codex-auth-presence.test.ts new file mode 100644 index 000000000..f4e10fa86 --- /dev/null +++ b/src/main/rate-limits/codex-auth-presence.test.ts @@ -0,0 +1,65 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { existsSyncMock, homedirMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + homedirMock: vi.fn() +})) + +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock +})) + +vi.mock('node:os', () => ({ + homedir: homedirMock +})) + +import { codexAuthExists } from './codex-auth-presence' + +describe('codexAuthExists', () => { + const originalCodexHome = process.env.CODEX_HOME + + beforeEach(() => { + vi.clearAllMocks() + homedirMock.mockReturnValue('/home/alice') + delete process.env.CODEX_HOME + }) + + afterEach(() => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + }) + + it('checks an explicit managed-account home first', () => { + existsSyncMock.mockReturnValue(true) + + expect(codexAuthExists('/managed/home')).toBe(true) + expect(existsSyncMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json')) + }) + + it('falls back to CODEX_HOME when no home is provided', () => { + process.env.CODEX_HOME = '/custom/codex' + existsSyncMock.mockReturnValue(true) + + expect(codexAuthExists()).toBe(true) + expect(existsSyncMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json')) + }) + + it('falls back to ~/.codex when neither home nor CODEX_HOME is set', () => { + existsSyncMock.mockReturnValue(false) + + expect(codexAuthExists()).toBe(false) + expect(existsSyncMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json')) + }) + + it('returns false instead of throwing when the fs check fails', () => { + existsSyncMock.mockImplementation(() => { + throw new Error('EACCES') + }) + + expect(codexAuthExists('/managed/home')).toBe(false) + }) +}) diff --git a/src/main/rate-limits/codex-auth-presence.ts b/src/main/rate-limits/codex-auth-presence.ts new file mode 100644 index 000000000..f972fcecb --- /dev/null +++ b/src/main/rate-limits/codex-auth-presence.ts @@ -0,0 +1,20 @@ +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +// Why: the background quota poller spawns the real `codex` binary to read rate +// limits. For users who installed Codex but never signed in, that spawn can +// only fail — and worse, surfaces as an unexpected Codex process starting in +// the background. A signed-in Codex always writes an auth.json under its +// CODEX_HOME, so gating the fetch on that file keeps the poller silent until +// the user actually uses Codex. +export function codexAuthExists(codexHomePath?: string | null): boolean { + // 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') + try { + return existsSync(join(home, 'auth.json')) + } catch { + return false + } +} 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 08674c9d9..3342b5c60 100644 --- a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts +++ b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts @@ -19,6 +19,11 @@ vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +// Auth gate is covered separately; these tests assume a signed-in Codex. +vi.mock('./codex-auth-presence', () => ({ + codexAuthExists: vi.fn(() => true) +})) + import { fetchCodexRateLimits } from './codex-fetcher' function makeDisposable() { 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 5c7291411..969ba02ce 100644 --- a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts +++ b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts @@ -18,6 +18,11 @@ vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +// Auth gate is covered separately; these tests assume a signed-in Codex. +vi.mock('./codex-auth-presence', () => ({ + codexAuthExists: vi.fn(() => true) +})) + import { fetchCodexRateLimits } from './codex-fetcher' function makeDisposable() { diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index b3f3591c0..cd108c874 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -19,7 +19,14 @@ vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +// 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) +})) + import { fetchCodexRateLimits } from './codex-fetcher' +import { codexAuthExists } from './codex-auth-presence' function makeDisposable() { return { dispose: vi.fn() } @@ -44,6 +51,22 @@ describe('fetchCodexRateLimits', () => { vi.useFakeTimers() vi.clearAllMocks() resolveCodexCommandMock.mockReturnValue('codex') + vi.mocked(codexAuthExists).mockReturnValue(true) + }) + + it('does not spawn Codex when the user is not signed in', async () => { + vi.mocked(codexAuthExists).mockReturnValue(false) + + await expect(fetchCodexRateLimits()).resolves.toMatchObject({ + provider: 'codex', + session: null, + weekly: null, + status: 'unavailable', + error: 'Codex not signed in' + }) + + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() }) it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => { diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 5ac0e3c8c..3af5b28a3 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -3,6 +3,7 @@ paths together in one file makes it easier to audit the protocol/parsing differences and ensure account-scoped env handling stays identical. */ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { spawn } from 'node:child_process' +import { codexAuthExists } from './codex-auth-presence' import { resolveCodexCommand } from '../codex-cli/command' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' @@ -520,6 +521,20 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { + // 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. + if (!codexAuthExists(options?.codexHomePath)) { + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Codex not signed in', + status: 'unavailable' + } + } + // Path A: try RPC first try { const rpcResult = await fetchViaRpc(options)