@bitkyc08/opencodex 2.89.0 → 2.90.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 (129) hide show
  1. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  2. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
  3. package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
  4. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +2 -2
  7. package/src/adapters/base.ts +4 -0
  8. package/src/adapters/claude-cli/adapter.ts +13 -7
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  10. package/src/adapters/coding-agent/turn.ts +14 -4
  11. package/src/adapters/cursor/protobuf-request.ts +3 -0
  12. package/src/adapters/google-progress-history.ts +19 -1
  13. package/src/adapters/google.ts +28 -9
  14. package/src/adapters/image.ts +5 -3
  15. package/src/adapters/input-media-guard.ts +5 -2
  16. package/src/adapters/kiro-refusal.ts +41 -1
  17. package/src/adapters/ollama-native.ts +1 -0
  18. package/src/adapters/openai-chat/messages.ts +34 -4
  19. package/src/adapters/openai-chat/passthrough.ts +37 -4
  20. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  21. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  22. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  23. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  24. package/src/adapters/openai-chat.ts +7 -6
  25. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  26. package/src/adapters/openai-responses/passthrough.ts +5 -1
  27. package/src/adapters/openai-responses/transport.ts +13 -1
  28. package/src/claude/desktop-3p-library.ts +43 -3
  29. package/src/claude/desktop-3p.ts +20 -10
  30. package/src/claude/desktop-picker-profile.ts +18 -9
  31. package/src/claude/desktop-remote-store-io.ts +2 -2
  32. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  33. package/src/codex/account-lifecycle.ts +19 -6
  34. package/src/codex/account-store.ts +27 -8
  35. package/src/codex/account-usability.ts +4 -1
  36. package/src/codex/auth-api/account-list.ts +10 -5
  37. package/src/codex/auth-api/account-pause-group.ts +2 -22
  38. package/src/codex/auth-api/login-collision.ts +59 -0
  39. package/src/codex/auth-api/login-flow.ts +12 -7
  40. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  41. package/src/codex/auth-collision.ts +31 -4
  42. package/src/codex/auth-context.ts +48 -6
  43. package/src/codex/cli-update-plan.ts +18 -3
  44. package/src/codex/collision-identity.ts +78 -0
  45. package/src/codex/main-account-hard-lock.ts +14 -0
  46. package/src/codex/routing/cooldown-math.ts +5 -0
  47. package/src/codex/routing/failure-window.ts +61 -9
  48. package/src/codex/routing.ts +4 -3
  49. package/src/codex/shim-diagnostics.ts +2 -1
  50. package/src/codex/shim-fingerprint.ts +11 -2
  51. package/src/codex/shim-templates.ts +16 -5
  52. package/src/codex/shim-windows-refresh.ts +224 -0
  53. package/src/codex/shim.ts +45 -21
  54. package/src/combos/failover.ts +8 -1
  55. package/src/config/provider-validation.ts +7 -2
  56. package/src/generated/compatibility-version.json +168 -108
  57. package/src/generated/model-metadata.ts +1 -1
  58. package/src/images/loop.ts +2 -2
  59. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  60. package/src/lib/provider-admission-wait.ts +14 -0
  61. package/src/lib/service-secrets.ts +37 -2
  62. package/src/lib/translator-budget.ts +1 -1
  63. package/src/lib/upstream-retry.ts +15 -0
  64. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  65. package/src/oauth/account-import/parser.ts +15 -2
  66. package/src/oauth/account-import/types.ts +7 -0
  67. package/src/oauth/chatgpt-shared.ts +10 -1
  68. package/src/oauth/dsh.ts +45 -2
  69. package/src/oauth/kiro.ts +44 -1
  70. package/src/oauth/login-flow-state.ts +11 -2
  71. package/src/providers/claude-cli-usage.ts +20 -5
  72. package/src/providers/initial-model-selection.ts +2 -2
  73. package/src/providers/key-failover.ts +10 -2
  74. package/src/providers/key-store.ts +8 -3
  75. package/src/providers/mistral-transport.ts +207 -0
  76. package/src/providers/openai-sidecar.ts +5 -0
  77. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  78. package/src/providers/quota/antigravity.ts +6 -2
  79. package/src/providers/quota/failure-codes.ts +18 -0
  80. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  81. package/src/providers/quota-routing-cache.ts +1 -1
  82. package/src/providers/quota.ts +5 -1
  83. package/src/providers/registry/entries-extended.ts +6 -1
  84. package/src/providers/routed-config.ts +1 -1
  85. package/src/providers/xai-transport.ts +2 -1
  86. package/src/responses/audio.ts +46 -0
  87. package/src/responses/input-media.ts +37 -15
  88. package/src/responses/parser-content.ts +22 -27
  89. package/src/responses/reasoning-retention.ts +218 -11
  90. package/src/responses/schema.ts +3 -4
  91. package/src/responses/web-search-replay.ts +11 -2
  92. package/src/server/chat-native.ts +8 -7
  93. package/src/server/index/serve-options.ts +8 -2
  94. package/src/server/index/websocket-handler.ts +10 -0
  95. package/src/server/lifecycle.ts +17 -1
  96. package/src/server/management/oauth-account-routes.ts +2 -1
  97. package/src/server/messages-native.ts +11 -0
  98. package/src/server/request-log.ts +16 -7
  99. package/src/server/responses/adapter-continuation.ts +2 -2
  100. package/src/server/responses/adapter-dispatch.ts +2 -2
  101. package/src/server/responses/codex-ws-exchange.ts +3 -0
  102. package/src/server/responses/codex-ws-metadata.ts +44 -0
  103. package/src/server/responses/codex-ws-session.ts +21 -1
  104. package/src/server/responses/combo-stream-preflight.ts +7 -2
  105. package/src/server/responses/compact.ts +2 -0
  106. package/src/server/responses/core-auth.ts +20 -0
  107. package/src/server/responses/core-codex-account.ts +21 -14
  108. package/src/server/responses/core-combo.ts +3 -0
  109. package/src/server/responses/core-opaque-recovery.ts +20 -11
  110. package/src/server/responses/deepseek-image-files.ts +121 -0
  111. package/src/server/responses/empty-completion-guard.ts +8 -0
  112. package/src/server/responses/fetch-helpers.ts +30 -10
  113. package/src/server/responses/input-admission.ts +6 -2
  114. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  115. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  116. package/src/server/responses/passthrough-completion.ts +41 -0
  117. package/src/server/responses/passthrough-delivery.ts +2 -0
  118. package/src/server/responses/passthrough-dispatch.ts +13 -13
  119. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  120. package/src/server/responses/reasoning-archive-error.ts +21 -0
  121. package/src/server/responses/request-prepare.ts +11 -1
  122. package/src/server/responses/request-transport.ts +61 -3
  123. package/src/service/orchestration.ts +17 -0
  124. package/src/types/provider.ts +2 -0
  125. package/src/types/request.ts +16 -1
  126. package/src/types.ts +1 -0
  127. package/src/web-search/loop.ts +2 -2
  128. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  129. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -19,6 +19,7 @@ import {
19
19
  readDesktopLibraryText,
20
20
  resolveDesktop3pConfigLibraryPath,
21
21
  SAFE_DESKTOP_PROFILE_ID,
22
+ serializeDesktopLibraryJson,
22
23
  type Desktop3pConfigLibraryOptions,
23
24
  type Desktop3pMetadata,
24
25
  type Desktop3pMetadataEntry,
@@ -48,7 +49,7 @@ function metadataPath(libraryPath: string): string {
48
49
  }
49
50
 
50
51
  function metadataJson(metadata: Desktop3pMetadata): string {
51
- return JSON.stringify(metadata, null, 2) + "\n";
52
+ return serializeDesktopLibraryJson(metadata);
52
53
  }
53
54
 
54
55
  function isValidMetadata(metadata: Desktop3pMetadata): boolean {
@@ -190,6 +191,10 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto
190
191
  const changed = !alreadySelected || !oldProfile.exists || oldProfile.content !== target;
191
192
  if (!changed) return { ok: true, changed: false, path: profile };
192
193
 
194
+ const entries = existing
195
+ ? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry)
196
+ : [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }];
197
+ const nextMetadataJson = metadataJson({ ...metadata, appliedId: entryId, entries });
193
198
  mkdirSync(join(configDir, PICKER_DIRECTORY), { recursive: true, mode: 0o700 });
194
199
  try {
195
200
  // Keep the same backup convention as the existing Desktop writer. It is removed with the
@@ -199,10 +204,7 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto
199
204
  if (!alreadySelected) {
200
205
  atomicWriteFile(previousStatePath, JSON.stringify({ entryId, previousAppliedId }) + "\n");
201
206
  }
202
- const entries = existing
203
- ? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry)
204
- : [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }];
205
- atomicWriteFile(metaPath, metadataJson({ ...metadata, appliedId: entryId, entries }));
207
+ atomicWriteFile(metaPath, nextMetadataJson);
206
208
  } catch {
207
209
  try {
208
210
  restoreFile(metaPath, oldMeta);
@@ -246,6 +248,8 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
246
248
  try { state = readPickerState(statePath); } catch { return { ok: false, reason: "profile_state_unreadable" }; }
247
249
 
248
250
  let metadataAfterPivot = metadata;
251
+ let standardPath: string | undefined;
252
+ let pivotMetadataJson: string | undefined;
249
253
  if (selected) {
250
254
  const previous = state?.entryId === picker.id ? state.previousAppliedId : null;
251
255
  const previousExists = previous !== null && previous !== picker.id && metadata.entries.some(entry => entry.id === previous);
@@ -253,16 +257,22 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
253
257
  metadataAfterPivot = { ...metadata, appliedId: previous };
254
258
  } else {
255
259
  const standardId = randomUUID();
256
- atomicWriteFile(profilePath(libraryPath, standardId), "{}\n");
260
+ standardPath = profilePath(libraryPath, standardId);
257
261
  metadataAfterPivot = {
258
262
  ...metadata,
259
263
  appliedId: standardId,
260
264
  entries: [...metadata.entries, { id: standardId, name: "opencodex-standard" }],
261
265
  };
262
266
  }
263
- atomicWriteFile(metaPath, metadataJson(metadataAfterPivot));
267
+ pivotMetadataJson = metadataJson(metadataAfterPivot);
264
268
  }
265
269
 
270
+ const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id);
271
+ const finalMetadataJson = metadataJson({ ...metadataAfterPivot, entries });
272
+
273
+ if (standardPath !== undefined) atomicWriteFile(standardPath, "{}\n");
274
+ if (pivotMetadataJson !== undefined) atomicWriteFile(metaPath, pivotMetadataJson);
275
+
266
276
  const residualPaths: string[] = [];
267
277
  for (const path of [profilePath(libraryPath, picker.id), `${profilePath(libraryPath, picker.id)}.bak`]) {
268
278
  try { unlinkIfPresent(path); } catch { /* report the path without exposing file contents */ }
@@ -272,9 +282,8 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
272
282
  return { ok: false, reason: "cleanup_incomplete", residualPaths };
273
283
  }
274
284
 
275
- const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id);
276
285
  try {
277
- atomicWriteFile(metaPath, metadataJson({ ...metadataAfterPivot, entries }));
286
+ atomicWriteFile(metaPath, finalMetadataJson);
278
287
  try { unlinkIfPresent(statePath); } catch { /* residual is reported below */ }
279
288
  if (existsSync(statePath)) return { ok: false, reason: "cleanup_incomplete", residualPaths: [statePath] };
280
289
  } catch {
@@ -2,7 +2,7 @@ import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { atomicWriteFile, getConfigDir } from "../config";
4
4
  import { hardenSecretDir } from "../lib/windows-secret-acl";
5
- import { isRecord, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
5
+ import { isRecord, MAX_DESKTOP_PROFILE_FILE_BYTES, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
6
6
  import { DesktopStoreError, digest } from "./desktop-remote-store-state";
7
7
 
8
8
  export const MAX_DESKTOP_METADATA_ENTRIES = 256;
@@ -45,7 +45,7 @@ export class StoreIO {
45
45
  try {
46
46
  // The shared verified regular-file read keeps a swapped FIFO from parking this
47
47
  // open before the post-read identity recheck below can run.
48
- const bytes = readDesktopLibraryBytes(path);
48
+ const bytes = readDesktopLibraryBytes(path, Math.min(maxBytes, MAX_DESKTOP_PROFILE_FILE_BYTES));
49
49
  if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict");
50
50
  const value: unknown = JSON.parse(bytes.toString("utf8"));
51
51
  if (!isRecord(value)) throw new DesktopStoreError("unsafe");
@@ -241,12 +241,12 @@ function consentedDesktopBundle(home: string, installId: string, run: Execute):
241
241
  const plistPath = realpathSync(join(home, "Library", "LaunchAgents", `${DESKTOP_PLIST_LABEL}.plist`));
242
242
  const registration = JSON.parse(
243
243
  run("/usr/bin/plutil", ["-convert", "json", "-o", "-", plistPath], 3_000),
244
- ) as { Label?: unknown; ProgramArguments?: unknown; Program?: unknown };
244
+ ) as { Label?: unknown; RunAtLoad?: unknown; ProgramArguments?: unknown; Program?: unknown };
245
245
  const args = registration.ProgramArguments;
246
246
  // The shapes auto-launch writes: [program] or [program, "--autostart"]; see the migration
247
247
  // rules in desktop/src-tauri/src/login_registration.rs, which classify anything else Leave.
248
248
  // This is a macOS plist path, regardless of the host running an injected launch probe.
249
- if (registration.Label !== DESKTOP_PLIST_LABEL || !Array.isArray(args)
249
+ if (registration.Label !== DESKTOP_PLIST_LABEL || registration.RunAtLoad !== true || !Array.isArray(args)
250
250
  || (args.length !== 1 && args.length !== 2) || typeof args[0] !== "string" || !posix.isAbsolute(args[0])
251
251
  || (args.length === 2 && args[1] !== "--autostart")
252
252
  || (registration.Program !== undefined && registration.Program !== args[0])) return null;
@@ -1,4 +1,5 @@
1
1
  import { observeSignedInCodexIdentity } from "./signed-in-identity";
2
+ import { observeMainCodexCollisionIdentity } from "./collision-identity";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import {
4
5
  deleteConfigTopLevelKey,
@@ -9,11 +10,12 @@ import {
9
10
  import { captureConfigTopLevelRollback } from "../config/rebase-provenance";
10
11
  import { removeCodexAccountCredential } from "./account-store";
11
12
  import { clearAccountNeedsReauth } from "./account-runtime-state";
12
- import { readCodexTokensResult } from "./auth-collision";
13
+ import { readMainAuth } from "./auth-collision";
13
14
  import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
14
15
  import { clearAccountQuota } from "./quota";
15
16
  import { forgetManualPreference } from "./routing/active-account";
16
17
  import { deleteAllHealthForAccount } from "./routing/health-store";
18
+ import { forgetCodexFailureWindow } from "./routing/failure-window";
17
19
  import { clearThreadAccountMapForAccount } from "./routing/thread-affinity";
18
20
 
19
21
  import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff";
@@ -49,11 +51,15 @@ export function purgeCodexAccountRuntimeState(accountId: string): void {
49
51
  clearAccountNeedsReauth(accountId);
50
52
  clearAccountQuota(accountId);
51
53
  clearThreadAccountMapForAccount(accountId);
52
- // Deliberately not via `./routing`: the account-delete path only needs the health rows and
53
- // the manual preference gone, and importing the router here closed a cycle through
54
- // routing/health-store and routing/active-account.
54
+ // Deliberately not via `./routing`: the account-delete path only needs the health rows
55
+ // and their sibling routing state gone, and importing the router barrel here closed a
56
+ // cycle through routing/health-store and routing/active-account. The leaf imports below
57
+ // carry every per-account forget that barrel helper used to perform — including the
58
+ // failure window, whose degrade and pin-warning state must not leak into a replacement
59
+ // account or a new physical login behind __main__.
55
60
  deleteAllHealthForAccount(accountId);
56
61
  forgetManualPreference(accountId);
62
+ forgetCodexFailureWindow(accountId);
57
63
  clearCodexPoolRefreshFailure(accountId);
58
64
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
59
65
  clearMainAccountInfoCache();
@@ -74,7 +80,8 @@ function purgeMainCodexAccountRuntimeState(): void {
74
80
  * cooldown, quota, reauth, and thread affinity do not leak across accounts.
75
81
  */
76
82
  export function reconcileMainCodexAccountRuntimeState(): boolean {
77
- const read = readCodexTokensResult();
83
+ const read = readMainAuth();
84
+ if (read.status === "missing" || read.status === "api-key-only") observeMainCodexCollisionIdentity();
78
85
  const currentAccountId = read.status === "ok"
79
86
  ? extractAccountId(read.tokens.id_token, read.tokens.access_token) ?? (read.tokens.account_id || null)
80
87
  : null;
@@ -86,11 +93,13 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
86
93
  observedMainChatgptAccountId = currentAccountId;
87
94
  if (previousAccountId === undefined || previousAccountId === currentAccountId) {
88
95
  observeMainQuotaIdentity(currentAccountId);
96
+ if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
89
97
  return false;
90
98
  }
91
99
 
92
100
  purgeMainCodexAccountRuntimeState();
93
101
  observeMainQuotaIdentity(currentAccountId);
102
+ if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
94
103
  return true;
95
104
  }
96
105
 
@@ -103,7 +112,8 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
103
112
  export function initializeMainAccountPolicyBinding(authPath: string): boolean {
104
113
  // Startup observes the pinned owned path inside the exclusive claim: bound the read so a
105
114
  // replaced non-regular or oversized file cannot stall startup inside that claim.
106
- const result = readCodexTokensResult(authPath, { bounded: true });
115
+ const result = readMainAuth(authPath);
116
+ if (result.status === "missing" || result.status === "api-key-only") observeMainCodexCollisionIdentity();
107
117
  if (result.status !== "ok") return false;
108
118
  const { tokens } = result;
109
119
  if (typeof tokens.access_token !== "string" || !tokens.access_token
@@ -124,6 +134,7 @@ export function initializeMainAccountPolicyBinding(authPath: string): boolean {
124
134
  if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState();
125
135
  observeSignedInCodexIdentity(tokens);
126
136
  observeMainQuotaIdentity(accountId);
137
+ observeMainCodexCollisionIdentity(tokens);
127
138
  return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined;
128
139
  }
129
140
 
@@ -136,6 +147,7 @@ export function applyConfirmedMainCodexAccountTransition(
136
147
  toAccountId: string,
137
148
  ): boolean {
138
149
  observeSignedInCodexIdentity();
150
+ observeMainCodexCollisionIdentity();
139
151
  if (!fromAccountId || !toAccountId || fromAccountId === toAccountId) {
140
152
  if (toAccountId) {
141
153
  observedMainChatgptAccountId = toAccountId;
@@ -150,6 +162,7 @@ export function applyConfirmedMainCodexAccountTransition(
150
162
  }
151
163
 
152
164
  export function resetMainCodexAccountIdentityTrackingForTests(): void {
165
+ observeMainCodexCollisionIdentity();
153
166
  observedMainChatgptAccountId = undefined;
154
167
  observeSignedInCodexIdentity();
155
168
  clearMainAccountCredentialPresence();
@@ -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