@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
@@ -26,7 +26,7 @@ export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota
26
26
  import { extractAccountId, decodeJwtPayload } from "./oauth/chatgpt";
27
27
  import { maskEmail } from "./privacy";
28
28
  export { maskEmail } from "./privacy";
29
- import type { OcxConfig } from "./types";
29
+ import type { CodexAccount, OcxConfig } from "./types";
30
30
 
31
31
  function jsonResponse(data: unknown, status = 200): Response {
32
32
  return new Response(JSON.stringify(data), {
@@ -40,6 +40,92 @@ const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
40
40
 
41
41
  const codexAuthLoginState = new Map<string, { status: string; accountId?: string; email?: string; error?: string; doneAt?: number }>();
42
42
 
43
+ function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
44
+ if (!ACCOUNT_ID_RE.test(accountId)) return null;
45
+ return (config.codexAccounts ?? []).find(account => account.id === accountId && !account.isMain) ?? null;
46
+ }
47
+
48
+ function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean {
49
+ const normalized = plan?.trim().toLowerCase();
50
+ return normalized === "go" || normalized === "free";
51
+ }
52
+
53
+ function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
54
+ quota: T,
55
+ plan: string | null | undefined,
56
+ ): T {
57
+ if (!quota || !isThirtyDayOnlyPlan(plan)) return quota;
58
+ return {
59
+ ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
60
+ ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
61
+ ...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}),
62
+ ...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}),
63
+ } as T;
64
+ }
65
+
66
+ function poolAccountDto(
67
+ account: CodexAccount,
68
+ quotaResult: PoolQuotaResult,
69
+ hasCredential: boolean,
70
+ ): Record<string, unknown> {
71
+ const quota = quotaForPlan(quotaResult.quota, account.plan);
72
+ return {
73
+ id: account.id,
74
+ email: maskEmail(account.email) ?? account.email,
75
+ ...(account.plan !== undefined ? { plan: account.plan } : {}),
76
+ ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
77
+ isMain: false,
78
+ quota: quota ? { ...quota } : null,
79
+ needsReauth: !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id),
80
+ hasCredential,
81
+ };
82
+ }
83
+
84
+ async function resolveResetCreditAuth(
85
+ runtimeConfig: OcxConfig,
86
+ accountId: string,
87
+ ): Promise<
88
+ | { ok: true; isMain: boolean; accessToken: string; chatgptAccountId: string }
89
+ | { ok: false; response: Response }
90
+ > {
91
+ if (accountId === "__main__") {
92
+ const tokens = readCodexTokens();
93
+ if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
94
+ return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id };
95
+ }
96
+ if (!ACCOUNT_ID_RE.test(accountId)) {
97
+ return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) };
98
+ }
99
+ if (!configuredPoolAccount(runtimeConfig, accountId)) {
100
+ return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) };
101
+ }
102
+ const cred = await getValidCodexToken(accountId);
103
+ return { ok: true, isMain: false, accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId };
104
+ }
105
+
106
+ function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } {
107
+ const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
108
+ const rawCredits = Array.isArray(obj.credits) ? obj.credits : [];
109
+ const credits = rawCredits.flatMap((raw): { granted_at: string; expires_at: string }[] => {
110
+ if (typeof raw !== "object" || raw === null) return [];
111
+ const credit = raw as Record<string, unknown>;
112
+ return typeof credit.granted_at === "string" && typeof credit.expires_at === "string"
113
+ ? [{ granted_at: credit.granted_at, expires_at: credit.expires_at }]
114
+ : [];
115
+ });
116
+ const rawAvailable = (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count
117
+ ?? obj.available_count;
118
+ return {
119
+ credits,
120
+ ...(typeof rawAvailable === "number" && Number.isFinite(rawAvailable) ? { available_count: rawAvailable } : {}),
121
+ };
122
+ }
123
+
124
+ function safeResetCreditConsumeDto(input: unknown): { code: string } {
125
+ const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
126
+ return { code: typeof obj.code === "string" ? obj.code : "unknown" };
127
+ }
128
+
43
129
  export function isUnverifiedCodexImportEnabled(): boolean {
44
130
  return process.env[MANUAL_IMPORT_ENV] === "1";
45
131
  }
@@ -127,7 +213,7 @@ interface PoolQuotaResult {
127
213
  needsReauth: boolean;
128
214
  }
129
215
 
130
- async function fetchPoolAccountQuota(accountId: string, forceRefresh = false): Promise<PoolQuotaResult> {
216
+ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
131
217
  const existing = getAccountQuota(accountId);
132
218
  if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
133
219
  return { quota: existing, needsReauth: false };
@@ -140,7 +226,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false): P
140
226
  });
141
227
  if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 };
142
228
  const data = (await resp.json()) as WhamUsageResponse;
143
- const quota = parseUsageQuota(data);
229
+ const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan });
144
230
  if (!quota) return { quota: existing ?? null, needsReauth: false };
145
231
  updateAccountQuota(
146
232
  accountId,
@@ -174,15 +260,9 @@ export async function handleCodexAuthAPI(
174
260
  const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
175
261
  const cred = getCodexAccountCredential(a.id);
176
262
  const quotaResult = cred
177
- ? await fetchPoolAccountQuota(a.id, forceRefresh)
263
+ ? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan)
178
264
  : { quota: null, needsReauth: true };
179
- return {
180
- ...a,
181
- email: maskEmail(a.email) ?? a.email,
182
- quota: quotaResult.quota ? { ...quotaResult.quota } : null,
183
- needsReauth: !cred || quotaResult.needsReauth || isAccountNeedsReauth(a.id),
184
- hasCredential: !!cred,
185
- };
265
+ return poolAccountDto(a, quotaResult, !!cred);
186
266
  });
187
267
  const main = {
188
268
  id: "__main__",
@@ -190,7 +270,7 @@ export async function handleCodexAuthAPI(
190
270
  plan: mainInfo.plan,
191
271
  isMain: true,
192
272
  hasCredential: true,
193
- quota: mainInfo.quota ? { ...mainInfo.quota, updatedAt: Date.now() } : null,
273
+ quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
194
274
  };
195
275
  return jsonResponse({ accounts: [main, ...withQuota] });
196
276
  }
@@ -301,39 +381,27 @@ export async function handleCodexAuthAPI(
301
381
  const accountId = url.searchParams.get("accountId");
302
382
  if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
303
383
 
304
- const isMain = accountId === "__main__";
305
- let accessToken: string;
306
- let chatgptAccountId: string;
307
-
308
384
  try {
309
- if (isMain) {
310
- const tokens = readCodexTokens();
311
- if (!tokens) return jsonResponse({ error: "Main Codex account not logged in" }, 401);
312
- accessToken = tokens.access_token;
313
- chatgptAccountId = tokens.account_id;
314
- } else {
315
- const cred = await getValidCodexToken(accountId);
316
- accessToken = cred.accessToken;
317
- chatgptAccountId = cred.chatgptAccountId;
318
- }
385
+ const auth = await resolveResetCreditAuth(getRuntimeConfig(config), accountId);
386
+ if (!auth.ok) return auth.response;
319
387
 
320
388
  const resp = await fetch(
321
389
  "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
322
390
  {
323
391
  headers: {
324
- Authorization: `Bearer ${accessToken}`,
325
- "ChatGPT-Account-Id": chatgptAccountId,
392
+ Authorization: `Bearer ${auth.accessToken}`,
393
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
326
394
  },
327
395
  signal: AbortSignal.timeout(8000),
328
396
  },
329
397
  );
330
398
  if (!resp.ok) {
331
- const text = await resp.text().catch(() => "");
332
- return jsonResponse({ error: `Upstream error ${resp.status}`, detail: text }, resp.status);
399
+ await resp.body?.cancel().catch(() => {});
400
+ return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
333
401
  }
334
- return jsonResponse(await resp.json());
402
+ return jsonResponse(safeResetCreditsDto(await resp.json()));
335
403
  } catch (e) {
336
- return jsonResponse({ error: String(e) }, 500);
404
+ return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500);
337
405
  }
338
406
  }
339
407
 
@@ -341,21 +409,9 @@ export async function handleCodexAuthAPI(
341
409
  const body = (await req.json().catch(() => ({}))) as { accountId?: string };
342
410
  if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
343
411
 
344
- const isMain = body.accountId === "__main__";
345
- let accessToken: string;
346
- let chatgptAccountId: string;
347
-
348
412
  try {
349
- if (isMain) {
350
- const tokens = readCodexTokens();
351
- if (!tokens) return jsonResponse({ error: "Main Codex account not logged in" }, 401);
352
- accessToken = tokens.access_token;
353
- chatgptAccountId = tokens.account_id;
354
- } else {
355
- const cred = await getValidCodexToken(body.accountId);
356
- accessToken = cred.accessToken;
357
- chatgptAccountId = cred.chatgptAccountId;
358
- }
413
+ const auth = await resolveResetCreditAuth(getRuntimeConfig(config), body.accountId);
414
+ if (!auth.ok) return auth.response;
359
415
 
360
416
  const idempotencyKey = crypto.randomUUID();
361
417
  const resp = await fetch(
@@ -363,8 +419,8 @@ export async function handleCodexAuthAPI(
363
419
  {
364
420
  method: "POST",
365
421
  headers: {
366
- Authorization: `Bearer ${accessToken}`,
367
- "ChatGPT-Account-Id": chatgptAccountId,
422
+ Authorization: `Bearer ${auth.accessToken}`,
423
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
368
424
  "Content-Type": "application/json",
369
425
  },
370
426
  body: JSON.stringify({ redeem_request_id: idempotencyKey }),
@@ -372,20 +428,21 @@ export async function handleCodexAuthAPI(
372
428
  },
373
429
  );
374
430
  if (!resp.ok) {
375
- const text = await resp.text().catch(() => "");
376
- return jsonResponse({ error: `Upstream error ${resp.status}`, detail: text }, resp.status);
431
+ await resp.body?.cancel().catch(() => {});
432
+ return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
377
433
  }
378
- const result = (await resp.json()) as { code: string };
434
+ const result = safeResetCreditConsumeDto(await resp.json());
379
435
  if (result.code === "reset") {
380
- if (isMain) {
436
+ if (auth.isMain) {
381
437
  await fetchMainAccountInfo(true);
382
438
  } else {
383
- await fetchPoolAccountQuota(body.accountId, true);
439
+ const account = configuredPoolAccount(getRuntimeConfig(config), body.accountId);
440
+ await fetchPoolAccountQuota(body.accountId, true, account?.plan);
384
441
  }
385
442
  }
386
443
  return jsonResponse(result);
387
444
  } catch (e) {
388
- return jsonResponse({ error: String(e) }, 500);
445
+ return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500);
389
446
  }
390
447
  }
391
448
 
@@ -44,6 +44,11 @@ export function checkAccountIdCollision(
44
44
  email?: string | null,
45
45
  plan?: string | null,
46
46
  ): { collision: true; reason: string } | { collision: false } {
47
+ const mainAccountId = getMainChatgptAccountId();
48
+ if (mainAccountId && mainAccountId === chatgptAccountId) {
49
+ return { collision: true, reason: "Account is already used by the main Codex login." };
50
+ }
51
+
47
52
  const candidateEmail = normalizedEmail(email);
48
53
  const candidateWorkspace = isWorkspacePlan(plan);
49
54
  for (const account of loadConfig().codexAccounts ?? []) {
@@ -311,7 +311,7 @@ function loadCatalogForSync(path: string): RawCatalog | null {
311
311
  const catalog = readCatalog(path);
312
312
  if (catalog && findNativeTemplate(catalog)) return catalog;
313
313
  return readCatalog(catalogBackupPathFor(path))
314
- ?? readCatalog(legacyCatalogBackupPath())
314
+ ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
315
315
  ?? readCatalog(CODEX_MODELS_CACHE_PATH)
316
316
  ?? materializeBundledCodexCatalog(path)
317
317
  ?? catalog;
@@ -332,8 +332,7 @@ function readCurrentCatalogOrCache(): RawCatalog | null {
332
332
  export function loadCatalogTemplate(): RawEntry | null {
333
333
  const catalogPath = readCodexCatalogPath();
334
334
  const native = findNativeTemplate(readCatalog(catalogPath))
335
- ?? findNativeTemplate(readCatalog(catalogBackupPathFor(catalogPath)))
336
- ?? findNativeTemplate(readCatalog(legacyCatalogBackupPath()))
335
+ ?? findNativeTemplate(readCatalogBackup(catalogPath))
337
336
  ?? findNativeTemplate(readCatalog(CODEX_MODELS_CACHE_PATH))
338
337
  ?? findNativeTemplate(loadBundledCodexCatalog());
339
338
  return native ? JSON.parse(JSON.stringify(native)) : null;
@@ -463,7 +462,8 @@ export function listCatalogNativeSlugs(): string[] {
463
462
  * (rather than the modified value left in the live catalog by a previous sync).
464
463
  */
465
464
  function readCatalogBackup(catalogPath: string): RawCatalog | null {
466
- return readCatalog(catalogBackupPathFor(catalogPath)) ?? readCatalog(legacyCatalogBackupPath());
465
+ return readCatalog(catalogBackupPathFor(catalogPath))
466
+ ?? (isDefaultCatalogPath(catalogPath) ? readCatalog(legacyCatalogBackupPath()) : null);
467
467
  }
468
468
 
469
469
  function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean {
@@ -486,7 +486,7 @@ function ensureCatalogBackup(catalogPath: string, catalog: RawCatalog): void {
486
486
  const dir = getConfigDir();
487
487
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
488
488
  writePristineCatalogBackup(catalogBackupPathFor(catalogPath), catalogPath, catalog);
489
- writePristineCatalogBackup(legacyCatalogBackupPath(), catalogPath, catalog);
489
+ if (isDefaultCatalogPath(catalogPath)) writePristineCatalogBackup(legacyCatalogBackupPath(), catalogPath, catalog);
490
490
  }
491
491
 
492
492
  function readNativeBaseline(catalogPath: string): Map<string, number> {
@@ -695,7 +695,6 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
695
695
  const template = findNativeTemplate(catalog);
696
696
 
697
697
  const goModels = await gatherRoutedModels(config);
698
- if (goModels.length === 0) return { added: 0, path: catalogPath };
699
698
  try {
700
699
  // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
701
700
  // (later syncs would otherwise overwrite it with featured-modified priorities).
@@ -719,7 +718,12 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
719
718
  .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && !goIds.has(m.slug as string))
720
719
  .map(m => {
721
720
  const slug = m.slug as string;
722
- const priority = rank.has(slug) ? rank.get(slug)! : (baseline.get(slug) ?? (m.priority as number));
721
+ const baselinePriority = baseline.get(slug) ?? (m.priority as number);
722
+ const priority = rank.has(slug)
723
+ ? rank.get(slug)!
724
+ : featured.length > 0
725
+ ? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
726
+ : baselinePriority;
723
727
  return normalizeServiceTiers({ ...m, priority });
724
728
  });
725
729
  // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
@@ -746,11 +750,19 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
746
750
  const catalogPath = readCodexCatalogPath();
747
751
  const catalog = readCatalog(catalogPath);
748
752
  if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
749
- const exactBackup = readCatalog(catalogBackupPathFor(catalogPath));
750
- if (exactBackup && Array.isArray(exactBackup.models)) {
751
- const removed = Math.max(0, catalog.models.length - exactBackup.models.length);
752
- atomicWriteFile(catalogPath, JSON.stringify(exactBackup, null, 2) + "\n");
753
- return { removed, kept: exactBackup.models.length, path: catalogPath };
753
+ const backup = readCatalogBackup(catalogPath);
754
+ if (backup && Array.isArray(backup.models)) {
755
+ const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" && m.slug.includes("/")).length;
756
+ const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
757
+ const userNativeAdditions = (catalog.models ?? []).filter(m =>
758
+ typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
759
+ );
760
+ const restored = {
761
+ ...backup,
762
+ models: [...backup.models, ...userNativeAdditions],
763
+ };
764
+ atomicWriteFile(catalogPath, JSON.stringify(restored, null, 2) + "\n");
765
+ return { removed, kept: restored.models.length, path: catalogPath };
754
766
  }
755
767
  const before = catalog.models.length;
756
768
  const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/")));
@@ -1,11 +1,17 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
3
4
  import { Database } from "bun:sqlite";
4
5
  import { CODEX_HOME } from "./codex-paths";
5
6
  import { atomicWriteFile, getConfigDir } from "./config";
6
7
 
7
8
  const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite");
8
- const HISTORY_BACKUP_PATH = join(getConfigDir(), "codex-history-backup.json");
9
+ function historyBackupPathFor(stateDbPath: string): string {
10
+ const normalized = process.platform === "win32" ? resolve(stateDbPath).toLowerCase() : resolve(stateDbPath);
11
+ const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
12
+ return join(getConfigDir(), `codex-history-backup-${id}.json`);
13
+ }
14
+ const HISTORY_BACKUP_PATH = historyBackupPathFor(STATE_DB_PATH);
9
15
  const RESUMABLE_SOURCES = ["cli", "vscode"] as const;
10
16
 
11
17
  type CodexHistoryProvider = "openai" | "opencodex";
@@ -34,6 +40,7 @@ interface BackupEntry {
34
40
 
35
41
  interface BackupManifest {
36
42
  version: 1;
43
+ stateDbPath?: string;
37
44
  entries: Record<string, BackupEntry>;
38
45
  }
39
46
 
@@ -43,26 +50,35 @@ interface NativeRestoreTarget {
43
50
  hasUserEvent: number;
44
51
  }
45
52
 
46
- function readBackup(path: string): BackupManifest {
47
- if (!existsSync(path)) return { version: 1, entries: {} };
53
+ function samePath(a: string, b: string): boolean {
54
+ const left = resolve(a);
55
+ const right = resolve(b);
56
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
57
+ }
58
+
59
+ function readBackup(path: string, stateDbPath?: string): BackupManifest {
60
+ if (!existsSync(path)) return { version: 1, stateDbPath, entries: {} };
48
61
  try {
49
62
  const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<BackupManifest>;
50
63
  if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") {
51
- return { version: 1, entries: {} };
64
+ return { version: 1, stateDbPath, entries: {} };
65
+ }
66
+ if (stateDbPath && typeof parsed.stateDbPath === "string" && !samePath(parsed.stateDbPath, stateDbPath)) {
67
+ return { version: 1, stateDbPath, entries: {} };
52
68
  }
53
- return { version: 1, entries: parsed.entries };
69
+ return { version: 1, stateDbPath: parsed.stateDbPath ?? stateDbPath, entries: parsed.entries };
54
70
  } catch {
55
- return { version: 1, entries: {} };
71
+ return { version: 1, stateDbPath, entries: {} };
56
72
  }
57
73
  }
58
74
 
59
- function writeBackup(path: string, manifest: BackupManifest): void {
75
+ function writeBackup(path: string, manifest: BackupManifest, stateDbPath?: string): void {
60
76
  if (Object.keys(manifest.entries).length === 0) {
61
77
  if (existsSync(path)) unlinkSync(path);
62
78
  return;
63
79
  }
64
80
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
65
- atomicWriteFile(path, JSON.stringify(manifest, null, 2) + "\n");
81
+ atomicWriteFile(path, JSON.stringify({ ...manifest, stateDbPath: manifest.stateDbPath ?? stateDbPath }, null, 2) + "\n");
66
82
  }
67
83
 
68
84
  function rememberOriginal(manifest: BackupManifest, row: ThreadRow): void {
@@ -211,9 +227,9 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
211
227
  `)
212
228
  .all();
213
229
 
214
- const manifest = readBackup(backupPath);
230
+ const manifest = readBackup(backupPath, stateDbPath);
215
231
  for (const row of [...openaiRows, ...execRows]) rememberOriginal(manifest, row);
216
- writeBackup(backupPath, manifest);
232
+ writeBackup(backupPath, manifest, stateDbPath);
217
233
 
218
234
  let files = 0;
219
235
  for (const row of openaiRows) {
@@ -262,7 +278,7 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
262
278
  }
263
279
 
264
280
  function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): CodexHistorySyncResult {
265
- const manifest = readBackup(backupPath);
281
+ const manifest = readBackup(backupPath, stateDbPath);
266
282
  const entries = Object.values(manifest.entries);
267
283
 
268
284
  const db = new Database(stateDbPath);
@@ -296,7 +312,7 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
296
312
  }
297
313
  });
298
314
  restore();
299
- writeBackup(backupPath, { version: 1, entries: {} });
315
+ writeBackup(backupPath, { version: 1, stateDbPath, entries: {} }, stateDbPath);
300
316
  const ejected = ejectRemainingOpencodexHistory(db);
301
317
  return ejected.rows > 0
302
318
  ? { rows: entries.length, files: files + ejected.files, ejectedRows: ejected.rows }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { atomicWriteFile, websocketsEnabled } from "./config";
3
- import { removeJournal } from "./codex-journal";
3
+ import { markJournalInjectedState, restoreJournalState, writeJournal } from "./codex-journal";
4
4
  import { restoreCodexCatalog } from "./codex-catalog";
5
5
  import { syncCodexHistoryProvider } from "./codex-history-provider";
6
6
  import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./codex-paths";
@@ -25,16 +25,36 @@ export interface InjectCodexOptions {
25
25
  * whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex
26
26
  * never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider.
27
27
  */
28
- export function buildProviderTableBlock(port: number, supportsWebsockets = false): string {
28
+ function isLoopbackHostname(hostname: string | undefined): boolean {
29
+ const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
30
+ return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
31
+ }
32
+
33
+ function providerBaseHost(hostname: string | undefined): string {
34
+ const trimmed = (hostname ?? "127.0.0.1").trim();
35
+ if (isLoopbackHostname(trimmed) || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "localhost";
36
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
37
+ return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
38
+ }
39
+
40
+ function shouldInjectApiAuthHeader(config: Pick<OcxConfig, "hostname"> | undefined): boolean {
41
+ return !isLoopbackHostname(config?.hostname);
42
+ }
43
+
44
+ export function buildProviderTableBlock(port: number, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string): string {
45
+ const host = providerBaseHost(hostname);
29
46
  const lines = [
30
47
  "",
31
48
  OCX_SECTION_MARKER,
32
49
  "[model_providers.opencodex]",
33
50
  'name = "OpenCodex Proxy"',
34
- `base_url = "http://localhost:${port}/v1"`,
51
+ `base_url = "http://${host}:${port}/v1"`,
35
52
  'wire_api = "responses"',
36
53
  "requires_openai_auth = true",
37
54
  ];
55
+ if (includeApiAuthHeader) {
56
+ lines.push('env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" }');
57
+ }
38
58
  if (supportsWebsockets) lines.push("supports_websockets = true");
39
59
  return lines.join("\n") + "\n";
40
60
  }
@@ -61,17 +81,6 @@ function stripExistingModelProvider(content: string): string {
61
81
  return out.join("\n");
62
82
  }
63
83
 
64
- function stripRootContextWindowOverrides(content: string): string {
65
- const lines = content.split("\n");
66
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
67
- return lines
68
- .filter((line, i) => {
69
- const isRoot = firstTable === -1 || i < firstTable;
70
- return !isRoot || !/^\s*model_(?:context_window|auto_compact_token_limit)\s*=/.test(line);
71
- })
72
- .join("\n");
73
- }
74
-
75
84
  function stripRootRoutedModel(content: string): string {
76
85
  const lines = content.split("\n");
77
86
  const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
@@ -142,7 +151,7 @@ function removeProfileSection(content: string): string {
142
151
  continue;
143
152
  }
144
153
  if (inProfile) {
145
- if (line.startsWith("[") && line.trim() !== "[profiles.opencodex]") {
154
+ if (/^\s*\[/.test(line) && line.trim() !== "[profiles.opencodex]") {
146
155
  inProfile = false;
147
156
  filtered.push(line);
148
157
  }
@@ -193,14 +202,16 @@ function stripOpencodexCatalogPath(content: string): string {
193
202
  .join("\n");
194
203
  }
195
204
 
196
- export function buildProfileFile(port: number, catalogPath?: string | null): string {
205
+ export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string): string {
206
+ const host = providerBaseHost(hostname);
197
207
  const lines = [
198
208
  "# OpenCodex proxy profile — use with: codex --profile opencodex",
199
- `# Routes all model requests through the opencodex proxy at localhost:${port}`,
209
+ `# Routes all model requests through the opencodex proxy at ${host}:${port}`,
200
210
  'model_provider = "opencodex"',
201
211
  ];
202
212
  if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
203
- lines.push("", "[features]", "fast_mode = true", "");
213
+ lines.push("", "[features]", "fast_mode = true");
214
+ lines.push(buildProviderTableBlock(port, supportsWebsockets, includeApiAuthHeader, hostname).trimEnd(), "");
204
215
  return lines.join("\n");
205
216
  }
206
217
 
@@ -221,6 +232,7 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
221
232
  return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` };
222
233
  }
223
234
 
235
+ writeJournal();
224
236
  let content = readFileSync(CODEX_CONFIG_PATH, "utf-8");
225
237
 
226
238
  // Idempotent clean-up of any prior injection: drop the provider table (marker-based) and every
@@ -231,7 +243,6 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
231
243
  }
232
244
  content = removeProfileSection(content);
233
245
  content = stripExistingModelProvider(content);
234
- content = stripRootContextWindowOverrides(content);
235
246
  content = normalizeServiceTier(content);
236
247
  content = ensureFastModeFeature(content);
237
248
 
@@ -241,10 +252,12 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
241
252
  // 1) Root key BEFORE the first table header (must be a global, not nested under a table).
242
253
  content = setRootModelProvider(content);
243
254
  // 2) Provider table appended at EOF (position-independent).
244
- content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}));
255
+ content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}), shouldInjectApiAuthHeader(config), config?.hostname);
245
256
 
257
+ const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), shouldInjectApiAuthHeader(config), config?.hostname);
246
258
  atomicWriteFile(CODEX_CONFIG_PATH, content);
247
- atomicWriteFile(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath));
259
+ atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
260
+ markJournalInjectedState(content, profileContent);
248
261
  const history = config?.syncResumeHistory !== false
249
262
  ? syncCodexHistoryProvider("opencodex")
250
263
  : { rows: 0, files: 0 };
@@ -279,7 +292,7 @@ function removeOcxSection(content: string): string {
279
292
  if (inOcxSection) {
280
293
  // End the injected section at the next table header that ISN'T our own — exact match so a
281
294
  // user's "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
282
- if (line.startsWith("[") && line.trim() !== "[model_providers.opencodex]") {
295
+ if (/^\s*\[/.test(line) && line.trim() !== "[model_providers.opencodex]") {
283
296
  inOcxSection = false;
284
297
  filtered.push(line);
285
298
  }
@@ -293,6 +306,7 @@ function removeOcxSection(content: string): string {
293
306
  /** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */
294
307
  export function stripOpencodexConfig(content: string): string {
295
308
  let out = content;
309
+ const hadRootOcxProvider = readRootTomlString(out, "model_provider") === "opencodex";
296
310
  if (out.includes("[model_providers.opencodex]")) {
297
311
  out = removeOcxSection(out);
298
312
  }
@@ -300,8 +314,7 @@ export function stripOpencodexConfig(content: string): string {
300
314
  // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
301
315
  // must match the detection regex above, or a detected line could survive un-removed.
302
316
  out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
303
- out = stripRootContextWindowOverrides(out);
304
- out = stripRootRoutedModel(out);
317
+ if (hadRootOcxProvider) out = stripRootRoutedModel(out);
305
318
  out = stripOpencodexCatalogPath(out);
306
319
  return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
307
320
  }
@@ -310,7 +323,7 @@ function hasOpencodexRouting(content: string): boolean {
310
323
  return content.includes("[model_providers.opencodex]") || /^\s*model_provider\s*=\s*"opencodex"/m.test(content);
311
324
  }
312
325
 
313
- export function removeCodexConfig(): { success: boolean; message: string } {
326
+ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): { success: boolean; message: string } {
314
327
  if (!existsSync(CODEX_CONFIG_PATH)) {
315
328
  return { success: false, message: "Codex config not found." };
316
329
  }
@@ -321,10 +334,12 @@ export function removeCodexConfig(): { success: boolean; message: string } {
321
334
  } else if (stripOpencodexConfig(content) !== content) {
322
335
  atomicWriteFile(CODEX_CONFIG_PATH, stripOpencodexConfig(content));
323
336
  }
324
- if (existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
337
+ if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
325
338
  return {
326
339
  success: true,
327
- message: had ? "Removed opencodex routing from Codex config + profile." : "opencodex not present in Codex config.",
340
+ message: had
341
+ ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}`
342
+ : "opencodex not present in Codex config.",
328
343
  };
329
344
  }
330
345
 
@@ -334,10 +349,12 @@ export function removeCodexConfig(): { success: boolean; message: string } {
334
349
  * handler, and `ocx restore`. Idempotent + atomic.
335
350
  */
336
351
  export function restoreNativeCodex(): { success: boolean; message: string } {
337
- const cfg = removeCodexConfig();
352
+ const journal = restoreJournalState();
353
+ const cfg = journal.configRestored
354
+ ? { success: true, message: "Codex config restored from opencodex journal." }
355
+ : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
338
356
  const cat = restoreCodexCatalog();
339
357
  const history = syncCodexHistoryProvider("openai");
340
- removeJournal();
341
358
  const msg = cat.removed > 0
342
359
  ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
343
360
  : cfg.message;