@bitkyc08/opencodex 2.5.6 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.ko.md +17 -6
  2. package/README.md +19 -7
  3. package/README.zh-CN.md +12 -3
  4. package/assets/architecture.png +0 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +88 -2
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-BS4X1QDi.js +9 -0
  10. package/gui/dist/assets/{index-CKqUwc02.css → index-BwvDb198.css} +1 -1
  11. package/gui/dist/index.html +2 -2
  12. package/package.json +20 -6
  13. package/src/adapters/anthropic.ts +16 -5
  14. package/src/adapters/google.ts +9 -2
  15. package/src/adapters/openai-chat.ts +13 -5
  16. package/src/bun-runtime.ts +22 -1
  17. package/src/cli-help.ts +111 -0
  18. package/src/cli-status.ts +164 -0
  19. package/src/cli.ts +77 -186
  20. package/src/codex-account-store.ts +47 -8
  21. package/src/codex-auth-api.ts +111 -54
  22. package/src/codex-auth-collision.ts +5 -0
  23. package/src/codex-catalog.ts +24 -12
  24. package/src/codex-history-provider.ts +29 -13
  25. package/src/codex-inject.ts +46 -29
  26. package/src/codex-journal.ts +77 -13
  27. package/src/codex-quota.ts +11 -3
  28. package/src/codex-routing.ts +14 -4
  29. package/src/codex-shim.ts +71 -24
  30. package/src/codex-websocket-registry.ts +20 -4
  31. package/src/config.ts +138 -4
  32. package/src/init.ts +7 -2
  33. package/src/oauth/callback-server.ts +22 -15
  34. package/src/oauth/index.ts +18 -4
  35. package/src/oauth/login-cli.ts +8 -1
  36. package/src/oauth/store.ts +2 -1
  37. package/src/process-control.ts +36 -0
  38. package/src/provider-label.ts +8 -0
  39. package/src/responses/parser.ts +18 -1
  40. package/src/router.ts +61 -5
  41. package/src/server.ts +878 -94
  42. package/src/service-secrets.ts +6 -0
  43. package/src/service.ts +293 -28
  44. package/src/types.ts +26 -1
  45. package/src/update.ts +16 -9
  46. package/src/usage-debug.ts +65 -0
  47. package/src/usage-log.ts +62 -0
  48. package/src/usage-summary.ts +0 -0
  49. package/src/ws-bridge.ts +2 -2
  50. package/gui/README.md +0 -73
  51. package/gui/dist/assets/index-CSUvRNAX.js +0 -9
package/src/cli.ts CHANGED
@@ -1,11 +1,25 @@
1
1
  #!/usr/bin/env bun
2
- import { execFileSync, spawn } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
3
  import { rmSync } from "node:fs";
4
4
  import { restoreNativeCodex } from "./codex-inject";
5
5
  import { restoreLegacyOpenaiHistory } from "./codex-history-provider";
6
6
  import { writeJournal, reconcileJournal } from "./codex-journal";
7
- import { codexAutoStartEnabled, getConfigDir, getConfigPath, loadConfig, readPid, removePid, saveConfig, writePid } from "./config";
7
+ import {
8
+ codexAutoStartEnabled,
9
+ getConfigDir,
10
+ loadConfig,
11
+ readPid,
12
+ readRuntimePort,
13
+ removePid,
14
+ removeRuntimePort,
15
+ saveConfig,
16
+ writePid,
17
+ writeRuntimePort,
18
+ } from "./config";
19
+ import { collectStatus } from "./cli-status";
20
+ import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./cli-help";
8
21
  import { findAvailablePort, shouldPersistSelectedPort } from "./ports";
22
+ import { killProxy } from "./process-control";
9
23
  import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
10
24
  import { drainAndShutdown, startServer } from "./server";
11
25
  import { maybeShowStarPrompt } from "./star-prompt";
@@ -13,94 +27,14 @@ import { maybeShowStarPrompt } from "./star-prompt";
13
27
  const args = process.argv.slice(2);
14
28
  const command = args[0];
15
29
 
16
- function printUsage() {
17
- console.log(`opencodex (ocx) — Universal provider proxy for Codex
18
-
19
- Usage:
20
- ocx init Interactive setup (provider + Codex config injection)
21
- ocx start [--port <port>] Start the proxy server (auto-syncs models to Codex)
22
- ocx stop Stop the proxy AND restore native Codex (plain codex works again)
23
- ocx restore Restore native Codex without stopping (alias: eject)
24
- ocx recover-history --legacy-openai
25
- Explicitly recover pre-backup syncResumeHistory rows
26
- ocx uninstall Remove service/shim/config and restore native Codex
27
- ocx service <sub> Run as a background service (install|start|stop|status|uninstall)
28
- ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall)
29
- ocx ensure Ensure the proxy is running and Codex config/cache are current
30
- ocx sync Fetch models from providers and inject into Codex config
31
- ocx sync-cache Refresh Codex's model cache from the active catalog
32
- ocx status Check proxy server status
33
- ocx login <provider> OAuth login (xai) — opens browser, stores token in ~/.opencodex/auth.json
34
- ocx logout <provider> Remove a stored OAuth login
35
- ocx update Update opencodex to the latest published version
36
- ocx help Show this help message
37
-
38
- Examples:
39
- ocx init Set up provider and inject into Codex
40
- ocx start Start on default port (10100)
41
- ocx start --port 8080 Start on custom port
42
- ocx sync Sync available models to Codex`);
43
- }
44
-
45
- function hasHelpFlag(values: string[]): boolean {
46
- return values.some(value => value === "--help" || value === "-h" || value === "help");
30
+ if (command === "--version" || command === "-v" || command === "version") {
31
+ printVersion();
32
+ process.exit(0);
47
33
  }
48
34
 
49
- function printSubcommandUsage(name: string | undefined): void {
50
- switch (name) {
51
- case "init":
52
- console.log("Usage: ocx init\n\nInteractive setup for providers and Codex config injection.");
53
- break;
54
- case "start":
55
- console.log("Usage: ocx start [--port <port>]\n\nStart the proxy server and sync models to Codex.");
56
- break;
57
- case "stop":
58
- console.log("Usage: ocx stop\n\nStop the proxy and restore native Codex config.");
59
- break;
60
- case "restore":
61
- case "eject":
62
- console.log(`Usage: ocx ${name}\n\nRestore native Codex config without stopping the proxy.`);
63
- break;
64
- case "recover-history":
65
- console.log("Usage: ocx recover-history --legacy-openai\n\nExplicitly recover pre-backup syncResumeHistory rows.");
66
- break;
67
- case "uninstall":
68
- case "remove":
69
- console.log(`Usage: ocx ${name}\n\nRemove service/shim/config and restore native Codex.`);
70
- break;
71
- case "service":
72
- console.log("Usage: ocx service <install|start|stop|status|uninstall>");
73
- break;
74
- case "codex-shim":
75
- console.log("Usage: ocx codex-shim <install|status|uninstall>");
76
- break;
77
- case "ensure":
78
- console.log("Usage: ocx ensure\n\nEnsure the proxy is running and Codex config/cache are current.");
79
- break;
80
- case "sync":
81
- console.log("Usage: ocx sync\n\nFetch provider models and inject them into Codex config.");
82
- break;
83
- case "sync-cache":
84
- console.log("Usage: ocx sync-cache\n\nRefresh Codex's model cache from the active catalog.");
85
- break;
86
- case "status":
87
- console.log("Usage: ocx status\n\nCheck proxy server status.");
88
- break;
89
- case "login":
90
- console.log("Usage: ocx login <provider>\n\nOAuth or API-key login for a provider.");
91
- break;
92
- case "logout":
93
- console.log("Usage: ocx logout <provider>\n\nRemove a stored provider login.");
94
- break;
95
- case "gui":
96
- console.log("Usage: ocx gui\n\nOpen the opencodex dashboard.");
97
- break;
98
- case "update":
99
- console.log("Usage: ocx update\n\nUpdate opencodex to the latest published version.");
100
- break;
101
- default:
102
- printUsage();
103
- }
35
+ if (command === "help" && args[1]) {
36
+ printSubcommandUsage(args[1]);
37
+ process.exit(0);
104
38
  }
105
39
 
106
40
  if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) {
@@ -131,10 +65,15 @@ async function syncModelsToCodex(port?: number) {
131
65
  }
132
66
 
133
67
  function parsePortOption(): number | undefined {
68
+ if (args.length === 1) return undefined;
69
+ if (args.length !== 3 || args[1] !== "--port") {
70
+ console.error("Usage: ocx start [--port <port>]");
71
+ process.exit(1);
72
+ }
134
73
  const portIdx = args.indexOf("--port");
135
74
  if (portIdx === -1) return undefined;
136
75
  const value = args[portIdx + 1];
137
- const port = value ? parseInt(value, 10) : NaN;
76
+ const port = value && /^\d+$/.test(value) ? Number(value) : NaN;
138
77
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
139
78
  console.error("Invalid port number");
140
79
  process.exit(1);
@@ -142,15 +81,12 @@ function parsePortOption(): number | undefined {
142
81
  return port;
143
82
  }
144
83
 
145
- function healthHost(hostname?: string): string {
146
- return !hostname || hostname === "0.0.0.0" || hostname === "::" ? "127.0.0.1" : hostname;
147
- }
148
-
149
84
  async function proxyHealthy(port?: number): Promise<boolean> {
150
85
  const config = loadConfig();
151
86
  const p = port ?? config.port ?? 10100;
152
87
  try {
153
- const res = await fetch(`http://${healthHost(config.hostname)}:${p}/healthz`, {
88
+ const hostname = !config.hostname || config.hostname === "0.0.0.0" || config.hostname === "::" ? "127.0.0.1" : config.hostname;
89
+ const res = await fetch(`http://${hostname}:${p}/healthz`, {
154
90
  signal: AbortSignal.timeout(750),
155
91
  });
156
92
  return res.ok;
@@ -185,6 +121,7 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
185
121
  }
186
122
 
187
123
  async function handleStart(options: { block?: boolean } = {}) {
124
+ const requestedPort = parsePortOption();
188
125
  reconcileJournal();
189
126
  const existingPid = readPid();
190
127
  if (existingPid) {
@@ -196,20 +133,21 @@ async function handleStart(options: { block?: boolean } = {}) {
196
133
  removePid(existingPid);
197
134
  }
198
135
 
199
- const requestedPort = parsePortOption();
200
136
  const port = await chooseListenPort(requestedPort);
201
137
 
202
138
  const server = startServer(port);
203
139
  writePid(process.pid);
204
- writeJournal();
205
140
 
206
141
  const config = loadConfig();
142
+ writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
143
+ writeJournal();
207
144
 
208
145
  let cleaned = false;
209
146
  const syncCleanup = () => {
210
147
  if (cleaned) return;
211
148
  cleaned = true;
212
149
  removePid(process.pid);
150
+ removeRuntimePort(process.pid);
213
151
  if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
214
152
  };
215
153
 
@@ -269,41 +207,6 @@ async function handleEnsure() {
269
207
  console.log(`✅ Proxy running on port ${config.port ?? port}`);
270
208
  }
271
209
 
272
- function killProxy(pid: number): void {
273
- if (!isProcessAlive(pid)) return;
274
- if (process.platform === "win32") {
275
- const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
276
- try {
277
- execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe" });
278
- } catch (err) {
279
- if (isProcessAlive(pid)) throw err;
280
- }
281
- } else {
282
- process.kill(pid, "SIGTERM");
283
- if (!waitForExit(pid, 5000)) process.kill(pid, "SIGKILL");
284
- }
285
- if (!waitForExit(pid, 5000)) throw new Error(`process ${pid} did not exit`);
286
- }
287
-
288
- function isProcessAlive(pid: number): boolean {
289
- try {
290
- process.kill(pid, 0);
291
- return true;
292
- } catch {
293
- return false;
294
- }
295
- }
296
-
297
- function waitForExit(pid: number, timeoutMs: number): boolean {
298
- const deadline = Date.now() + timeoutMs;
299
- const marker = new Int32Array(new SharedArrayBuffer(4));
300
- while (Date.now() < deadline) {
301
- if (!isProcessAlive(pid)) return true;
302
- Atomics.wait(marker, 0, 0, 50);
303
- }
304
- return !isProcessAlive(pid);
305
- }
306
-
307
210
  function handleStop() {
308
211
  const stoppedService = stopServiceIfInstalled();
309
212
  if (stoppedService) console.log("🛑 Service manager stopped (won't respawn).");
@@ -315,6 +218,7 @@ function handleStop() {
315
218
  killProxy(pid);
316
219
  console.log(`✅ Proxy (PID ${pid}) stopped.`);
317
220
  removePid(pid);
221
+ removeRuntimePort(pid);
318
222
  } catch {
319
223
  stopFailed = true;
320
224
  console.error(`❌ Failed to stop proxy (PID ${pid}).`);
@@ -341,19 +245,19 @@ async function handleUninstall() {
341
245
  }
342
246
  };
343
247
 
344
- runStep("service removed", () => {
345
- stopServiceIfInstalled();
346
- return uninstallServiceIfInstalled();
347
- });
248
+ runStep("service stopped", () => stopServiceIfInstalled());
348
249
 
349
250
  runStep("proxy stopped", () => {
350
251
  const pid = readPid();
351
252
  if (!pid) return false;
352
253
  killProxy(pid);
353
254
  removePid(pid);
255
+ removeRuntimePort(pid);
354
256
  return true;
355
257
  });
356
258
 
259
+ runStep("service removed", () => uninstallServiceIfInstalled());
260
+
357
261
  runStep("native Codex restored", () => {
358
262
  const r = restoreNativeCodex();
359
263
  if (!r.success) throw new Error(r.message);
@@ -368,9 +272,13 @@ async function handleUninstall() {
368
272
  console.error(`⚠️ Codex autostart shim removed failed: ${err instanceof Error ? err.message : String(err)}`);
369
273
  }
370
274
 
371
- runStep("opencodex config removed", () => {
372
- rmSync(getConfigDir(), { recursive: true, force: true });
373
- });
275
+ if (failures.length === 0) {
276
+ runStep("opencodex config removed", () => {
277
+ rmSync(getConfigDir(), { recursive: true, force: true });
278
+ });
279
+ } else {
280
+ console.error("Leaving opencodex config/backups in place so the failed restore step can be retried.");
281
+ }
374
282
 
375
283
  if (failures.length > 0) {
376
284
  console.error(`\nUninstall finished with ${failures.length} failed step(s): ${failures.join(", ")}`);
@@ -379,56 +287,35 @@ async function handleUninstall() {
379
287
  console.log("\n✅ opencodex local state removed. Remove the package with: npm uninstall -g @bitkyc08/opencodex");
380
288
  }
381
289
 
382
- type HealthCheck = {
383
- ok: boolean;
384
- label: string;
385
- };
290
+ async function handleStatus() {
291
+ const statusArgs = args.slice(1);
292
+ const wantsJson = statusArgs.length === 1 && statusArgs[0] === "--json";
293
+ if (statusArgs.length > 1 || (statusArgs.length === 1 && !wantsJson)) {
294
+ console.error("Usage: ocx status [--json]");
295
+ process.exit(1);
296
+ }
386
297
 
387
- async function checkProxyHealth(port: number, hostname?: string): Promise<HealthCheck> {
388
- const url = `http://${healthHost(hostname)}:${port}/healthz`;
389
- const controller = new AbortController();
390
- const timer = setTimeout(() => controller.abort(), 800);
391
- try {
392
- const response = await fetch(url, { signal: controller.signal });
393
- if (!response.ok) return { ok: false, label: `${url} returned HTTP ${response.status}` };
394
- const body = await response.json().catch(() => null) as { version?: unknown; uptime?: unknown } | null;
395
- const version = typeof body?.version === "string" ? ` v${body.version}` : "";
396
- const uptime = typeof body?.uptime === "number" ? `, uptime ${Math.round(body.uptime)}s` : "";
397
- return { ok: true, label: `${url} ok${version}${uptime}` };
398
- } catch (error) {
399
- const reason = error instanceof Error && error.name === "AbortError" ? "timed out" : "unreachable";
400
- return { ok: false, label: `${url} ${reason}` };
401
- } finally {
402
- clearTimeout(timer);
298
+ const status = await collectStatus();
299
+ if (wantsJson) {
300
+ console.log(JSON.stringify(status.json, null, 2));
301
+ return;
403
302
  }
404
- }
405
303
 
406
- async function handleStatus() {
407
- const config = loadConfig();
408
- const port = config.port ?? 10100;
409
- const pid = readPid();
410
- const health = await checkProxyHealth(port, config.hostname);
411
- const proxyLabel = pid && health.ok
412
- ? `running (PID ${pid})`
413
- : pid
414
- ? `PID file points to PID ${pid}, but health check failed`
415
- : health.ok
416
- ? "reachable, but PID file is missing or stale"
417
- : "not running";
418
-
419
- if (pid || health.ok) {
420
- console.log(`✅ Proxy: ${proxyLabel}`);
304
+ if (status.json.proxy.pid || status.json.proxy.health.ok) {
305
+ console.log(`✅ Proxy: ${status.proxyLabel}`);
421
306
  } else {
422
- console.log(`❌ Proxy: ${proxyLabel}`);
307
+ console.log(`❌ Proxy: ${status.proxyLabel}`);
423
308
  }
424
- console.log(` Health: ${health.label}`);
425
- console.log(` Dashboard: http://localhost:${port}/`);
426
- console.log(` Config: ${getConfigPath()}`);
427
- console.log(` Default provider: ${config.defaultProvider}`);
428
- console.log(` Codex autostart: ${codexAutoStartEnabled(config) ? "enabled" : "disabled"}`);
429
- console.log(` Service: ${serviceStatusSummary()}`);
430
- const { codexShimStatus } = await import("./codex-shim");
431
- console.log(` ${codexShimStatus()}`);
309
+ console.log(` Health: ${status.healthLabel}`);
310
+ console.log(` Dashboard: ${status.json.dashboard.url}`);
311
+ console.log(` Config: ${status.json.paths.config}`);
312
+ console.log(` PID file: ${status.json.paths.pid}`);
313
+ console.log(` Runtime: ${status.json.paths.runtime}`);
314
+ console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}`);
315
+ console.log(` Default provider: ${status.json.defaultProvider}`);
316
+ console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`);
317
+ console.log(` Service: ${status.json.service.summary}`);
318
+ console.log(` ${status.json.codexShim.summary}`);
432
319
  }
433
320
 
434
321
  function handleRecoverHistory() {
@@ -497,8 +384,8 @@ switch (command) {
497
384
  case "gui": {
498
385
  const cfg = await import("./config");
499
386
  const config = cfg.loadConfig();
500
- const guiUrl = `http://localhost:${config.port}`;
501
- if (!cfg.readPid()) {
387
+ let pid = cfg.readPid();
388
+ if (!pid) {
502
389
  console.log("Proxy not running. Starting...");
503
390
  const child = spawn(process.execPath, [process.argv[1], "start"], {
504
391
  detached: true,
@@ -508,7 +395,11 @@ switch (command) {
508
395
  });
509
396
  child.unref();
510
397
  await new Promise(r => setTimeout(r, 1000));
398
+ pid = cfg.readPid();
511
399
  }
400
+ const runtimePort = pid ? cfg.readRuntimePort(pid) : null;
401
+ const guiPort = runtimePort?.port ?? config.port;
402
+ const guiUrl = `http://localhost:${guiPort}`;
512
403
  console.log(`Opening ${guiUrl}`);
513
404
  const { openUrl } = await import("./open-url");
514
405
  openUrl(guiUrl);
@@ -535,7 +426,7 @@ switch (command) {
535
426
  break;
536
427
  }
537
428
  default:
538
- console.error("Usage: ocx codex-shim <install|status|uninstall>");
429
+ console.error("Usage: ocx codex-shim <install|status|uninstall|remove>");
539
430
  process.exit(1);
540
431
  }
541
432
  break;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { getConfigDir, atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "./config";
4
+ import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "./config";
5
5
  import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "./types";
6
6
 
7
7
  type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
@@ -87,6 +87,7 @@ function loadCodexAccountRecordStore(): CodexAccountStore {
87
87
  }
88
88
  return normalized;
89
89
  } catch {
90
+ backupInvalidConfig(path);
90
91
  return {};
91
92
  }
92
93
  }
@@ -145,10 +146,13 @@ export function saveCodexAccountCredentialIfGeneration(
145
146
  if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) {
146
147
  return false;
147
148
  }
149
+ const refreshGrantFingerprint = current.credential.refreshToken === cred.refreshToken
150
+ ? current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(cred.refreshToken)
151
+ : refreshGrantFingerprintForToken(cred.refreshToken);
148
152
  store[id] = {
149
153
  credential: cred,
150
154
  generation: generation + 1,
151
- refreshGrantFingerprint: current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(current.credential.refreshToken),
155
+ refreshGrantFingerprint,
152
156
  replacedAt: current.replacedAt,
153
157
  };
154
158
  persist(store);
@@ -191,7 +195,8 @@ export class CodexCredentialRefreshLockTimeoutError extends Error {
191
195
  }
192
196
 
193
197
  type CodexTokenResult = { accessToken: string; chatgptAccountId: string; generation: number };
194
- const refreshLocks = new Map<string, Promise<CodexTokenResult>>();
198
+ type CodexRefreshResult = CodexTokenResult & { credential?: CodexAccountCredentials };
199
+ const refreshLocks = new Map<string, Promise<CodexRefreshResult>>();
195
200
 
196
201
  function codexRefreshLockPath(lockKey: string): string {
197
202
  const digest = createHash("sha256").update(lockKey).digest("hex").slice(0, 32);
@@ -283,22 +288,50 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
283
288
 
284
289
  const existing = refreshLocks.get(refreshGrantFingerprint);
285
290
  if (existing) {
286
- await existing;
291
+ const refreshed = await existing;
292
+ const current = readCodexAccountRecord(id);
293
+ const currentCred = current?.deletedAt == null ? current?.credential : undefined;
294
+ if (
295
+ current &&
296
+ currentCred &&
297
+ refreshed.credential &&
298
+ recordGrantFingerprint(current) === refreshGrantFingerprint
299
+ ) {
300
+ if (!saveCodexAccountCredentialIfGeneration(id, current.generation, refreshed.credential)) {
301
+ throw new CodexCredentialGenerationConflictError();
302
+ }
303
+ return {
304
+ accessToken: refreshed.credential.accessToken,
305
+ chatgptAccountId: refreshed.credential.chatgptAccountId,
306
+ generation: current.generation + 1,
307
+ };
308
+ }
287
309
  return getValidCodexToken(id);
288
310
  }
289
311
 
290
- const refreshPromise = withCodexRefreshFileLock(refreshGrantFingerprint, async (): Promise<CodexTokenResult> => {
312
+ const refreshPromise = withCodexRefreshFileLock(refreshGrantFingerprint, async (): Promise<CodexRefreshResult> => {
291
313
  const lockedRecord = readCodexAccountRecord(id);
292
314
  const lockedCred = lockedRecord?.deletedAt == null ? lockedRecord?.credential : undefined;
293
315
  if (!lockedRecord || !lockedCred) throw new CodexCredentialGenerationConflictError();
294
316
  const startGeneration = lockedRecord.generation;
295
317
  const lockedRefreshGrantFingerprint = recordGrantFingerprint(lockedRecord);
296
- if (lockedRefreshGrantFingerprint !== refreshGrantFingerprint) throw new CodexCredentialGenerationConflictError();
318
+ if (lockedRefreshGrantFingerprint !== refreshGrantFingerprint) {
319
+ if (lockedCred.expiresAt > Date.now() + REFRESH_SKEW_MS) {
320
+ return {
321
+ accessToken: lockedCred.accessToken,
322
+ chatgptAccountId: lockedCred.chatgptAccountId,
323
+ generation: startGeneration,
324
+ credential: lockedCred,
325
+ };
326
+ }
327
+ throw new CodexCredentialGenerationConflictError();
328
+ }
297
329
  if (lockedCred.expiresAt > Date.now() + REFRESH_SKEW_MS) {
298
330
  return {
299
331
  accessToken: lockedCred.accessToken,
300
332
  chatgptAccountId: lockedCred.chatgptAccountId,
301
333
  generation: startGeneration,
334
+ credential: lockedCred,
302
335
  };
303
336
  }
304
337
  const sameGrantFreshCredential = findFreshCredentialForGrant(refreshGrantFingerprint, id);
@@ -310,6 +343,7 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
310
343
  accessToken: sameGrantFreshCredential.accessToken,
311
344
  chatgptAccountId: sameGrantFreshCredential.chatgptAccountId,
312
345
  generation: startGeneration + 1,
346
+ credential: sameGrantFreshCredential,
313
347
  };
314
348
  }
315
349
  const res = await fetch(CHATGPT_TOKEN_URL, {
@@ -345,11 +379,16 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
345
379
  if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, updated)) {
346
380
  throw new CodexCredentialGenerationConflictError();
347
381
  }
348
- return { accessToken: updated.accessToken, chatgptAccountId: updated.chatgptAccountId, generation: startGeneration + 1 };
382
+ return { accessToken: updated.accessToken, chatgptAccountId: updated.chatgptAccountId, generation: startGeneration + 1, credential: updated };
349
383
  }).finally(() => {
350
384
  refreshLocks.delete(refreshGrantFingerprint);
351
385
  });
352
386
 
353
387
  refreshLocks.set(refreshGrantFingerprint, refreshPromise);
354
- return refreshPromise;
388
+ const result = await refreshPromise;
389
+ return {
390
+ accessToken: result.accessToken,
391
+ chatgptAccountId: result.chatgptAccountId,
392
+ generation: result.generation,
393
+ };
355
394
  }