@bitkyc08/opencodex 2.58.0 → 2.59.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 (188) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +1 -1
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/cursor/catalog.ts +11 -0
  10. package/src/adapters/cursor/effort-map.ts +16 -2
  11. package/src/adapters/cursor/envelope-echo.ts +55 -2
  12. package/src/adapters/cursor/message-mapper.ts +3 -2
  13. package/src/adapters/cursor/protobuf-request.ts +8 -5
  14. package/src/adapters/cursor/request-builder.ts +14 -3
  15. package/src/adapters/cursor/thread-continuity.ts +105 -31
  16. package/src/adapters/cursor/tool-guidance.ts +5 -4
  17. package/src/adapters/cursor.ts +42 -1
  18. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  19. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  20. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  21. package/src/adapters/devin.ts +75 -13
  22. package/src/adapters/google-antigravity-wire.ts +29 -2
  23. package/src/adapters/google-http.ts +8 -1
  24. package/src/adapters/google.ts +23 -4
  25. package/src/adapters/openai-chat/response-events.ts +61 -0
  26. package/src/adapters/openai-chat.ts +5 -10
  27. package/src/adapters/openai-responses/passthrough.ts +10 -1
  28. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  29. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  30. package/src/adapters/responses-tool-schema.ts +76 -46
  31. package/src/adapters/run-turn-queue.ts +17 -4
  32. package/src/bridge/response-json.ts +1 -1
  33. package/src/bridge/sse.ts +165 -24
  34. package/src/claude/context-windows.ts +22 -0
  35. package/src/claude/outbound.ts +35 -4
  36. package/src/cli/account-api.ts +4 -3
  37. package/src/cli/account-extended.ts +22 -2
  38. package/src/cli/account-orca-import.ts +63 -0
  39. package/src/cli/account.ts +32 -4
  40. package/src/cli/capabilities.ts +40 -0
  41. package/src/cli/claude.ts +29 -1
  42. package/src/cli/codex-cli-update.ts +97 -2
  43. package/src/cli/dispatch.ts +54 -0
  44. package/src/cli/doctor.ts +197 -2
  45. package/src/cli/help.ts +4 -1
  46. package/src/cli/index.ts +88 -20
  47. package/src/cli/models-runtime.ts +33 -4
  48. package/src/cli/registry.ts +11 -1
  49. package/src/cli/runtime-api.ts +44 -0
  50. package/src/cli/start-args.ts +94 -0
  51. package/src/cli/system-command.ts +2 -0
  52. package/src/client/machine-api.ts +4 -3
  53. package/src/client/machine-listener.ts +14 -1
  54. package/src/clients/config-export/constants.ts +2 -3
  55. package/src/clients/config-export.ts +5 -5
  56. package/src/codex/account-store.ts +81 -5
  57. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  58. package/src/codex/auth-api/routes.ts +17 -2
  59. package/src/codex/auth-context.ts +16 -12
  60. package/src/codex/catalog/build-entries.ts +25 -4
  61. package/src/codex/catalog/derive-entry.ts +8 -1
  62. package/src/codex/catalog/effort.ts +10 -6
  63. package/src/codex/catalog/gather-capture.ts +1 -0
  64. package/src/codex/catalog/model-hints.ts +37 -5
  65. package/src/codex/catalog/parsing.ts +83 -5
  66. package/src/codex/catalog/reserve-warn.ts +96 -0
  67. package/src/codex/catalog/retained-sync.ts +19 -0
  68. package/src/codex/catalog/routed-gather.ts +42 -3
  69. package/src/codex/cli-installation-identity.ts +210 -0
  70. package/src/codex/cli-installation-targets.ts +158 -0
  71. package/src/codex/convergence.ts +5 -0
  72. package/src/codex/history-provider.ts +4 -1
  73. package/src/codex/history-state-open.ts +105 -0
  74. package/src/codex/inject/config-toml.ts +44 -2
  75. package/src/codex/inject.ts +3 -2
  76. package/src/codex/lineage.ts +83 -32
  77. package/src/codex/loopback-target.ts +31 -0
  78. package/src/codex/main-account-hard-lock.ts +2 -1
  79. package/src/codex/main-account.ts +10 -3
  80. package/src/codex/main-device-reauth.ts +17 -9
  81. package/src/codex/model-entitlements.ts +60 -1
  82. package/src/codex/observed-model-denials.ts +137 -0
  83. package/src/codex/orca-auth-source.ts +94 -0
  84. package/src/codex/orca-import.ts +219 -0
  85. package/src/codex/prompt-text-probe.ts +282 -12
  86. package/src/codex/quota-401-recovery.ts +12 -0
  87. package/src/codex/quota-types.ts +65 -0
  88. package/src/codex/quota.ts +24 -19
  89. package/src/codex/routing/cooldown-math.ts +8 -47
  90. package/src/codex/routing/pin-drain.ts +57 -0
  91. package/src/codex/routing.ts +13 -15
  92. package/src/codex/subagent-model-fallback.ts +94 -0
  93. package/src/codex/windows-installation-files.ts +224 -0
  94. package/src/combos/failover.ts +122 -5
  95. package/src/config/diagnostics.ts +21 -0
  96. package/src/config/load-degrade.ts +15 -0
  97. package/src/config/pending-teardown.ts +8 -0
  98. package/src/config/process-state.ts +36 -3
  99. package/src/config/provider-relative-send-path.ts +16 -0
  100. package/src/config/proxy-env.ts +23 -5
  101. package/src/config/schema/config-schema.ts +21 -0
  102. package/src/config/schema/leaf-validators.ts +64 -17
  103. package/src/generated/compatibility-version.json +235 -163
  104. package/src/generated/model-metadata.ts +1 -1
  105. package/src/lib/bounded-body.ts +4 -2
  106. package/src/lib/destination-policy.ts +48 -6
  107. package/src/lib/errors.ts +3 -15
  108. package/src/lib/local-destinations.ts +32 -5
  109. package/src/lib/provider-outbound.ts +3 -3
  110. package/src/lib/proxy-env.ts +70 -3
  111. package/src/lib/request-execution-budget.ts +11 -3
  112. package/src/lib/response-body-inactivity.ts +193 -0
  113. package/src/lib/retry-delay.ts +69 -0
  114. package/src/lib/socks5-fetch.ts +631 -0
  115. package/src/lib/spend-reservation-ledger.ts +115 -9
  116. package/src/lib/workflow-budget.ts +145 -8
  117. package/src/oauth/account-quota-rank.ts +72 -15
  118. package/src/oauth/generic-account-failover.ts +40 -27
  119. package/src/oauth/orcarouter.ts +15 -2
  120. package/src/oauth/store.ts +8 -0
  121. package/src/providers/codex-capacity.ts +9 -0
  122. package/src/providers/devin-provider-merge-migration.ts +33 -12
  123. package/src/providers/free-directory.ts +20 -2
  124. package/src/providers/key-failover.ts +261 -7
  125. package/src/providers/model-rename-migration.ts +1 -0
  126. package/src/providers/openai-sidecar.ts +4 -0
  127. package/src/providers/opencode-go-transport.ts +14 -5
  128. package/src/providers/quota/report-cache.ts +3 -0
  129. package/src/providers/registry/entries-extended.ts +96 -0
  130. package/src/providers/registry/model-seeds.ts +78 -21
  131. package/src/responses/apply-patch-envelope.ts +44 -11
  132. package/src/responses/bridge-search-replay-cache.ts +152 -0
  133. package/src/responses/code-mode-helper-compat.ts +26 -16
  134. package/src/responses/custom-tool-compat.ts +1 -1
  135. package/src/responses/hosted-tool-policy.ts +85 -2
  136. package/src/responses/schema.ts +9 -2
  137. package/src/server/auth-cors.ts +26 -0
  138. package/src/server/chat-completions.ts +9 -4
  139. package/src/server/chat-native-sse.ts +26 -9
  140. package/src/server/chat-native.ts +10 -4
  141. package/src/server/claude-messages.ts +24 -2
  142. package/src/server/gui-static.ts +36 -2
  143. package/src/server/inbound-body-admission.ts +187 -0
  144. package/src/server/index.ts +15 -19
  145. package/src/server/management/api-access.ts +3 -4
  146. package/src/server/management/config-routes.ts +31 -6
  147. package/src/server/management/provider-capability-config.ts +35 -7
  148. package/src/server/management/provider-routes.ts +70 -18
  149. package/src/server/proxy-liveness.ts +97 -2
  150. package/src/server/relay.ts +17 -24
  151. package/src/server/request-log.ts +25 -1
  152. package/src/server/responses/adapter-continuation.ts +71 -27
  153. package/src/server/responses/adapter-delivery.ts +39 -8
  154. package/src/server/responses/adapter-dispatch.ts +52 -24
  155. package/src/server/responses/compact.ts +60 -11
  156. package/src/server/responses/core-codex-account.ts +83 -22
  157. package/src/server/responses/core-normalize.ts +12 -5
  158. package/src/server/responses/fetch-helpers.ts +68 -2
  159. package/src/server/responses/passthrough-delivery.ts +10 -1
  160. package/src/server/responses/passthrough-dispatch.ts +113 -48
  161. package/src/server/responses/passthrough-execution.ts +11 -1
  162. package/src/server/responses/request-prepare.ts +29 -0
  163. package/src/server/responses/request-send-budget.ts +84 -7
  164. package/src/server/responses/request-sidecar-auth.ts +16 -8
  165. package/src/server/responses/request-spend.ts +38 -9
  166. package/src/server/responses/request-transport.ts +13 -10
  167. package/src/server/responses/run-turn-execution.ts +20 -5
  168. package/src/server/responses/sidecar-execution.ts +2 -0
  169. package/src/server/responses/ws-upstream.ts +2 -1
  170. package/src/server/responses-custom-tool-repair.ts +2 -2
  171. package/src/server/sse-frame-buffer.ts +12 -10
  172. package/src/server/sse-payload-rewrite.ts +36 -9
  173. package/src/server/system-env-shell.ts +5 -1
  174. package/src/server/system-env.ts +7 -1
  175. package/src/server/workflow-refusal.ts +56 -2
  176. package/src/service/cli.ts +16 -6
  177. package/src/service/guards.ts +10 -0
  178. package/src/service/health.ts +43 -0
  179. package/src/service/state.ts +7 -2
  180. package/src/types/accounts.ts +4 -0
  181. package/src/types/config.ts +100 -3
  182. package/src/types/provider.ts +19 -0
  183. package/src/types/request.ts +7 -1
  184. package/src/types/wire.ts +9 -1
  185. package/src/usage/expected-prices.ts +28 -0
  186. package/src/usage/log.ts +87 -4
  187. package/src/web-search/passthrough-bridge.ts +39 -5
  188. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
@@ -14,9 +14,11 @@
14
14
  *
15
15
  * What it is for, and what it is not for:
16
16
  *
17
- * - FIRST PLACEMENT. A child with no binding of its own may start where its family is already
18
- * warm; see `pickLineageServingAccount` in ./routing. Once bound, the child is an ordinary
19
- * binding, so a later move of the parent does not drag it.
17
+ * - FIRST PLACEMENT. Largely subsumed by cohort keying (#4780): a member of a tree that any
18
+ * other member has already bound resolves to that same binding, so there is nothing to place.
19
+ * `pickLineageServingAccount` in ./routing remains for the one case cohort keying cannot
20
+ * unify -- a session-less chain whose parent is not recorded in this scope -- and is gated on
21
+ * the parent's key actually differing from this request's.
20
22
  * - COST ATTRIBUTION. {@link codexThreadLineageLookup} and {@link codexLineageRootForRequest}
21
23
  * answer which root workflow a conversation belongs to, so a grandchild's spend aggregates
22
24
  * onto the root. No budget is implemented here.
@@ -155,30 +157,83 @@ export function codexConversationKeyFor(familyId: string, threadId: string): str
155
157
  .digest("base64url")}`;
156
158
  }
157
159
 
160
+ /**
161
+ * The cohort anchor's key: the same string on both sides of the derivation.
162
+ *
163
+ * A cohort is identified by one value the whole tree shares, so the key is that value keyed
164
+ * against itself rather than against a member. Using {@link codexConversationKeyFor} keeps one
165
+ * derivation in the module, which is what guarantees a lineage record's `conversationKey` stays
166
+ * byte-identical to the key the thread actually binds under.
167
+ */
168
+ function cohortKeyFromAnchor(sessionId: string | undefined, fallbackAnchor: string): string {
169
+ const anchor = sessionId ?? fallbackAnchor;
170
+ return codexConversationKeyFor(anchor, anchor);
171
+ }
172
+
173
+ /**
174
+ * The cohort this request belongs to, or undefined when it names no cohort at all.
175
+ *
176
+ * The session IS the cohort, which is exactly how upstream keys the prompt cache:
177
+ * `prompt_cache_key()` returns `responses_metadata.session_id` (or `{source}:{parent_thread_id}`
178
+ * for an internal session), and `AgentControl.session_id` is the root thread's id, shared with
179
+ * every sub-agent spawned from that root. Two requests carrying the same `prompt_cache_key` must
180
+ * not be served by different accounts, and keying on the session is what makes that structural
181
+ * rather than a hint (#4780).
182
+ *
183
+ * Without a session there is nothing in the headers that names the tree, so the cohort is
184
+ * whatever the parent is already bound to. That lookup is what keeps a chain of parent-only
185
+ * turns converging on one key: anchoring each depth on its own parent would split the cohort
186
+ * again at every hop. When the parent has not been seen in this scope the parent id anchors it,
187
+ * which is the same key that parent derives for itself.
188
+ */
189
+ function cohortConversationKey(
190
+ headers: Headers,
191
+ sessionId: string | undefined,
192
+ parentThreadId: string | undefined,
193
+ now: number,
194
+ ): string | undefined {
195
+ if (sessionId !== undefined) return cohortKeyFromAnchor(sessionId, sessionId);
196
+ if (parentThreadId === undefined) return undefined;
197
+ const recorded = liveLineageRecord(
198
+ lineageByScope.get(codexLineageScopeKey(headers)),
199
+ parentThreadId,
200
+ now,
201
+ );
202
+ return recorded?.conversationKey ?? cohortKeyFromAnchor(undefined, parentThreadId);
203
+ }
204
+
158
205
  /**
159
206
  * Resolve a request's conversation identity, or undefined when it carries no bindable thread
160
207
  * identity at all.
161
208
  *
162
- * The set of requests that produce NO key is deliberately unchanged from the pre-#4546 rule: a
163
- * bare `thread-id` with neither a session nor a parent stays unbound, exactly as the Desktop
164
- * fallback required both halves of its pair. Only the VALUE moves, and only for requests that
165
- * name a parent:
209
+ * The set of requests that produce NO key is deliberately unchanged, through both #4546 and
210
+ * #4780: a bare `thread-id` with neither a session nor a parent stays unbound, exactly as the
211
+ * Desktop fallback required both halves of its pair. Only the VALUE moves.
212
+ *
213
+ * Every member of one tree resolves to the SAME key, because the cohort is the binding unit
214
+ * (#4780):
215
+ *
216
+ * - root (`session-id` + `thread-id`) -> the session's cohort key;
217
+ * - child and grandchild (parent + own `thread-id`) -> the same cohort key, since they carry the
218
+ * same session;
219
+ * - parent-only (no `thread-id`) -> the same cohort key, derived from the session or, without
220
+ * one, read from the parent's record.
166
221
  *
167
- * - root (`session-id` + `thread-id`) -> HMAC(session, thread), unchanged;
168
- * - child (parent + own `thread-id`) -> HMAC(session ?? parent, thread), previously the raw parent
169
- * id, which is what made siblings share one entry and made a child's first turn land on a key
170
- * the root had never bound;
171
- * - parent-only (no `thread-id`) -> the parent's OWN recorded key when this scope has one, and
172
- * otherwise HMAC(session ?? parent, parent).
222
+ * THIS IS NOT A REVERT OF #4546 wp8, and reading it as one would flip it straight back. wp8
223
+ * fixed a real incoherence: a child keyed under the RAW parent id, which is a different identity
224
+ * from the root's own `app:HMAC(session, thread)` binding, so siblings shared an entry unrelated
225
+ * to the root's and a grandchild keying on its own parent landed on a key nobody had ever bound.
226
+ * A cohort key cannot produce that, because the root's own binding IS the cohort key: there is
227
+ * one identity for the tree rather than two competing ones. What wp8 additionally gave each
228
+ * thread -- a binding of its own -- is what #4780 deliberately gives up, and the reason is that
229
+ * upstream never agreed to it: `prompt_cache_key` is keyed on the session the whole tree shares,
230
+ * so a proxy that splits the tree makes every split member assert a warm prefix that is cold on
231
+ * its account.
173
232
  *
174
- * That last case is the one with a trap in it. A parent-only turn belongs to the parent's
175
- * conversation, so it has to land on the binding the parent is already using -- but the parent's
176
- * key is HMAC(session, thread), and HMAC(parent, parent) reproduces it only when the session id
177
- * and the thread id are the same string. Codex's own root happens to satisfy that, which is
178
- * exactly why deriving the key looks correct until a caller whose session differs from its thread
179
- * starts a COLD conversation on every parent-only turn and overwrites the parent's record on the
180
- * way through. So the recorded key wins, the session-derived key is the fallback that reproduces
181
- * it when the parent has not been seen in this scope, and the raw parent id is never the answer.
233
+ * The parent-only case keeps the trap it always had. Such a turn belongs to the parent's
234
+ * conversation, so it must land on the binding the parent is already using. With a session in
235
+ * hand that is immediate, since both derive the same cohort key. Without one, the recorded key
236
+ * wins and the parent id anchors the fallback; the raw parent id is never the answer.
182
237
  */
183
238
  export function codexConversationIdentity(
184
239
  headers: Headers,
@@ -190,24 +245,20 @@ export function codexConversationIdentity(
190
245
 
191
246
  if (threadId === undefined) {
192
247
  if (parentThreadId === undefined) return undefined;
193
- const recorded = liveLineageRecord(
194
- lineageByScope.get(codexLineageScopeKey(headers)),
195
- parentThreadId,
196
- now,
197
- );
248
+ const parentOnlyKey = cohortConversationKey(headers, sessionId, parentThreadId, now);
249
+ if (parentOnlyKey === undefined) return undefined;
198
250
  return {
199
- conversationKey: recorded?.conversationKey
200
- ?? codexConversationKeyFor(sessionId ?? parentThreadId, parentThreadId),
251
+ conversationKey: parentOnlyKey,
201
252
  recordThreadId: parentThreadId,
202
253
  ...(sessionId !== undefined ? { sessionId } : {}),
203
254
  legacyConversationKey: parentThreadId,
204
255
  declaresParent: false,
205
256
  };
206
257
  }
207
- const familyId = sessionId ?? parentThreadId;
208
- if (familyId === undefined) return undefined;
258
+ const conversationKey = cohortConversationKey(headers, sessionId, parentThreadId, now);
259
+ if (conversationKey === undefined) return undefined;
209
260
  return {
210
- conversationKey: codexConversationKeyFor(familyId, threadId),
261
+ conversationKey,
211
262
  recordThreadId: threadId,
212
263
  ...(sessionId !== undefined ? { sessionId } : {}),
213
264
  ...(parentThreadId !== undefined ? { parentThreadId } : {}),
@@ -313,7 +364,7 @@ function lineageFor(
313
364
  const parentConversationKey = parentThreadId === undefined
314
365
  ? undefined
315
366
  : parentRecord?.conversationKey
316
- ?? codexConversationKeyFor(identity.sessionId ?? parentThreadId, parentThreadId);
367
+ ?? cohortKeyFromAnchor(identity.sessionId, parentThreadId);
317
368
  const rootSessionKey = parentConversationKey === undefined
318
369
  ? identity.conversationKey
319
370
  : parentRecord?.rootSessionKey ?? parentConversationKey;
@@ -5,6 +5,37 @@ import { NATIVE_RESERVE_MODEL } from "./catalog/native-models";
5
5
  export const CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE =
6
6
  "Luna Reserve compatibility is only available as a conversation model, not a vision helper. Choose another vision model.";
7
7
 
8
+ export const CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE =
9
+ "Luna Reserve (gpt-reserve) is not forwarded without the local Desktop authless opt-in."
10
+ + " OpenCodex holds no Reserve entitlement to send for this request, so the upstream would answer with a"
11
+ + " usage-limit error that names neither the cause nor the fix."
12
+ + " Enable the opt-in with 'ocx system settings --desktop-authless on' (or set codexDesktopAuthless to true"
13
+ + " in config.json), then retry. Choose another model to keep working without it.";
14
+
15
+ /**
16
+ * Strict complement of {@link isCodexReserveRequestEligible} for the FLAG reason ONLY (#4940).
17
+ *
18
+ * Read the two together. This answers a narrower question: Reserve was asked for, every ingress
19
+ * condition eligibility requires already holds, and the single missing piece is the operator
20
+ * opt-in. Flipping `codexDesktopAuthless` to true therefore always turns a true here into a true
21
+ * from {@link isCodexReserveRequestEligible}, which is what makes it honest for the refusal to
22
+ * name that one setting. The other two ineligibility reasons are deliberately not covered: a
23
+ * client role and a non-loopback admission source are different situations, and the correct
24
+ * answer for both is still to forward exactly as before.
25
+ *
26
+ * Callers classify the concrete destination as canonical forward before using this predicate, the
27
+ * same obligation {@link isCodexReserveHelperUnsupported} carries. An operator who has aliased or
28
+ * routed `gpt-reserve` onto some other provider owns a path that works, and it must keep working.
29
+ */
30
+ export function isCodexReserveOptInMissing(
31
+ config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
32
+ modelId: string,
33
+ admission: Pick<DataPlaneAdmission, "source"> | undefined,
34
+ ): boolean {
35
+ return modelId === NATIVE_RESERVE_MODEL && config.codexDesktopAuthless !== true
36
+ && config.runtimeRole !== "client" && admission?.source === "loopback";
37
+ }
38
+
8
39
  /** Callers classify the concrete destination as canonical forward before using this predicate. */
9
40
  export function isCodexReserveHelperUnsupported(
10
41
  config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
@@ -1,7 +1,8 @@
1
1
  import type { OcxConfig } from "../types";
2
2
  import { getMainPolicyQuota } from "./quota";
3
+ import { MAIN_ACCOUNT_HARD_LOCK_PERCENT } from "./quota-types";
3
4
 
4
- export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99;
5
+ export { MAIN_ACCOUNT_HARD_LOCK_PERCENT };
5
6
 
6
7
  export interface MainAccountHardLockStatus {
7
8
  enabled: boolean;
@@ -296,7 +296,10 @@ function persistNativeMainReauthTokens(
296
296
  * main account's reauth quarantine for the new credential generation.
297
297
  */
298
298
  export function beginNativeMainReauth(): {
299
- commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }>;
299
+ commit: (
300
+ tokens: NativeMainReauthTokens,
301
+ options?: { signal?: AbortSignal },
302
+ ) => Promise<{ chatgptAccountId: string }>;
300
303
  } {
301
304
  const expected = readMainAuthJsonCredential();
302
305
  if (!expected || !expected.chatgptAccountId) {
@@ -305,7 +308,10 @@ export function beginNativeMainReauth(): {
305
308
  );
306
309
  }
307
310
  return {
308
- async commit(tokens: NativeMainReauthTokens): Promise<{ chatgptAccountId: string }> {
311
+ async commit(
312
+ tokens: NativeMainReauthTokens,
313
+ options: { signal?: AbortSignal } = {},
314
+ ): Promise<{ chatgptAccountId: string }> {
309
315
  if (!tokens.accessToken || !tokens.refreshToken || !tokens.idToken) {
310
316
  throw new NativeMainReauthUnavailableError("Device grant did not produce a complete token set");
311
317
  }
@@ -322,11 +328,12 @@ export function beginNativeMainReauth(): {
322
328
  "Native main traffic is blocked by startup or recovery state",
323
329
  );
324
330
  }
331
+ if (options.signal?.aborted) throw options.signal.reason;
325
332
  assertMainAuthJsonSnapshotUnchanged(expected);
326
333
  persistNativeMainReauthTokens(expected, tokens);
327
334
  clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
328
335
  return { chatgptAccountId: tokens.chatgptAccountId };
329
- }, { waitMs: 30_000 });
336
+ }, { waitMs: 30_000, signal: options.signal });
330
337
  },
331
338
  };
332
339
  }
@@ -54,7 +54,9 @@ interface ActiveFlow {
54
54
  /** Set once auth.json has been replaced; cancellation can no longer win. */
55
55
  published: boolean;
56
56
  /** Snapshot-holding commit prepared at start; closure-private identity. */
57
- prepared?: { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> };
57
+ prepared?: {
58
+ commit: (tokens: NativeMainReauthTokens, options?: { signal?: AbortSignal }) => Promise<{ chatgptAccountId: string }>;
59
+ };
58
60
  }
59
61
 
60
62
  /** Bounded terminal retention so status/cancel stay answerable after completion. */
@@ -65,7 +67,9 @@ const terminalFlows = new Map<string, { status: MainDeviceReauthStatus; expiresA
65
67
 
66
68
  export interface MainDeviceReauthDeps {
67
69
  login?: (ctrl: OAuthController) => Promise<NativeDeviceLogin>;
68
- beginCommit?: () => { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> };
70
+ beginCommit?: () => {
71
+ commit: (tokens: NativeMainReauthTokens, options?: { signal?: AbortSignal }) => Promise<{ chatgptAccountId: string }>;
72
+ };
69
73
  flowId?: () => string;
70
74
  now?: () => number;
71
75
  }
@@ -81,10 +85,13 @@ function sweepTerminal(now: number): void {
81
85
  }
82
86
 
83
87
  function finish(flow: ActiveFlow, status: MainDeviceReauthStatus, now: number): void {
84
- // Publication beats a racing cancellation: once auth.json was replaced the
85
- // honest terminal is succeeded, never cancelled (080). Every other terminal
86
- // is first-write-wins so a superseded or cancelled completion cannot
87
- // publish a later result.
88
+ // Terminal results are first-write-wins, with one exception (080): once the
89
+ // commit actually replaced auth.json, the honest terminal is succeeded. The
90
+ // signal now fences the claim wait and the pre-write recheck, but it cannot
91
+ // fence the gap between the synchronous write and this call — the claim
92
+ // teardown and the promise resolution both yield, so a cancel arriving there
93
+ // would otherwise report "cancelled" for a credential that was replaced and
94
+ // a reauth quarantine that was cleared.
88
95
  if (isTerminal(flow.status)) {
89
96
  if (!(flow.published && status.status === "succeeded")) return;
90
97
  }
@@ -165,15 +172,16 @@ export function startMainDeviceReauth(deps: MainDeviceReauthDeps = {}): MainDevi
165
172
  if (flow.controller.signal.aborted) return;
166
173
  if (!isTerminal(flow.status)) flow.status = { flowId, status: "committing" };
167
174
  // Recheck immediately before the write: a cancel that landed while the
168
- // grant was resolving must not reach auth.json. Once the commit DOES
169
- // publish, the terminal is succeeded even if a cancel raced it (080).
175
+ // grant was resolving must not reach auth.json. The commit itself is
176
+ // fenced by the same signal, so a cancel delivered while the claim
177
+ // waits aborts the publication instead of racing it.
170
178
  if (activeFlow !== flow || flow.controller.signal.aborted || isTerminal(flow.status)) return;
171
179
  await flow.prepared!.commit({
172
180
  accessToken: grant.credential.access,
173
181
  refreshToken: grant.credential.refresh,
174
182
  idToken: grant.idToken,
175
183
  chatgptAccountId: grant.credential.accountId!,
176
- });
184
+ }, { signal: flow.controller.signal });
177
185
  flow.published = true;
178
186
  finish(flow, { flowId, status: "succeeded", credentialUpdated: true }, clock());
179
187
  } catch (error) {
@@ -17,6 +17,13 @@ import { loadPersistedCodexRuntime } from "./runtime";
17
17
  import { codexRuntimeStateEpoch } from "./runtime";
18
18
  import upstreamModelsSnapshot from "./data/upstream-models.json";
19
19
  import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
20
+ import {
21
+ clearObservedCodexModelDenial,
22
+ forgetObservedCodexModelDenialsForAccount,
23
+ observedDeniedCodexAccountIdsForModel,
24
+ recordObservedCodexModelDenial,
25
+ resetObservedCodexModelDenialsForTests,
26
+ } from "./observed-model-denials";
20
27
 
21
28
  const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models";
22
29
 
@@ -870,6 +877,11 @@ function needsEntitlementRefresh(
870
877
  const cached = accountModelsCache.get(cacheKeyFor(accountId, clientVersion));
871
878
  if (cached && cached.credentialIdentity !== credentialIdentity) {
872
879
  invalidateCodexModelEntitlementsForAccount(accountId);
880
+ // The credential itself changed, so evidence gathered under the previous one answers for a
881
+ // different subscription. This is the only call site that knows that: the two gated-model
882
+ // sites in `core-codex-account.ts` invalidate a STALE roster for an unchanged credential,
883
+ // and clearing observed refusals there would discard the very evidence #4906 is about.
884
+ forgetObservedCodexModelDenialsForAccount(accountId);
873
885
  } else if (cached && cached.expiresAt > now) {
874
886
  return false;
875
887
  }
@@ -1280,17 +1292,63 @@ export function cachedDeniedCodexAccountIdsForModel(
1280
1292
  if (state === "granted") granted.add(accountId);
1281
1293
  else if (state === "denied") denied.add(accountId);
1282
1294
  }
1295
+ // The roster is not the only evidence, and on this path it is usually the weaker one. A cached
1296
+ // roster expires in five minutes and nothing on the flagship request path refetches it, so
1297
+ // absent an ongoing catalog sync the loop above contributes nothing at all. An upstream
1298
+ // refusal does not expire on that schedule and is not a snapshot of a pending answer: it is
1299
+ // the account's own Codex surface naming this model and declining it (#4906).
1300
+ for (const accountId of observedDeniedCodexAccountIdsForModel(modelId, now) ?? []) {
1301
+ // Under the caller's read fence, like the roster loop above. Nothing here reads account
1302
+ // storage, but an excluded account must stay UNKNOWN rather than denied so a profile switch
1303
+ // or a request-owned credential produces the same selection it does today.
1304
+ if (options.excludeAccountIds?.has(accountId)) continue;
1305
+ denied.add(accountId);
1306
+ }
1283
1307
  // One account holds one entry per client version, and upstream filters the roster by that
1284
1308
  // version. So the same account can legitimately carry a granted entry under a current client
1285
1309
  // and a denied one under an older client that predates the model. Positive evidence is
1286
1310
  // authoritative regardless of which version asked for it -- the same rule
1287
1311
  // `codexModelEntitlementStateForRoster` applies within a single entry -- so a grant anywhere
1288
1312
  // clears the denial rather than being outvoted by whichever entry the map happened to yield
1289
- // last.
1313
+ // last. It outranks an observed refusal for the same reason: a confirmed roster that lists the
1314
+ // model is the newer answer, and a rollout that reaches an account must not be held back by a
1315
+ // refusal it has already superseded.
1290
1316
  for (const accountId of granted) denied.delete(accountId);
1291
1317
  return denied.size > 0 ? denied : undefined;
1292
1318
  }
1293
1319
 
1320
+ /**
1321
+ * Record an authenticated upstream refusal as this account's own evidence about `modelId`.
1322
+ *
1323
+ * Scoped to {@link ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS} because that is the set whose
1324
+ * availability varies per account while the row stays visible, and it is the set
1325
+ * {@link cachedDeniedCodexAccountIdsForModel} will read back. A model outside it either has no
1326
+ * per-account variance or is gated by the fail-closed roster path, where an ordering preference
1327
+ * would change nothing.
1328
+ *
1329
+ * The caller must have matched the exact allow-listed refusal body first. A status alone is not
1330
+ * admissible here: 400 covers every malformed request too, and remembering one of those as an
1331
+ * entitlement fact would steer routing away from a perfectly capable account.
1332
+ */
1333
+ export function recordCodexModelDenialEvidence(
1334
+ accountId: string | null | undefined,
1335
+ modelId: string | undefined,
1336
+ now = Date.now(),
1337
+ ): void {
1338
+ if (!accountId || !modelId) return;
1339
+ if (!ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return;
1340
+ recordObservedCodexModelDenial(accountId, modelId, now);
1341
+ }
1342
+
1343
+ /** Drop the refusal evidence for a pair the account has just served successfully. */
1344
+ export function clearCodexModelDenialEvidence(
1345
+ accountId: string | null | undefined,
1346
+ modelId: string | undefined,
1347
+ ): void {
1348
+ if (!accountId || !modelId) return;
1349
+ clearObservedCodexModelDenial(accountId, modelId);
1350
+ }
1351
+
1294
1352
  /** Synchronous projection for management/catalog readers after a discovery pass. */
1295
1353
  export function cachedAvailableAccountGatedNativeModels(
1296
1354
  now = Date.now(),
@@ -1351,6 +1409,7 @@ export function resetCodexModelEntitlementCacheForTests(): void {
1351
1409
  negativeCredentialMemo.clear();
1352
1410
  entitlementEnsureFlights.clear();
1353
1411
  runtimeVersionMemo = null;
1412
+ resetObservedCodexModelDenialsForTests();
1354
1413
  }
1355
1414
 
1356
1415
  /** Test-only snapshot for proving publication fences, which cache lookup intentionally masks. */
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Per-account model denials observed from an authenticated upstream refusal.
3
+ *
4
+ * [Decision Log]
5
+ * - 목적과 의도: Keep the one piece of account-specific model evidence that is never stale --
6
+ * the upstream's own refusal -- instead of discarding it after a single retry.
7
+ * - 기존 구현 및 제약 조건: `cachedDeniedCodexAccountIdsForModel` reads authenticated `/models`
8
+ * rosters, and those entries expire five minutes after they are fetched
9
+ * (`MODEL_ROSTER_TTL_MS`). Nothing on the flagship request path refills them, because
10
+ * `resolveCodexModelEntitlements` is awaited only for `ACCOUNT_GATED_NATIVE_OPENAI_MODELS`,
11
+ * which no longer holds the flagships. So the denial set is usually absent, the #4797
12
+ * ordering rules become the identity function, and the pool picks on quota alone (#4906).
13
+ * - 검토한 주요 대안: Fetch a roster on the flagship request path, lengthen the roster TTL, or
14
+ * infer availability from the account's plan name.
15
+ * - 선택한 방식: Record the exact upstream unsupported-model refusal per (account, model) and
16
+ * let selection read it alongside the roster.
17
+ * - 다른 대안 대신 이 방식을 선택한 이유: A roster fetch on the request path puts an
18
+ * authenticated upstream call in front of the most commonly requested models in the product,
19
+ * which is what the cache-only contract exists to prevent. A longer TTL keeps a shard's
20
+ * stale absence around for longer without adding any evidence. A plan name proves nothing
21
+ * about a grant -- `available_in_plans` for `gpt-6-astra` lists `free` while free accounts
22
+ * are refused -- and #3022 is what happened the last time availability was inferred rather
23
+ * than observed.
24
+ * - 장점, 단점 및 영향: A refusal is spent once and then remembered, so the pool stops
25
+ * re-sending a model to the account that just refused it. The evidence is confirmed rather
26
+ * than inferred, it is still only an ordering preference, and it is overridden by any
27
+ * positive roster grant for the same pair.
28
+ *
29
+ * What this deliberately is NOT: an eligibility filter. Readers treat these ids exactly like
30
+ * roster denials -- `withoutModelDeniedAccounts` restores them when filtering would empty the
31
+ * candidate list, and `preferModelEntitledAccount` leaves the active account alone when no
32
+ * entitled alternative exists. No model is hidden from any catalog and no request is refused
33
+ * before dispatch, so the 2026-09-04 owner decision that the flagships fail open is untouched.
34
+ */
35
+
36
+ /**
37
+ * Six hours, against a five-minute roster TTL.
38
+ *
39
+ * The asymmetry is the point. A roster entry is a snapshot of an answer that may simply not
40
+ * have arrived yet, so it expires quickly and absence means "unknown". A refusal is an answer:
41
+ * upstream named this model and this account and said no. It still expires, because a rollout
42
+ * can reach an account between two requests, and the two faster paths back are a positive
43
+ * roster grant (which overrides this outright) and a successful response from the same account
44
+ * for the same model (which clears the entry).
45
+ */
46
+ const OBSERVED_DENIAL_TTL_MS = 6 * 60 * 60_000;
47
+
48
+ /** Bounded like the roster cache: pool size times flagship count, with room to spare. */
49
+ const OBSERVED_DENIAL_MAX_ENTRIES = 512;
50
+
51
+ /** `accountId\u0000modelId` -> expiry. Insertion order is the eviction order. */
52
+ const observedDenials = new Map<string, number>();
53
+
54
+ function denialKey(accountId: string, modelId: string): string {
55
+ return `${accountId}\u0000${modelId}`;
56
+ }
57
+
58
+ function accountIdOfDenialKey(key: string): string {
59
+ return key.slice(0, key.indexOf("\u0000"));
60
+ }
61
+
62
+ function modelIdOfDenialKey(key: string): string {
63
+ return key.slice(key.indexOf("\u0000") + 1);
64
+ }
65
+
66
+ /**
67
+ * Remember that `accountId` was refused `modelId` by its own authenticated upstream.
68
+ *
69
+ * Re-recording refreshes the entry rather than extending an older one, so a pair that keeps
70
+ * being refused stays remembered and one that stops being refused ages out.
71
+ */
72
+ export function recordObservedCodexModelDenial(
73
+ accountId: string,
74
+ modelId: string,
75
+ now = Date.now(),
76
+ ): void {
77
+ const key = denialKey(accountId, modelId);
78
+ // Delete before set so the refreshed entry moves to the back of the eviction order.
79
+ observedDenials.delete(key);
80
+ observedDenials.set(key, now + OBSERVED_DENIAL_TTL_MS);
81
+ while (observedDenials.size > OBSERVED_DENIAL_MAX_ENTRIES) {
82
+ const oldest = observedDenials.keys().next();
83
+ if (oldest.done) break;
84
+ observedDenials.delete(oldest.value);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Forget one pair, because the account just served the model.
90
+ *
91
+ * A success is newer and stronger evidence than the refusal that preceded it: whatever the
92
+ * entitlement was when upstream refused, it is not that now.
93
+ */
94
+ export function clearObservedCodexModelDenial(accountId: string, modelId: string): void {
95
+ observedDenials.delete(denialKey(accountId, modelId));
96
+ }
97
+
98
+ /**
99
+ * Forget every pair for one account, because its credential changed.
100
+ *
101
+ * A reauthenticated account can be a different subscription entirely, so evidence gathered
102
+ * under the previous credential says nothing about this one -- the same reasoning
103
+ * `invalidateCodexModelEntitlementsForAccount` applies to cached rosters.
104
+ */
105
+ export function forgetObservedCodexModelDenialsForAccount(accountId: string | null | undefined): void {
106
+ if (!accountId) return;
107
+ for (const key of [...observedDenials.keys()]) {
108
+ if (accountIdOfDenialKey(key) === accountId) observedDenials.delete(key);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Accounts refused `modelId` within the retention window.
114
+ *
115
+ * Returns `undefined` rather than an empty set when nothing is recorded, matching
116
+ * `cachedDeniedCodexAccountIdsForModel`: a caller must not be able to read "no evidence" as
117
+ * "nobody is denied".
118
+ */
119
+ export function observedDeniedCodexAccountIdsForModel(
120
+ modelId: string | undefined,
121
+ now = Date.now(),
122
+ ): ReadonlySet<string> | undefined {
123
+ if (!modelId) return undefined;
124
+ const denied = new Set<string>();
125
+ for (const [key, expiresAt] of [...observedDenials]) {
126
+ if (expiresAt <= now) {
127
+ observedDenials.delete(key);
128
+ continue;
129
+ }
130
+ if (modelIdOfDenialKey(key) === modelId) denied.add(accountIdOfDenialKey(key));
131
+ }
132
+ return denied.size > 0 ? denied : undefined;
133
+ }
134
+
135
+ export function resetObservedCodexModelDenialsForTests(): void {
136
+ observedDenials.clear();
137
+ }
@@ -0,0 +1,94 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
3
+
4
+ const MAX_AUTH_BYTES = 256 * 1024;
5
+ export const ORCA_ACCOUNT_DIRECTORY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+
7
+ export function sameLocalPath(left: string, right: string): boolean {
8
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
9
+ }
10
+
11
+ /** Reject links at every component; never follow an external credential reference. */
12
+ export function assertPlainLocalPath(path: string): string {
13
+ if (/^(?:\\\\|\/\/)/.test(path)) throw new Error("Network and device credential paths are unsupported.");
14
+ const absolute = resolve(path);
15
+ let current = parse(absolute).root;
16
+ for (const component of relative(current, absolute).split(/[\\/]/).filter(Boolean)) {
17
+ current = join(current, component);
18
+ if (lstatSync(current).isSymbolicLink()) throw new Error("Unsafe local credential path.");
19
+ }
20
+ if (!sameLocalPath(realpathSync(absolute), absolute)) throw new Error("Unsafe local credential path.");
21
+ return absolute;
22
+ }
23
+
24
+ export function readBoundedLocalFile(path: string, limit = MAX_AUTH_BYTES): string {
25
+ assertPlainLocalPath(path);
26
+ const before = lstatSync(path);
27
+ if (!before.isFile() || before.size > limit) throw new Error("Invalid local credential file.");
28
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
29
+ try {
30
+ const opened = fstatSync(fd);
31
+ if (!opened.isFile() || opened.size > limit || before.dev !== opened.dev || before.ino !== opened.ino) {
32
+ throw new Error("Local credential file changed.");
33
+ }
34
+ // The cap bounds the read, not the allocation: tiny registry files should not
35
+ // allocate 32 MiB on each preview/recheck. One extra byte detects concurrent growth.
36
+ const bytes = Buffer.alloc(opened.size + 1);
37
+ let length = 0;
38
+ while (length < bytes.length) {
39
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
40
+ if (!count) break;
41
+ length += count;
42
+ }
43
+ if (length !== opened.size) throw new Error("Local credential file changed.");
44
+ assertPlainLocalPath(path);
45
+ const after = lstatSync(path);
46
+ if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) {
47
+ throw new Error("Local credential file changed.");
48
+ }
49
+ return bytes.subarray(0, length).toString("utf8");
50
+ } finally { closeSync(fd); }
51
+ }
52
+
53
+ function object(value: unknown): Record<string, unknown> {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid local OAuth credential.");
55
+ return value as Record<string, unknown>;
56
+ }
57
+
58
+ export function parseOrcaAuth(raw: string, requireFresh = true) {
59
+ const auth = object(JSON.parse(raw));
60
+ if ((auth.auth_mode !== undefined && auth.auth_mode !== "chatgpt")
61
+ || (auth.OPENAI_API_KEY !== undefined && auth.OPENAI_API_KEY !== null && auth.OPENAI_API_KEY !== "")) {
62
+ throw new Error("Expected a ChatGPT OAuth credential.");
63
+ }
64
+ const tokens = object(auth.tokens);
65
+ if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" || !tokens.account_id) {
66
+ throw new Error("Local OAuth credential lacks account identity.");
67
+ }
68
+ const parts = tokens.access_token.split(".");
69
+ if (parts.length !== 3) throw new Error("Invalid local OAuth bearer.");
70
+ const claims = object(JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")));
71
+ const identity = object(claims["https://api.openai.com/auth"]);
72
+ if (identity.chatgpt_account_id !== tokens.account_id || typeof claims.sub !== "string" || !claims.sub
73
+ || typeof claims.exp !== "number" || !Number.isSafeInteger(claims.exp) || !Number.isSafeInteger(claims.exp * 1000)
74
+ || (requireFresh && claims.exp * 1000 <= Date.now() + 60_000)) {
75
+ throw new Error("Local OAuth identity or expiry is invalid.");
76
+ }
77
+ return { accessToken: tokens.access_token, refreshToken: "", expiresAt: claims.exp * 1000,
78
+ chatgptAccountId: tokens.account_id, sourceSubject: claims.sub };
79
+ }
80
+
81
+ export function readOrcaAuthSource(path: string) {
82
+ if (!isAbsolute(path) || !ORCA_ACCOUNT_DIRECTORY.test(dirname(dirname(path)).split(/[\\/]/).pop() ?? "")
83
+ || dirname(path).split(/[\\/]/).pop() !== "home"
84
+ || dirname(dirname(dirname(path))).split(/[\\/]/).pop() !== "codex-accounts"
85
+ || path.split(/[\\/]/).pop() !== "auth.json") throw new Error("Invalid Orca credential location.");
86
+ try {
87
+ const accountDirectory = dirname(dirname(path)).split(/[\\/]/).pop()!;
88
+ if (readBoundedLocalFile(join(dirname(path), ".orca-managed-home"), 128).trim() !== accountDirectory) {
89
+ throw new Error("Orca home ownership marker does not match.");
90
+ }
91
+ return { ...parseOrcaAuth(readBoundedLocalFile(path)), sourceAuthPath: path };
92
+ }
93
+ catch { throw new Error("Orca credential unavailable; update the account in Orca and retry."); }
94
+ }