@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -136,25 +136,37 @@ export function peekCodexAccountRecordStore(): Record<string, CodexAccountCreden
136
136
 
137
137
  /** Read the Codex account record store from disk with hardened permissions, and hand it to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
138
138
  function loadCodexAccountRecordStore(): CodexAccountStore {
139
+ const result = loadCodexAccountRecordStoreResult();
140
+ return result.status === "ok" ? result.records : {};
141
+ }
142
+
143
+ type CodexAccountRecordStoreResult = { status: "ok"; records: CodexAccountStore } | { status: "failed" };
144
+
145
+ /** Retain read/parse failure separately for consumers that must preserve existing evidence. */
146
+ function loadCodexAccountRecordStoreResult(): CodexAccountRecordStoreResult {
139
147
  const path = codexAccountsPath();
140
148
  hardenConfigDir();
141
149
  hardenExistingSecret(path);
142
- if (!existsSync(path)) { notifyCodexStoreLoaded({}); return {}; }
143
150
  const normalized: CodexAccountStore = {};
144
151
  try {
145
152
  const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore;
153
+ if (!isObject(raw)) throw new Error("invalid Codex account store shape");
146
154
  for (const [id, value] of Object.entries(raw)) {
147
155
  const record = normalizeRecord(value);
148
156
  if (record) normalized[id] = record;
149
157
  }
150
- } catch {
158
+ } catch (error) {
159
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
160
+ notifyCodexStoreLoaded({});
161
+ return { status: "ok", records: {} };
162
+ }
151
163
  backupInvalidConfig(path);
152
164
  notifyCodexStoreLoaded({});
153
- return {};
165
+ return { status: "failed" };
154
166
  }
155
167
  // Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path.
156
168
  notifyCodexStoreLoaded(normalized);
157
- return normalized;
169
+ return { status: "ok", records: normalized };
158
170
  }
159
171
 
160
172
  /** Atomically write the Codex account store, then hand it to the opt-in decision-quota slot so active advisory evidence tracks the persisted credentials. */
@@ -400,6 +412,10 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
400
412
  return !!record?.credential && record.deletedAt == null && record.generation === generation;
401
413
  }
402
414
 
415
+ /** Callable for existing liveness consumers; stateful consumers can distinguish unknown snapshots. */
416
+ export type CodexAccountGenerationLiveCheck = ((id: string, generation: number) => boolean)
417
+ & { readonly snapshotStatus: "ok" | "failed" };
418
+
403
419
  /**
404
420
  * The same verdict as {@link isCodexAccountGenerationLive}, over ONE store load.
405
421
  *
@@ -409,12 +425,15 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
409
425
  * several accounts in one synchronous pass on the request path, so it opens one checker and
410
426
  * closes over the snapshot instead.
411
427
  */
412
- export function beginCodexAccountGenerationLiveCheck(): (id: string, generation: number) => boolean {
413
- const snapshot = loadCodexAccountRecordSnapshot();
414
- return (id, generation) => {
428
+ export function beginCodexAccountGenerationLiveCheck(): CodexAccountGenerationLiveCheck {
429
+ let result: CodexAccountRecordStoreResult;
430
+ try { result = loadCodexAccountRecordStoreResult(); }
431
+ catch { result = { status: "failed" }; }
432
+ const snapshot = result.status === "ok" ? result.records : {};
433
+ return Object.assign((id: string, generation: number) => {
415
434
  const record = snapshot[id];
416
435
  return !!record?.credential && record.deletedAt == null && record.generation === generation;
417
- };
436
+ }, { snapshotStatus: result.status });
418
437
  }
419
438
 
420
439
  export function saveCodexAccountCredentialIfGeneration(
@@ -9,6 +9,7 @@ import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./a
9
9
  import type { OcxConfig } from "../types";
10
10
  import { isNativeMainTrafficBlocked } from "./native-profile-startup";
11
11
  import { isMainAccountHardLocked } from "./main-account-hard-lock";
12
+ import { configuredCodexMainDuplicate } from "./collision-identity";
12
13
 
13
14
  export interface CodexAccountUsabilityOptions {
14
15
  /** Route using cached runtime state only; the caller must reject selected main before auth. */
@@ -64,7 +65,8 @@ export type CodexAccountUnusableReason =
64
65
  | "not_in_pool"
65
66
  | "missing_credential"
66
67
  | "deleted"
67
- | "validation_pending";
68
+ | "validation_pending"
69
+ | "duplicate_main";
68
70
 
69
71
  /**
70
72
  * The single source of truth for both selection and its explanation. `isCodexAccountUsable()` is
@@ -110,6 +112,7 @@ export function codexAccountUnusableReason(
110
112
  if (!record?.credential) return "missing_credential";
111
113
  if (record.deletedAt != null) return "deleted";
112
114
  if (record.codexValidationPending) return "validation_pending";
115
+ if (configuredCodexMainDuplicate(config, accountId)) return "duplicate_main";
113
116
  return undefined;
114
117
  }
115
118
 
@@ -1,7 +1,6 @@
1
1
  import { getMainAccountExternalUsageWarning, type MainAccountExternalUsageWarning } from "../main-account-external-usage";
2
2
  import { codexCreditsDtoField, hasCodexCreditsObservation, pruneCodexCredits } from "../credits";
3
3
  import type { CodexCredits } from "../credits";
4
- import { getMainChatgptAccountId } from "../auth-collision";
5
4
  import { codexAccountLogLabel } from "../account-label";
6
5
  import { poolQuotaHistoryIdentity, getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store";
7
6
  import { getAccountQuota, isCodexQuotaExhausted, setAccountQuotaFromParsed, withoutRetiredCodexQuota } from "../quota";
@@ -35,6 +34,7 @@ import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONC
35
34
  import type { PoolQuotaResult } from "./pool-quota-probe";
36
35
  import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
37
36
  import { isPassiveRead } from "../../lib/passive-read";
37
+ import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate, getObservedMainCodexCollisionIdentity } from "../collision-identity";
38
38
 
39
39
  export function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
40
40
  quota: T,
@@ -133,6 +133,7 @@ export function poolAccountDto(
133
133
  // health projection. Emitting only the boolean is what left #4212's reporter guessing which
134
134
  // account took their model away and why, so name the cause they actually have to act on.
135
135
  const reauthReason: CodexAccountReauthReason | undefined = rawReauthReason ?? (health.status === "reauth_required" ? health.reason : undefined);
136
+ const duplicateMain = configuredCodexMainDuplicate(config, account.id);
136
137
  return {
137
138
  id: account.id,
138
139
  email: projectEmail(account.email, maskEmails) ?? account.email,
@@ -150,7 +151,10 @@ export function poolAccountDto(
150
151
  ...codexCreditsDtoField(config, account.id, poolQuotaHistoryIdentity(account.id) ?? null),
151
152
  needsReauth: needsReauth || health.status === "reauth_required",
152
153
  ...(reauthReason !== undefined ? { reauthReason } : {}),
153
- ...(isCodexAccountPlanExcluded(config, account.id) ? {
154
+ ...(duplicateMain ? {
155
+ selectionExcludedReason: "duplicate_main" as const,
156
+ selectionExcludedAction: CODEX_MAIN_DUPLICATE_ACTION,
157
+ } : isCodexAccountPlanExcluded(config, account.id) ? {
154
158
  selectionExcludedReason: "plan_excluded" as const,
155
159
  selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
156
160
  } : {}),
@@ -184,8 +188,9 @@ export interface CodexAuthAccountDto {
184
188
  * needs the operator; `/api/oauth/accounts` already carries the same field name.
185
189
  */
186
190
  reauthReason?: CodexAccountReauthReason;
187
- /** Automatic selection policy only; explicit routes retain their usual auth checks. */
188
- selectionExcludedReason?: "plan_excluded";
191
+ /** Plan policy is automatic-only; duplicate-main exclusions also apply to explicit routes. */
192
+ selectionExcludedReason?: "plan_excluded" | "duplicate_main";
193
+ selectionExcludedAction?: string;
189
194
  selectionExcludedPlan?: string;
190
195
  hasCredential: boolean;
191
196
  health: OAuthAccountHealth;
@@ -418,7 +423,7 @@ export async function listCodexAuthAccountsSnapshot(
418
423
  quota: mainInfo.quota
419
424
  ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
420
425
  : null,
421
- ...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId()),
426
+ ...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getObservedMainCodexCollisionIdentity()?.accountId ?? null),
422
427
  ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
423
428
  };
424
429
  return {
@@ -1,35 +1,15 @@
1
1
  import { observeSignedInCodexIdentity } from "../signed-in-identity";
2
2
  import { lstatSync } from "node:fs";
3
3
  import type { OcxConfig } from "../../types";
4
- import { readCodexTokensResult } from "../auth-collision";
5
- import type { CodexTokenReadResult } from "../auth-collision";
4
+ import { readMainAuth } from "../auth-collision";
6
5
  import { linkedCodexAccountIds } from "../account-identity";
7
6
  import { resolveCodexHomeDir } from "../home";
8
7
  import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
9
8
  import { withNativeMainSharedClaim } from "../native-main-claim";
10
- import { MAX_AUTH_BYTES, readBounded, resolveNativeProfileContext } from "../native-profile-store";
9
+ import { resolveNativeProfileContext } from "../native-profile-store";
11
10
  import { NativeProfileError } from "../native-profile-types";
12
11
  import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http";
13
12
 
14
- type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
15
-
16
- /** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
17
- function readMainAuth(authPath: string): MainAuth {
18
- const main = readCodexTokensResult(authPath, { bounded: true });
19
- if (main.status !== "invalid") return main;
20
- try {
21
- const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
22
- if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
23
- const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
24
- const apiKeyOnly = envelope.tokens == null
25
- && (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
26
- && typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
27
- return apiKeyOnly ? { status: "api-key-only" } : main;
28
- } catch {
29
- return main;
30
- }
31
- }
32
-
33
13
  /** Hold admission throughout; claim the physical main whenever its home exists. */
34
14
  export async function withCodexAccountPauseGroup(
35
15
  config: OcxConfig,
@@ -0,0 +1,59 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { extractAccountId, extractAccountIdClaims, extractEmail } from "../../oauth/chatgpt";
3
+ import { checkAccountIdCollision, readMainAuth } from "../auth-collision";
4
+ import { resolveCodexHomeDir } from "../home";
5
+ import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
6
+ import { withNativeMainSharedClaim } from "../native-main-claim";
7
+ import { resolveNativeProfileContext } from "../native-profile-store";
8
+ import { NativeProfileError } from "../native-profile-types";
9
+ import { extractChatgptPlanType } from "../plan";
10
+ import { isNativeMainClaimUnavailable } from "./http";
11
+
12
+ export class CodexCollisionReadBusyError extends Error {
13
+ constructor() { super("Native Codex login is busy or unreadable; retry login shortly."); }
14
+ }
15
+
16
+ /** Only admission owns physical reads; synchronous collision consumers use observed identity. */
17
+ export async function checkAccountIdCollisionWithNativeMain(
18
+ accountId: string, email?: string | null, plan?: unknown, excludeAccountId?: string | null,
19
+ ): Promise<ReturnType<typeof checkAccountIdCollision>> {
20
+ const lease = tryAcquireNativeMainProfileClaim();
21
+ if (!lease) throw new CodexCollisionReadBusyError();
22
+ try {
23
+ const codexHome = resolveCodexHomeDir();
24
+ try { lstatSync(codexHome); } catch (error) {
25
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
26
+ return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
27
+ }
28
+ throw new CodexCollisionReadBusyError();
29
+ }
30
+ const context = resolveNativeProfileContext({ codexHome });
31
+ return await withNativeMainSharedClaim(context, async () => {
32
+ const main = readMainAuth(context.authPath);
33
+ if (main.status === "missing" || main.status === "api-key-only") {
34
+ return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
35
+ }
36
+ if (main.status !== "ok") throw new CodexCollisionReadBusyError();
37
+ const tokens = main.tokens;
38
+ const nativeId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id;
39
+ const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)];
40
+ const emails = [extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)].filter(Boolean);
41
+ if (!nativeId || typeof tokens.account_id !== "string" || typeof tokens.access_token !== "string"
42
+ || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")
43
+ || claims.some(claim => claim.conflict || (claim.accountId && claim.accountId !== nativeId))
44
+ || (tokens.account_id && tokens.account_id !== nativeId) || new Set(emails).size > 1) {
45
+ throw new CodexCollisionReadBusyError();
46
+ }
47
+ return checkAccountIdCollision(accountId, email, plan, excludeAccountId, {
48
+ accountId: nativeId, email: extractEmail(tokens.id_token, tokens.access_token),
49
+ plan: extractChatgptPlanType(tokens.id_token, tokens.access_token),
50
+ });
51
+ });
52
+ } catch (error) {
53
+ if (isNativeMainClaimUnavailable(error)
54
+ || (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) {
55
+ throw new CodexCollisionReadBusyError();
56
+ }
57
+ throw error;
58
+ } finally { lease.release(); }
59
+ }
@@ -5,7 +5,8 @@ import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
5
5
  import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config";
6
6
  import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces";
7
7
  import { catalogRefreshIsPending, normalizeCatalogDisposition } from "../catalog-refresh-status";
8
- import { checkAccountIdCollision } from "../auth-collision";
8
+ import { checkAccountIdCollisionWithNativeMain as checkAccountIdCollision, CodexCollisionReadBusyError } from "./login-collision";
9
+ import { extractChatgptPlanType } from "../plan";
9
10
  import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
10
11
  import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff";
11
12
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
@@ -262,8 +263,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
262
263
  break;
263
264
  }
264
265
 
265
- let email = cred.email || accountId;
266
- let plan: string | undefined;
266
+ let email = cred.email?.trim() || "";
267
+ let plan = extractChatgptPlanType(undefined, cred.access);
267
268
  let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
268
269
  let policyQuota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
269
270
  try {
@@ -274,8 +275,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
274
275
  });
275
276
  if (resp.ok) {
276
277
  const data = (await resp.json()) as WhamUsageResponse;
277
- email = data.email ?? email;
278
- plan = nonEmptyPlan(data.plan_type) ?? undefined;
278
+ email = data.email?.trim() || email;
279
+ plan = nonEmptyPlan(data.plan_type) ?? plan;
279
280
  quota = parseUsageQuota(data);
280
281
  policyQuota = isValidWhamHistoryObservation(data) ? quota : null;
281
282
  }
@@ -322,7 +323,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
322
323
  }
323
324
 
324
325
  // 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
325
- const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
326
+ const collision = await checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
326
327
  if (collision.collision) {
327
328
  setCodexLoginState(flowId, {
328
329
  status: "error", error: collision.reason, doneAt: Date.now(),
@@ -331,6 +332,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
331
332
  break;
332
333
  }
333
334
 
335
+ email ||= accountId;
336
+
334
337
  // A successful authenticated WHAM read can prove quota is exhausted without
335
338
  // spending an inference request. Store the account, but defer inference validation
336
339
  // and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
@@ -470,7 +473,9 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
470
473
  });
471
474
  }
472
475
  } catch (error) {
473
- const message = error instanceof ConfigMutationLockError
476
+ const message = error instanceof CodexCollisionReadBusyError
477
+ ? "Native Codex login is busy or unreadable; retry login shortly."
478
+ : error instanceof ConfigMutationLockError
474
479
  || error instanceof CodexCredentialRefreshLockTimeoutError
475
480
  ? "Configuration is busy; retry login shortly."
476
481
  : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
@@ -13,7 +13,7 @@ import { isAccountNeedsReauth } from "../account-runtime-state";
13
13
  import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
14
14
  import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper";
15
15
  import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache";
16
- import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
16
+ import { getMainAccountHardLockStatus, latestMainBlockingResetAt } from "../main-account-hard-lock";
17
17
  import type { OcxConfig } from "../../types";
18
18
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
19
19
  import { providerCodexAccountMode } from "../../providers/registry";
@@ -78,8 +78,55 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
78
78
 
79
79
  const MAIN_CREDIT_RENEWAL_AGE_MS = 3 * 60_000;
80
80
 
81
+ /** Traffic headers renew usage, never the independent credit observation (#7377). */
82
+ function poolCreditRenewalDue(config: OcxConfig, accountId: string, now = Date.now()): boolean {
83
+ const account = configuredPoolAccount(config, accountId);
84
+ if (!account || !codexAccountUsesCreditsAfterLimit(config, accountId)
85
+ || isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false;
86
+ const quota = getAccountQuota(accountId);
87
+ if (codexUsageLimitResetAt(quota, account.plan, now) === undefined) return false;
88
+ const observedAt = quota?.credits?.observedAt;
89
+ if (observedAt !== undefined && Number.isFinite(observedAt)
90
+ && now - observedAt >= 0 && now - observedAt < MAIN_CREDIT_RENEWAL_AGE_MS) return false;
91
+ const attempt = poolQuotaPrimeAttemptedAt.get(accountId);
92
+ return !attempt || attempt.generation !== readCodexAccountRecord(accountId)?.generation
93
+ || now - attempt.at >= MAIN_CREDIT_RENEWAL_AGE_MS;
94
+ }
95
+
81
96
  let mainHardLockRecoveryInFlight: Promise<void> | null = null;
82
- let mainHardLockRecoveryAttempt: { identity: number; credential: number; after: number; delay: number } | undefined;
97
+ /** `floor` and `resetBound` apply only while a predicted reset is (or just was) holding the lock. */
98
+ type MainRecoveryAttempt = { identity: number; credential: number; after: number; delay: number; floor?: number; resetBound?: number };
99
+ let mainHardLockRecoveryAttempt: MainRecoveryAttempt | undefined;
100
+ /** The identity/credential that already waited out its first hold interval. */
101
+ let mainHardLockHoldSeen: { identity: number; credential: number } | undefined;
102
+ const MAIN_HOLD_MIN_INTERVAL_MS = nextQuotaQueryDelay();
103
+
104
+ /** The predicted reset of a blocked main, or undefined when no future reset is known. */
105
+ function predictedMainResetHold(status: ReturnType<typeof getMainAccountHardLockStatus>): number | undefined {
106
+ return status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()
107
+ ? status.resetAt : undefined;
108
+ }
109
+
110
+ /**
111
+ * A hold read is due at its backoff deadline, pulled in to the latest known predicted reset (even
112
+ * one that moved earlier or just passed) but never sooner than one base interval after the last read.
113
+ */
114
+ function mainRecoveryDueAt(attempt: MainRecoveryAttempt, holdResetAt: number | undefined, config: OcxConfig): number {
115
+ // Once a hold elapses the status omits its reset; current evidence may still name a revised one.
116
+ const bound = holdResetAt ?? (attempt.resetBound === undefined ? undefined
117
+ : Math.min(attempt.resetBound, latestMainBlockingResetAt(config) ?? Infinity));
118
+ return bound === undefined ? attempt.after : Math.max(attempt.floor ?? 0, Math.min(attempt.after, bound));
119
+ }
120
+
121
+ /** First sight of a hold for this credential waits one base interval; transient retries do not re-arm it. */
122
+ function armMainResetHold(identity: number, credential: number, holdResetAt: number): void {
123
+ const at = Date.now() + MAIN_HOLD_MIN_INTERVAL_MS;
124
+ mainHardLockHoldSeen = { identity, credential };
125
+ mainHardLockRecoveryAttempt = { identity, credential, delay: MAIN_HOLD_MIN_INTERVAL_MS, after: at, floor: at, resetBound: holdResetAt };
126
+ }
127
+
128
+ const mainHoldSeenFor = (identity: number, credential: number): boolean =>
129
+ mainHardLockHoldSeen?.identity === identity && mainHardLockHoldSeen.credential === credential;
83
130
 
84
131
  /** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */
85
132
  export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise<void> {
@@ -98,10 +145,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
98
145
  || (observedAt !== undefined && now - observedAt >= MAIN_CREDIT_RENEWAL_AGE_MS));
99
146
  };
100
147
  const status = getMainAccountHardLockStatus(config);
148
+ if (status.state !== "blocked") mainHardLockHoldSeen = undefined;
101
149
  if (status.state !== "blocked" && !creditRecoveryNeeded()) { mainHardLockRecoveryAttempt = undefined; return; }
102
150
  if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
103
- // A predicted reset is a scheduling hint, never proof that the hard lock can be lifted.
104
- if (status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()) return;
151
+ // A predicted reset is a scheduling hint, never proof that the hard lock can be lifted. The
152
+ // hold is rechecked on the capped WHAM schedule so an early reset redeemed elsewhere is
153
+ // learned from metadata (#7312); a paused main receives no hold rechecks.
154
+ const holdResetAt = predictedMainResetHold(status);
155
+ if (holdResetAt !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)) return;
105
156
  // Reconcile the owned physical credential before skipping work for an older one.
106
157
  reconcileMainCodexAccountRuntimeState();
107
158
  const physical = readCodexTokens();
@@ -109,9 +160,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
109
160
  const identity = captureMainAccountIdentityGeneration();
110
161
  const credential = getMainQuotaCredentialGeneration();
111
162
  const previous = mainHardLockRecoveryAttempt;
163
+ if (holdResetAt !== undefined && !mainHoldSeenFor(identity, credential)) {
164
+ armMainResetHold(identity, credential, holdResetAt);
165
+ return;
166
+ }
167
+ const sameAttempt = previous?.identity === identity && previous.credential === credential;
168
+ if (sameAttempt && holdResetAt !== undefined) previous.resetBound = holdResetAt;
112
169
  const queryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
113
- if ((previous?.identity === identity && previous.credential === credential
114
- && previous.after > Date.now()) || queryAfter > Date.now()) return;
170
+ if ((sameAttempt && mainRecoveryDueAt(previous, holdResetAt, config) > Date.now()) || queryAfter > Date.now()) return;
115
171
  const lease = tryAcquireNativeMainProfileClaim();
116
172
  if (!lease) return;
117
173
  mainHardLockRecoveryInFlight = (async () => {
@@ -130,16 +186,20 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
130
186
  // Native refresh retains its own grant-scoped refusal; global quarantine would outlive it.
131
187
  return;
132
188
  }
133
- const currentStatus = getMainAccountHardLockStatus(config);
189
+ const currentHold = predictedMainResetHold(getMainAccountHardLockStatus(config));
134
190
  if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
135
191
  || !isMainAccountIdentityGenerationLive(identityGeneration)
136
192
  || (passive && !creditRecoveryNeeded())
137
- || (currentStatus.state === "blocked" && currentStatus.resetAt !== undefined
138
- && currentStatus.resetAt > Date.now())) return;
193
+ || (currentHold !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID))) return;
139
194
  const credential = getMainQuotaCredentialGeneration();
195
+ // A hold that appeared during preparation is first seen now and waits its base interval.
196
+ if (currentHold !== undefined && !mainHoldSeenFor(identityGeneration, credential)) {
197
+ armMainResetHold(identityGeneration, credential, currentHold);
198
+ return;
199
+ }
140
200
  // Reconcile the physical bearer before consulting the previous credential's delay.
141
201
  if (previous?.identity === identityGeneration && previous.credential === credential
142
- && previous.after > Date.now()) return;
202
+ && mainRecoveryDueAt(previous, currentHold, config) > Date.now()) return;
143
203
  const currentQueryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
144
204
  if (currentQueryAfter > Date.now()) {
145
205
  mainHardLockRecoveryAttempt = { identity: identityGeneration, credential,
@@ -162,8 +222,10 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
162
222
  if (transientAuth) mainHardLockRecoveryAttempt = undefined;
163
223
  else {
164
224
  const delay = nextQuotaQueryDelay(previousDelay || undefined);
225
+ const hold = predictedMainResetHold(getMainAccountHardLockStatus(config));
165
226
  mainHardLockRecoveryAttempt = getMainAccountHardLockStatus(config).state === "blocked" || creditRecoveryNeeded()
166
- ? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter) }
227
+ ? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter),
228
+ ...(hold !== undefined ? { floor: Date.now() + MAIN_HOLD_MIN_INTERVAL_MS, resetBound: hold } : {}) }
167
229
  : undefined;
168
230
  }
169
231
  } else if (queryAfter > Date.now()) {
@@ -186,6 +248,7 @@ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): voi
186
248
  afterTick: () => {
187
249
  void runCodexCooldownRecoveryProbes(config);
188
250
  void runMainAccountHardLockRecovery(config);
251
+ void primeCodexPoolQuotas(config, "credit-renewal");
189
252
  },
190
253
  });
191
254
  }
@@ -265,6 +328,7 @@ export async function primeCodexPoolQuotas(
265
328
  primeInFlight = trackBackgroundWork(`codex-pool-quota-prime:${reason}`, (async () => {
266
329
  const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
267
330
  const stale = pool.filter(a => {
331
+ if (reason === "credit-renewal") return poolCreditRenewalDue(runtimeConfig, a.id);
268
332
  const q = getAccountQuota(a.id);
269
333
  const observationStale = reason === "priority-failback" && q
270
334
  && !codexQuotaHasFreshUsage(q, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
@@ -278,7 +342,10 @@ export async function primeCodexPoolQuotas(
278
342
  if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
279
343
  return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
280
344
  });
345
+ if (reason === "credit-renewal" && stale.length === 0) return;
281
346
  const primeMain = async () => {
347
+ // Main renewal owns its physical-identity claims and passive probe separately.
348
+ if (reason === "credit-renewal") return;
282
349
  const mainLease = tryAcquireNativeMainPrimeLease();
283
350
  if (!mainLease) return;
284
351
  try {
@@ -311,11 +378,12 @@ export async function primeCodexPoolQuotas(
311
378
  primeMain(),
312
379
  mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
313
380
  if (!getCodexAccountCredential(a.id)) return;
381
+ if (reason === "credit-renewal" && !poolCreditRenewalDue(getRuntimeConfig(config), a.id)) return;
314
382
  let result: PoolQuotaResult;
315
383
  try {
316
384
  const quota = getAccountQuota(a.id);
317
- const bypassCachedQuota = reason === "priority-failback" && quota !== null
318
- && !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
385
+ const bypassCachedQuota = reason === "credit-renewal" || (reason === "priority-failback" && quota !== null
386
+ && !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS));
319
387
  result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime);
320
388
  } catch (error) {
321
389
  // Local quota-flight saturation proves no WHAM request existed for this account.
@@ -4,8 +4,9 @@ import { getCodexAccountCredential } from "./account-store";
4
4
  import { loadConfig } from "../config";
5
5
  import { resolveCodexHomeDir } from "./home";
6
6
  import { extractAccountId } from "../oauth/chatgpt";
7
- import { isSelectableCodexPoolAccount } from "./account-id";
7
+ import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
8
8
  import { codexPlanKey } from "./plan";
9
+ import { getObservedMainCodexCollisionIdentity, sameCodexCollisionIdentity, type CodexCollisionIdentity } from "./collision-identity";
9
10
  import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store";
10
11
 
11
12
  export interface CodexTokens {
@@ -94,17 +95,23 @@ function isWorkspacePlan(plan: unknown): boolean {
94
95
  return !!key && /team|business|enterprise|workspace|edu/.test(key);
95
96
  }
96
97
 
97
- // Main login and managed pool accounts are separate duplicate buckets.
98
- // Inside the pool, personal and workspace subscriptions are also separate buckets.
99
- // Within each pool bucket, keep the original ChatGPT account id + email collision guard.
98
+ // Main login and pool accounts share the account id + email collision guard.
99
+ // Personal and workspace subscriptions remain separate buckets.
100
100
  export function checkAccountIdCollision(
101
101
  chatgptAccountId: string,
102
102
  email?: string | null,
103
103
  plan?: unknown,
104
104
  excludeAccountId?: string | null,
105
+ main: CodexCollisionIdentity | null = getObservedMainCodexCollisionIdentity() ?? null,
105
106
  ): { collision: true; reason: string } | { collision: false } {
106
107
  const candidateEmail = normalizedEmail(email);
107
108
  const candidateWorkspace = isWorkspacePlan(plan);
109
+
110
+ if (excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && main
111
+ && sameCodexCollisionIdentity({ accountId: chatgptAccountId, email, plan }, main)) {
112
+ return { collision: true, reason: "Account is already configured as the native Codex main account." };
113
+ }
114
+
108
115
  for (const account of loadConfig().codexAccounts ?? []) {
109
116
  if (excludeAccountId && account.id === excludeAccountId) continue;
110
117
  if (!isSelectableCodexPoolAccount(account)) continue;
@@ -117,3 +124,23 @@ export function checkAccountIdCollision(
117
124
  }
118
125
  return { collision: false };
119
126
  }
127
+
128
+ export type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
129
+
130
+ /** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
131
+ export function readMainAuth(authPath = join(resolveCodexHomeDir(), "auth.json")): MainAuth {
132
+ const main = readCodexTokensResult(authPath, { bounded: true });
133
+ if (main.status !== "invalid") return main;
134
+ try {
135
+ const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
136
+ if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
137
+ const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
138
+ const apiKeyOnly = envelope.tokens == null
139
+ && (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
140
+ && typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
141
+ return apiKeyOnly ? { status: "api-key-only" } : main;
142
+ } catch {
143
+ return main;
144
+ }
145
+ }
146
+