mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Fix WSL Codex auth timeout state
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<string, SharedAuthFilesystemOperation<boolean>>()
|
||||
const authPresenceProbeByPath = new Map<string, SharedAuthFilesystemOperation<CodexAuthPresence>>()
|
||||
|
||||
export type CodexAuthPresence = 'present' | 'absent' | 'timeout' | 'unavailable'
|
||||
|
||||
type CodexAuthPresenceOptions = {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation<boolean> {
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation<CodexAuthPresence> {
|
||||
const existing = authPresenceProbeByPath.get(authPath)
|
||||
if (existing) {
|
||||
return existing
|
||||
@@ -22,12 +30,14 @@ function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation<b
|
||||
// Why: aborting a Node fs promise does not necessarily cancel an already
|
||||
// issued UNC operation. Share the raw probe until it really settles so a
|
||||
// disconnected WSL home cannot accumulate native requests across polls.
|
||||
const probe = createAuthFilesystemOperation(authPath, () =>
|
||||
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<b
|
||||
// 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 async function codexAuthExists(
|
||||
export async function probeCodexAuthPresence(
|
||||
codexHomePath?: string | null,
|
||||
options: CodexAuthPresenceOptions = {}
|
||||
): Promise<boolean> {
|
||||
): Promise<CodexAuthPresence> {
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<boolean>((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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user