@bitkyc08/opencodex 2.49.0 → 2.51.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 +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -58,7 +58,9 @@ function isCredentialRecord(value: unknown): value is CodexAccountCredentialReco
58
58
  && (value.replacedAt === undefined || typeof value.replacedAt === "number")
59
59
  && (value.lastCodexValidatedAt === undefined || typeof value.lastCodexValidatedAt === "number")
60
60
  && (value.lastCodexValidationStatus === undefined || value.lastCodexValidationStatus === "ok" || value.lastCodexValidationStatus === "failed")
61
- && (value.lastCodexValidationError === undefined || typeof value.lastCodexValidationError === "string");
61
+ && (value.lastCodexValidationError === undefined || typeof value.lastCodexValidationError === "string")
62
+ && (value.codexValidationPending === undefined || typeof value.codexValidationPending === "boolean")
63
+ && (value.lastCodexValidationTerminal === undefined || typeof value.lastCodexValidationTerminal === "boolean");
62
64
  }
63
65
 
64
66
  export function refreshGrantFingerprintForToken(refreshToken: string): string {
@@ -118,11 +120,21 @@ function persistCredentialMutation(store: CodexAccountStore): void {
118
120
  advanceCodexCredentialMutationEpoch();
119
121
  }
120
122
 
123
+ /**
124
+ * Validation metadata that survives a credential write.
125
+ *
126
+ * `lastCodexValidationTerminal` is deliberately NOT in this list. Every credential write —
127
+ * re-login, the CAS refresh commit, same-grant alias propagation — rebuilds the record from this
128
+ * pick list, so leaving the marker out is what makes a successful refresh or a re-authentication
129
+ * erase a terminal verdict. Both events disprove "the grant was revoked", and a verdict that
130
+ * could only ever be set would brand an account dead forever on one spurious `invalid_grant`.
131
+ */
121
132
  function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick<
122
133
  CodexAccountCredentialRecord,
123
- "lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError"
134
+ "lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError" | "codexValidationPending"
124
135
  > {
125
136
  return {
137
+ ...(record?.codexValidationPending === true ? { codexValidationPending: true } : {}),
126
138
  ...(record?.lastCodexValidatedAt !== undefined ? { lastCodexValidatedAt: record.lastCodexValidatedAt } : {}),
127
139
  ...(record?.lastCodexValidationStatus !== undefined ? { lastCodexValidationStatus: record.lastCodexValidationStatus } : {}),
128
140
  ...(record?.lastCodexValidationError !== undefined ? { lastCodexValidationError: record.lastCodexValidationError } : {}),
@@ -135,8 +147,12 @@ export function getCodexAccountCredential(id: string): CodexAccountCredentials |
135
147
  return record.credential ?? null;
136
148
  }
137
149
 
138
- export function saveCodexAccountCredential(id: string, cred: CodexAccountCredentials): void {
139
- withCredentialMutationLockSync(() => {
150
+ export function saveCodexAccountCredential(
151
+ id: string,
152
+ cred: CodexAccountCredentials,
153
+ options: { validationPending?: boolean } = {},
154
+ ): number {
155
+ return withCredentialMutationLockSync(() => {
140
156
  const store = loadCodexAccountRecordStore();
141
157
  const current = store[id];
142
158
  const refreshGrantFingerprint = current?.credential?.refreshToken === cred.refreshToken
@@ -148,37 +164,84 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent
148
164
  refreshGrantFingerprint,
149
165
  replacedAt: current ? Date.now() : undefined,
150
166
  ...preservedValidationMetadata(current),
167
+ ...(options.validationPending ? {
168
+ codexValidationPending: true,
169
+ lastCodexValidatedAt: undefined,
170
+ lastCodexValidationStatus: undefined,
171
+ lastCodexValidationError: undefined,
172
+ } : {}),
151
173
  };
152
174
  persistCredentialMutation(store);
175
+ return store[id].generation;
153
176
  });
154
177
  }
155
178
 
156
- export function markCodexAccountValidated(id: string, atMs: number = Date.now()): void {
179
+ export function markCodexAccountValidated(id: string, atMs: number = Date.now(), generation?: number): void {
157
180
  withCredentialMutationLockSync(() => {
158
181
  const store = loadCodexAccountRecordStore();
159
182
  const current = store[id];
160
183
  if (!current || current.deletedAt != null || !current.credential) return;
184
+ if (current.codexValidationPending && generation === undefined) return;
185
+ if (generation !== undefined && current.generation !== generation) return;
161
186
  store[id] = {
162
187
  ...current,
163
188
  lastCodexValidatedAt: atMs,
164
189
  lastCodexValidationStatus: "ok",
165
190
  lastCodexValidationError: undefined,
191
+ codexValidationPending: undefined,
192
+ // A completed validation is the direct refutation of a terminal verdict, and this
193
+ // spread would otherwise carry the old marker forward.
194
+ lastCodexValidationTerminal: undefined,
166
195
  };
167
- persist(store);
196
+ // Becoming routable invalidates credential-derived caches; a timestamp-only
197
+ // update on an already validated account preserves the existing epoch policy.
198
+ if (current.codexValidationPending) persistCredentialMutation(store);
199
+ else persist(store);
168
200
  });
169
201
  }
170
202
 
171
- export function markCodexAccountValidationFailed(id: string, reason: string): void {
172
- withCredentialMutationLockSync(() => {
203
+ export interface MarkCodexAccountValidationFailedOptions {
204
+ /**
205
+ * Write only while the stored record is still at this generation.
206
+ *
207
+ * A validation attempt is not atomic with the store: an operator can re-authenticate the
208
+ * account, or another writer can commit a refresh, while a probe is still in flight. Without
209
+ * this fence the late failure lands on whatever credential happens to be there and brands a
210
+ * freshly installed one dead. Declining to write is the safe direction — the failure cannot be
211
+ * attributed to a credential the caller never observed.
212
+ */
213
+ expectedGeneration?: number;
214
+ /** The grant itself is revoked or expired; only a re-login clears it. */
215
+ terminal?: boolean;
216
+ }
217
+
218
+ /** Returns whether the verdict was actually persisted (false when the fence declined it). */
219
+ export function markCodexAccountValidationFailed(
220
+ id: string,
221
+ reason: string,
222
+ options: MarkCodexAccountValidationFailedOptions = {},
223
+ ): boolean {
224
+ return withCredentialMutationLockSync(() => {
173
225
  const store = loadCodexAccountRecordStore();
174
226
  const current = store[id];
175
- if (!current || current.deletedAt != null || !current.credential) return;
227
+ if (!current || current.deletedAt != null || !current.credential) return false;
228
+ // Deferred validation is settled only by a caller that names the generation it observed.
229
+ // An unfenced write must never resolve a pending account, whichever verdict it carries.
230
+ if (current.codexValidationPending && options.expectedGeneration === undefined) return false;
231
+ if (options.expectedGeneration !== undefined && current.generation !== options.expectedGeneration) {
232
+ return false;
233
+ }
176
234
  store[id] = {
177
235
  ...current,
178
236
  lastCodexValidationStatus: "failed",
179
237
  lastCodexValidationError: reason,
238
+ // Only ever set here. A transient failure must not clear a terminal marker set earlier,
239
+ // and it must not invent one either, so the flag is written only when the caller proves
240
+ // the grant is dead.
241
+ ...(options.terminal ? { lastCodexValidationTerminal: true } : {}),
180
242
  };
181
243
  persist(store);
244
+ return true;
182
245
  });
183
246
  }
184
247
 
@@ -1,4 +1,4 @@
1
- import { getCodexAccountCredential } from "./account-store";
1
+ import { readCodexAccountRecord } from "./account-store";
2
2
  import { isAccountNeedsReauth } from "./account-runtime-state";
3
3
  import {
4
4
  MAIN_CODEX_ACCOUNT_ID,
@@ -20,33 +20,70 @@ export interface CodexAccountUsabilityOptions {
20
20
  modelEligibleAccountIds?: ReadonlySet<string>;
21
21
  }
22
22
 
23
- export function isCodexAccountUsable(
23
+ /**
24
+ * Why an account was refused, in the order the checks run. This is the attribution half of
25
+ * selection: an operator whose model quietly disappeared needs to know that one account fell out
26
+ * and why, not merely that the pool got smaller (#4212).
27
+ */
28
+ export type CodexAccountUnusableReason =
29
+ | "model_not_entitled"
30
+ | "main_hard_locked"
31
+ | "main_traffic_blocked"
32
+ | "legacy_pool_sentinel"
33
+ | "needs_reauth"
34
+ | "main_credential_unavailable"
35
+ | "not_in_pool"
36
+ | "missing_credential"
37
+ | "deleted"
38
+ | "validation_pending";
39
+
40
+ /**
41
+ * The single source of truth for both selection and its explanation. `isCodexAccountUsable()` is
42
+ * this function's boolean projection rather than a parallel copy of the same branches, so a reason
43
+ * can never claim an account is fine while routing drops it, or name a cause routing did not use.
44
+ */
45
+ export function codexAccountUnusableReason(
24
46
  config: OcxConfig,
25
47
  accountId: string,
26
48
  options: CodexAccountUsabilityOptions = {},
27
- ): boolean {
28
- if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false;
49
+ ): CodexAccountUnusableReason | undefined {
50
+ if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) {
51
+ return "model_not_entitled";
52
+ }
29
53
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
30
- if (isMainAccountHardLocked(config)) return false;
54
+ if (isMainAccountHardLocked(config)) return "main_hard_locked";
31
55
  // Startup recovery owns the physical auth/vault boundary. Never parse or select
32
56
  // native __main__ while an encrypted switch journal is pending or inconclusive.
33
- if (!options.nativeMainSelectionOnly && isNativeMainTrafficBlocked()) return false;
57
+ if (!options.nativeMainSelectionOnly && isNativeMainTrafficBlocked()) return "main_traffic_blocked";
34
58
  // A legacy pool row with the sentinel makes an active `__main__` ambiguous.
35
59
  // Fail closed until the authenticated compatibility-delete path removes it.
36
- if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false;
37
- if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return false;
60
+ if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return "legacy_pool_sentinel";
61
+ if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return "needs_reauth";
38
62
  // A selection-only caller owns the recovery/drain fence and will reject main
39
63
  // before reservation or token materialization. Treat cached main as a routing
40
64
  // candidate without touching the credential file so affinity is not rebound.
41
- if (options.nativeMainSelectionOnly) return true;
65
+ if (options.nativeMainSelectionOnly) return undefined;
42
66
  // Main account: a refresh grant is enough to route; materialization refreshes before I/O.
43
- return options.isMainAccountTokenLive
67
+ const mainLive = options.isMainAccountTokenLive
44
68
  ? options.isMainAccountTokenLive()
45
69
  : isMainAccountCredentialUsable();
70
+ return mainLive ? undefined : "main_credential_unavailable";
46
71
  }
47
72
  const exists = (config.codexAccounts ?? [])
48
73
  .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
49
- if (!exists) return false;
50
- if (isAccountNeedsReauth(accountId)) return false;
51
- return !!getCodexAccountCredential(accountId);
74
+ if (!exists) return "not_in_pool";
75
+ if (isAccountNeedsReauth(accountId)) return "needs_reauth";
76
+ const record = readCodexAccountRecord(accountId);
77
+ if (!record?.credential) return "missing_credential";
78
+ if (record.deletedAt != null) return "deleted";
79
+ if (record.codexValidationPending) return "validation_pending";
80
+ return undefined;
81
+ }
82
+
83
+ export function isCodexAccountUsable(
84
+ config: OcxConfig,
85
+ accountId: string,
86
+ options: CodexAccountUsabilityOptions = {},
87
+ ): boolean {
88
+ return codexAccountUnusableReason(config, accountId, options) === undefined;
52
89
  }
@@ -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,8 +115,8 @@ 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 { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
118
+ import { emailMaskingEnabled, projectEmail } from "../lib/privacy";
119
+ import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "./warmup";
119
120
  export { maskEmail } from "../lib/privacy";
120
121
  import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types";
121
122
  import type { CatalogDisposition } from "./convergence-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>();
@@ -362,20 +364,46 @@ function mainQuotaWithCarriedResetCredits(
362
364
  };
363
365
  }
364
366
 
367
+ /**
368
+ * Why an account needs the operator. `missing_credential`, `refresh_failed`, and
369
+ * `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized`
370
+ * and `forbidden` exist because the shared health projection may return them; today
371
+ * `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union
372
+ * keeps this field correct if that projection widens rather than silently dropping a reason.
373
+ */
374
+ export type CodexAccountReauthReason =
375
+ | "missing_credential"
376
+ | "refresh_failed"
377
+ | "quota_unauthorized"
378
+ | "unauthorized"
379
+ | "forbidden";
380
+
365
381
  function poolAccountDto(
366
382
  account: CodexAccount,
367
383
  quotaResult: PoolQuotaResult,
368
384
  hasCredential: boolean,
369
385
  paused: boolean,
370
386
  priority: number,
387
+ maskEmails: boolean,
371
388
  ): CodexAuthAccountDto {
372
389
  const plan = codexPlanValue(account.plan);
373
390
  const quota = quotaForPlan(quotaResult.quota, plan);
374
- const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
391
+ const runtimeReauth = isAccountNeedsReauth(account.id);
392
+ const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth;
375
393
  const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
394
+ // `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the
395
+ // health projection. Emitting only the boolean is what left #4212's reporter guessing which
396
+ // account took their model away and why, so name the cause they actually have to act on.
397
+ const reauthReason: CodexAccountReauthReason | undefined = !hasCredential
398
+ ? "missing_credential"
399
+ : runtimeReauth
400
+ ? "refresh_failed"
401
+ : quotaResult.needsReauth
402
+ ? "quota_unauthorized"
403
+ : health.status === "reauth_required" ? health.reason : undefined;
376
404
  return {
377
405
  id: account.id,
378
- email: maskEmail(account.email) ?? account.email,
406
+ email: projectEmail(account.email, maskEmails) ?? account.email,
379
407
  ...(account.alias !== undefined ? { alias: account.alias } : {}),
380
408
  ...(plan !== undefined ? { plan } : {}),
381
409
  logLabel: codexAccountLogLabel(account),
@@ -383,7 +411,8 @@ function poolAccountDto(
383
411
  paused,
384
412
  priority,
385
413
  quota: quota ? { ...quota } : null,
386
- needsReauth,
414
+ needsReauth: needsReauth || health.status === "reauth_required",
415
+ ...(reauthReason !== undefined ? { reauthReason } : {}),
387
416
  hasCredential,
388
417
  ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
389
418
  ...oauthAccountHealthFields("codex", account.id, health),
@@ -585,7 +614,11 @@ async function verifyCodexAccountWarmup(
585
614
  return {
586
615
  ok: false,
587
616
  response: jsonResponse({
588
- error: "Codex account warmup failed. Reauthenticate the account and try again.",
617
+ // Every fallback model was refused for a provisioning reason, so telling the operator to
618
+ // reauthenticate sends them back through a login that already succeeded.
619
+ error: isCodexWarmupProvisioningFailure(err)
620
+ ? "Codex account warmup failed. Verify account model access or provisioning and try again."
621
+ : "Codex account warmup failed. Reauthenticate the account and try again.",
589
622
  code: "codex_warmup_failed",
590
623
  reason,
591
624
  accountId,
@@ -638,7 +671,7 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void
638
671
 
639
672
  interface StagedNewCodexAccountState {
640
673
  credential: CodexAccountCredentials;
641
- validatedAt: number;
674
+ validatedAt?: number;
642
675
  }
643
676
 
644
677
  type PersistNewCodexAccountOutcome =
@@ -691,8 +724,10 @@ function persistNewCodexAccount(
691
724
  }
692
725
 
693
726
  try {
694
- saveCodexAccountCredential(addedAccount.id, staged.credential);
695
- markCodexAccountValidated(addedAccount.id, staged.validatedAt);
727
+ const generation = saveCodexAccountCredential(addedAccount.id, staged.credential, {
728
+ validationPending: staged.validatedAt === undefined,
729
+ });
730
+ if (staged.validatedAt !== undefined) markCodexAccountValidated(addedAccount.id, staged.validatedAt, generation);
696
731
  clearAccountNeedsReauth(addedAccount.id);
697
732
  } catch {
698
733
  // Config is already durable. Return the failure outcome through the coordinator so its
@@ -1101,6 +1136,7 @@ interface PoolQuotaRefreshFlight {
1101
1136
  superseded?: boolean;
1102
1137
  startCredentialGeneration?: number;
1103
1138
  resolvedCredentialGeneration?: number;
1139
+ validatePending?: boolean;
1104
1140
  };
1105
1141
  promise: Promise<PoolQuotaResult>;
1106
1142
  }
@@ -1151,6 +1187,11 @@ export interface CodexAuthAccountDto {
1151
1187
  priority: number;
1152
1188
  quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
1153
1189
  needsReauth?: boolean;
1190
+ /**
1191
+ * Which of the independent causes behind `needsReauth` fired. Present only when the account
1192
+ * needs the operator; `/api/oauth/accounts` already carries the same field name.
1193
+ */
1194
+ reauthReason?: CodexAccountReauthReason;
1154
1195
  hasCredential: boolean;
1155
1196
  health: OAuthAccountHealth;
1156
1197
  healthLabel: OAuthHealthLabel;
@@ -1480,11 +1521,12 @@ async function fetchFreshPoolAccountQuota(
1480
1521
  }
1481
1522
  }
1482
1523
 
1483
- async function fetchPoolAccountQuota(
1524
+ export async function fetchPoolAccountQuota(
1484
1525
  accountId: string,
1485
1526
  forceRefresh = false,
1486
1527
  configuredPlan?: string,
1487
1528
  getValidToken: typeof getValidCodexToken = getValidCodexToken,
1529
+ validatePending = false,
1488
1530
  afterDispatchSequence?: number,
1489
1531
  ): Promise<PoolQuotaResult> {
1490
1532
  const existing = getAccountQuota(accountId);
@@ -1507,7 +1549,11 @@ async function fetchPoolAccountQuota(
1507
1549
  && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence)
1508
1550
  && generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
1509
1551
  });
1510
- if (current) return current.promise;
1552
+ if (current) {
1553
+ // A manual refresh joining a passive read must not lose its validation intent.
1554
+ current.state.validatePending ||= validatePending;
1555
+ return current.promise;
1556
+ }
1511
1557
  if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
1512
1558
 
1513
1559
  // A post-reset request must not let an older same-account response overwrite its evidence.
@@ -1517,6 +1563,7 @@ async function fetchPoolAccountQuota(
1517
1563
  }
1518
1564
  const state: PoolQuotaRefreshFlight["state"] = {
1519
1565
  startCredentialGeneration: record?.generation,
1566
+ validatePending,
1520
1567
  };
1521
1568
  const refresh = fetchFreshPoolAccountQuota(
1522
1569
  accountId,
@@ -1528,18 +1575,54 @@ async function fetchPoolAccountQuota(
1528
1575
  onDispatch: sequence => { state.dispatchSequence = sequence; },
1529
1576
  mayPublish: () => state.superseded !== true,
1530
1577
  },
1531
- );
1578
+ ).then(async result => {
1579
+ // A passive flight has consumed its validation decision. Remove it before
1580
+ // promise settlement queues other continuations, so a late explicit caller
1581
+ // starts fresh work instead of setting an intent nobody will read again.
1582
+ if (!state.validatePending) {
1583
+ releaseFlight();
1584
+ return result;
1585
+ }
1586
+ // Only an explicit account-list refresh finishes deferred registration. Passive quota
1587
+ // polls and startup priming remain read-only with respect to inference spending.
1588
+ const generation = result.freshCredentialGeneration;
1589
+ const record = state.validatePending ? readCodexAccountRecord(accountId) : null;
1590
+ if (record?.codexValidationPending && record.credential && record.deletedAt == null
1591
+ && generation !== undefined && record.generation === generation
1592
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) {
1593
+ try {
1594
+ await warmCodexAccount({
1595
+ accessToken: record.credential.accessToken,
1596
+ chatgptAccountId: record.credential.chatgptAccountId,
1597
+ });
1598
+ markCodexAccountValidated(accountId, Date.now(), generation);
1599
+ clearAccountNeedsReauth(accountId, generation);
1600
+ } catch (error) {
1601
+ // Keep the durable restriction on any failed/partial inference response, even
1602
+ // when WHAM just reported headroom. No raw upstream text enters diagnostics.
1603
+ const reason = codexWarmupFailureReason(error);
1604
+ if (reason === "http_status:401" || reason === "http_status:403") {
1605
+ markCodexAccountValidationFailed(accountId, reason, { expectedGeneration: generation });
1606
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), generation);
1607
+ }
1608
+ }
1609
+ }
1610
+ return result;
1611
+ });
1532
1612
  const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
1533
1613
  const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
1534
1614
  activeFlights.add(flight);
1535
1615
  if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
1536
- try {
1537
- return await refresh;
1538
- } finally {
1616
+ const releaseFlight = () => {
1539
1617
  activeFlights.delete(flight);
1540
1618
  if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
1541
1619
  poolQuotaRefreshInFlight.delete(accountId);
1542
1620
  }
1621
+ };
1622
+ try {
1623
+ return await refresh;
1624
+ } finally {
1625
+ releaseFlight();
1543
1626
  }
1544
1627
  }
1545
1628
 
@@ -1594,8 +1677,10 @@ async function refreshAfterManualReset(
1594
1677
  }
1595
1678
  return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration };
1596
1679
  };
1680
+ // `validatePending` is false here: a manual reset settles cooldown, and finishing deferred
1681
+ // registration stays reserved for an explicit dashboard account-list refresh.
1597
1682
  const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken,
1598
- didReset ? afterDispatchSequence : undefined);
1683
+ false, didReset ? afterDispatchSequence : undefined);
1599
1684
  const record = readCodexAccountRecord(accountId);
1600
1685
  const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId
1601
1686
  && (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence
@@ -1876,9 +1961,12 @@ export interface CodexAuthAccountsSnapshot {
1876
1961
  export async function listCodexAuthAccountsSnapshot(
1877
1962
  config: OcxConfig,
1878
1963
  forceRefresh = false,
1964
+ options: { validatePending?: boolean } = {},
1879
1965
  ): Promise<CodexAuthAccountsSnapshot> {
1880
1966
  const runtimeConfig = getRuntimeConfig(config);
1881
1967
  const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
1968
+ // One redaction decision for the whole snapshot, read once from the operator's config (#3859).
1969
+ const maskEmails = emailMaskingEnabled(runtimeConfig);
1882
1970
  const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
1883
1971
  const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
1884
1972
  const cred = getCodexAccountCredential(account.id);
@@ -1887,7 +1975,7 @@ export async function listCodexAuthAccountsSnapshot(
1887
1975
  quotaResult = { quota: null, needsReauth: true };
1888
1976
  } else {
1889
1977
  try {
1890
- quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan);
1978
+ quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true);
1891
1979
  } catch (error) {
1892
1980
  if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
1893
1981
  quotaResult = {
@@ -1923,6 +2011,7 @@ export async function listCodexAuthAccountsSnapshot(
1923
2011
  false,
1924
2012
  isCodexAccountPaused(runtimeConfig, accountId),
1925
2013
  getCodexAccountPriority(runtimeConfig, accountId),
2014
+ maskEmails,
1926
2015
  )];
1927
2016
  }
1928
2017
  const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
@@ -1942,6 +2031,7 @@ export async function listCodexAuthAccountsSnapshot(
1942
2031
  true,
1943
2032
  isCodexAccountPaused(runtimeConfig, accountId),
1944
2033
  getCodexAccountPriority(runtimeConfig, accountId),
2034
+ maskEmails,
1945
2035
  )];
1946
2036
  });
1947
2037
  const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
@@ -1950,15 +2040,23 @@ export async function listCodexAuthAccountsSnapshot(
1950
2040
  const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
1951
2041
  ? mainResult.hasCredential
1952
2042
  : getMainAccountCredentialPresence() ?? false;
1953
- const mainNeedsReauth = (mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential)
1954
- || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
2043
+ const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
2044
+ const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
1955
2045
  const mainHealth = projectCodexAccountHealth({
1956
2046
  accountId: MAIN_CODEX_ACCOUNT_ID,
1957
2047
  needsReauth: mainNeedsReauth,
1958
2048
  });
2049
+ // The main row carries the same attribution as a pool row. Reaching this point without
2050
+ // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the
2051
+ // cause is a refresh that did not complete.
2052
+ const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
2053
+ ? "missing_credential"
2054
+ : mainNeedsReauth
2055
+ ? "refresh_failed"
2056
+ : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
1959
2057
  const main: CodexAuthAccountDto = {
1960
2058
  id: MAIN_CODEX_ACCOUNT_ID,
1961
- email: maskEmail(mainInfo.email) ?? "Codex App login",
2059
+ email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
1962
2060
  plan: mainInfo.plan,
1963
2061
  ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
1964
2062
  && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
@@ -1970,6 +2068,7 @@ export async function listCodexAuthAccountsSnapshot(
1970
2068
  priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1971
2069
  hasCredential: hasMainCredential,
1972
2070
  needsReauth: mainNeedsReauth,
2071
+ ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
1973
2072
  quota: mainInfo.quota ? {
1974
2073
  ...quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan),
1975
2074
  } : null,
@@ -2019,8 +2118,12 @@ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountI
2019
2118
  }
2020
2119
  }
2021
2120
 
2022
- export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
2023
- return (await listCodexAuthAccountsSnapshot(config, forceRefresh)).accounts;
2121
+ export async function listCodexAuthAccounts(
2122
+ config: OcxConfig,
2123
+ forceRefresh = false,
2124
+ options: { validatePending?: boolean } = {},
2125
+ ): Promise<CodexAuthAccountDto[]> {
2126
+ return (await listCodexAuthAccountsSnapshot(config, forceRefresh, options)).accounts;
2024
2127
  }
2025
2128
 
2026
2129
  interface PauseExhaustedResult {
@@ -2129,6 +2232,7 @@ export async function handleCodexAuthAPI(
2129
2232
  url: URL,
2130
2233
  config: OcxConfig,
2131
2234
  convergeCodexCatalog?: CodexAuthCatalogConvergence,
2235
+ principal?: import("../server/management-auth").ManagementPrincipal,
2132
2236
  ): Promise<Response | null> {
2133
2237
 
2134
2238
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
@@ -2136,6 +2240,14 @@ export async function handleCodexAuthAPI(
2136
2240
  return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) });
2137
2241
  }
2138
2242
 
2243
+ if (url.pathname === "/api/codex-auth/accounts/refresh" && req.method === "POST") {
2244
+ // Inference spends quota: only a dashboard session carries the consent
2245
+ // required by AGENTS_INSTALL.md. Raw-admin/CLI refreshes remain observational.
2246
+ return jsonResponse({ accounts: await listCodexAuthAccounts(config, true, {
2247
+ validatePending: principal === "gui-session",
2248
+ }) });
2249
+ }
2250
+
2139
2251
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
2140
2252
  return manualImportDisabledResponse();
2141
2253
  }
@@ -2313,6 +2425,9 @@ export async function handleCodexAuthAPI(
2313
2425
  const exists = (runtimeConfig.codexAccounts ?? [])
2314
2426
  .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
2315
2427
  if (!exists) return jsonResponse({ error: "Account not found" }, 400);
2428
+ if (readCodexAccountRecord(body.accountId)?.codexValidationPending) {
2429
+ return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409);
2430
+ }
2316
2431
  }
2317
2432
  runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
2318
2433
  // "Use this account now" outranks selection order until the account is spent:
@@ -2757,7 +2872,12 @@ export async function handleCodexAuthAPI(
2757
2872
  break;
2758
2873
  }
2759
2874
 
2760
- const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
2875
+ // A successful authenticated WHAM read can prove quota is exhausted without
2876
+ // spending an inference request. Store the account, but defer inference validation
2877
+ // and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
2878
+ const warmup = isCodexQuotaExhausted(quota, plan)
2879
+ ? { ok: true as const, validatedAt: undefined }
2880
+ : await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
2761
2881
  if (!warmup.ok) {
2762
2882
  const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
2763
2883
  setCodexLoginState(flowId, {
@@ -2797,11 +2917,13 @@ export async function handleCodexAuthAPI(
2797
2917
  };
2798
2918
 
2799
2919
  if (existingIdx >= 0) {
2800
- saveCodexAccountCredential(accountId, credential);
2920
+ const generation = saveCodexAccountCredential(accountId, credential, {
2921
+ validationPending: warmup.validatedAt === undefined,
2922
+ });
2801
2923
  // A successful reauthentication replaces the credential generation. Do not let a
2802
2924
  // failed optional WHAM probe make the replacement inherit quota from the old record.
2803
2925
  if (reauth) clearAccountQuota(accountId);
2804
- markCodexAccountValidated(accountId, warmup.validatedAt);
2926
+ if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation);
2805
2927
  clearAccountNeedsReauth(accountId);
2806
2928
  if (quota) setAccountQuotaFromParsed(accountId, quota);
2807
2929
  // Keep the pool id stable; refresh display metadata after a successful login/reauth.
@@ -2852,6 +2974,7 @@ export async function handleCodexAuthAPI(
2852
2974
  status: "done",
2853
2975
  accountId,
2854
2976
  email,
2977
+ ...(warmup.validatedAt === undefined ? { validationPending: true } : {}),
2855
2978
  ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
2856
2979
  doneAt: Date.now(),
2857
2980
  });
@@ -2952,6 +3075,9 @@ export async function handleCodexAuthAPI(
2952
3075
  if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
2953
3076
  const flowId = url.searchParams.get("flowId");
2954
3077
  const accountId = url.searchParams.get("accountId")?.trim();
3078
+ // Transient flow state carries the address of the account being added, so it follows the
3079
+ // same operator policy as the stored accounts it is about to become.
3080
+ const maskFlowEmails = emailMaskingEnabled(config);
2955
3081
  // Reauth always has a pre-existing credential; never treat "credential exists" as success
2956
3082
  // when the flow map entry is gone (would false-complete on lost/expired flow state).
2957
3083
  const reauthStatus = url.searchParams.get("reauth") === "1";
@@ -2964,13 +3090,15 @@ export async function handleCodexAuthAPI(
2964
3090
  && !isAccountNeedsReauth(accountId)
2965
3091
  && getCodexAccountCredential(accountId)
2966
3092
  ) {
2967
- return jsonResponse({ status: "done", accountId });
3093
+ return jsonResponse({ status: "done", accountId,
3094
+ ...(readCodexAccountRecord(accountId)?.codexValidationPending ? { validationPending: true } : {}),
3095
+ });
2968
3096
  }
2969
- return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" });
3097
+ return jsonResponse(st ? { ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined } : { status: "expired" });
2970
3098
  }
2971
3099
  // Legacy fallback: return latest pending flow
2972
3100
  for (const [, st] of codexAuthLoginState) {
2973
- if (st.status === "pending") return jsonResponse({ ...st, email: maskEmail(st.email) ?? undefined });
3101
+ if (st.status === "pending") return jsonResponse({ ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined });
2974
3102
  }
2975
3103
  return jsonResponse({ status: "idle" });
2976
3104
  }