mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-31 19:40:54 +00:00
The rename retry landed, but its test kept the watcher reading in a tight loop, and on real Windows that lost 8 runs in 12: a reader holding the file open every instant is a race no bounded retry can win, because an open handle blocks a rename until it closes. That is not a reader either app has. The watcher now polls at 1ms, which still samples the file thousands of times and still catches a lost update, while leaving the writers room to rename. The locked path passes 15 of 15 on Windows, and the unlocked path still reports a rolled-back write. Also restores the lineage case the review asked for: a refresh where the account id and refresh token are unchanged and only the id_token subject moves, so the subject comparison is what discards the write. Both copies.
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
// @vitest-environment node
|
||
// What happens to `~/.codex/auth.json` when the desktop app refreshes a Codex login.
|
||
//
|
||
// The grant retires the refresh token on disk the moment the endpoint accepts it, so
|
||
// everything between parsing the response and having the new one on disk is a window in
|
||
// which losing the process signs the user out of Codex. These run against a real credential
|
||
// file, because the write is the thing being pinned.
|
||
import { readFileSync } from 'node:fs'
|
||
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||
import { tmpdir } from 'node:os'
|
||
import path from 'node:path'
|
||
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
import { fetchCodexQuota } from './codex'
|
||
|
||
const NOW = Date.parse('2026-09-02T12:00:00.000Z')
|
||
const OLD_REFRESH = 'refresh-token-that-the-grant-retires'
|
||
const NEW_REFRESH = 'refresh-token-the-grant-returns'
|
||
|
||
let root: string
|
||
let authPath: string
|
||
|
||
// `last_refresh` is older than the eight-day staleness window, so every fetch here takes the
|
||
// proactive refresh path.
|
||
function authDoc(): Record<string, unknown> {
|
||
return {
|
||
auth_mode: 'chatgpt',
|
||
tokens: {
|
||
access_token: 'access-old',
|
||
refresh_token: OLD_REFRESH,
|
||
id_token: 'id-old',
|
||
account_id: 'account-1',
|
||
},
|
||
last_refresh: new Date(NOW - 30 * 86_400_000).toISOString(),
|
||
}
|
||
}
|
||
|
||
async function writeAuth(doc: Record<string, unknown>, mode = 0o600): Promise<void> {
|
||
// A real login file is private to its user, and readSecureFile rejects group or world bits.
|
||
await writeFile(authPath, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode })
|
||
}
|
||
|
||
async function readAuth(): Promise<Record<string, any>> {
|
||
return JSON.parse(await readFile(authPath, 'utf8')) as Record<string, any>
|
||
}
|
||
|
||
function json(body: unknown, status = 200): Response {
|
||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||
}
|
||
|
||
const rotatedGrant = () => json({ access_token: 'access-new', refresh_token: NEW_REFRESH, id_token: 'id-new' })
|
||
const usagePayload = () => json({
|
||
plan_type: 'pro',
|
||
rate_limit: { primary_window: { used_percent: 10, limit_window_seconds: 18_000 } },
|
||
})
|
||
|
||
type Handler = () => Response | Promise<Response>
|
||
|
||
function routes(handlers: { token: Handler; usage: Handler }): typeof fetch {
|
||
return (async (input: unknown) => {
|
||
const url = typeof input === 'string' ? input : String((input as { url?: string }).url ?? input)
|
||
return url.includes('auth.openai.com') ? handlers.token() : handlers.usage()
|
||
}) as unknown as typeof fetch
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
root = await mkdtemp(path.join(tmpdir(), 'codeburn-app-codex-refresh-'))
|
||
authPath = path.join(root, 'auth.json')
|
||
})
|
||
|
||
afterEach(async () => {
|
||
vi.restoreAllMocks()
|
||
await rm(root, { recursive: true, force: true })
|
||
})
|
||
|
||
describe('Codex quota credential rotation', () => {
|
||
it('writes the rotated refresh token to auth.json and leaves no temp file behind', async () => {
|
||
await writeAuth(authDoc())
|
||
|
||
const result = await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
|
||
})
|
||
|
||
expect(result.quota.connection).toBe('connected')
|
||
const saved = await readAuth()
|
||
expect(saved.tokens.refresh_token).toBe(NEW_REFRESH)
|
||
expect(saved.tokens.access_token).toBe('access-new')
|
||
expect(saved.tokens.id_token).toBe('id-new')
|
||
expect(saved.tokens.account_id).toBe('account-1')
|
||
expect(saved.auth_mode).toBe('chatgpt')
|
||
expect(saved.last_refresh).toBe(new Date(NOW).toISOString())
|
||
expect(await readdir(root)).toEqual(['auth.json'])
|
||
})
|
||
|
||
it('keeps the rotated refresh token when the quota request fails after the refresh', async () => {
|
||
await writeAuth(authDoc())
|
||
|
||
const result = await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
fetch: routes({ token: rotatedGrant, usage: () => json({ error: 'boom' }, 500) }),
|
||
})
|
||
|
||
expect(result.quota.connection).toBe('transientFailure')
|
||
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
|
||
})
|
||
|
||
it('has the rotated refresh token on disk before it makes another request', async () => {
|
||
await writeAuth(authDoc())
|
||
|
||
// The renderer's poll is racing a per-provider timeout, and quit reaps the main process
|
||
// outright, so anything still awaited after the grant can simply be killed. Reading the
|
||
// file from inside the next request proves the write already completed.
|
||
let onDiskAtUsage: string | null = null
|
||
const result = await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
fetch: routes({
|
||
token: rotatedGrant,
|
||
usage: () => {
|
||
onDiskAtUsage = readFileSync(authPath, 'utf8')
|
||
throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||
},
|
||
}),
|
||
})
|
||
|
||
expect(result.quota.connection).toBe('transientFailure')
|
||
expect(onDiskAtUsage).not.toBeNull()
|
||
expect(JSON.parse(onDiskAtUsage!).tokens.refresh_token).toBe(NEW_REFRESH)
|
||
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
|
||
})
|
||
|
||
it('persists a rotated refresh token even when the grant returns no access token', async () => {
|
||
await writeAuth(authDoc())
|
||
|
||
await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
fetch: routes({ token: () => json({ refresh_token: NEW_REFRESH }), usage: usagePayload }),
|
||
})
|
||
|
||
const saved = await readAuth()
|
||
expect(saved.tokens.refresh_token).toBe(NEW_REFRESH)
|
||
expect(saved.tokens.access_token).toBe('access-old')
|
||
})
|
||
|
||
it('falls back to the credential it holds when the merge re-read fails', async () => {
|
||
await writeAuth(authDoc())
|
||
|
||
await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
readFileSync: () => { throw new Error('EBUSY: resource busy or locked') },
|
||
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
|
||
})
|
||
|
||
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
|
||
})
|
||
|
||
it('reports a credential write it could not complete instead of failing silently', async () => {
|
||
await writeAuth(authDoc())
|
||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
|
||
await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
writeFileSync: () => { throw new Error('EROFS: read-only file system') },
|
||
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
|
||
})
|
||
|
||
expect(errors).toHaveBeenCalledTimes(1)
|
||
const message = String(errors.mock.calls[0]?.[0])
|
||
expect(message).toContain(authPath)
|
||
expect(message).toContain('codex login')
|
||
expect(message).not.toContain(NEW_REFRESH)
|
||
})
|
||
|
||
it('discards the rotation rather than resurrecting a login that was removed', async () => {
|
||
await writeAuth(authDoc())
|
||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
|
||
await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
// The Codex CLI signed the user out between our read and the grant.
|
||
readFileSync: () => null,
|
||
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
|
||
})
|
||
|
||
expect((await readAuth()).tokens.refresh_token).toBe(OLD_REFRESH)
|
||
expect(errors).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('sends the grant on its own timeout rather than the caller’s abort signal', async () => {
|
||
await writeAuth(authDoc())
|
||
const caller = new AbortController()
|
||
const signals: Array<AbortSignal | null | undefined> = []
|
||
|
||
await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
signal: caller.signal,
|
||
fetch: (async (input: unknown, init?: RequestInit) => {
|
||
const url = typeof input === 'string' ? input : String(input)
|
||
signals.push(init?.signal)
|
||
if (url.includes('auth.openai.com')) {
|
||
// A per-provider timeout firing here would cancel a rotation nothing can undo.
|
||
caller.abort()
|
||
return rotatedGrant()
|
||
}
|
||
return usagePayload()
|
||
}) as unknown as typeof fetch,
|
||
})
|
||
|
||
// The grant request never carried the caller's signal, so aborting it did not reach it.
|
||
expect(signals[0]?.aborted).toBe(false)
|
||
// The usage request that follows does carry it, which is what the abort is for.
|
||
expect(signals[1]?.aborted).toBe(true)
|
||
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
|
||
})
|
||
|
||
it('discards a rotation when the login on disk changes while the grant is in flight', async () => {
|
||
await writeAuth(authDoc())
|
||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
let grantStarted = (): void => {}
|
||
const reachedGrant = new Promise<void>(resolve => { grantStarted = resolve })
|
||
let releaseGrant = (): void => {}
|
||
const heldGrant = new Promise<void>(resolve => { releaseGrant = resolve })
|
||
|
||
const pending = fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
fetch: routes({
|
||
token: async () => { grantStarted(); await heldGrant; return rotatedGrant() },
|
||
usage: usagePayload,
|
||
}),
|
||
})
|
||
|
||
// The user ran `codex login` and switched accounts while our grant was open.
|
||
await reachedGrant
|
||
await writeAuth({
|
||
auth_mode: 'chatgpt',
|
||
tokens: {
|
||
access_token: 'access-other',
|
||
refresh_token: 'refresh-other-account',
|
||
id_token: 'id-other',
|
||
account_id: 'account-2',
|
||
},
|
||
last_refresh: new Date(NOW).toISOString(),
|
||
})
|
||
releaseGrant()
|
||
await pending
|
||
|
||
// The new account's document is left exactly as the CLI wrote it: our grant
|
||
// spent account-1's refresh token, so writing it back here would splice
|
||
// account-1's token onto account-2's identity.
|
||
const saved = await readAuth()
|
||
expect(saved.tokens.account_id).toBe('account-2')
|
||
expect(saved.tokens.refresh_token).toBe('refresh-other-account')
|
||
expect(saved.tokens.refresh_token).not.toBe(NEW_REFRESH)
|
||
expect(errors).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
// The id_token subject is the last of the three lineage fields and the only one the earlier
|
||
// account-switch case does not reach, since that one differs on account_id first. Here the
|
||
// account_id and refresh_token are identical on both documents and only the JWT subject
|
||
// moves, so a discard proves the subject check itself fires.
|
||
it('discards the rotation when only the id_token subject has changed', async () => {
|
||
const jwtWithSubject = (sub: string): string =>
|
||
`h.${Buffer.from(JSON.stringify({ sub })).toString('base64url')}.s`
|
||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
await writeAuth({
|
||
auth_mode: 'chatgpt',
|
||
tokens: { access_token: 'access-old', refresh_token: OLD_REFRESH, id_token: jwtWithSubject('user-a'), account_id: 'account-1' },
|
||
last_refresh: new Date(NOW - 30 * 86_400_000).toISOString(),
|
||
})
|
||
let grantStarted = (): void => {}
|
||
const reachedGrant = new Promise<void>(resolve => { grantStarted = resolve })
|
||
let releaseGrant = (): void => {}
|
||
const heldGrant = new Promise<void>(resolve => { releaseGrant = resolve })
|
||
|
||
const pending = fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
fetch: routes({
|
||
token: async () => { grantStarted(); await heldGrant; return rotatedGrant() },
|
||
usage: usagePayload,
|
||
}),
|
||
})
|
||
|
||
// Same account_id and refresh_token, a different person behind the same slot.
|
||
await reachedGrant
|
||
await writeAuth({
|
||
auth_mode: 'chatgpt',
|
||
tokens: { access_token: 'access-b', refresh_token: OLD_REFRESH, id_token: jwtWithSubject('user-b'), account_id: 'account-1' },
|
||
last_refresh: new Date(NOW).toISOString(),
|
||
})
|
||
releaseGrant()
|
||
await pending
|
||
|
||
const saved = await readAuth()
|
||
expect(saved.tokens.id_token).toBe(jwtWithSubject('user-b'))
|
||
expect(saved.tokens.refresh_token).not.toBe(NEW_REFRESH)
|
||
expect(errors).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('still writes the rotation when the login on disk is unchanged', async () => {
|
||
await writeAuth(authDoc())
|
||
let grantStarted = (): void => {}
|
||
const reachedGrant = new Promise<void>(resolve => { grantStarted = resolve })
|
||
let releaseGrant = (): void => {}
|
||
const heldGrant = new Promise<void>(resolve => { releaseGrant = resolve })
|
||
|
||
const pending = fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
fetch: routes({
|
||
token: async () => { grantStarted(); await heldGrant; return rotatedGrant() },
|
||
usage: usagePayload,
|
||
}),
|
||
})
|
||
|
||
// Same account, same refresh token: an unrelated rewrite is not a lineage change.
|
||
await reachedGrant
|
||
await writeAuth({ ...authDoc(), extra_field: 'harmless' })
|
||
releaseGrant()
|
||
await pending
|
||
|
||
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
|
||
})
|
||
|
||
it('keeps the rotated refresh token when the reread file merely omits lineage keys', async () => {
|
||
await writeAuth(authDoc())
|
||
|
||
await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
// A cleanly parsed ChatGPT document that simply lacks account_id and
|
||
// refresh_token is not proof of a different login.
|
||
readFileSync: () => JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'access-old' } }),
|
||
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
|
||
})
|
||
|
||
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
|
||
})
|
||
|
||
// POSIX mode bits do not exist on Windows, where the ACL carries the privacy.
|
||
it.skipIf(process.platform === 'win32')('rewrites the credential with the permissions it already had', async () => {
|
||
for (const mode of [0o600, 0o400]) {
|
||
await rm(authPath, { force: true })
|
||
await writeAuth(authDoc(), mode)
|
||
const before = (await stat(authPath)).mode & 0o777
|
||
|
||
await fetchCodexQuota({
|
||
authPath,
|
||
now: () => NOW,
|
||
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
|
||
})
|
||
|
||
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
|
||
expect((await stat(authPath)).mode & 0o777).toBe(before)
|
||
expect(before & 0o077).toBe(0)
|
||
}
|
||
})
|
||
})
|