Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion packages/providers/src/codex/token-store.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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([]);
});
});
49 changes: 40 additions & 9 deletions packages/providers/src/codex/token-store.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -83,7 +93,24 @@ export class CodexTokenStore {
async write(auth: StoredCodexAuth): Promise<void> {
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;
}

Expand All @@ -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();
Expand All @@ -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();
}
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -177,6 +179,16 @@ export const ERROR_CODE_DESCRIPTIONS: Record<CodesignErrorCode, ErrorCodeDescrip
userFacingKey: 'err.CLAUDE_CODE_OAUTH_ONLY',
category: 'provider',
},
CODEX_TOKEN_PARSE_FAILED: {
userFacing: 'Local ChatGPT login is corrupted. Please re-login in Settings.',
userFacingKey: 'err.CODEX_TOKEN_PARSE_FAILED',
category: 'provider',
},
CODEX_TOKEN_NOT_LOGGED_IN: {
userFacing: 'ChatGPT subscription is not signed in. Please log in via Settings.',
userFacingKey: 'err.CODEX_TOKEN_NOT_LOGGED_IN',
category: 'provider',
},

// Generation / input
INPUT_EMPTY_PROMPT: {
Expand Down
Loading