Skip to content

Commit 0ecef6a

Browse files
authored
fix(codex): wrap token-store JSON parse errors and use atomic write (#128) (#162)
## Summary Win11 用户偶发 \`Error invoking remote method 'codesign:v1:generate': CodesignError: Unexpected end of JSON input\`。根因:\`codex-auth.json\` 被进程被 kill / antivirus 干扰 truncate 后,\`token-store.read()\` 抛裸 \`Error\`,IPC 透传时只剩 \`SyntaxError.message\`,用户完全不知所措。 ## What changed - **\`packages/providers/src/codex/token-store.ts\`**: - \`read()\` 的 \`JSON.parse\` 失败 → \`CodesignError(CODEX_TOKEN_PARSE_FAILED)\` with \`SyntaxError\` as cause - schema invalid → 同样归到 \`CODEX_TOKEN_PARSE_FAILED\` - "未登录"路径 → \`CodesignError(CODEX_TOKEN_NOT_LOGGED_IN)\` - \`write()\` 改原子写:写到 \`\${path}.tmp.\${pid}\` 再 \`rename\`,失败时清理 tmp(参考 \`reported-fingerprints.ts:75\` 的模式) - **\`packages/shared/src/error-codes.ts\`** + i18n (en/zh-CN):新增 \`CODEX_TOKEN_PARSE_FAILED\` + \`CODEX_TOKEN_NOT_LOGGED_IN\` 两个 code,文案分别提示"重新登录" ## Test plan - [x] 5 new tests in \`token-store.test.ts\`:truncated JSON / invalid schema / not-logged-in / atomic write failure / tmp cleanup - [x] \`pnpm test\` providers (136) all green - [x] \`pnpm typecheck\` + \`pnpm lint\` clean Closes #128 --------- Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 5d22e60 commit 0ecef6a

3 files changed

Lines changed: 143 additions & 10 deletions

File tree

packages/providers/src/codex/token-store.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { randomBytes } from 'node:crypto';
2-
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
2+
import { mkdir, readFile, readdir, rm, stat, unlink, writeFile } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import { dirname, join } from 'node:path';
5+
import { ERROR_CODES } from '@open-codesign/shared';
56
import { afterEach, describe, expect, it, vi } from 'vitest';
67
import type { TokenSet } from './oauth';
78
import { CodexTokenStore, type CodexTokenStoreOptions, type StoredCodexAuth } from './token-store';
@@ -204,6 +205,75 @@ describe('CodexTokenStore', () => {
204205
await expect(store.read()).rejects.toThrow(/Invalid Codex token store/);
205206
});
206207

208+
it('read() raises CodesignError(CODEX_TOKEN_PARSE_FAILED) on truncated JSON', async () => {
209+
const { store, filePath } = makeStore();
210+
await mkdir(dirname(filePath), { recursive: true });
211+
// Simulate a partial/truncated write — valid-looking prefix, cut short.
212+
await writeFile(filePath, '{"schemaVersion":1,"accessToken":"ac', 'utf8');
213+
await expect(store.read()).rejects.toMatchObject({
214+
name: 'CodesignError',
215+
code: ERROR_CODES.CODEX_TOKEN_PARSE_FAILED,
216+
});
217+
});
218+
219+
it('read() raises CodesignError(CODEX_TOKEN_PARSE_FAILED) when schema is invalid', async () => {
220+
const { store, filePath } = makeStore();
221+
await mkdir(dirname(filePath), { recursive: true });
222+
await writeFile(filePath, JSON.stringify({ hello: 'world' }), 'utf8');
223+
await expect(store.read()).rejects.toMatchObject({
224+
name: 'CodesignError',
225+
code: ERROR_CODES.CODEX_TOKEN_PARSE_FAILED,
226+
});
227+
});
228+
229+
it('getValidAccessToken() raises CodesignError(CODEX_TOKEN_NOT_LOGGED_IN) when file missing', async () => {
230+
const { store } = makeStore();
231+
await expect(store.getValidAccessToken()).rejects.toMatchObject({
232+
name: 'CodesignError',
233+
code: ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN,
234+
});
235+
});
236+
237+
it('write() is atomic — tmp cleaned up and original file untouched when rename fails', async () => {
238+
// Put a directory at filePath so rename(tmpPath, filePath) fails (EISDIR).
239+
const base = join(tmpdir(), `codex-token-test-${randomBytes(8).toString('hex')}`);
240+
const filePath = join(base, 'creds');
241+
createdPaths.push(filePath);
242+
createdPaths.push(base);
243+
await mkdir(filePath, { recursive: true });
244+
// Drop a sentinel inside so the dir is non-empty on platforms where empty-
245+
// dir rename would silently replace it.
246+
await writeFile(join(filePath, 'sentinel'), 'marker', 'utf8');
247+
248+
const store = new CodexTokenStore({ filePath, refreshFn: vi.fn(), now: () => NOW });
249+
await expect(store.write(baseAuth())).rejects.toBeInstanceOf(Error);
250+
251+
// Original directory + sentinel still present.
252+
const sentinel = await readFile(join(filePath, 'sentinel'), 'utf8');
253+
expect(sentinel).toBe('marker');
254+
255+
// No leftover .tmp.* files in the parent dir.
256+
const leftovers = (await readdir(base)).filter((n) => n.includes('.tmp.'));
257+
expect(leftovers).toEqual([]);
258+
259+
// Manual cleanup since createdPaths only does unlink (not rmdir).
260+
await rm(base, { recursive: true, force: true });
261+
});
262+
263+
it('write() succeeds with mode 0o600 and leaves no tmp files behind', async () => {
264+
const { store, filePath } = makeStore();
265+
const auth = baseAuth({ accessToken: 'atomic-ok' });
266+
await store.write(auth);
267+
const body = JSON.parse(await readFile(filePath, 'utf8')) as StoredCodexAuth;
268+
expect(body).toEqual(auth);
269+
const s = await stat(filePath);
270+
expect(s.mode & 0o777).toBe(0o600);
271+
const leftovers = (await readdir(dirname(filePath))).filter((n) =>
272+
n.startsWith(`${filePath.split('/').pop()}.tmp.`),
273+
);
274+
expect(leftovers).toEqual([]);
275+
});
276+
207277
it('clears stored auth and throws Chinese error when refresh hits invalid_grant', async () => {
208278
const refreshFn = vi
209279
.fn()
@@ -253,4 +323,24 @@ describe('CodexTokenStore', () => {
253323
);
254324
await expect(store.read()).rejects.toThrow(/Invalid Codex token store/);
255325
});
326+
327+
it('concurrent write() calls leave the file in a valid state (no tmp collision)', async () => {
328+
const { store, filePath } = makeStore();
329+
const authA = baseAuth({ accessToken: 'concurrent-A' });
330+
const authB = baseAuth({ accessToken: 'concurrent-B' });
331+
332+
// Fire both writes without awaiting in between. Before the fix these
333+
// would race on the same `${path}.tmp.${pid}` and one could unlink or
334+
// overwrite the other's tmp, potentially leaving the target file
335+
// missing or corrupted.
336+
await Promise.all([store.write(authA), store.write(authB)]);
337+
338+
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as StoredCodexAuth;
339+
expect(['concurrent-A', 'concurrent-B']).toContain(persisted.accessToken);
340+
341+
const leftovers = (await readdir(dirname(filePath))).filter((n) =>
342+
n.startsWith(`${filePath.split('/').pop()}.tmp.`),
343+
);
344+
expect(leftovers).toEqual([]);
345+
});
256346
});

packages/providers/src/codex/token-store.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
1+
import { randomUUID } from 'node:crypto';
2+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
23
import { dirname } from 'node:path';
4+
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
35
import { type TokenSet, decodeJwtClaims, refreshTokens as defaultRefreshTokens } from './oauth';
46

57
export interface StoredCodexAuth {
@@ -20,6 +22,7 @@ export interface CodexTokenStoreOptions {
2022
}
2123

2224
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
25+
const NOT_LOGGED_IN_MSG = 'ChatGPT 订阅未登录或已登出,请重新登录。';
2326

2427
function extractEmail(jwt: string): string | null {
2528
const claims = decodeJwtClaims(jwt);
@@ -70,11 +73,18 @@ export class CodexTokenStore {
7073
let parsed: unknown;
7174
try {
7275
parsed = JSON.parse(body);
73-
} catch {
74-
throw new Error(`Invalid Codex token store at ${this.filePath}`);
76+
} catch (cause) {
77+
throw new CodesignError(
78+
`Invalid Codex token store at ${this.filePath}`,
79+
ERROR_CODES.CODEX_TOKEN_PARSE_FAILED,
80+
{ cause },
81+
);
7582
}
7683
if (!isStoredCodexAuth(parsed)) {
77-
throw new Error(`Invalid Codex token store at ${this.filePath}`);
84+
throw new CodesignError(
85+
`Invalid Codex token store at ${this.filePath}`,
86+
ERROR_CODES.CODEX_TOKEN_PARSE_FAILED,
87+
);
7888
}
7989
this.cache = parsed;
8090
return parsed;
@@ -83,7 +93,24 @@ export class CodexTokenStore {
8393
async write(auth: StoredCodexAuth): Promise<void> {
8494
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
8595
const body = JSON.stringify(auth, null, 2);
86-
await writeFile(this.filePath, body, { encoding: 'utf8', mode: 0o600 });
96+
// Write to a pid + UUID scoped tmp then atomically rename. The UUID
97+
// suffix prevents intra-process races when two write() calls overlap
98+
// (same pid would otherwise collide on the tmp path and could unlink
99+
// or rename each other's file). rename() itself is atomic on POSIX and
100+
// Windows (Node >= 10). Guards against truncated writes on Win11 when
101+
// the process is killed or antivirus interferes mid-write (issue #128).
102+
const tmpPath = `${this.filePath}.tmp.${process.pid}.${randomUUID()}`;
103+
try {
104+
await writeFile(tmpPath, body, { encoding: 'utf8', mode: 0o600 });
105+
await rename(tmpPath, this.filePath);
106+
} catch (err) {
107+
try {
108+
await unlink(tmpPath);
109+
} catch {
110+
// ignore — tmp may not exist if writeFile itself failed
111+
}
112+
throw err;
113+
}
87114
this.cache = auth;
88115
}
89116

@@ -101,7 +128,7 @@ export class CodexTokenStore {
101128
await this.read();
102129
}
103130
if (this.cache === null) {
104-
throw new Error('ChatGPT 订阅未登录或已登出,请重新登录。');
131+
throw new CodesignError(NOT_LOGGED_IN_MSG, ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN);
105132
}
106133
if (this.now() >= this.cache.expiresAt - EXPIRY_BUFFER_MS) {
107134
return this.runRefresh();
@@ -114,7 +141,7 @@ export class CodexTokenStore {
114141
await this.read();
115142
}
116143
if (this.cache === null) {
117-
throw new Error('ChatGPT 订阅未登录或已登出,请重新登录。');
144+
throw new CodesignError(NOT_LOGGED_IN_MSG, ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN);
118145
}
119146
return this.runRefresh();
120147
}
@@ -133,7 +160,7 @@ export class CodexTokenStore {
133160
await this.read();
134161
}
135162
if (this.cache === null) {
136-
throw new Error('ChatGPT 订阅未登录或已登出,请重新登录。');
163+
throw new CodesignError(NOT_LOGGED_IN_MSG, ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN);
137164
}
138165
const current = this.cache;
139166
let next: TokenSet;
@@ -148,7 +175,11 @@ export class CodexTokenStore {
148175
/\b401\b/.test(msg);
149176
if (isBadCredential) {
150177
await this.clear();
151-
throw new Error('ChatGPT 订阅已失效,请重新登录', { cause: err });
178+
throw new CodesignError(
179+
'ChatGPT 订阅已失效,请重新登录',
180+
ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN,
181+
{ cause: err },
182+
);
152183
}
153184
throw err;
154185
}

packages/shared/src/error-codes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const ERROR_CODES = {
2929
PROVIDER_ABORTED: 'PROVIDER_ABORTED',
3030
PROVIDER_RETRY_EXHAUSTED: 'PROVIDER_RETRY_EXHAUSTED',
3131
CLAUDE_CODE_OAUTH_ONLY: 'CLAUDE_CODE_OAUTH_ONLY',
32+
CODEX_TOKEN_PARSE_FAILED: 'CODEX_TOKEN_PARSE_FAILED',
33+
CODEX_TOKEN_NOT_LOGGED_IN: 'CODEX_TOKEN_NOT_LOGGED_IN',
3234

3335
// Generation / input
3436
INPUT_EMPTY_PROMPT: 'INPUT_EMPTY_PROMPT',
@@ -177,6 +179,16 @@ export const ERROR_CODE_DESCRIPTIONS: Record<CodesignErrorCode, ErrorCodeDescrip
177179
userFacingKey: 'err.CLAUDE_CODE_OAUTH_ONLY',
178180
category: 'provider',
179181
},
182+
CODEX_TOKEN_PARSE_FAILED: {
183+
userFacing: 'Local ChatGPT login is corrupted. Please re-login in Settings.',
184+
userFacingKey: 'err.CODEX_TOKEN_PARSE_FAILED',
185+
category: 'provider',
186+
},
187+
CODEX_TOKEN_NOT_LOGGED_IN: {
188+
userFacing: 'ChatGPT subscription is not signed in. Please log in via Settings.',
189+
userFacingKey: 'err.CODEX_TOKEN_NOT_LOGGED_IN',
190+
category: 'provider',
191+
},
180192

181193
// Generation / input
182194
INPUT_EMPTY_PROMPT: {

0 commit comments

Comments
 (0)