@bitkyc08/opencodex 2.48.0 → 2.50.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -12,6 +12,7 @@ import {
12
12
  isCodexAccountGenerationLive,
13
13
  forceRefreshCodexPoolToken,
14
14
  markCodexAccountValidated,
15
+ markCodexAccountValidationFailed,
15
16
  readCodexAccountRecord,
16
17
  saveCodexAccountCredential,
17
18
  CodexCredentialGenerationConflictError,
@@ -114,7 +115,7 @@ export { clearMainAccountInfoCache } from "./main-account-cache";
114
115
  import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome";
115
116
  import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock";
116
117
  import { observeMainReserveRevocation } from "./reserve-availability";
117
- import { maskEmail } from "../lib/privacy";
118
+ import { emailMaskingEnabled, projectEmail } from "../lib/privacy";
118
119
  import { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
119
120
  export { maskEmail } from "../lib/privacy";
120
121
  import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types";
@@ -192,6 +193,7 @@ interface CodexLoginStateRow {
192
193
  code?: string;
193
194
  needsReauth?: boolean;
194
195
  catalogRefreshPending?: boolean;
196
+ validationPending?: boolean;
195
197
  doneAt?: number;
196
198
  }
197
199
  const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
@@ -241,12 +243,15 @@ function codexAccountPersistenceConflict(
241
243
  }
242
244
 
243
245
  /**
244
- * The exact label `parseUsageQuota` emits for the Codex Spark window (quota.ts).
246
+ * The exact labels `parseUsageQuota` emits for the Codex Spark windows (quota.ts).
245
247
  * Matching on the label rather than on "is a custom window" is load-bearing: the same array
246
248
  * carries Cursor's First-party models / API usage, Anthropic's Fable / Opus / Sonnet,
247
249
  * Antigravity's Gem / Cla, Kimi's subscription credits and a dozen dynamic provider meters.
248
250
  */
249
- const CODEX_SPARK_WINDOW_LABEL = "GPT-5.3-Codex-Spark Weekly";
251
+ const CODEX_SPARK_WINDOW_LABELS = new Set([
252
+ "GPT-5.3-Codex-Spark 5h",
253
+ "GPT-5.3-Codex-Spark Weekly",
254
+ ]);
250
255
 
251
256
  /**
252
257
  * Drop the Spark window unless the operator asked for it (default hidden).
@@ -264,7 +269,7 @@ export function withSparkVisibility<T extends Omit<StoredAccountQuota, "updatedA
264
269
  ): T {
265
270
  if (!quota?.customWindows?.length) return quota;
266
271
  if (loadConfig().showCodexSparkQuota === true) return quota;
267
- const kept = quota.customWindows.filter(window => window.label !== CODEX_SPARK_WINDOW_LABEL);
272
+ const kept = quota.customWindows.filter(window => !CODEX_SPARK_WINDOW_LABELS.has(window.label));
268
273
  if (kept.length === quota.customWindows.length) return quota;
269
274
  // An empty list is dropped rather than serialized: an absent field and an empty array should
270
275
  // not be two different ways of saying "no custom windows" on the wire.
@@ -365,6 +370,7 @@ function poolAccountDto(
365
370
  hasCredential: boolean,
366
371
  paused: boolean,
367
372
  priority: number,
373
+ maskEmails: boolean,
368
374
  ): CodexAuthAccountDto {
369
375
  const plan = codexPlanValue(account.plan);
370
376
  const quota = quotaForPlan(quotaResult.quota, plan);
@@ -372,7 +378,7 @@ function poolAccountDto(
372
378
  const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
373
379
  return {
374
380
  id: account.id,
375
- email: maskEmail(account.email) ?? account.email,
381
+ email: projectEmail(account.email, maskEmails) ?? account.email,
376
382
  ...(account.alias !== undefined ? { alias: account.alias } : {}),
377
383
  ...(plan !== undefined ? { plan } : {}),
378
384
  logLabel: codexAccountLogLabel(account),
@@ -380,7 +386,7 @@ function poolAccountDto(
380
386
  paused,
381
387
  priority,
382
388
  quota: quota ? { ...quota } : null,
383
- needsReauth,
389
+ needsReauth: needsReauth || health.status === "reauth_required",
384
390
  hasCredential,
385
391
  ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
386
392
  ...oauthAccountHealthFields("codex", account.id, health),
@@ -635,7 +641,7 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void
635
641
 
636
642
  interface StagedNewCodexAccountState {
637
643
  credential: CodexAccountCredentials;
638
- validatedAt: number;
644
+ validatedAt?: number;
639
645
  }
640
646
 
641
647
  type PersistNewCodexAccountOutcome =
@@ -688,8 +694,10 @@ function persistNewCodexAccount(
688
694
  }
689
695
 
690
696
  try {
691
- saveCodexAccountCredential(addedAccount.id, staged.credential);
692
- markCodexAccountValidated(addedAccount.id, staged.validatedAt);
697
+ const generation = saveCodexAccountCredential(addedAccount.id, staged.credential, {
698
+ validationPending: staged.validatedAt === undefined,
699
+ });
700
+ if (staged.validatedAt !== undefined) markCodexAccountValidated(addedAccount.id, staged.validatedAt, generation);
693
701
  clearAccountNeedsReauth(addedAccount.id);
694
702
  } catch {
695
703
  // Config is already durable. Return the failure outcome through the coordinator so its
@@ -1098,6 +1106,7 @@ interface PoolQuotaRefreshFlight {
1098
1106
  superseded?: boolean;
1099
1107
  startCredentialGeneration?: number;
1100
1108
  resolvedCredentialGeneration?: number;
1109
+ validatePending?: boolean;
1101
1110
  };
1102
1111
  promise: Promise<PoolQuotaResult>;
1103
1112
  }
@@ -1477,11 +1486,12 @@ async function fetchFreshPoolAccountQuota(
1477
1486
  }
1478
1487
  }
1479
1488
 
1480
- async function fetchPoolAccountQuota(
1489
+ export async function fetchPoolAccountQuota(
1481
1490
  accountId: string,
1482
1491
  forceRefresh = false,
1483
1492
  configuredPlan?: string,
1484
1493
  getValidToken: typeof getValidCodexToken = getValidCodexToken,
1494
+ validatePending = false,
1485
1495
  afterDispatchSequence?: number,
1486
1496
  ): Promise<PoolQuotaResult> {
1487
1497
  const existing = getAccountQuota(accountId);
@@ -1504,7 +1514,11 @@ async function fetchPoolAccountQuota(
1504
1514
  && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence)
1505
1515
  && generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
1506
1516
  });
1507
- if (current) return current.promise;
1517
+ if (current) {
1518
+ // A manual refresh joining a passive read must not lose its validation intent.
1519
+ current.state.validatePending ||= validatePending;
1520
+ return current.promise;
1521
+ }
1508
1522
  if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
1509
1523
 
1510
1524
  // A post-reset request must not let an older same-account response overwrite its evidence.
@@ -1514,6 +1528,7 @@ async function fetchPoolAccountQuota(
1514
1528
  }
1515
1529
  const state: PoolQuotaRefreshFlight["state"] = {
1516
1530
  startCredentialGeneration: record?.generation,
1531
+ validatePending,
1517
1532
  };
1518
1533
  const refresh = fetchFreshPoolAccountQuota(
1519
1534
  accountId,
@@ -1525,18 +1540,54 @@ async function fetchPoolAccountQuota(
1525
1540
  onDispatch: sequence => { state.dispatchSequence = sequence; },
1526
1541
  mayPublish: () => state.superseded !== true,
1527
1542
  },
1528
- );
1543
+ ).then(async result => {
1544
+ // A passive flight has consumed its validation decision. Remove it before
1545
+ // promise settlement queues other continuations, so a late explicit caller
1546
+ // starts fresh work instead of setting an intent nobody will read again.
1547
+ if (!state.validatePending) {
1548
+ releaseFlight();
1549
+ return result;
1550
+ }
1551
+ // Only an explicit account-list refresh finishes deferred registration. Passive quota
1552
+ // polls and startup priming remain read-only with respect to inference spending.
1553
+ const generation = result.freshCredentialGeneration;
1554
+ const record = state.validatePending ? readCodexAccountRecord(accountId) : null;
1555
+ if (record?.codexValidationPending && record.credential && record.deletedAt == null
1556
+ && generation !== undefined && record.generation === generation
1557
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) {
1558
+ try {
1559
+ await warmCodexAccount({
1560
+ accessToken: record.credential.accessToken,
1561
+ chatgptAccountId: record.credential.chatgptAccountId,
1562
+ });
1563
+ markCodexAccountValidated(accountId, Date.now(), generation);
1564
+ clearAccountNeedsReauth(accountId, generation);
1565
+ } catch (error) {
1566
+ // Keep the durable restriction on any failed/partial inference response, even
1567
+ // when WHAM just reported headroom. No raw upstream text enters diagnostics.
1568
+ const reason = codexWarmupFailureReason(error);
1569
+ if (reason === "http_status:401" || reason === "http_status:403") {
1570
+ markCodexAccountValidationFailed(accountId, reason, { expectedGeneration: generation });
1571
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), generation);
1572
+ }
1573
+ }
1574
+ }
1575
+ return result;
1576
+ });
1529
1577
  const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
1530
1578
  const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
1531
1579
  activeFlights.add(flight);
1532
1580
  if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
1533
- try {
1534
- return await refresh;
1535
- } finally {
1581
+ const releaseFlight = () => {
1536
1582
  activeFlights.delete(flight);
1537
1583
  if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
1538
1584
  poolQuotaRefreshInFlight.delete(accountId);
1539
1585
  }
1586
+ };
1587
+ try {
1588
+ return await refresh;
1589
+ } finally {
1590
+ releaseFlight();
1540
1591
  }
1541
1592
  }
1542
1593
 
@@ -1591,8 +1642,10 @@ async function refreshAfterManualReset(
1591
1642
  }
1592
1643
  return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration };
1593
1644
  };
1645
+ // `validatePending` is false here: a manual reset settles cooldown, and finishing deferred
1646
+ // registration stays reserved for an explicit dashboard account-list refresh.
1594
1647
  const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken,
1595
- didReset ? afterDispatchSequence : undefined);
1648
+ false, didReset ? afterDispatchSequence : undefined);
1596
1649
  const record = readCodexAccountRecord(accountId);
1597
1650
  const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId
1598
1651
  && (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence
@@ -1873,9 +1926,12 @@ export interface CodexAuthAccountsSnapshot {
1873
1926
  export async function listCodexAuthAccountsSnapshot(
1874
1927
  config: OcxConfig,
1875
1928
  forceRefresh = false,
1929
+ options: { validatePending?: boolean } = {},
1876
1930
  ): Promise<CodexAuthAccountsSnapshot> {
1877
1931
  const runtimeConfig = getRuntimeConfig(config);
1878
1932
  const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
1933
+ // One redaction decision for the whole snapshot, read once from the operator's config (#3859).
1934
+ const maskEmails = emailMaskingEnabled(runtimeConfig);
1879
1935
  const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
1880
1936
  const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
1881
1937
  const cred = getCodexAccountCredential(account.id);
@@ -1884,7 +1940,7 @@ export async function listCodexAuthAccountsSnapshot(
1884
1940
  quotaResult = { quota: null, needsReauth: true };
1885
1941
  } else {
1886
1942
  try {
1887
- quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan);
1943
+ quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true);
1888
1944
  } catch (error) {
1889
1945
  if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
1890
1946
  quotaResult = {
@@ -1920,6 +1976,7 @@ export async function listCodexAuthAccountsSnapshot(
1920
1976
  false,
1921
1977
  isCodexAccountPaused(runtimeConfig, accountId),
1922
1978
  getCodexAccountPriority(runtimeConfig, accountId),
1979
+ maskEmails,
1923
1980
  )];
1924
1981
  }
1925
1982
  const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
@@ -1939,6 +1996,7 @@ export async function listCodexAuthAccountsSnapshot(
1939
1996
  true,
1940
1997
  isCodexAccountPaused(runtimeConfig, accountId),
1941
1998
  getCodexAccountPriority(runtimeConfig, accountId),
1999
+ maskEmails,
1942
2000
  )];
1943
2001
  });
1944
2002
  const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
@@ -1955,7 +2013,7 @@ export async function listCodexAuthAccountsSnapshot(
1955
2013
  });
1956
2014
  const main: CodexAuthAccountDto = {
1957
2015
  id: MAIN_CODEX_ACCOUNT_ID,
1958
- email: maskEmail(mainInfo.email) ?? "Codex App login",
2016
+ email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
1959
2017
  plan: mainInfo.plan,
1960
2018
  ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
1961
2019
  && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
@@ -2016,8 +2074,12 @@ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountI
2016
2074
  }
2017
2075
  }
2018
2076
 
2019
- export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
2020
- return (await listCodexAuthAccountsSnapshot(config, forceRefresh)).accounts;
2077
+ export async function listCodexAuthAccounts(
2078
+ config: OcxConfig,
2079
+ forceRefresh = false,
2080
+ options: { validatePending?: boolean } = {},
2081
+ ): Promise<CodexAuthAccountDto[]> {
2082
+ return (await listCodexAuthAccountsSnapshot(config, forceRefresh, options)).accounts;
2021
2083
  }
2022
2084
 
2023
2085
  interface PauseExhaustedResult {
@@ -2126,6 +2188,7 @@ export async function handleCodexAuthAPI(
2126
2188
  url: URL,
2127
2189
  config: OcxConfig,
2128
2190
  convergeCodexCatalog?: CodexAuthCatalogConvergence,
2191
+ principal?: import("../server/management-auth").ManagementPrincipal,
2129
2192
  ): Promise<Response | null> {
2130
2193
 
2131
2194
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
@@ -2133,6 +2196,14 @@ export async function handleCodexAuthAPI(
2133
2196
  return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) });
2134
2197
  }
2135
2198
 
2199
+ if (url.pathname === "/api/codex-auth/accounts/refresh" && req.method === "POST") {
2200
+ // Inference spends quota: only a dashboard session carries the consent
2201
+ // required by AGENTS_INSTALL.md. Raw-admin/CLI refreshes remain observational.
2202
+ return jsonResponse({ accounts: await listCodexAuthAccounts(config, true, {
2203
+ validatePending: principal === "gui-session",
2204
+ }) });
2205
+ }
2206
+
2136
2207
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
2137
2208
  return manualImportDisabledResponse();
2138
2209
  }
@@ -2310,6 +2381,9 @@ export async function handleCodexAuthAPI(
2310
2381
  const exists = (runtimeConfig.codexAccounts ?? [])
2311
2382
  .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
2312
2383
  if (!exists) return jsonResponse({ error: "Account not found" }, 400);
2384
+ if (readCodexAccountRecord(body.accountId)?.codexValidationPending) {
2385
+ return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409);
2386
+ }
2313
2387
  }
2314
2388
  runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
2315
2389
  // "Use this account now" outranks selection order until the account is spent:
@@ -2754,7 +2828,12 @@ export async function handleCodexAuthAPI(
2754
2828
  break;
2755
2829
  }
2756
2830
 
2757
- const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
2831
+ // A successful authenticated WHAM read can prove quota is exhausted without
2832
+ // spending an inference request. Store the account, but defer inference validation
2833
+ // and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
2834
+ const warmup = isCodexQuotaExhausted(quota, plan)
2835
+ ? { ok: true as const, validatedAt: undefined }
2836
+ : await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
2758
2837
  if (!warmup.ok) {
2759
2838
  const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
2760
2839
  setCodexLoginState(flowId, {
@@ -2794,11 +2873,13 @@ export async function handleCodexAuthAPI(
2794
2873
  };
2795
2874
 
2796
2875
  if (existingIdx >= 0) {
2797
- saveCodexAccountCredential(accountId, credential);
2876
+ const generation = saveCodexAccountCredential(accountId, credential, {
2877
+ validationPending: warmup.validatedAt === undefined,
2878
+ });
2798
2879
  // A successful reauthentication replaces the credential generation. Do not let a
2799
2880
  // failed optional WHAM probe make the replacement inherit quota from the old record.
2800
2881
  if (reauth) clearAccountQuota(accountId);
2801
- markCodexAccountValidated(accountId, warmup.validatedAt);
2882
+ if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation);
2802
2883
  clearAccountNeedsReauth(accountId);
2803
2884
  if (quota) setAccountQuotaFromParsed(accountId, quota);
2804
2885
  // Keep the pool id stable; refresh display metadata after a successful login/reauth.
@@ -2849,6 +2930,7 @@ export async function handleCodexAuthAPI(
2849
2930
  status: "done",
2850
2931
  accountId,
2851
2932
  email,
2933
+ ...(warmup.validatedAt === undefined ? { validationPending: true } : {}),
2852
2934
  ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
2853
2935
  doneAt: Date.now(),
2854
2936
  });
@@ -2949,6 +3031,9 @@ export async function handleCodexAuthAPI(
2949
3031
  if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
2950
3032
  const flowId = url.searchParams.get("flowId");
2951
3033
  const accountId = url.searchParams.get("accountId")?.trim();
3034
+ // Transient flow state carries the address of the account being added, so it follows the
3035
+ // same operator policy as the stored accounts it is about to become.
3036
+ const maskFlowEmails = emailMaskingEnabled(config);
2952
3037
  // Reauth always has a pre-existing credential; never treat "credential exists" as success
2953
3038
  // when the flow map entry is gone (would false-complete on lost/expired flow state).
2954
3039
  const reauthStatus = url.searchParams.get("reauth") === "1";
@@ -2961,13 +3046,15 @@ export async function handleCodexAuthAPI(
2961
3046
  && !isAccountNeedsReauth(accountId)
2962
3047
  && getCodexAccountCredential(accountId)
2963
3048
  ) {
2964
- return jsonResponse({ status: "done", accountId });
3049
+ return jsonResponse({ status: "done", accountId,
3050
+ ...(readCodexAccountRecord(accountId)?.codexValidationPending ? { validationPending: true } : {}),
3051
+ });
2965
3052
  }
2966
- return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" });
3053
+ return jsonResponse(st ? { ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined } : { status: "expired" });
2967
3054
  }
2968
3055
  // Legacy fallback: return latest pending flow
2969
3056
  for (const [, st] of codexAuthLoginState) {
2970
- if (st.status === "pending") return jsonResponse({ ...st, email: maskEmail(st.email) ?? undefined });
3057
+ if (st.status === "pending") return jsonResponse({ ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined });
2971
3058
  }
2972
3059
  return jsonResponse({ status: "idle" });
2973
3060
  }
@@ -6,6 +6,7 @@ import { resolveCodexHomeDir } from "./home";
6
6
  import { extractAccountId } from "../oauth/chatgpt";
7
7
  import { isSelectableCodexPoolAccount } from "./account-id";
8
8
  import { codexPlanKey } from "./plan";
9
+ import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store";
9
10
 
10
11
  export interface CodexTokens {
11
12
  access_token: string;
@@ -31,12 +32,21 @@ function hasErrnoCode(error: unknown, code: string): boolean {
31
32
  /**
32
33
  * Reads the Codex CLI credential file and classifies the outcome. Reads once instead of doing an
33
34
  * `existsSync` pre-check, so a file replaced between check and read cannot be misread as absent.
35
+ * An already-owned lifecycle may supply its pinned auth path instead of resolving ambient home.
36
+ * `bounded` opts into the native-profile bounded reader (regular file, size-capped, no-follow,
37
+ * non-blocking) for startup observation paths that run inside the owner claim; bounded violations
38
+ * classify as `unreadable`. Legacy callers keep the unbounded read.
34
39
  * Never returns or logs the raw error or any token material.
35
40
  */
36
- export function readCodexTokensResult(): CodexTokenReadResult {
41
+ export function readCodexTokensResult(
42
+ authPath = join(resolveCodexHomeDir(), "auth.json"),
43
+ options?: { bounded?: boolean },
44
+ ): CodexTokenReadResult {
37
45
  let raw: string;
38
46
  try {
39
- raw = readFileSync(join(resolveCodexHomeDir(), "auth.json"), "utf-8");
47
+ raw = options?.bounded === true
48
+ ? readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8")
49
+ : readFileSync(authPath, "utf-8");
40
50
  } catch (error) {
41
51
  return { status: hasErrnoCode(error, "ENOENT") ? "missing" : "unreadable" };
42
52
  }
@@ -1,11 +1,13 @@
1
- import { createHmac, randomBytes } from "node:crypto";
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import {
3
3
  CodexCredentialGenerationConflictError,
4
4
  CodexCredentialRefreshLockTimeoutError,
5
5
  CodexCredentialRefreshBusyError,
6
6
  CodexCredentialRefreshStaleError,
7
+ getCodexAccountCredential,
7
8
  getValidCodexToken,
8
9
  isCodexAccountGenerationLive,
10
+ readCodexAccountRecord,
9
11
  } from "./account-store";
10
12
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
11
13
  import { ConfigMutationLockError } from "../config";
@@ -21,7 +23,7 @@ import {
21
23
  isMainAccountTokenLive,
22
24
  type NativeMainRefreshDependencies,
23
25
  } from "./main-account";
24
- import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
26
+ import { isMainAccountPolicyBindingPending, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
25
27
  import type { NativeMainStartupBlockReason } from "./native-profile-startup";
26
28
  import {
27
29
  codexQuotaScopeForModel,
@@ -49,7 +51,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
49
51
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
50
52
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
51
53
  import { retainedUtf8Bytes } from "../lib/admission";
52
- import { extractAccountId } from "../oauth/chatgpt";
54
+ import { extractAccountId, extractEmail } from "../oauth/chatgpt";
53
55
  import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock";
54
56
  import {
55
57
  captureMainAccountIdentityGeneration,
@@ -62,7 +64,7 @@ import {
62
64
  } from "./main-account-cache";
63
65
  import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target";
64
66
  import type { DataPlaneAdmission } from "../server/auth-cors";
65
- import { getMainReserveAuthorization, isMainReserveAuthorizationLive, type MainReserveAuthorization } from "./reserve-availability";
67
+ import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
66
68
  import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
67
69
 
68
70
  const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
@@ -199,6 +201,19 @@ export class CodexPoolAuthenticationError extends Error {
199
201
  }
200
202
  }
201
203
 
204
+ class CodexAccountValidationPendingError extends CodexPoolAuthenticationError {
205
+ constructor() {
206
+ super("Codex account validation is pending; refresh quota after recovery to validate it");
207
+ this.name = "CodexAccountValidationPendingError";
208
+ }
209
+ }
210
+
211
+ function assertCodexAccountValidationReady(accountId: string): void {
212
+ if (accountId !== MAIN_CODEX_ACCOUNT_ID && readCodexAccountRecord(accountId)?.codexValidationPending) {
213
+ throw new CodexAccountValidationPendingError();
214
+ }
215
+ }
216
+
202
217
  export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE =
203
218
  "OpenCodex local native-main profile maintenance is active; retry this request";
204
219
 
@@ -455,6 +470,54 @@ function callerMatchesObservedMain(headers: Headers): boolean {
455
470
  return matchesMainQuotaCredential(bearer, effectiveAccountId);
456
471
  }
457
472
 
473
+ /** Constant-time digest comparison so bearer bytes never drive branch timing. */
474
+ function sameCredentialMaterial(a: string, b: string): boolean {
475
+ return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
476
+ }
477
+
478
+ /**
479
+ * The early-cooldown caller-main fallback must not resurrect the subscription that is cooling
480
+ * down. Fail closed on ambiguity: an unreadable caller identity cannot be distinguished from the
481
+ * cooled account. A distinct workspace account id is always safe; an exact materialized
482
+ * bearer + account tuple marks the same subscription. Beyond that, the stable native user id is
483
+ * the strongest available evidence: it survives an email change and a token rotation, and it
484
+ * separates members who share one workspace account id even when neither credential carries an
485
+ * email. Email remains the fallback when no comparable user id exists on both sides. Coexisting
486
+ * personal/business registrations with the same email and account id over-deny during the
487
+ * cooldown — the safe direction.
488
+ */
489
+ function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean {
490
+ const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
491
+ if (!bearer) return true;
492
+ const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
493
+ if (callerAccountId === undefined) return true;
494
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
495
+ // No physical-main read to identify the caller: the observed-main equality tag suffices.
496
+ return callerMatchesObservedMain(headers);
497
+ }
498
+ const stored = getCodexAccountCredential(accountId);
499
+ const entry = config.codexAccounts?.find(account => account.id === accountId);
500
+ const cooledAccountId = stored?.chatgptAccountId || entry?.chatgptAccountId;
501
+ if (!cooledAccountId) return true;
502
+ if (cooledAccountId !== callerAccountId) return false;
503
+ if (stored?.accessToken && sameCredentialMaterial(bearer, stored.accessToken)) return true;
504
+ // Same namespace on both sides, never `sub`: this is the ChatGPT per-user identity the reserve
505
+ // path already trusts. A credential whose own two encodings of it disagree cannot identify
506
+ // anyone, so it fails closed even when the disagreement is on the stored side.
507
+ const callerUser = nativeUserIdClaims(bearer);
508
+ const cooledUser = stored?.accessToken
509
+ ? nativeUserIdClaims(stored.accessToken)
510
+ : { userId: undefined, conflict: false };
511
+ if (callerUser.conflict || cooledUser.conflict) return true;
512
+ if (callerUser.userId !== undefined && cooledUser.userId !== undefined) {
513
+ return callerUser.userId === cooledUser.userId;
514
+ }
515
+ const callerEmail = extractEmail(undefined, bearer)?.trim().toLowerCase() || undefined;
516
+ const cooledEmail = entry?.email?.trim().toLowerCase() || undefined;
517
+ if (callerEmail !== undefined && cooledEmail !== undefined) return callerEmail === cooledEmail;
518
+ return true;
519
+ }
520
+
458
521
  function captureObservedMainWriter(): MainQuotaWriter | undefined {
459
522
  const identityKey = getObservedMainQuotaIdentityKey();
460
523
  return identityKey === undefined ? undefined : {
@@ -536,6 +599,7 @@ export class CodexThreadAffinityExpiredError extends Error {
536
599
 
537
600
  export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
538
601
  return !(cause instanceof CodexMainAccountHardLockError)
602
+ && !(cause instanceof CodexAccountValidationPendingError)
539
603
  && !(cause instanceof CodexReserveUnavailableError)
540
604
  && !(cause instanceof CodexCredentialGenerationConflictError)
541
605
  && !(cause instanceof CodexCredentialRefreshLockTimeoutError)
@@ -598,20 +662,31 @@ export async function resolveCodexAuthContext(
598
662
  throw new CodexReserveUnavailableError();
599
663
  }
600
664
  const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
601
- const preserveRequestOwnedMainPin = requestScopedMainCredential
665
+ const requestOwnedMainPinCandidate = requestScopedMainCredential
602
666
  && fixedAccountId === undefined
603
667
  && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
604
668
  && isEffectiveCodexAccountPinned(config)
605
669
  && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
606
- && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy))
607
670
  && requestOwnedMainPinHasQuotaHeadroom(config);
671
+ // During an owned startup, equality cannot be established until recovery and the
672
+ // memory-only policy binding finish. This read-only fence never probes a foreign home.
673
+ if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) {
674
+ throw new CodexMainProfileDrainingError();
675
+ }
676
+ const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate
677
+ && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy));
608
678
  if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
609
679
  throw new Error("Codex auth context cannot select and exclude an account simultaneously");
610
680
  }
611
681
  const resolveCallerOwnedMainContext = async (): Promise<CodexAuthContext> => {
612
- if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
613
682
  const substituteStoredMain = options.substituteMainCredentialForDirect === true;
683
+ // An internal route change can strip the admission bearer before this point.
684
+ // Trusted substitution still has to claim and validate stored main below.
685
+ if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
614
686
  if (!substituteStoredMain) {
687
+ if (policy.codexMainAccountHardLock === true && isMainAccountPolicyBindingPending()) {
688
+ throw new CodexMainProfileDrainingError();
689
+ }
615
690
  if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
616
691
  if (reserve) {
617
692
  const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
@@ -851,6 +926,9 @@ export async function resolveCodexAuthContext(
851
926
  } finally {
852
927
  selectionAdmission?.release();
853
928
  }
929
+ // Legacy selectors may retain an unusable account for actionable errors. A
930
+ // deferred credential must never become request auth through that fallback.
931
+ assertCodexAccountValidationReady(accountId);
854
932
  // Lazy prime: if the selected account has no quota yet, the pool is likely
855
933
  // unprimed (dashboard never opened, or startup prime was blocked). Kick a
856
934
  // best-effort prime so the NEXT routing decision has real scores. This never
@@ -885,6 +963,14 @@ export async function resolveCodexAuthContext(
885
963
  ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined
886
964
  : tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
887
965
  if (!probeLeaseId) {
966
+ // The selector can retain the configured Pool account when no stored
967
+ // alternate is eligible. A validated caller may still serve this request,
968
+ // just as it can after an upstream rejection, without changing Pool state.
969
+ if (requestScopedMainCredential && fixedAccountId === undefined
970
+ && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
971
+ && !callerIsCooledPoolAccount(headers, config, accountId)) {
972
+ return await resolveCallerOwnedMainContext();
973
+ }
888
974
  throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
889
975
  }
890
976
  }
@@ -938,6 +1024,7 @@ export async function resolveCodexAuthContext(
938
1024
 
939
1025
  try {
940
1026
  const token = await getValidCodexToken(accountId);
1027
+ assertCodexAccountValidationReady(accountId);
941
1028
  return {
942
1029
  kind: "pool",
943
1030
  accountId,
@@ -977,6 +1064,7 @@ export function applyCodexAuthContextToProvider(
977
1064
  mode: CodexAccountMode | undefined,
978
1065
  ): OcxRuntimeProviderConfig {
979
1066
  if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider;
1067
+ assertCodexAccountValidationReady(ctx.accountId);
980
1068
  return {
981
1069
  ...provider,
982
1070
  _codexAccountOverride: {
@@ -1019,6 +1107,7 @@ export function materializeCodexUpstreamAuth(
1019
1107
  if (value) selected.set(name, value);
1020
1108
  }
1021
1109
  if (ctx.kind === "pool" || ctx.kind === "main-pool") {
1110
+ assertCodexAccountValidationReady(ctx.accountId);
1022
1111
  selected.set("authorization", `Bearer ${ctx.accessToken}`);
1023
1112
  selected.set("chatgpt-account-id", ctx.chatgptAccountId);
1024
1113
  if (ctx.kind === "main-pool") {
@@ -144,6 +144,29 @@ export interface CatalogModel {
144
144
  codexToolMode?: "code_mode_only" | "shell";
145
145
  /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
146
146
  capabilities?: string[];
147
+ /**
148
+ * This row is listed but cannot currently serve a request (#1711). Today the only value is
149
+ * "no_credit", set when every usable target has positive quota-exhaustion evidence.
150
+ *
151
+ * It is NOT visibility. The row stays `visibility: "list"` on purpose: the issue explicitly
152
+ * rejects hiding, and Codex Desktop only understands "list" and "hide" anyway, so hiding would
153
+ * be the one outcome the reporter asked not to have. An OpenCodex-aware consumer greys the
154
+ * entry; the native picker ignores the field, which is the honest limit of what a custom
155
+ * catalog field can do.
156
+ */
157
+ quotaInactiveReason?: "no_credit";
158
+ /**
159
+ * Discovered per-token cost class for this routed model (#3666). "free" means the provider's
160
+ * own /models row reported a numeric zero for BOTH the prompt and the completion rate;
161
+ * "paid" means at least one rate is above zero. ABSENT means unknown — the provider published
162
+ * no usable pair, or this row never came from a models API at all.
163
+ *
164
+ * Fail closed: a partial, non-numeric, or negative rate leaves the field absent, never "free",
165
+ * because showing a paid model under a Free filter costs the user money while hiding a free
166
+ * one costs a click. This is a management/Dashboard projection only — deriveEntry never
167
+ * serializes it into the Codex catalog, and it never affects routing or visibility.
168
+ */
169
+ pricingStatus?: "free" | "paid";
147
170
  /** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */
148
171
  catalogKind?: typeof CODEX_CUSTOM_MODEL_CATALOG_KIND | typeof CODEX_PROVIDER_MODEL_CATALOG_KIND;
149
172
  }