fix: don't spawn Codex in the background when signed out (#4593)

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.
This commit is contained in:
Benny Jiang 2026-06-03 17:10:51 -07:00 committed by GitHub
parent 1ebbab575c
commit 6ba4bf4fd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 133 additions and 0 deletions

View File

@ -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)
})
})

View File

@ -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
}
}

View File

@ -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() {

View File

@ -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() {

View File

@ -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 () => {

View File

@ -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<Provi
export async function fetchCodexRateLimits(
options?: FetchCodexRateLimitsOptions
): Promise<ProviderRateLimits> {
// 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)