@bitkyc08/opencodex 2.55.0 → 2.57.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 (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,566 @@
1
+ import { withCodexAccountLogLabel } from "../account-label";
2
+ import { getCodexAccountCredential, markCodexAccountValidated, readCodexAccountRecord, saveCodexAccountCredential, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError } from "../account-store";
3
+ import { clearAccountQuota, isCodexQuotaExhausted, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
4
+ import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
5
+ import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config";
6
+ import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces";
7
+ import { catalogRefreshIsPending, normalizeCatalogDisposition } from "../catalog-refresh-status";
8
+ import { checkAccountIdCollision } from "../auth-collision";
9
+ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
10
+ import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff";
11
+ import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
12
+ import { emailMaskingEnabled, projectEmail } from "../../lib/privacy";
13
+ import { CodexWarmupError, codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup";
14
+ import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../../types";
15
+ import type { CatalogDisposition } from "../convergence-types";
16
+ import { isValidCodexAccountId } from "../account-id";
17
+ import { codexAccountIdNamespaceCollisionError } from "../account-namespace-match";
18
+ import { jsonResponse } from "./http";
19
+ import { codexAuthLoginState, MAX_CODEX_LOGIN_STATE_ROWS, CODEX_LOGIN_TERMINAL_TTL_MS, CodexLoginStateBusyError, setCodexLoginState, pruneCodexLoginState, expireCodexAuthFlow } from "./login-state";
20
+ import type { CodexLoginStateRow } from "./login-state";
21
+ import { getRuntimeConfig, configuredPoolAccount, nonEmptyPlan, saveRuntimeConfig } from "./runtime-config";
22
+
23
+ const CODEX_CREDENTIAL_PERSISTENCE_ERROR = "Account was saved, but credential setup did not complete. Reauthenticate or remove the account.";
24
+ const CODEX_CREDENTIAL_PERSISTENCE_CODE = "codex_credential_persistence_failed";
25
+
26
+ export function codexAccountPersistenceConflict(
27
+ config: OcxConfig,
28
+ accountId: string,
29
+ mode: "create" | "reauth",
30
+ ): string | undefined {
31
+ if (mode === "reauth") {
32
+ return configuredPoolAccount(config, accountId)
33
+ ? undefined
34
+ : "Pool account was removed while login was in progress. Add it again as a new account.";
35
+ }
36
+ const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId);
37
+ if (namespaceCollision) return namespaceCollision;
38
+ return (config.codexAccounts ?? []).some(account => account.id === accountId)
39
+ || Boolean(getCodexAccountCredential(accountId))
40
+ ? `Account id already exists: ${accountId}`
41
+ : undefined;
42
+ }
43
+
44
+ export async function verifyCodexAccountWarmup(
45
+ accountId: string,
46
+ accessToken: string,
47
+ chatgptAccountId: string,
48
+ ): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response }> {
49
+ try {
50
+ await warmCodexAccount({ accessToken, chatgptAccountId });
51
+ return { ok: true, validatedAt: Date.now() };
52
+ } catch (err) {
53
+ const reason = codexWarmupFailureReason(err);
54
+ if (err instanceof CodexWarmupError && err.code === "http_status" && err.status === 429) {
55
+ return {
56
+ ok: false,
57
+ response: jsonResponse({
58
+ error: "Codex account warmup was rate limited. Retry later or after the account's usage limit resets.",
59
+ code: "codex_warmup_rate_limited",
60
+ reason,
61
+ accountId,
62
+ }, 429),
63
+ };
64
+ }
65
+ return {
66
+ ok: false,
67
+ response: jsonResponse({
68
+ // Every fallback model was refused for a provisioning reason, so telling the operator to
69
+ // reauthenticate sends them back through a login that already succeeded.
70
+ error: isCodexWarmupProvisioningFailure(err)
71
+ ? "Codex account warmup failed. Verify account model access or provisioning and try again."
72
+ : "Codex account warmup failed. Reauthenticate the account and try again.",
73
+ code: "codex_warmup_failed",
74
+ reason,
75
+ accountId,
76
+ }, 401),
77
+ };
78
+ }
79
+ }
80
+
81
+ export interface StagedNewCodexAccountState {
82
+ credential: CodexAccountCredentials;
83
+ validatedAt?: number;
84
+ }
85
+
86
+ export type PersistNewCodexAccountOutcome =
87
+ | { status: "committed"; pickerVisibilityChanged: boolean }
88
+ | { status: "publication-failed"; pickerVisibilityChanged: boolean };
89
+
90
+ export function codexCredentialPersistenceFailure(accountId: string, catalogRefreshPending: boolean) {
91
+ return {
92
+ error: CODEX_CREDENTIAL_PERSISTENCE_ERROR,
93
+ code: CODEX_CREDENTIAL_PERSISTENCE_CODE,
94
+ accountId,
95
+ needsReauth: true as const,
96
+ ...(catalogRefreshPending ? { catalogRefreshPending: true as const } : {}),
97
+ };
98
+ }
99
+
100
+ /** Persist config before publishing secret or runtime state under the shared mutation coordinator. */
101
+ export function persistNewCodexAccount(
102
+ sourceConfig: OcxConfig,
103
+ runtimeConfig: OcxConfig,
104
+ addedAccount: CodexAccount,
105
+ staged: StagedNewCodexAccountState,
106
+ ): PersistNewCodexAccountOutcome {
107
+ return withConfigMutationLockSync(() => {
108
+ const previousConfig = { ...runtimeConfig };
109
+ let pickerVisibilityChanged: boolean;
110
+ try {
111
+ const accounts = [...(runtimeConfig.codexAccounts ?? [])];
112
+ const retainedPickerBindingRestored = codexAccountPickerEnabled(runtimeConfig)
113
+ && Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id);
114
+ accounts.push(addedAccount);
115
+ runtimeConfig.codexAccounts = accounts;
116
+
117
+ // Presence of the explicit flag distinguishes a dashboard-managed map from
118
+ // a hand-authored legacy map. Preserve manual maps exactly.
119
+ const tracksPickerNamespaces = runtimeConfig.codexAccountPickerEnabled !== undefined;
120
+ if (tracksPickerNamespaces && runtimeConfig.codexAccountNamespaces) {
121
+ runtimeConfig.codexAccountNamespaces = { ...runtimeConfig.codexAccountNamespaces };
122
+ }
123
+ const namespaceAdded = tracksPickerNamespaces
124
+ && appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount);
125
+ pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored;
126
+ saveRuntimeConfig(sourceConfig, runtimeConfig);
127
+ } catch (error) {
128
+ for (const key of Object.keys(runtimeConfig) as Array<keyof OcxConfig>) {
129
+ delete runtimeConfig[key];
130
+ }
131
+ Object.assign(runtimeConfig, previousConfig);
132
+ throw error;
133
+ }
134
+
135
+ try {
136
+ const generation = saveCodexAccountCredential(addedAccount.id, staged.credential, {
137
+ validationPending: staged.validatedAt === undefined,
138
+ });
139
+ if (staged.validatedAt !== undefined) markCodexAccountValidated(addedAccount.id, staged.validatedAt, generation);
140
+ clearAccountNeedsReauth(addedAccount.id);
141
+ } catch {
142
+ // Config is already durable. Return the failure outcome through the coordinator so its
143
+ // generation commit is not rolled back while config.json remains changed.
144
+ return { status: "publication-failed" as const, pickerVisibilityChanged };
145
+ }
146
+ return { status: "committed" as const, pickerVisibilityChanged };
147
+ });
148
+ }
149
+
150
+ /** Bounded catalog-convergence callback supplied by the management dispatcher. */
151
+ export type CodexAuthCatalogConvergence = () => Promise<CatalogDisposition>;
152
+
153
+ export interface AccountNamespaceCatalogRefresh {
154
+ catalogRefreshPending: boolean;
155
+ }
156
+
157
+ /** Collapse post-persistence convergence into the one public recovery bit. */
158
+ export async function convergeAccountNamespaceCatalog(
159
+ config: OcxConfig,
160
+ changed: boolean,
161
+ convergeCodexCatalog?: CodexAuthCatalogConvergence,
162
+ ): Promise<AccountNamespaceCatalogRefresh> {
163
+ if (!changed || !codexAccountPickerEnabled(config)) {
164
+ return { catalogRefreshPending: false };
165
+ }
166
+ if (!convergeCodexCatalog) return { catalogRefreshPending: true };
167
+
168
+ try {
169
+ const catalogRefresh = normalizeCatalogDisposition(await convergeCodexCatalog());
170
+ if (!catalogRefresh) return { catalogRefreshPending: true };
171
+ return { catalogRefreshPending: catalogRefreshIsPending(catalogRefresh) };
172
+ } catch {
173
+ return { catalogRefreshPending: true };
174
+ }
175
+ }
176
+
177
+ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig, convergeCodexCatalog?: CodexAuthCatalogConvergence): Promise<Response> {
178
+ const body = (await req.json().catch(() => ({}))) as {
179
+ id?: string;
180
+ reauth?: boolean;
181
+ openBrowser?: unknown;
182
+ device?: unknown;
183
+ };
184
+ // Device mode: no local browser, no loopback listener. The only way to add
185
+ // an account to a headless hub (#3366).
186
+ const useDeviceFlow = body.device === true;
187
+ const requestedAccountId = body.id?.trim();
188
+ const reauth = body.reauth === true;
189
+ if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
190
+ return jsonResponse({ error: "Invalid account id format" }, 400);
191
+ }
192
+ const accountId = requestedAccountId || `chatgpt-${Date.now()}`;
193
+ const runtimeConfig = getRuntimeConfig(config);
194
+ const preflightConflict = !reauth
195
+ ? codexAccountPersistenceConflict(runtimeConfig, accountId, "create")
196
+ : undefined;
197
+ if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
198
+ if (reauth) {
199
+ if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400);
200
+ if (!configuredPoolAccount(runtimeConfig, accountId)) {
201
+ return jsonResponse({ error: "Unknown pool account for reauth" }, 404);
202
+ }
203
+ }
204
+ pruneCodexLoginState();
205
+ if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
206
+ const busy = new CodexLoginStateBusyError();
207
+ const response = jsonResponse({ error: busy.message, code: busy.code }, 503);
208
+ response.headers.set("Retry-After", "1");
209
+ return response;
210
+ }
211
+ const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
212
+ const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() };
213
+ codexAuthLoginState.set(flowId, loginOwner);
214
+ try {
215
+ const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../../oauth");
216
+ const result = await startLoginFlow("chatgpt", {
217
+ forceLogin: true,
218
+ ...(useDeviceFlow ? { flow: "device" as const } : {}),
219
+ });
220
+
221
+ // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
222
+ // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
223
+ // Both login routes share one resolver so this surface cannot drift from the other.
224
+ const { shouldOpenBrowserForLogin } = await import("../../oauth/open-browser-choice");
225
+ // A device flow's URL is a verification page the user opens on ANOTHER
226
+ // machine. Opening it on the hub host is useless at best, and on a
227
+ // headless host it fails. `deviceCode` is the same signal the generic
228
+ // OAuth login route uses to make this decision.
229
+ if (result.url && !result.deviceCode && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
230
+ const { openUrl } = await import("../../lib/open-url");
231
+ openUrl(result.url);
232
+ }
233
+
234
+ (async () => {
235
+ try {
236
+ let completed = false;
237
+ // The device grant lives 15 minutes and the whole point is that the
238
+ // user walks to another device to enter the code. A 5-minute server
239
+ // budget would kill the flow at minute five while the grant is still
240
+ // valid. The extra 30 attempts past 450 are settlement margin: a user
241
+ // who authorizes in the final seconds still needs the token exchange
242
+ // and credential write to land before this loop gives up.
243
+ const pollAttempts = useDeviceFlow ? 480 : 150;
244
+ for (let i = 0; i < pollAttempts; i++) {
245
+ await new Promise(r => setTimeout(r, 2000));
246
+ const st = getLoginStatus("chatgpt");
247
+ if (st.done && st.loggedIn) {
248
+ const { getCredential } = await import("../../oauth/store");
249
+ const cred = getCredential("chatgpt");
250
+ if (cred) {
251
+ const oauthAccountId = cred.accountId;
252
+ if (!oauthAccountId) {
253
+ setCodexLoginState(flowId, {
254
+ status: "error",
255
+ error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
256
+ doneAt: Date.now(),
257
+ });
258
+ completed = true;
259
+ break;
260
+ }
261
+
262
+ let email = cred.email || accountId;
263
+ let plan: string | undefined;
264
+ let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
265
+ try {
266
+ const tokens = { access_token: cred.access, account_id: oauthAccountId };
267
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
268
+ headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
269
+ signal: AbortSignal.timeout(8000),
270
+ });
271
+ if (resp.ok) {
272
+ const data = (await resp.json()) as WhamUsageResponse;
273
+ email = data.email ?? email;
274
+ plan = nonEmptyPlan(data.plan_type) ?? undefined;
275
+ quota = parseUsageQuota(data);
276
+ }
277
+ } catch { /* wham fetch is non-blocking */ }
278
+ // Reauth must refresh the same ChatGPT identity already bound to this pool slot.
279
+ // Otherwise a different login would silently overwrite credentials under a trusted id.
280
+ if (reauth) {
281
+ const existingCred = getCodexAccountCredential(accountId);
282
+ const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId);
283
+ const expectedChatgptId = existingCred?.chatgptAccountId?.trim();
284
+ const expectedEmail = poolAccount?.email?.trim().toLowerCase();
285
+ const gotEmail = email.trim().toLowerCase();
286
+ if (expectedChatgptId) {
287
+ if (expectedChatgptId !== oauthAccountId) {
288
+ setCodexLoginState(flowId, {
289
+ status: "error",
290
+ error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
291
+ doneAt: Date.now(),
292
+ });
293
+ completed = true;
294
+ break;
295
+ }
296
+ } else if (expectedEmail) {
297
+ if (!gotEmail || gotEmail !== expectedEmail) {
298
+ setCodexLoginState(flowId, {
299
+ status: "error",
300
+ error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
301
+ doneAt: Date.now(),
302
+ });
303
+ completed = true;
304
+ break;
305
+ }
306
+ } else {
307
+ // No chatgptAccountId and no pool email — refuse silent identity replacement
308
+ // (including empty credential slots that still have a pool row).
309
+ setCodexLoginState(flowId, {
310
+ status: "error",
311
+ error: "Cannot verify account identity for reauth. Remove this account and add it again.",
312
+ doneAt: Date.now(),
313
+ });
314
+ completed = true;
315
+ break;
316
+ }
317
+ }
318
+
319
+ // 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
320
+ const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
321
+ if (collision.collision) {
322
+ setCodexLoginState(flowId, {
323
+ status: "error", error: collision.reason, doneAt: Date.now(),
324
+ });
325
+ completed = true;
326
+ break;
327
+ }
328
+
329
+ // A successful authenticated WHAM read can prove quota is exhausted without
330
+ // spending an inference request. Store the account, but defer inference validation
331
+ // and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
332
+ const warmup = isCodexQuotaExhausted(quota, plan)
333
+ ? { ok: true as const, validatedAt: undefined }
334
+ : await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
335
+ if (!warmup.ok) {
336
+ const body = await warmup.response.json().catch(() => ({})) as { error?: string; code?: string; reason?: string };
337
+ setCodexLoginState(flowId, {
338
+ status: "error",
339
+ error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
340
+ code: body.code,
341
+ doneAt: Date.now(),
342
+ });
343
+ completed = true;
344
+ break;
345
+ }
346
+
347
+ const latestConfig = getRuntimeConfig(config);
348
+ const accounts = latestConfig.codexAccounts ?? [];
349
+ const existingIdx = accounts.findIndex(account => account.id === accountId);
350
+ let pickerVisibilityChanged = false;
351
+ let newAccountPersistence: PersistNewCodexAccountOutcome | null = null;
352
+ const commitConflict = codexAccountPersistenceConflict(
353
+ latestConfig,
354
+ accountId,
355
+ reauth ? "reauth" : "create",
356
+ );
357
+ if (commitConflict) {
358
+ setCodexLoginState(flowId, {
359
+ status: "error",
360
+ error: commitConflict,
361
+ doneAt: Date.now(),
362
+ });
363
+ completed = true;
364
+ break;
365
+ }
366
+
367
+ const credential: CodexAccountCredentials = {
368
+ accessToken: cred.access,
369
+ refreshToken: cred.refresh,
370
+ expiresAt: cred.expires,
371
+ chatgptAccountId: oauthAccountId,
372
+ };
373
+
374
+ if (existingIdx >= 0) {
375
+ const generation = saveCodexAccountCredential(accountId, credential, {
376
+ validationPending: warmup.validatedAt === undefined,
377
+ });
378
+ // A successful reauthentication replaces the credential generation. Do not let a
379
+ // failed optional WHAM probe make the replacement inherit quota from the old record.
380
+ if (reauth) clearAccountQuota(accountId);
381
+ // The refresh cooldown is learned about a CREDENTIAL, not about an account, and it
382
+ // is keyed by account id alone. A replacement generation therefore inherits the
383
+ // dead one's 15-60s quarantine: selection keeps excluding an account that was just
384
+ // authenticated, and with a healthy sibling the thread detours and loses its warm
385
+ // cache and continuation. A successful save is the proof the old failures were
386
+ // about a credential that no longer exists.
387
+ clearCodexPoolRefreshFailure(accountId);
388
+ if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation);
389
+ clearAccountNeedsReauth(accountId);
390
+ if (quota) setAccountQuotaFromParsed(accountId, quota);
391
+ // Keep the pool id stable; refresh display metadata after a successful login/reauth.
392
+ accounts[existingIdx] = withCodexAccountLogLabel({
393
+ ...accounts[existingIdx],
394
+ email,
395
+ plan: plan ?? accounts[existingIdx].plan,
396
+ isMain: false,
397
+ }, accounts);
398
+ latestConfig.codexAccounts = accounts;
399
+ saveRuntimeConfig(config, latestConfig);
400
+ } else {
401
+ const addedAccount = withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts);
402
+ newAccountPersistence = persistNewCodexAccount(
403
+ config,
404
+ latestConfig,
405
+ addedAccount,
406
+ {
407
+ credential,
408
+ validatedAt: warmup.validatedAt,
409
+ },
410
+ );
411
+ pickerVisibilityChanged = newAccountPersistence.pickerVisibilityChanged;
412
+ }
413
+ reconcileLiveStateStores();
414
+ if (newAccountPersistence?.status === "publication-failed") {
415
+ markAccountNeedsReauth(accountId);
416
+ }
417
+ // A new quota row is generation-gated by live account ownership. Reconcile the
418
+ // durable config owner first so a partial prior sweep cannot reject this write.
419
+ if (newAccountPersistence?.status === "committed" && quota) {
420
+ setAccountQuotaFromParsed(accountId, quota);
421
+ }
422
+ const { catalogRefreshPending } = await convergeAccountNamespaceCatalog(
423
+ latestConfig,
424
+ pickerVisibilityChanged,
425
+ convergeCodexCatalog,
426
+ );
427
+ if (newAccountPersistence?.status === "publication-failed") {
428
+ setCodexLoginState(flowId, {
429
+ status: "error",
430
+ ...codexCredentialPersistenceFailure(accountId, catalogRefreshPending),
431
+ doneAt: Date.now(),
432
+ });
433
+ completed = true;
434
+ } else {
435
+ setCodexLoginState(flowId, {
436
+ status: "done",
437
+ accountId,
438
+ email,
439
+ ...(warmup.validatedAt === undefined ? { validationPending: true } : {}),
440
+ ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
441
+ doneAt: Date.now(),
442
+ });
443
+ completed = true;
444
+ }
445
+ }
446
+ break;
447
+ }
448
+ if (st.done && st.error) {
449
+ setCodexLoginState(flowId, {
450
+ status: "error",
451
+ // startLoginFlow projects background failures before storing login status, so
452
+ // fixed actionable OAuth messages retain their type-derived remediation here.
453
+ error: st.error,
454
+ doneAt: Date.now(),
455
+ });
456
+ completed = true;
457
+ break;
458
+ }
459
+ }
460
+ if (!completed) {
461
+ setCodexLoginState(flowId, {
462
+ status: "error",
463
+ error: "Login timed out before OAuth completed.",
464
+ doneAt: Date.now(),
465
+ });
466
+ }
467
+ } catch (error) {
468
+ const message = error instanceof ConfigMutationLockError
469
+ || error instanceof CodexCredentialRefreshLockTimeoutError
470
+ ? "Configuration is busy; retry login shortly."
471
+ : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
472
+ ? "Credential refresh is busy; retry login shortly."
473
+ : publicOAuthAuthenticationErrorMessage(error);
474
+ setCodexLoginState(flowId, {
475
+ status: "error",
476
+ error: message,
477
+ doneAt: Date.now(),
478
+ });
479
+ } finally {
480
+ // TTL: keep completed flow state available for clients that miss a short polling window.
481
+ setTimeout(() => { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); }, CODEX_LOGIN_TERMINAL_TTL_MS);
482
+ }
483
+ })();
484
+
485
+ setCodexLoginState(flowId, { status: "pending" });
486
+ return jsonResponse({
487
+ ok: true,
488
+ flowId,
489
+ url: result.url,
490
+ instructions: result.instructions,
491
+ // Dropped before #3366: every device-code surface renders this field,
492
+ // so withholding it left the GUI and CLI with no code to show.
493
+ ...(result.deviceCode ? { deviceCode: result.deviceCode } : {}),
494
+ });
495
+ } catch (e) {
496
+ if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
497
+ const msg = e instanceof Error ? e.message : String(e);
498
+ if (msg === "A login for chatgpt is already in progress") {
499
+ return jsonResponse({ error: msg, status: "pending" }, 409);
500
+ }
501
+ if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
502
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
503
+ response.headers.set("Retry-After", "1");
504
+ return response;
505
+ }
506
+ const { publicOAuthAuthenticationErrorMessage } = await import("../../oauth");
507
+ return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(e) }, 500);
508
+ }
509
+ }
510
+
511
+ export async function handleCodexAuthLoginCode(req: Request): Promise<Response> {
512
+ const body = (await req.json().catch(() => ({}))) as { flowId?: unknown; input?: unknown };
513
+ const flowId = typeof body.flowId === "string" ? body.flowId.trim() : "";
514
+ const input = typeof body.input === "string" ? body.input : "";
515
+ if (!flowId) return jsonResponse({ error: "flowId required" }, 400);
516
+ if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
517
+
518
+ // Import may yield; validate afterwards so cancel/replace cannot race a stale flow through.
519
+ const { submitManualLoginCode } = await import("../../oauth");
520
+ const flow = codexAuthLoginState.get(flowId);
521
+ if (!flow) return jsonResponse({ error: "login flow expired or unknown" }, 400);
522
+ if (flow.status !== "pending") return jsonResponse({ error: "login flow is not pending" }, 400);
523
+
524
+ const result = submitManualLoginCode("chatgpt", input);
525
+ if (!result.ok) return jsonResponse({ error: result.error }, 400);
526
+ return jsonResponse({ ok: true }, 202);
527
+ }
528
+
529
+ export async function handleCodexAuthLoginCancel(req: Request): Promise<Response> {
530
+ const body = (await req.json().catch(() => ({}))) as { flowId?: string };
531
+ const { cancelLoginFlow } = await import("../../oauth");
532
+ const cancelled = cancelLoginFlow("chatgpt");
533
+ expireCodexAuthFlow(body.flowId ?? null);
534
+ return jsonResponse({ ok: true, cancelled });
535
+ }
536
+
537
+ export async function handleCodexAuthLoginStatus(req: Request, url: URL, config: OcxConfig): Promise<Response> {
538
+ const flowId = url.searchParams.get("flowId");
539
+ const accountId = url.searchParams.get("accountId")?.trim();
540
+ // Transient flow state carries the address of the account being added, so it follows the
541
+ // same operator policy as the stored accounts it is about to become.
542
+ const maskFlowEmails = emailMaskingEnabled(config);
543
+ // Reauth always has a pre-existing credential; never treat "credential exists" as success
544
+ // when the flow map entry is gone (would false-complete on lost/expired flow state).
545
+ const reauthStatus = url.searchParams.get("reauth") === "1";
546
+ if (flowId) {
547
+ const st = codexAuthLoginState.get(flowId);
548
+ if (
549
+ !st
550
+ && accountId
551
+ && !reauthStatus
552
+ && !isAccountNeedsReauth(accountId)
553
+ && getCodexAccountCredential(accountId)
554
+ ) {
555
+ return jsonResponse({ status: "done", accountId,
556
+ ...(readCodexAccountRecord(accountId)?.codexValidationPending ? { validationPending: true } : {}),
557
+ });
558
+ }
559
+ return jsonResponse(st ? { ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined } : { status: "expired" });
560
+ }
561
+ // Legacy fallback: return latest pending flow
562
+ for (const [, st] of codexAuthLoginState) {
563
+ if (st.status === "pending") return jsonResponse({ ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined });
564
+ }
565
+ return jsonResponse({ status: "idle" });
566
+ }
@@ -0,0 +1,64 @@
1
+ import { ResourceAdmissionError } from "../../lib/admission";
2
+
3
+ export const MAX_CODEX_LOGIN_STATE_ROWS = 32;
4
+ export const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000;
5
+ export interface CodexLoginStateRow {
6
+ status: string;
7
+ startedAt: number;
8
+ accountId?: string;
9
+ email?: string;
10
+ error?: string;
11
+ code?: string;
12
+ needsReauth?: boolean;
13
+ catalogRefreshPending?: boolean;
14
+ validationPending?: boolean;
15
+ doneAt?: number;
16
+ }
17
+ export const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
18
+ export class CodexLoginStateBusyError extends ResourceAdmissionError {
19
+ constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; }
20
+ }
21
+
22
+ export function setCodexLoginState(flowId: string, patch: Partial<CodexLoginStateRow>): void {
23
+ const row = codexAuthLoginState.get(flowId);
24
+ if (row) Object.assign(row, patch);
25
+ }
26
+
27
+ export function pruneCodexLoginState(now = Date.now()): void {
28
+ for (const [id, row] of codexAuthLoginState) {
29
+ if (row.doneAt !== undefined && now - row.doneAt >= CODEX_LOGIN_TERMINAL_TTL_MS) codexAuthLoginState.delete(id);
30
+ }
31
+ while (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
32
+ const terminal = [...codexAuthLoginState].filter(([, row]) => row.doneAt !== undefined)
33
+ .sort((a, b) => (a[1].doneAt ?? 0) - (b[1].doneAt ?? 0))[0];
34
+ if (!terminal) break;
35
+ codexAuthLoginState.delete(terminal[0]);
36
+ }
37
+ }
38
+
39
+ export function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
40
+ const ids = flowId
41
+ ? [flowId]
42
+ : [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
43
+ for (const id of ids) {
44
+ let owner = codexAuthLoginState.get(id);
45
+ if (!owner) {
46
+ pruneCodexLoginState();
47
+ if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue;
48
+ owner = { status: "error", startedAt: Date.now() };
49
+ codexAuthLoginState.set(id, owner);
50
+ }
51
+ Object.assign(owner, { status: "error", error, doneAt: Date.now() });
52
+ setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000);
53
+ }
54
+ }
55
+ /** Package-internal admission-test seam: seed synthetic login-flow rows and return a prefix-scoped cleanup. */
56
+ export function seedLoginRowsForTests(prefix: string, count: number): () => void {
57
+ for (let index = 0; index < count; index++) {
58
+ codexAuthLoginState.set(`${prefix}-login-${index}`, { status: "starting", startedAt: Date.now() });
59
+ }
60
+ return () => {
61
+ for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key);
62
+ };
63
+ }
64
+