@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,458 @@
1
+ /**
2
+ * Codex V2 conversation lineage: root, parent, child, grandchild (#4546, wp8).
3
+ *
4
+ * The pool affinity key used to prefer `x-codex-parent-thread-id`, which collapsed two different
5
+ * identities into one map. A root bound under `app:HMAC(session, thread)` while every child bound
6
+ * under the RAW parent id, so siblings shared one binding entry unrelated to the root's, and a
7
+ * grandchild keyed on its own parent landed on a key nobody had ever bound. The proxy therefore
8
+ * treated one workflow as unrelated strangers even while the provider saw a single prompt-cache
9
+ * family.
10
+ *
11
+ * This module records the real relation -- each thread's own conversation key, its immediate
12
+ * parent's thread id, and the transitive root -- scoped per authenticated caller, bounded, and
13
+ * process-local like the binding map it feeds.
14
+ *
15
+ * What it is for, and what it is not for:
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.
20
+ * - COST ATTRIBUTION. {@link codexThreadLineageLookup} and {@link codexLineageRootForRequest}
21
+ * answer which root workflow a conversation belongs to, so a grandchild's spend aggregates
22
+ * onto the root. No budget is implemented here.
23
+ * - WORKER CLASSIFICATION, exposed but not rewired. Admission classifies header-only today: a
24
+ * request naming a parent plus a distinct `thread-id` is worker traffic, and a request without
25
+ * `thread-id` is interactive even when it belongs to a recorded fan-out.
26
+ * {@link codexLineageWorkflowLane} is the lineage-backed answer a later lane consumes.
27
+ *
28
+ * Scope is an HMAC of the caller's Authorization header under a process-local key, the same
29
+ * posture as the affinity key itself. Two callers presenting identical thread ids can never read
30
+ * each other's lineage, and no raw identifier or durable hash is stored.
31
+ *
32
+ * LIFETIME, stated plainly because the word "affinity" invites the opposite assumption: none of
33
+ * this survives the process. The binding map is in memory, and the HMAC key above is fresh random
34
+ * bytes taken at module load, so a restart does not merely forget the table -- it makes yesterday's
35
+ * keys unreproducible. This is a warm-start hint for the life of one proxy process, never durable
36
+ * account ownership, and nothing here should be read as a promise to a conversation that outlives
37
+ * a restart.
38
+ *
39
+ * The one upgrade that is neither a fresh start nor an untouched process is a code swap under a
40
+ * live conversation, where the binding map is still populated with entries made under the
41
+ * pre-#4546 RAW parent key. Silently rebinding those cold is the exact defect this module exists
42
+ * to prevent, so a request that names a parent carries {@link CodexThreadLineage.legacyConversationKey}
43
+ * -- the key the old rule would have returned -- and routing adopts that binding once under the new
44
+ * key and retires the legacy entry. It is a one-way migration, not a second lookup path.
45
+ */
46
+ import { createHmac, randomBytes } from "node:crypto";
47
+ import { retainedUtf8Bytes } from "../lib/admission";
48
+
49
+ const CODEX_LINEAGE_COMPONENT_MAX_BYTES = 512;
50
+ const CODEX_LINEAGE_KEY = randomBytes(32);
51
+
52
+ /**
53
+ * Mirrors `CODEX_THREAD_AFFINITY_IDLE_TTL_MS` in ./routing. Deliberately duplicated rather than
54
+ * imported: lineage is a leaf module, and a value import from the routing module that consumes it
55
+ * would turn an erased type-only edge into a real cycle.
56
+ */
57
+ export const CODEX_LINEAGE_IDLE_TTL_MS = 24 * 60 * 60_000;
58
+ /** Records per authenticated scope, on the order of the binding map's own 2048-entry cap. */
59
+ export const CODEX_LINEAGE_MAX_ENTRIES = 2048;
60
+ /** Distinct authenticated callers retained. Without this the scope map is the unbounded one. */
61
+ export const CODEX_LINEAGE_MAX_SCOPES = 64;
62
+ /** Sibling hints kept per parent, most recently used first. */
63
+ export const CODEX_LINEAGE_MAX_SIBLINGS = 8;
64
+
65
+ const LOCAL_LINEAGE_SCOPE = "local";
66
+
67
+ export type CodexWorkflowLane = "worker" | "interactive";
68
+
69
+ interface CodexLineageRecord {
70
+ threadId: string;
71
+ /** This thread's own pool binding key, byte-identical to what `codexPoolAffinityKey` returns. */
72
+ conversationKey: string;
73
+ /** Immediate parent's raw thread id, retained once seen even if a later turn omits the header. */
74
+ parentThreadId?: string;
75
+ /** Topmost ancestor's conversation key: a grandchild resolves to the root's, not its parent's. */
76
+ rootSessionKey: string;
77
+ lastUsedAt: number;
78
+ }
79
+
80
+ interface CodexLineageScope {
81
+ /** threadId -> record, iterated oldest-first so TTL pruning and eviction stay amortised O(1). */
82
+ records: Map<string, CodexLineageRecord>;
83
+ /** conversationKey -> threadId, so cost attribution is a lookup instead of a scan. */
84
+ threadIdByConversationKey: Map<string, string>;
85
+ /** parentThreadId -> child thread ids, most recent first, capped. */
86
+ childThreadIdsByParent: Map<string, string[]>;
87
+ lastUsedAt: number;
88
+ }
89
+
90
+ /**
91
+ * What placement and cost attribution are allowed to see. `parentConversationKey` is the parent's
92
+ * OWN binding key -- either recorded, or derived from the shared session when the parent has not
93
+ * been seen yet -- never the raw header value the old affinity key returned.
94
+ */
95
+ export interface CodexThreadLineage {
96
+ readonly conversationKey: string;
97
+ readonly rootSessionKey: string;
98
+ readonly parentThreadId?: string;
99
+ readonly parentConversationKey?: string;
100
+ /**
101
+ * The key the pre-#4546 rule would have returned for this request -- the RAW parent id -- when
102
+ * that differs from the key it binds under now. Present so routing can adopt a binding left by
103
+ * the old rule exactly once; see the lifetime note at the top of this file.
104
+ */
105
+ readonly legacyConversationKey?: string;
106
+ /** Siblings under the same declared parent, most recently used first. */
107
+ readonly siblingConversationKeys: readonly string[];
108
+ }
109
+
110
+ /** The identity the pool affinity key and the lineage record are both derived from. */
111
+ export interface CodexConversationIdentity {
112
+ readonly conversationKey: string;
113
+ /** Thread id this request records under: its own, or the parent's on a parent-only request. */
114
+ readonly recordThreadId: string;
115
+ readonly sessionId?: string;
116
+ readonly parentThreadId?: string;
117
+ /** Raw parent id, i.e. the key the pre-#4546 rule returned for this request. */
118
+ readonly legacyConversationKey?: string;
119
+ /** True only when the request names a parent distinct from its own thread. */
120
+ readonly declaresParent: boolean;
121
+ }
122
+
123
+ const lineageByScope = new Map<string, CodexLineageScope>();
124
+
125
+ /** A record is only evidence while it is live; an idle-expired one answers like no record. */
126
+ function liveLineageRecord(
127
+ scope: CodexLineageScope | undefined,
128
+ threadId: string,
129
+ now: number,
130
+ ): CodexLineageRecord | undefined {
131
+ const record = scope?.records.get(threadId);
132
+ return record !== undefined && now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS
133
+ ? record
134
+ : undefined;
135
+ }
136
+
137
+ function boundedLineageComponent(value: string | null): string | undefined {
138
+ const normalized = value?.trim();
139
+ if (!normalized) return undefined;
140
+ if (retainedUtf8Bytes(normalized) > CODEX_LINEAGE_COMPONENT_MAX_BYTES) return undefined;
141
+ return normalized;
142
+ }
143
+
144
+ /**
145
+ * The single derivation behind both the pool affinity key and lineage records. Keeping it here,
146
+ * rather than duplicated at the call site, is what guarantees a record's `conversationKey` is
147
+ * byte-identical to the key the thread actually binds under.
148
+ */
149
+ export function codexConversationKeyFor(familyId: string, threadId: string): string {
150
+ return `app:${createHmac("sha256", CODEX_LINEAGE_KEY)
151
+ .update("opencodex-app-pool-affinity-v1\0")
152
+ .update(familyId)
153
+ .update("\0")
154
+ .update(threadId)
155
+ .digest("base64url")}`;
156
+ }
157
+
158
+ /**
159
+ * Resolve a request's conversation identity, or undefined when it carries no bindable thread
160
+ * identity at all.
161
+ *
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:
166
+ *
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).
173
+ *
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.
182
+ */
183
+ export function codexConversationIdentity(
184
+ headers: Headers,
185
+ now = Date.now(),
186
+ ): CodexConversationIdentity | undefined {
187
+ const threadId = boundedLineageComponent(headers.get("thread-id"));
188
+ const sessionId = boundedLineageComponent(headers.get("session-id"));
189
+ const parentThreadId = boundedLineageComponent(headers.get("x-codex-parent-thread-id"));
190
+
191
+ if (threadId === undefined) {
192
+ if (parentThreadId === undefined) return undefined;
193
+ const recorded = liveLineageRecord(
194
+ lineageByScope.get(codexLineageScopeKey(headers)),
195
+ parentThreadId,
196
+ now,
197
+ );
198
+ return {
199
+ conversationKey: recorded?.conversationKey
200
+ ?? codexConversationKeyFor(sessionId ?? parentThreadId, parentThreadId),
201
+ recordThreadId: parentThreadId,
202
+ ...(sessionId !== undefined ? { sessionId } : {}),
203
+ legacyConversationKey: parentThreadId,
204
+ declaresParent: false,
205
+ };
206
+ }
207
+ const familyId = sessionId ?? parentThreadId;
208
+ if (familyId === undefined) return undefined;
209
+ return {
210
+ conversationKey: codexConversationKeyFor(familyId, threadId),
211
+ recordThreadId: threadId,
212
+ ...(sessionId !== undefined ? { sessionId } : {}),
213
+ ...(parentThreadId !== undefined ? { parentThreadId } : {}),
214
+ ...(parentThreadId !== undefined ? { legacyConversationKey: parentThreadId } : {}),
215
+ declaresParent: parentThreadId !== undefined && parentThreadId !== threadId,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Which authenticated caller this request's lineage belongs to. The bearer is never stored; an
221
+ * unauthenticated (loopback-trusted) request lands in the single local scope.
222
+ */
223
+ export function codexLineageScopeKey(headers: Headers): string {
224
+ const authorization = headers.get("authorization")?.trim();
225
+ if (!authorization) return LOCAL_LINEAGE_SCOPE;
226
+ return `auth:${createHmac("sha256", CODEX_LINEAGE_KEY)
227
+ .update("opencodex-lineage-scope-v1\0")
228
+ .update(authorization)
229
+ .digest("base64url")}`;
230
+ }
231
+
232
+ function dropLineageRecord(scope: CodexLineageScope, threadId: string): void {
233
+ const record = scope.records.get(threadId);
234
+ if (record === undefined) return;
235
+ scope.records.delete(threadId);
236
+ if (scope.threadIdByConversationKey.get(record.conversationKey) === threadId) {
237
+ scope.threadIdByConversationKey.delete(record.conversationKey);
238
+ }
239
+ if (record.parentThreadId === undefined) return;
240
+ const siblings = scope.childThreadIdsByParent.get(record.parentThreadId);
241
+ if (siblings === undefined) return;
242
+ const remaining = siblings.filter(id => id !== threadId);
243
+ if (remaining.length === 0) scope.childThreadIdsByParent.delete(record.parentThreadId);
244
+ else scope.childThreadIdsByParent.set(record.parentThreadId, remaining);
245
+ }
246
+
247
+ /** Records are held in least-recently-used order, so the expired ones are a prefix. */
248
+ function pruneLineageScope(scope: CodexLineageScope, now: number): void {
249
+ for (const [threadId, record] of scope.records) {
250
+ if (now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS) break;
251
+ dropLineageRecord(scope, threadId);
252
+ }
253
+ while (scope.records.size > CODEX_LINEAGE_MAX_ENTRIES) {
254
+ const oldest = scope.records.keys().next();
255
+ if (oldest.done === true) break;
256
+ dropLineageRecord(scope, oldest.value);
257
+ }
258
+ }
259
+
260
+ function pruneLineageScopes(now: number): void {
261
+ for (const [scopeKey, scope] of lineageByScope) {
262
+ if (now - scope.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS) break;
263
+ lineageByScope.delete(scopeKey);
264
+ }
265
+ while (lineageByScope.size > CODEX_LINEAGE_MAX_SCOPES) {
266
+ const oldest = lineageByScope.keys().next();
267
+ if (oldest.done === true) break;
268
+ lineageByScope.delete(oldest.value);
269
+ }
270
+ }
271
+
272
+ function touchLineageScope(scopeKey: string, now: number): CodexLineageScope {
273
+ const existing = lineageByScope.get(scopeKey);
274
+ const scope: CodexLineageScope = existing ?? {
275
+ records: new Map<string, CodexLineageRecord>(),
276
+ threadIdByConversationKey: new Map<string, string>(),
277
+ childThreadIdsByParent: new Map<string, string[]>(),
278
+ lastUsedAt: now,
279
+ };
280
+ if (existing !== undefined) lineageByScope.delete(scopeKey);
281
+ scope.lastUsedAt = now;
282
+ lineageByScope.set(scopeKey, scope);
283
+ pruneLineageScopes(now);
284
+ pruneLineageScope(scope, now);
285
+ return scope;
286
+ }
287
+
288
+ /**
289
+ * The lineage view for one identity inside one scope, computed without writing anything.
290
+ *
291
+ * A parent seen for the first time through one of its children is derived rather than invented:
292
+ * the child knows the shared session, so HMAC(session, parent) reproduces the key the parent
293
+ * binds under. Once the parent has actually been recorded, its own key wins.
294
+ *
295
+ * Depth is transitive by construction -- a grandchild inherits its parent's resolved root instead
296
+ * of re-deriving one hop -- so a workflow never scatters across several roots.
297
+ */
298
+ function lineageFor(
299
+ scope: CodexLineageScope | undefined,
300
+ identity: CodexConversationIdentity,
301
+ now: number,
302
+ ): CodexThreadLineage {
303
+ const previous = liveLineageRecord(scope, identity.recordThreadId, now);
304
+ // A turn that omits the parent header does not orphan a thread whose parent is already known.
305
+ // That retention is the whole of the lineage-backed worker answer below.
306
+ const parentThreadId = identity.declaresParent
307
+ ? identity.parentThreadId
308
+ : previous?.parentThreadId;
309
+
310
+ const parentRecord = parentThreadId !== undefined
311
+ ? liveLineageRecord(scope, parentThreadId, now)
312
+ : undefined;
313
+ const parentConversationKey = parentThreadId === undefined
314
+ ? undefined
315
+ : parentRecord?.conversationKey
316
+ ?? codexConversationKeyFor(identity.sessionId ?? parentThreadId, parentThreadId);
317
+ const rootSessionKey = parentConversationKey === undefined
318
+ ? identity.conversationKey
319
+ : parentRecord?.rootSessionKey ?? parentConversationKey;
320
+
321
+ const siblingConversationKeys: string[] = [];
322
+ if (parentThreadId !== undefined && scope !== undefined) {
323
+ for (const siblingThreadId of scope.childThreadIdsByParent.get(parentThreadId) ?? []) {
324
+ if (siblingThreadId === identity.recordThreadId) continue;
325
+ const sibling = liveLineageRecord(scope, siblingThreadId, now);
326
+ if (sibling !== undefined) siblingConversationKeys.push(sibling.conversationKey);
327
+ }
328
+ }
329
+
330
+ // Only a key the old rule would have produced AND that this request no longer uses is a
331
+ // migration candidate. A root's key is unchanged, so it never carries one.
332
+ const legacyConversationKey = identity.legacyConversationKey !== undefined
333
+ && identity.legacyConversationKey !== identity.conversationKey
334
+ ? identity.legacyConversationKey
335
+ : undefined;
336
+
337
+ return {
338
+ conversationKey: identity.conversationKey,
339
+ rootSessionKey,
340
+ ...(parentThreadId !== undefined ? { parentThreadId } : {}),
341
+ ...(parentConversationKey !== undefined ? { parentConversationKey } : {}),
342
+ ...(legacyConversationKey !== undefined ? { legacyConversationKey } : {}),
343
+ siblingConversationKeys,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Read this request's lineage without recording it.
349
+ *
350
+ * A preview must see what the final resolution will see, but it must not be the thing that
351
+ * creates the record: preview runs before auth has decided whether this request may hold Pool
352
+ * state at all, and a record written there would outlive a decision to hold none.
353
+ */
354
+ export function resolveCodexThreadLineage(
355
+ headers: Headers,
356
+ now = Date.now(),
357
+ ): CodexThreadLineage | undefined {
358
+ const identity = codexConversationIdentity(headers, now);
359
+ if (identity === undefined) return undefined;
360
+ return lineageFor(lineageByScope.get(codexLineageScopeKey(headers)), identity, now);
361
+ }
362
+
363
+ /** Record this request's thread relation and return the resolved lineage. */
364
+ export function recordCodexThreadLineage(
365
+ headers: Headers,
366
+ now = Date.now(),
367
+ ): CodexThreadLineage | undefined {
368
+ const identity = codexConversationIdentity(headers, now);
369
+ if (identity === undefined) return undefined;
370
+ const scope = touchLineageScope(codexLineageScopeKey(headers), now);
371
+ const lineage = lineageFor(scope, identity, now);
372
+ const parentThreadId = lineage.parentThreadId;
373
+
374
+ // Re-insert rather than mutate: the records map doubles as the LRU order.
375
+ dropLineageRecord(scope, identity.recordThreadId);
376
+ scope.records.set(identity.recordThreadId, {
377
+ threadId: identity.recordThreadId,
378
+ conversationKey: identity.conversationKey,
379
+ ...(parentThreadId !== undefined ? { parentThreadId } : {}),
380
+ rootSessionKey: lineage.rootSessionKey,
381
+ lastUsedAt: now,
382
+ });
383
+ scope.threadIdByConversationKey.set(identity.conversationKey, identity.recordThreadId);
384
+ if (parentThreadId !== undefined) {
385
+ const siblings = (scope.childThreadIdsByParent.get(parentThreadId) ?? [])
386
+ .filter(id => id !== identity.recordThreadId);
387
+ siblings.unshift(identity.recordThreadId);
388
+ scope.childThreadIdsByParent.set(parentThreadId, siblings.slice(0, CODEX_LINEAGE_MAX_SIBLINGS));
389
+ }
390
+ pruneLineageScope(scope, now);
391
+
392
+ return lineage;
393
+ }
394
+
395
+ /**
396
+ * Cost-attribution lookup for other layers: which root workflow owns this conversation key.
397
+ * Scoped like the records, so a caller can only ever resolve inside its own scope, and read-only
398
+ * -- reading a lineage for accounting must not extend its lifetime.
399
+ */
400
+ export function codexThreadLineageLookup(
401
+ conversationKey: string,
402
+ scopeKey: string,
403
+ now = Date.now(),
404
+ ): { conversationKey: string; rootSessionKey: string; parentThreadId?: string } | undefined {
405
+ const scope = lineageByScope.get(scopeKey);
406
+ if (scope === undefined) return undefined;
407
+ const threadId = scope.threadIdByConversationKey.get(conversationKey);
408
+ if (threadId === undefined) return undefined;
409
+ const record = scope.records.get(threadId);
410
+ if (record === undefined || now - record.lastUsedAt > CODEX_LINEAGE_IDLE_TTL_MS) return undefined;
411
+ return {
412
+ conversationKey: record.conversationKey,
413
+ rootSessionKey: record.rootSessionKey,
414
+ ...(record.parentThreadId !== undefined ? { parentThreadId: record.parentThreadId } : {}),
415
+ };
416
+ }
417
+
418
+ /**
419
+ * The root a request's spend belongs to, for a caller holding headers rather than a key. An
420
+ * unrecorded conversation is its own root, so this never answers null for a bindable request and
421
+ * an accounting layer has no reason to invent one.
422
+ */
423
+ export function codexLineageRootForRequest(headers: Headers, now = Date.now()): string | undefined {
424
+ const identity = codexConversationIdentity(headers, now);
425
+ if (identity === undefined) return undefined;
426
+ return codexThreadLineageLookup(identity.conversationKey, codexLineageScopeKey(headers), now)
427
+ ?.rootSessionKey
428
+ ?? identity.conversationKey;
429
+ }
430
+
431
+ /**
432
+ * The lineage-backed worker/interactive answer.
433
+ *
434
+ * Admission classifies header-only today: a request is worker traffic only when it names a parent
435
+ * AND a distinct `thread-id`, so a fan-out turn that stopped sending the parent header reads as
436
+ * interactive. This keeps that rule and adds what the headers could not say -- a thread already
437
+ * recorded with a parent is worker traffic. Nothing here changes admission; a later lane consumes
438
+ * it.
439
+ */
440
+ export function codexLineageWorkflowLane(headers: Headers, now = Date.now()): CodexWorkflowLane {
441
+ const threadId = boundedLineageComponent(headers.get("thread-id"));
442
+ const parentThreadId = boundedLineageComponent(headers.get("x-codex-parent-thread-id"));
443
+ if (parentThreadId !== undefined && threadId !== undefined && threadId !== parentThreadId) {
444
+ return "worker";
445
+ }
446
+ if (threadId === undefined) return "interactive";
447
+ const record = lineageByScope.get(codexLineageScopeKey(headers))?.records.get(threadId);
448
+ return record !== undefined
449
+ && now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS
450
+ && record.parentThreadId !== undefined
451
+ ? "worker"
452
+ : "interactive";
453
+ }
454
+
455
+ /** Test-only reset; production state is process-local and dies with the process. */
456
+ export function clearCodexThreadLineageForTests(): void {
457
+ lineageByScope.clear();
458
+ }
@@ -1,15 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readBoundedResponseBody } from "../lib/bounded-body";
3
- import type { OcxConfig } from "../types";
3
+ import type { CodexAccountCredentialRecord, OcxConfig } from "../types";
4
4
  import { isSelectableCodexPoolAccount } from "./account-id";
5
- import { getValidCodexToken, readCodexAccountRecord } from "./account-store";
5
+ import { getValidCodexToken, loadCodexAccountRecordSnapshot } from "./account-store";
6
6
  import {
7
7
  getMainAccountToken,
8
8
  getValidMainAccountToken,
9
9
  MAIN_CODEX_ACCOUNT_ID,
10
10
  type NativeMainRefreshDependencies,
11
11
  } from "./main-account";
12
- import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
12
+ import {
13
+ ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
14
+ NATIVE_GPT6_ASTRA_MODEL,
15
+ } from "./catalog/native-models";
13
16
  import { loadPersistedCodexRuntime } from "./runtime";
14
17
  import { codexRuntimeStateEpoch } from "./runtime";
15
18
  import upstreamModelsSnapshot from "./data/upstream-models.json";
@@ -520,17 +523,48 @@ function boundedCacheSet(accountId: string, value: CachedAccountModels): void {
520
523
  evictClass(accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX));
521
524
  }
522
525
 
526
+ /**
527
+ * An identity resolver scoped to one caller's pass, reading each backing store at most once.
528
+ *
529
+ * The identity check itself is unchanged -- same prefix rule, same tombstone and missing-credential
530
+ * rejection, same `pool:<generation>:<chatgptAccountId>` shape -- but the READ is hoisted. Per-id
531
+ * resolution reloads and reparses the whole `codex-accounts.json` every call, so a loop over cache
532
+ * entries paid one full-store read per entry: the denial reader admits 64 accounts with four client
533
+ * versions each, which is up to 256 synchronous reads to score a single warm flagship request.
534
+ *
535
+ * Both stores are read lazily, so a pass that touches only Direct callers, or only native main,
536
+ * still opens nothing it does not need. Neither backing read is memoized across passes: a resolver
537
+ * lives for one synchronous loop, and that loop has no suspension point, so nothing this process
538
+ * does can change the file underneath it. A snapshot is therefore not staler than per-entry reads
539
+ * would have been -- it is strictly more coherent, because a foreign writer landing mid-loop can no
540
+ * longer give the earlier entries one generation and the later ones another.
541
+ */
542
+ function credentialIdentityResolver(): (accountId: string) => string | undefined {
543
+ let records: Readonly<Record<string, CodexAccountCredentialRecord>> | undefined;
544
+ let mainRead = false;
545
+ let mainIdentity: string | undefined;
546
+ return (accountId: string): string | undefined => {
547
+ if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) {
548
+ return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`;
549
+ }
550
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
551
+ if (!mainRead) {
552
+ const token = getMainAccountToken();
553
+ mainIdentity = token ? `main:${token.chatgptAccountId}` : undefined;
554
+ mainRead = true;
555
+ }
556
+ return mainIdentity;
557
+ }
558
+ records ??= loadCodexAccountRecordSnapshot();
559
+ const record = records[accountId];
560
+ if (!record?.credential || record.deletedAt != null) return undefined;
561
+ return `pool:${record.generation}:${record.credential.chatgptAccountId}`;
562
+ };
563
+ }
564
+
565
+ /** Single-id resolution. Identical to one call through a fresh {@link credentialIdentityResolver}. */
523
566
  function currentCredentialIdentity(accountId: string): string | undefined {
524
- if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) {
525
- return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`;
526
- }
527
- if (accountId === MAIN_CODEX_ACCOUNT_ID) {
528
- const token = getMainAccountToken();
529
- return token ? `main:${token.chatgptAccountId}` : undefined;
530
- }
531
- const record = readCodexAccountRecord(accountId);
532
- if (!record?.credential || record.deletedAt != null) return undefined;
533
- return `pool:${record.generation}:${record.credential.chatgptAccountId}`;
567
+ return credentialIdentityResolver()(accountId);
534
568
  }
535
569
 
536
570
  async function accountCredentialSnapshot(
@@ -926,8 +960,10 @@ export async function ensureCodexEntitlementFreshness(
926
960
  );
927
961
  const candidates = normalizedCandidateAccountIds(config);
928
962
  const mutationEpoch = codexCredentialMutationEpoch();
963
+ // Same hoist as the denial pass: this prologue is synchronous and reads once per candidate.
964
+ const identityOf = credentialIdentityResolver();
929
965
  const identityEntries = candidates.map(accountId => (
930
- [accountId, currentCredentialIdentity(accountId) ?? null] as const
966
+ [accountId, identityOf(accountId) ?? null] as const
931
967
  ));
932
968
  const identityVector = new Map(identityEntries);
933
969
  const workset = candidates.filter(accountId => needsEntitlementRefresh(
@@ -980,8 +1016,9 @@ export function getCodexModelEntitlementStatus(
980
1016
  clientVersion?: string | null,
981
1017
  ): CodexModelEntitlementStatus {
982
1018
  const version = resolveCodexEntitlementClientVersion(clientVersion);
1019
+ const identityOf = credentialIdentityResolver();
983
1020
  const accounts = candidateAccountIds(config).flatMap(accountId => {
984
- const credentialIdentity = currentCredentialIdentity(accountId);
1021
+ const credentialIdentity = identityOf(accountId);
985
1022
  return credentialIdentity ? [{ accountId, credentialIdentity }] : [];
986
1023
  });
987
1024
  if (accounts.length === 0) return { status: "unavailable" };
@@ -1159,6 +1196,101 @@ export function availableAccountGatedNativeModels(
1159
1196
  )));
1160
1197
  }
1161
1198
 
1199
+ /**
1200
+ * Native models that stay unconditionally VISIBLE while their per-account availability still
1201
+ * varies.
1202
+ *
1203
+ * This is deliberately not `ACCOUNT_GATED_NATIVE_OPENAI_MODELS` and must never become it. That set
1204
+ * fails closed on ABSENCE of evidence: membership hides the row from the catalog and refuses the
1205
+ * request before dispatch, which is exactly what the owner decision of 2026-09-04 removed the
1206
+ * flagships from. A timed-out fetch or a shard that has not caught up would make the model vanish
1207
+ * from the picker, and "opencodex lost my model" is a worse failure than one upstream 400.
1208
+ *
1209
+ * This set carries the opposite polarity. It admits only a CONFIRMED DENIAL as evidence, and it
1210
+ * feeds an ordering preference rather than a refusal, so absent or stale evidence changes nothing.
1211
+ * That is the distinction #4768 asked for: a pool holding a Plus account and a Free account should
1212
+ * stop handing Sol/Astra to the Free account whose own authenticated roster already says it cannot
1213
+ * serve them, without gating the model on evidence that may never arrive.
1214
+ */
1215
+ export const ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
1216
+ "gpt-5.6-sol",
1217
+ "gpt-5.6-terra",
1218
+ "gpt-5.6-luna",
1219
+ NATIVE_GPT6_ASTRA_MODEL,
1220
+ ]);
1221
+
1222
+ /**
1223
+ * Accounts whose OWN authenticated roster definitively omits `modelId`, read synchronously from
1224
+ * evidence discovery has already gathered.
1225
+ *
1226
+ * Synchronous and cache-only by contract. The gated path may await `resolveCodexModelEntitlements`
1227
+ * because a gated model is rare and already pays a bounded discovery call; the flagships are the
1228
+ * most commonly requested models in the product, and putting an authenticated upstream fetch per
1229
+ * account on that request path would trade one occasional 400 for latency on every turn. The cache
1230
+ * this reads is warmed anyway: `modelsForCredential` stores each account's FULL roster, and
1231
+ * background catalog sync (`src/codex/catalog/retained-sync.ts`) and convergence already resolve
1232
+ * entitlements for every pool account.
1233
+ *
1234
+ * Returns `undefined` rather than an empty set when nothing is denied, so a caller cannot confuse
1235
+ * "no account is denied" with "no evidence exists" — both mean the same thing here, which is that
1236
+ * selection must be left exactly as it was.
1237
+ *
1238
+ * Only `denied` counts. `unknown` covers an unconfirmed account, a roster fetched under a client
1239
+ * version too old to return the model, and an expired or credential-stale entry; none of those is
1240
+ * proof that the account lacks the model, and treating them as proof is how 2.36.0 removed
1241
+ * sol/terra/luna from accounts that owned them (#3022).
1242
+ */
1243
+ export function cachedDeniedCodexAccountIdsForModel(
1244
+ modelId: string | undefined,
1245
+ now = Date.now(),
1246
+ options: { excludeAccountIds?: ReadonlySet<string> } = {},
1247
+ ): ReadonlySet<string> | undefined {
1248
+ if (!modelId || !ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return undefined;
1249
+ const denied = new Set<string>();
1250
+ const granted = new Set<string>();
1251
+ // One resolver for the whole pass: the loop below runs once per cached (account, client version)
1252
+ // entry, and resolving an identity per entry meant a full account-store read per entry.
1253
+ const identityOf = credentialIdentityResolver();
1254
+ for (const [key, entry] of accountModelsCache) {
1255
+ const accountId = accountIdOfCacheKey(key);
1256
+ // A forwarded Direct credential is one request's caller, never a pool candidate.
1257
+ if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) continue;
1258
+ // The caller's read fence, honoured BEFORE `identityOf` below, because that is the read: for
1259
+ // native main it resolves the physical stored token. A request that is forbidden to read main
1260
+ // -- a profile switch draining it, or a request-owned credential that owns no main state --
1261
+ // must not reread account storage just to score an ordering preference. Dropping the account
1262
+ // leaves it UNKNOWN rather than denied, which is the same outcome as having no cached roster
1263
+ // for it and changes no selection. The resolver reads lazily for the same reason: an excluded
1264
+ // account `continue`s here, so its store is never opened at all.
1265
+ if (options.excludeAccountIds?.has(accountId)) continue;
1266
+ if (entry.expiresAt <= now) continue;
1267
+ // A credential we can currently read AND that differs is proof the entry answers for a
1268
+ // different account than this id now names, so its denial is not evidence about the current
1269
+ // one. An UNREADABLE credential is not proof of anything, and the same unknown-is-not-denied
1270
+ // discipline that governs rosters governs identities: it leaves the entry in place rather
1271
+ // than manufacturing a reason to ignore it.
1272
+ const identity = identityOf(accountId);
1273
+ if (identity !== undefined && identity !== entry.credentialIdentity) continue;
1274
+ const state = codexModelEntitlementStateForRoster(
1275
+ entry.models,
1276
+ entry.confirmed,
1277
+ entry.clientVersion,
1278
+ modelId,
1279
+ );
1280
+ if (state === "granted") granted.add(accountId);
1281
+ else if (state === "denied") denied.add(accountId);
1282
+ }
1283
+ // One account holds one entry per client version, and upstream filters the roster by that
1284
+ // version. So the same account can legitimately carry a granted entry under a current client
1285
+ // and a denied one under an older client that predates the model. Positive evidence is
1286
+ // authoritative regardless of which version asked for it -- the same rule
1287
+ // `codexModelEntitlementStateForRoster` applies within a single entry -- so a grant anywhere
1288
+ // clears the denial rather than being outvoted by whichever entry the map happened to yield
1289
+ // last.
1290
+ for (const accountId of granted) denied.delete(accountId);
1291
+ return denied.size > 0 ? denied : undefined;
1292
+ }
1293
+
1162
1294
  /** Synchronous projection for management/catalog readers after a discovery pass. */
1163
1295
  export function cachedAvailableAccountGatedNativeModels(
1164
1296
  now = Date.now(),
@@ -1192,6 +1324,11 @@ export function cachedAvailableAccountGatedNativeModels(
1192
1324
 
1193
1325
  export function isCodexModelEntitlementSnapshotCurrent(snapshot: CodexModelEntitlementSnapshot): boolean {
1194
1326
  for (const [accountId, identity] of snapshot.credentialIdentities) {
1327
+ // Deliberately per-id, unlike the passes above. This is a fail-closed publication gate asking
1328
+ // whether a snapshot is STILL current, so the freshest possible answer per account is the
1329
+ // point of the read. A pass-wide snapshot would be a coherence win everywhere else and a
1330
+ // small weakening here: it could answer "current" for a later account from a record a
1331
+ // concurrent reauth had already replaced.
1195
1332
  if (currentCredentialIdentity(accountId) !== identity) return false;
1196
1333
  }
1197
1334
  return true;