diff --git a/packages/providers/src/codex/token-store.test.ts b/packages/providers/src/codex/token-store.test.ts index 66f6973d..4586b3a8 100644 --- a/packages/providers/src/codex/token-store.test.ts +++ b/packages/providers/src/codex/token-store.test.ts @@ -1,7 +1,8 @@ import { randomBytes } from 'node:crypto'; -import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { ERROR_CODES } from '@open-codesign/shared'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { TokenSet } from './oauth'; import { CodexTokenStore, type CodexTokenStoreOptions, type StoredCodexAuth } from './token-store'; @@ -204,6 +205,75 @@ describe('CodexTokenStore', () => { await expect(store.read()).rejects.toThrow(/Invalid Codex token store/); }); + it('read() raises CodesignError(CODEX_TOKEN_PARSE_FAILED) on truncated JSON', async () => { + const { store, filePath } = makeStore(); + await mkdir(dirname(filePath), { recursive: true }); + // Simulate a partial/truncated write — valid-looking prefix, cut short. + await writeFile(filePath, '{"schemaVersion":1,"accessToken":"ac', 'utf8'); + await expect(store.read()).rejects.toMatchObject({ + name: 'CodesignError', + code: ERROR_CODES.CODEX_TOKEN_PARSE_FAILED, + }); + }); + + it('read() raises CodesignError(CODEX_TOKEN_PARSE_FAILED) when schema is invalid', async () => { + const { store, filePath } = makeStore(); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify({ hello: 'world' }), 'utf8'); + await expect(store.read()).rejects.toMatchObject({ + name: 'CodesignError', + code: ERROR_CODES.CODEX_TOKEN_PARSE_FAILED, + }); + }); + + it('getValidAccessToken() raises CodesignError(CODEX_TOKEN_NOT_LOGGED_IN) when file missing', async () => { + const { store } = makeStore(); + await expect(store.getValidAccessToken()).rejects.toMatchObject({ + name: 'CodesignError', + code: ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN, + }); + }); + + it('write() is atomic — tmp cleaned up and original file untouched when rename fails', async () => { + // Put a directory at filePath so rename(tmpPath, filePath) fails (EISDIR). + const base = join(tmpdir(), `codex-token-test-${randomBytes(8).toString('hex')}`); + const filePath = join(base, 'creds'); + createdPaths.push(filePath); + createdPaths.push(base); + await mkdir(filePath, { recursive: true }); + // Drop a sentinel inside so the dir is non-empty on platforms where empty- + // dir rename would silently replace it. + await writeFile(join(filePath, 'sentinel'), 'marker', 'utf8'); + + const store = new CodexTokenStore({ filePath, refreshFn: vi.fn(), now: () => NOW }); + await expect(store.write(baseAuth())).rejects.toBeInstanceOf(Error); + + // Original directory + sentinel still present. + const sentinel = await readFile(join(filePath, 'sentinel'), 'utf8'); + expect(sentinel).toBe('marker'); + + // No leftover .tmp.* files in the parent dir. + const leftovers = (await readdir(base)).filter((n) => n.includes('.tmp.')); + expect(leftovers).toEqual([]); + + // Manual cleanup since createdPaths only does unlink (not rmdir). + await rm(base, { recursive: true, force: true }); + }); + + it('write() succeeds with mode 0o600 and leaves no tmp files behind', async () => { + const { store, filePath } = makeStore(); + const auth = baseAuth({ accessToken: 'atomic-ok' }); + await store.write(auth); + const body = JSON.parse(await readFile(filePath, 'utf8')) as StoredCodexAuth; + expect(body).toEqual(auth); + const s = await stat(filePath); + expect(s.mode & 0o777).toBe(0o600); + const leftovers = (await readdir(dirname(filePath))).filter((n) => + n.startsWith(`${filePath.split('/').pop()}.tmp.`), + ); + expect(leftovers).toEqual([]); + }); + it('clears stored auth and throws Chinese error when refresh hits invalid_grant', async () => { const refreshFn = vi .fn() @@ -253,4 +323,24 @@ describe('CodexTokenStore', () => { ); await expect(store.read()).rejects.toThrow(/Invalid Codex token store/); }); + + it('concurrent write() calls leave the file in a valid state (no tmp collision)', async () => { + const { store, filePath } = makeStore(); + const authA = baseAuth({ accessToken: 'concurrent-A' }); + const authB = baseAuth({ accessToken: 'concurrent-B' }); + + // Fire both writes without awaiting in between. Before the fix these + // would race on the same `${path}.tmp.${pid}` and one could unlink or + // overwrite the other's tmp, potentially leaving the target file + // missing or corrupted. + await Promise.all([store.write(authA), store.write(authB)]); + + const persisted = JSON.parse(await readFile(filePath, 'utf8')) as StoredCodexAuth; + expect(['concurrent-A', 'concurrent-B']).toContain(persisted.accessToken); + + const leftovers = (await readdir(dirname(filePath))).filter((n) => + n.startsWith(`${filePath.split('/').pop()}.tmp.`), + ); + expect(leftovers).toEqual([]); + }); }); diff --git a/packages/providers/src/codex/token-store.ts b/packages/providers/src/codex/token-store.ts index 1f0ff77e..96d6561a 100644 --- a/packages/providers/src/codex/token-store.ts +++ b/packages/providers/src/codex/token-store.ts @@ -1,5 +1,7 @@ -import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; +import { CodesignError, ERROR_CODES } from '@open-codesign/shared'; import { type TokenSet, decodeJwtClaims, refreshTokens as defaultRefreshTokens } from './oauth'; export interface StoredCodexAuth { @@ -20,6 +22,7 @@ export interface CodexTokenStoreOptions { } const EXPIRY_BUFFER_MS = 5 * 60 * 1000; +const NOT_LOGGED_IN_MSG = 'ChatGPT 订阅未登录或已登出,请重新登录。'; function extractEmail(jwt: string): string | null { const claims = decodeJwtClaims(jwt); @@ -70,11 +73,18 @@ export class CodexTokenStore { let parsed: unknown; try { parsed = JSON.parse(body); - } catch { - throw new Error(`Invalid Codex token store at ${this.filePath}`); + } catch (cause) { + throw new CodesignError( + `Invalid Codex token store at ${this.filePath}`, + ERROR_CODES.CODEX_TOKEN_PARSE_FAILED, + { cause }, + ); } if (!isStoredCodexAuth(parsed)) { - throw new Error(`Invalid Codex token store at ${this.filePath}`); + throw new CodesignError( + `Invalid Codex token store at ${this.filePath}`, + ERROR_CODES.CODEX_TOKEN_PARSE_FAILED, + ); } this.cache = parsed; return parsed; @@ -83,7 +93,24 @@ export class CodexTokenStore { async write(auth: StoredCodexAuth): Promise { await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 }); const body = JSON.stringify(auth, null, 2); - await writeFile(this.filePath, body, { encoding: 'utf8', mode: 0o600 }); + // Write to a pid + UUID scoped tmp then atomically rename. The UUID + // suffix prevents intra-process races when two write() calls overlap + // (same pid would otherwise collide on the tmp path and could unlink + // or rename each other's file). rename() itself is atomic on POSIX and + // Windows (Node >= 10). Guards against truncated writes on Win11 when + // the process is killed or antivirus interferes mid-write (issue #128). + const tmpPath = `${this.filePath}.tmp.${process.pid}.${randomUUID()}`; + try { + await writeFile(tmpPath, body, { encoding: 'utf8', mode: 0o600 }); + await rename(tmpPath, this.filePath); + } catch (err) { + try { + await unlink(tmpPath); + } catch { + // ignore — tmp may not exist if writeFile itself failed + } + throw err; + } this.cache = auth; } @@ -101,7 +128,7 @@ export class CodexTokenStore { await this.read(); } if (this.cache === null) { - throw new Error('ChatGPT 订阅未登录或已登出,请重新登录。'); + throw new CodesignError(NOT_LOGGED_IN_MSG, ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN); } if (this.now() >= this.cache.expiresAt - EXPIRY_BUFFER_MS) { return this.runRefresh(); @@ -114,7 +141,7 @@ export class CodexTokenStore { await this.read(); } if (this.cache === null) { - throw new Error('ChatGPT 订阅未登录或已登出,请重新登录。'); + throw new CodesignError(NOT_LOGGED_IN_MSG, ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN); } return this.runRefresh(); } @@ -133,7 +160,7 @@ export class CodexTokenStore { await this.read(); } if (this.cache === null) { - throw new Error('ChatGPT 订阅未登录或已登出,请重新登录。'); + throw new CodesignError(NOT_LOGGED_IN_MSG, ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN); } const current = this.cache; let next: TokenSet; @@ -148,7 +175,11 @@ export class CodexTokenStore { /\b401\b/.test(msg); if (isBadCredential) { await this.clear(); - throw new Error('ChatGPT 订阅已失效,请重新登录', { cause: err }); + throw new CodesignError( + 'ChatGPT 订阅已失效,请重新登录', + ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN, + { cause: err }, + ); } throw err; } diff --git a/packages/shared/src/error-codes.ts b/packages/shared/src/error-codes.ts index 65d2ada2..761c6b0a 100644 --- a/packages/shared/src/error-codes.ts +++ b/packages/shared/src/error-codes.ts @@ -29,6 +29,8 @@ export const ERROR_CODES = { PROVIDER_ABORTED: 'PROVIDER_ABORTED', PROVIDER_RETRY_EXHAUSTED: 'PROVIDER_RETRY_EXHAUSTED', CLAUDE_CODE_OAUTH_ONLY: 'CLAUDE_CODE_OAUTH_ONLY', + CODEX_TOKEN_PARSE_FAILED: 'CODEX_TOKEN_PARSE_FAILED', + CODEX_TOKEN_NOT_LOGGED_IN: 'CODEX_TOKEN_NOT_LOGGED_IN', // Generation / input INPUT_EMPTY_PROMPT: 'INPUT_EMPTY_PROMPT', @@ -177,6 +179,16 @@ export const ERROR_CODE_DESCRIPTIONS: Record