From 6ba4bf4fd459c1dd80c8e37b34bd4bcfff93716c Mon Sep 17 00:00:00 2001 From: Benny Jiang <32006222+Bennyoooo@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:10:51 -0700 Subject: [PATCH] fix: don't spawn Codex in the background when signed out (#4593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rate-limit quota poller fetches Codex usage on app start, window focus, and every poll. Unlike the Claude and Gemini fetchers (which read credentials from files/keychain and hit an HTTP endpoint), the Codex fetcher always spawns the real codex binary — 'codex app-server' over RPC, and on RPC failure an interactive codex PTY running /status. For users who have Codex installed but never signed in, this surfaces as an unexpected Codex process starting in the background even though they never launched Codex. The spawn can only fail without auth. Gate the fetch on the presence of Codex auth (auth.json under the resolved CODEX_HOME / managed-account home). When absent, return status 'unavailable' without spawning anything, mirroring how the other providers stay quiet until configured. Signed-in users are unaffected. --- .../rate-limits/codex-auth-presence.test.ts | 65 +++++++++++++++++++ src/main/rate-limits/codex-auth-presence.ts | 20 ++++++ .../codex-fetcher-auth-errors.test.ts | 5 ++ .../codex-fetcher-pty-settle.test.ts | 5 ++ src/main/rate-limits/codex-fetcher.test.ts | 23 +++++++ src/main/rate-limits/codex-fetcher.ts | 15 +++++ 6 files changed, 133 insertions(+) create mode 100644 src/main/rate-limits/codex-auth-presence.test.ts create mode 100644 src/main/rate-limits/codex-auth-presence.ts 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)