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