@bitkyc08/opencodex 2.59.0 → 2.60.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 (194) hide show
  1. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  2. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +4 -1
  5. package/src/adapters/anthropic-image-codec.ts +16 -2
  6. package/src/adapters/anthropic-image-normalize.ts +49 -2
  7. package/src/adapters/anthropic.ts +4 -1
  8. package/src/adapters/base.ts +23 -0
  9. package/src/adapters/coding-agent/turn.ts +22 -2
  10. package/src/adapters/command-code.ts +50 -3
  11. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  12. package/src/adapters/cursor/discovery.ts +11 -8
  13. package/src/adapters/cursor/live-transport.ts +26 -9
  14. package/src/adapters/cursor/request-builder.ts +7 -1
  15. package/src/adapters/cursor/transport.ts +19 -0
  16. package/src/adapters/cursor.ts +3 -1
  17. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  18. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  19. package/src/adapters/devin.ts +50 -13
  20. package/src/adapters/google-antigravity-replay.ts +1 -1
  21. package/src/adapters/google-antigravity-wire.ts +29 -5
  22. package/src/adapters/google-http.ts +49 -10
  23. package/src/adapters/google-tool-schema.ts +595 -31
  24. package/src/adapters/google-wire-compiler.ts +93 -10
  25. package/src/adapters/google-wire-shape.ts +461 -0
  26. package/src/adapters/google.ts +37 -6
  27. package/src/adapters/openai-chat-images.ts +3 -1
  28. package/src/adapters/openai-chat.ts +5 -1
  29. package/src/adapters/openai-responses/image-gen.ts +8 -6
  30. package/src/adapters/openai-responses/passthrough.ts +15 -3
  31. package/src/adapters/openai-responses/reasoning.ts +7 -0
  32. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  33. package/src/bridge/sse.ts +3 -151
  34. package/src/cli/account-extended.ts +4 -4
  35. package/src/cli/dispatch.ts +3 -3
  36. package/src/cli/doctor.ts +28 -9
  37. package/src/cli/hub.ts +3 -2
  38. package/src/cli/index.ts +7 -2
  39. package/src/cli/opencode.ts +2 -2
  40. package/src/cli/provider.ts +13 -1
  41. package/src/client/machine-api.ts +2 -2
  42. package/src/client/machine-listener.ts +2 -2
  43. package/src/client/runtime.ts +26 -2
  44. package/src/codex/account-store.ts +65 -0
  45. package/src/codex/auth-api/account-list.ts +19 -11
  46. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  47. package/src/codex/catalog/gather-capture.ts +21 -2
  48. package/src/codex/catalog/model-hints.ts +29 -28
  49. package/src/codex/catalog/parsing.ts +7 -0
  50. package/src/codex/catalog/provider-models.ts +19 -2
  51. package/src/codex/catalog/retained-sync.ts +22 -26
  52. package/src/codex/catalog/routed-gather.ts +19 -0
  53. package/src/codex/context-compat.ts +5 -2
  54. package/src/codex/desired-state.ts +4 -1
  55. package/src/codex/history-job.ts +6 -6
  56. package/src/codex/history-provider.ts +20 -166
  57. package/src/codex/history-rollout-read.ts +174 -0
  58. package/src/codex/internal/catalog-writer.ts +33 -1
  59. package/src/codex/model-cache.ts +47 -0
  60. package/src/codex/model-entitlements.ts +29 -10
  61. package/src/codex/observed-model-denials.ts +101 -8
  62. package/src/codex/prompt-text-probe.ts +9 -6
  63. package/src/codex/routing.ts +7 -1
  64. package/src/codex/shim.ts +1 -1
  65. package/src/codex/subagent-model-fallback.ts +22 -4
  66. package/src/combos/failover.ts +3 -0
  67. package/src/config/admitted-identity.ts +222 -0
  68. package/src/config/diagnostics.ts +22 -1
  69. package/src/config/feature-flags.ts +5 -0
  70. package/src/config/load-degrade.ts +18 -0
  71. package/src/config/proxy-env.ts +8 -2
  72. package/src/config/schema/compaction-triggers.ts +11 -0
  73. package/src/config/schema/config-schema.ts +4 -0
  74. package/src/config/schema/leaf-validators.ts +12 -0
  75. package/src/config.ts +2 -2
  76. package/src/generated/compatibility-version.json +249 -169
  77. package/src/grok/reset-coupons.ts +38 -19
  78. package/src/images/loop.ts +6 -1
  79. package/src/integrations/aside-profile-context.ts +37 -3
  80. package/src/integrations/aside-profile-journal.ts +68 -3
  81. package/src/integrations/aside-profiles.ts +128 -3
  82. package/src/integrations/mutation-plan.ts +815 -0
  83. package/src/integrations/writer.ts +85 -99
  84. package/src/lab/live/transport.ts +4 -0
  85. package/src/lab/live/types.ts +5 -0
  86. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  87. package/src/lib/admin-secrets.ts +9 -1
  88. package/src/lib/debug-log-buffer.ts +6 -1
  89. package/src/lib/debug.ts +23 -0
  90. package/src/lib/errors.ts +79 -0
  91. package/src/lib/http-response-semantics.ts +57 -0
  92. package/src/lib/lab-live-pinned-sender.ts +26 -12
  93. package/src/lib/pinned-http.ts +142 -2
  94. package/src/lib/plain-data.ts +103 -0
  95. package/src/lib/process-control.ts +13 -5
  96. package/src/lib/provider-outbound.ts +50 -2
  97. package/src/lib/socks5-fetch.ts +136 -26
  98. package/src/lib/spend-ledger-owner.ts +364 -0
  99. package/src/lib/spend-reservation-ledger.ts +218 -27
  100. package/src/lib/windows-system-proxy.ts +16 -11
  101. package/src/oauth/callback-server.ts +4 -3
  102. package/src/oauth/generic-account-failover.ts +1 -0
  103. package/src/oauth/health.ts +12 -1
  104. package/src/oauth/index.ts +3 -107
  105. package/src/oauth/login-flow-state.ts +127 -0
  106. package/src/providers/derive.ts +34 -17
  107. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  108. package/src/providers/key-failover.ts +66 -18
  109. package/src/providers/model-rename-migration.ts +55 -1
  110. package/src/providers/model-rename-startup.ts +7 -5
  111. package/src/providers/openai-virtual-models.ts +42 -2
  112. package/src/providers/quota/antigravity.ts +22 -2
  113. package/src/providers/quota/vendor-probes-key.ts +1 -1
  114. package/src/providers/registry/entries-core.ts +39 -17
  115. package/src/providers/registry/entries-extended.ts +35 -1
  116. package/src/providers/registry/model-ids.ts +168 -0
  117. package/src/providers/registry/model-seeds.ts +9 -0
  118. package/src/providers/registry/types.ts +2 -0
  119. package/src/providers/resolved-model-policy-merge.ts +167 -0
  120. package/src/providers/resolved-model-policy.ts +406 -0
  121. package/src/providers/stale-vision-classification-migration.ts +137 -0
  122. package/src/responses/apply-patch-envelope.ts +0 -12
  123. package/src/responses/freeform-wrapper-scan.ts +279 -0
  124. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  125. package/src/responses/progressive-freeform-input.ts +130 -0
  126. package/src/responses/reasoning-envelope.ts +30 -0
  127. package/src/responses/state.ts +5 -12
  128. package/src/responses/tool-name-aliases.ts +15 -1
  129. package/src/router.ts +91 -115
  130. package/src/routing/compatibility/behavior.ts +9 -0
  131. package/src/routing/compatibility/subject.ts +16 -1
  132. package/src/server/adapter-resolve.ts +9 -0
  133. package/src/server/auth-cors.ts +3 -0
  134. package/src/server/chat-completions.ts +5 -2
  135. package/src/server/claude-messages.ts +6 -3
  136. package/src/server/effort-row.ts +11 -3
  137. package/src/server/grok-responses-control-frame.ts +160 -1
  138. package/src/server/index/serve-options.ts +86 -29
  139. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  140. package/src/server/index/websocket-handler.ts +6 -1
  141. package/src/server/index.ts +16 -13
  142. package/src/server/management/aside-profile-routes.ts +266 -7
  143. package/src/server/management/config-routes.ts +18 -2
  144. package/src/server/management/context.ts +3 -0
  145. package/src/server/management/integration-routes.ts +287 -5
  146. package/src/server/management/metrics-routes.ts +20 -0
  147. package/src/server/management/model-rows.ts +224 -12
  148. package/src/server/management/route-registry.ts +14 -0
  149. package/src/server/management/shared.ts +10 -3
  150. package/src/server/management/system-restart.ts +7 -2
  151. package/src/server/management/system-routes.ts +2 -0
  152. package/src/server/management/usage-aggregate-cache.ts +4 -0
  153. package/src/server/management-api.ts +2 -0
  154. package/src/server/management-auth.ts +15 -1
  155. package/src/server/readiness.ts +29 -10
  156. package/src/server/relay-eager.ts +24 -2
  157. package/src/server/relay.ts +119 -10
  158. package/src/server/request-log.ts +55 -2
  159. package/src/server/request-metrics.ts +236 -0
  160. package/src/server/responses/adapter-continuation.ts +3 -3
  161. package/src/server/responses/adapter-dispatch.ts +11 -6
  162. package/src/server/responses/compact.ts +32 -10
  163. package/src/server/responses/compaction-routing.ts +111 -0
  164. package/src/server/responses/core-codex-account.ts +8 -3
  165. package/src/server/responses/core-combo.ts +7 -7
  166. package/src/server/responses/core-normalize.ts +6 -12
  167. package/src/server/responses/core-opaque-recovery.ts +1 -0
  168. package/src/server/responses/core-options.ts +4 -0
  169. package/src/server/responses/encrypted-payload.ts +20 -2
  170. package/src/server/responses/passthrough-delivery.ts +31 -14
  171. package/src/server/responses/passthrough-dispatch.ts +36 -9
  172. package/src/server/responses/policy-fallback.ts +5 -13
  173. package/src/server/responses/request-prepare.ts +67 -17
  174. package/src/server/responses/request-send-budget.ts +5 -1
  175. package/src/server/responses/request-sidecar-auth.ts +1 -1
  176. package/src/server/responses/request-transport.ts +2 -2
  177. package/src/server/responses/run-turn-execution.ts +25 -3
  178. package/src/server/responses/sidecar-execution.ts +17 -2
  179. package/src/server/responses/ws-upstream.ts +14 -27
  180. package/src/server/responses-custom-tool-repair.ts +27 -54
  181. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  182. package/src/server/sse-payload-rewrite.ts +1 -1
  183. package/src/tray/windows-tray.ps1 +155 -3
  184. package/src/types/config.ts +11 -3
  185. package/src/types/provider.ts +18 -0
  186. package/src/types/request.ts +2 -0
  187. package/src/types/tools.ts +14 -0
  188. package/src/types.ts +1 -0
  189. package/src/vision/eligibility.ts +88 -9
  190. package/src/vision/plan.ts +34 -10
  191. package/src/web-search/executor.ts +41 -2
  192. package/src/web-search/loop.ts +6 -1
  193. package/gui/dist/assets/index-C5IebErG.js +0 -136
  194. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -2,7 +2,11 @@ import { createHash } from "node:crypto";
2
2
  import { readBoundedResponseBody } from "../lib/bounded-body";
3
3
  import type { CodexAccountCredentialRecord, OcxConfig } from "../types";
4
4
  import { isSelectableCodexPoolAccount } from "./account-id";
5
- import { getValidCodexToken, loadCodexAccountRecordSnapshot } from "./account-store";
5
+ import {
6
+ beginCodexAccountGenerationLiveCheck,
7
+ getValidCodexToken,
8
+ loadCodexAccountRecordSnapshot,
9
+ } from "./account-store";
6
10
  import {
7
11
  getMainAccountToken,
8
12
  getValidMainAccountToken,
@@ -22,6 +26,7 @@ import {
22
26
  forgetObservedCodexModelDenialsForAccount,
23
27
  observedDeniedCodexAccountIdsForModel,
24
28
  recordObservedCodexModelDenial,
29
+ setObservedDenialGenerationCheck,
25
30
  resetObservedCodexModelDenialsForTests,
26
31
  } from "./observed-model-denials";
27
32
 
@@ -1297,13 +1302,14 @@ export function cachedDeniedCodexAccountIdsForModel(
1297
1302
  // absent an ongoing catalog sync the loop above contributes nothing at all. An upstream
1298
1303
  // refusal does not expire on that schedule and is not a snapshot of a pending answer: it is
1299
1304
  // 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
- }
1305
+ // The caller's read fence is passed IN rather than applied to the result, so an excluded
1306
+ // account is skipped before the credential-generation validation reads account storage
1307
+ // (#4952). An excluded account must stay UNKNOWN rather than denied, so a profile switch or
1308
+ // a request-owned credential produces the same selection it does today.
1309
+ const observedDenied = observedDeniedCodexAccountIdsForModel(modelId, now, {
1310
+ ...(options.excludeAccountIds ? { excludeAccountIds: options.excludeAccountIds } : {}),
1311
+ });
1312
+ for (const accountId of observedDenied ?? []) denied.add(accountId);
1307
1313
  // One account holds one entry per client version, and upstream filters the roster by that
1308
1314
  // version. So the same account can legitimately carry a granted entry under a current client
1309
1315
  // and a denied one under an older client that predates the model. Positive evidence is
@@ -1330,23 +1336,36 @@ export function cachedDeniedCodexAccountIdsForModel(
1330
1336
  * admissible here: 400 covers every malformed request too, and remembering one of those as an
1331
1337
  * entitlement fact would steer routing away from a perfectly capable account.
1332
1338
  */
1339
+ // Denial evidence is credential-scoped (#4952). The store stays a leaf module, so the
1340
+ // liveness predicate is injected here, where the account store is already a dependency.
1341
+ setObservedDenialGenerationCheck(beginCodexAccountGenerationLiveCheck);
1342
+
1333
1343
  export function recordCodexModelDenialEvidence(
1334
1344
  accountId: string | null | undefined,
1335
1345
  modelId: string | undefined,
1346
+ generation: number | null | undefined,
1336
1347
  now = Date.now(),
1337
1348
  ): void {
1338
1349
  if (!accountId || !modelId) return;
1339
1350
  if (!ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return;
1340
- recordObservedCodexModelDenial(accountId, modelId, now);
1351
+ // A caller that cannot name a credential generation records ACCOUNT-scoped evidence rather
1352
+ // than none. The one production context in that position is `main-pool`, whose credential
1353
+ // lives in `auth.json` and has no pool generation; discarding its refusals would revert
1354
+ // #4906 for the stored main login (#4952). Request-owned `main` never reaches here — its
1355
+ // `accountId` is null and the guard above returns.
1356
+ recordObservedCodexModelDenial(accountId, modelId, typeof generation === "number" ? generation : undefined, now);
1341
1357
  }
1342
1358
 
1343
1359
  /** Drop the refusal evidence for a pair the account has just served successfully. */
1344
1360
  export function clearCodexModelDenialEvidence(
1345
1361
  accountId: string | null | undefined,
1346
1362
  modelId: string | undefined,
1363
+ generation: number | null | undefined,
1347
1364
  ): void {
1348
1365
  if (!accountId || !modelId) return;
1349
- clearObservedCodexModelDenial(accountId, modelId);
1366
+ // Mirror of the write: an account-scoped success clears account-scoped evidence. It cannot
1367
+ // clear a credential-scoped entry that names a newer generation, and vice versa (#4952).
1368
+ clearObservedCodexModelDenial(accountId, modelId, typeof generation === "number" ? generation : undefined);
1350
1369
  }
1351
1370
 
1352
1371
  /** Synchronous projection for management/catalog readers after a discovery pass. */
@@ -48,8 +48,55 @@ const OBSERVED_DENIAL_TTL_MS = 6 * 60 * 60_000;
48
48
  /** Bounded like the roster cache: pool size times flagship count, with room to spare. */
49
49
  const OBSERVED_DENIAL_MAX_ENTRIES = 512;
50
50
 
51
- /** `accountId\u0000modelId` -> expiry. Insertion order is the eviction order. */
52
- const observedDenials = new Map<string, number>();
51
+ /**
52
+ * One entry of refusal evidence.
53
+ *
54
+ * `generation` is the credential generation the refusal was observed under (#4952). Evidence
55
+ * is about a CREDENTIAL, not an account id: reauthenticating the same internal account can
56
+ * swap the subscription underneath it, so a refusal from the previous credential says nothing
57
+ * about the replacement. Carrying the generation lets a reader ignore superseded evidence and
58
+ * lets a late reply from an older generation be refused rather than applied.
59
+ *
60
+ * It is OPTIONAL because not every account that can be refused has one. A `main-pool` context
61
+ * — the stored main login taking part in rotation — carries a real `accountId` and a
62
+ * `writerGeneration`, but no pool credential generation, because its credential lives in
63
+ * `auth.json` rather than the pool store. Dropping its evidence would have silently reverted
64
+ * #4906 for that account: the pool would re-send the same model to the login that just refused
65
+ * it, on every request, forever. An entry without a generation is account-scoped, and the
66
+ * generation fences below simply do not apply to it. This is the same rule the quota writer
67
+ * already uses in `core-codex-account.ts`, where an absent credential generation skips the
68
+ * liveness check instead of discarding the write.
69
+ */
70
+ interface ObservedDenial {
71
+ expiresAt: number;
72
+ generation?: number;
73
+ }
74
+
75
+ /** `accountId\u0000modelId` -> entry. Insertion order is the eviction order. */
76
+ const observedDenials = new Map<string, ObservedDenial>();
77
+
78
+ /** Whether `generation` is still the account's live credential. */
79
+ type GenerationLiveCheck = (accountId: string, generation: number) => boolean;
80
+
81
+ /**
82
+ * Opens one liveness check.
83
+ *
84
+ * A FACTORY rather than a bare predicate because the production implementation reads the
85
+ * credential store, and a lookup can ask about several accounts. Loading once per lookup and
86
+ * closing over that snapshot is the shape `loadCodexAccountRecordSnapshot` exists for; a bare
87
+ * predicate would reload and reparse the whole store per row, on the request path.
88
+ *
89
+ * Injected rather than imported at module scope so this stays a leaf module: `account-store`
90
+ * reads the credential file, and a unit test of this map should not have to stand one up.
91
+ */
92
+ type GenerationLiveCheckFactory = () => GenerationLiveCheck;
93
+
94
+ let beginGenerationLiveCheck: GenerationLiveCheckFactory = () => () => true;
95
+
96
+ /** Wire the liveness predicate. Called once at startup; tests substitute their own. */
97
+ export function setObservedDenialGenerationCheck(begin: GenerationLiveCheckFactory): void {
98
+ beginGenerationLiveCheck = begin;
99
+ }
53
100
 
54
101
  function denialKey(accountId: string, modelId: string): string {
55
102
  return `${accountId}\u0000${modelId}`;
@@ -72,12 +119,24 @@ function modelIdOfDenialKey(key: string): string {
72
119
  export function recordObservedCodexModelDenial(
73
120
  accountId: string,
74
121
  modelId: string,
122
+ generation: number | undefined,
75
123
  now = Date.now(),
76
124
  ): void {
125
+ // A refusal dispatched under generation G can arrive after G+1 has been saved. Reject it
126
+ // BEFORE touching the map at all, not merely when this key already holds newer evidence:
127
+ // with no entry for this key the stale row would otherwise be inserted, and at the entry
128
+ // bound it would evict a valid row that nothing can restore (#4952).
129
+ if (generation !== undefined && !beginGenerationLiveCheck()(accountId, generation)) return;
77
130
  const key = denialKey(accountId, modelId);
131
+ const existing = observedDenials.get(key);
132
+ // Second fence, for the window where the replacement credential has been dispatched but the
133
+ // store read above still answers live. Both sides must name a generation to be comparable;
134
+ // an account-scoped entry is not older or newer than a credential-scoped one.
135
+ if (existing !== undefined && existing.generation !== undefined && generation !== undefined
136
+ && existing.generation > generation) return;
78
137
  // Delete before set so the refreshed entry moves to the back of the eviction order.
79
138
  observedDenials.delete(key);
80
- observedDenials.set(key, now + OBSERVED_DENIAL_TTL_MS);
139
+ observedDenials.set(key, { expiresAt: now + OBSERVED_DENIAL_TTL_MS, generation });
81
140
  while (observedDenials.size > OBSERVED_DENIAL_MAX_ENTRIES) {
82
141
  const oldest = observedDenials.keys().next();
83
142
  if (oldest.done) break;
@@ -91,8 +150,20 @@ export function recordObservedCodexModelDenial(
91
150
  * A success is newer and stronger evidence than the refusal that preceded it: whatever the
92
151
  * entitlement was when upstream refused, it is not that now.
93
152
  */
94
- export function clearObservedCodexModelDenial(accountId: string, modelId: string): void {
95
- observedDenials.delete(denialKey(accountId, modelId));
153
+ export function clearObservedCodexModelDenial(
154
+ accountId: string,
155
+ modelId: string,
156
+ generation: number | undefined,
157
+ ): void {
158
+ const key = denialKey(accountId, modelId);
159
+ const existing = observedDenials.get(key);
160
+ if (!existing) return;
161
+ // The mirror of the write fence: a success dispatched under G arriving after G+1 was
162
+ // refused must not clear the replacement's evidence (#4952). Equal generations clear,
163
+ // because that is the ordinary "this account just served this model" case.
164
+ if (existing.generation !== undefined && generation !== undefined
165
+ && existing.generation > generation) return;
166
+ observedDenials.delete(key);
96
167
  }
97
168
 
98
169
  /**
@@ -119,19 +190,41 @@ export function forgetObservedCodexModelDenialsForAccount(accountId: string | nu
119
190
  export function observedDeniedCodexAccountIdsForModel(
120
191
  modelId: string | undefined,
121
192
  now = Date.now(),
193
+ options: { excludeAccountIds?: ReadonlySet<string> } = {},
122
194
  ): ReadonlySet<string> | undefined {
123
195
  if (!modelId) return undefined;
124
196
  const denied = new Set<string>();
125
- for (const [key, expiresAt] of [...observedDenials]) {
126
- if (expiresAt <= now) {
197
+ // Opened lazily and at most once: a lookup that matches no credential-scoped row must not
198
+ // read the credential store at all.
199
+ let isLive: GenerationLiveCheck | undefined;
200
+ for (const [key, entry] of [...observedDenials]) {
201
+ if (entry.expiresAt <= now) {
127
202
  observedDenials.delete(key);
128
203
  continue;
129
204
  }
130
- if (modelIdOfDenialKey(key) === modelId) denied.add(accountIdOfDenialKey(key));
205
+ // Model and the caller's exclusion fence FIRST. The issue asks for identity validation
206
+ // after the exclusion read, and an excluded account — a draining profile switch, or a
207
+ // request-owned credential — must produce no credential-store read on its behalf.
208
+ if (modelIdOfDenialKey(key) !== modelId) continue;
209
+ const accountId = accountIdOfDenialKey(key);
210
+ if (options.excludeAccountIds?.has(accountId)) continue;
211
+ if (entry.generation !== undefined) {
212
+ isLive ??= beginGenerationLiveCheck();
213
+ // Superseded evidence stops denying the replacement. It is SKIPPED, not deleted: the
214
+ // predicate cannot tell "this account reauthenticated" from "the credential store could
215
+ // not be read", and deleting on the second would throw away valid evidence that a
216
+ // transient read failure was never entitled to touch. Skipping already delivers the
217
+ // routing outcome the issue asks for, on every read, without depending on the
218
+ // conditional account-wide forget that cannot run when no roster was ever cached.
219
+ // Superseded rows still leave by TTL, by eviction, and by the account-wide forget.
220
+ if (!isLive(accountId, entry.generation)) continue;
221
+ }
222
+ denied.add(accountId);
131
223
  }
132
224
  return denied.size > 0 ? denied : undefined;
133
225
  }
134
226
 
135
227
  export function resetObservedCodexModelDenialsForTests(): void {
136
228
  observedDenials.clear();
229
+ beginGenerationLiveCheck = () => () => true;
137
230
  }
@@ -75,7 +75,7 @@ const UNMAPPED_LAYER_IDS = [
75
75
  // The Rust source names a <git_attribution> marker pair, but a world-state section is
76
76
  // DIFF-rendered: it emits nothing on a turn where its state has not changed. Live
77
77
  // `codex debug prompt-input` (codex-cli 0.145.0, 32978 bytes) showed no such block and
78
- // no attribution text. Listing the id here reports "not exposed" honestly instead of
78
+ // no attribution text. Listing the id here reports "unmapped" honestly instead of
79
79
  // claiming a tag this extractor has never actually matched - the same mistake the
80
80
  // header above records for permissions.
81
81
  "git-attribution",
@@ -85,7 +85,7 @@ export interface LayerText {
85
85
  /** Rendered text, when this layer produced a section on the probed turn. */
86
86
  text: string | null;
87
87
  /** Why the text is absent, when it is. */
88
- reason: "ok" | "empty-source" | "not-rendered" | "not-exposed" | "unavailable";
88
+ reason: "ok" | "empty-source" | "not-rendered" | "not-exposed" | "unmapped" | "unavailable";
89
89
  bytes: number;
90
90
  /**
91
91
  * `expanded` is text Codex sends as written. `template` is a catalog
@@ -103,7 +103,7 @@ export interface LayerText {
103
103
 
104
104
  /**
105
105
  * Why the base prompt is or is not readable, at the granularity a reader can act
106
- * on. `LayerText.reason` has five coarse values and cannot express any of this,
106
+ * on. `LayerText.reason` has six coarse values and cannot express any of this,
107
107
  * which is why the detailed answer travels on its own record.
108
108
  */
109
109
  export type BasePromptReason =
@@ -374,7 +374,7 @@ function readBasePrompt(codexHome: string): BasePromptText {
374
374
  /**
375
375
  * Project the base prompt onto the legacy `base-instructions` layer slot.
376
376
  *
377
- * The slot is lossy by construction - five coarse reasons, no renderer that reads
377
+ * The slot is lossy by construction - six coarse reasons, no renderer that reads
378
378
  * `representation` - so it carries only what it can carry honestly: published text
379
379
  * when the source is text Codex sends, and otherwise no text at all. A template is
380
380
  * deliberately NOT `ok` here, because the dialog labels every `ok` layer "Text
@@ -944,9 +944,12 @@ export async function probePromptText(
944
944
 
945
945
  // Layers whose rendered tag we have not confirmed against live output. Leaving
946
946
  // them absent made the GUI fall through to "unavailable", which claims the probe
947
- // failed when it succeeded. Saying we have no mapping is the smaller claim.
947
+ // failed when it succeeded. Keep this distinct from the base prompt's
948
+ // "not-exposed", which is confirmed to travel outside the printable message
949
+ // list - reusing it showed a base-prompt-specific explanation for unrelated
950
+ // layers.
948
951
  for (const id of UNMAPPED_LAYER_IDS) {
949
- layers[id] ??= { text: null, reason: "not-exposed", bytes: 0 };
952
+ layers[id] ??= { text: null, reason: "unmapped", bytes: 0 };
950
953
  }
951
954
  return { ok: true, codexHome, layers, base, ...(reportedRuntime ? { runtime: reportedRuntime } : {}) };
952
955
  }
@@ -1,5 +1,6 @@
1
1
  import { saveConfigPreservingClaudeCode } from "../config";
2
- import { isCodexAccountGenerationLive } from "./account-store";
2
+ import { isCodexAccountGenerationLive, registerCodexRefreshGenerationHandoff } from "./account-store";
3
+ import { handOffThreadAffinityGeneration } from "./routing/thread-affinity";
3
4
  import { codexAccountLogLabel } from "./account-label";
4
5
  import { isCodexAccountPaused } from "./account-pause";
5
6
  import { clearCodexAccountPin, pinnedCodexAccountId } from "./account-priority";
@@ -205,6 +206,11 @@ export {
205
206
  } from "./routing/active-account";
206
207
  export { codexAccountPinDrainReason } from "./routing/pin-drain";
207
208
  export type { CodexPinDrainReason } from "./routing/pin-drain";
209
+
210
+ // A shared refresh can outlive the request that opened it. Register the affinity
211
+ // handoff with the flight so a detached G -> G+1 commit cannot strand bindings at G.
212
+ registerCodexRefreshGenerationHandoff(handOffThreadAffinityGeneration);
213
+
208
214
  function hasConfiguredPoolAccount(
209
215
  config: OcxConfig,
210
216
  accountId: string,
package/src/codex/shim.ts CHANGED
@@ -241,7 +241,7 @@ function backupPathFor(path: string): string {
241
241
  */
242
242
  function destroyedShimMessage(file: ShimFileState): string {
243
243
  const wrapper = existsSync(file.wrapperPath)
244
- ? isShim(file.wrapperPath) ? "present but unusable" : "present but not an opencodex shim"
244
+ ? stableShimPathProbe(file.wrapperPath)?.prefix.includes(SHIM_MARKER) ? "present but unusable" : "present but not an opencodex shim"
245
245
  : "missing";
246
246
  const backup = existsSync(file.backupPath) ? "present" : "missing";
247
247
  const base = `Codex autostart shim not restored: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`;
@@ -905,8 +905,16 @@ export function hasCodexAgentModelFallbackField(role: string, codexHome = CODEX_
905
905
  }
906
906
 
907
907
  /** Roles whose TOML still carries `model_fallback`, including empty arrays. */
908
- export function scanCodexAgentRolesWithTomlModelFallback(codexHome = CODEX_HOME): string[] {
909
- return listCodexAgentRoles(codexHome).filter(role => hasCodexAgentModelFallbackField(role, codexHome));
908
+ export function scanCodexAgentRolesWithTomlModelFallback(
909
+ codexHome = CODEX_HOME,
910
+ onListError?: (cause: unknown) => void,
911
+ ): string[] {
912
+ try {
913
+ return listCodexAgentRoles(codexHome).filter(role => hasCodexAgentModelFallbackField(role, codexHome));
914
+ } catch (cause) {
915
+ onListError?.(cause);
916
+ return [];
917
+ }
910
918
  }
911
919
 
912
920
  const TOML_MODEL_KEY = /^\s*(?:model|"model"|'model')\s*=/;
@@ -984,9 +992,19 @@ const OPENCODEX_DERIVED_ROLE_MARKERS = ["generated-by: opencodex", "ocx-route:"]
984
992
  * authorizes writing to, repairing, or removing these files, and the marker-based ownership rules
985
993
  * that govern the files opencodex does write are unchanged.
986
994
  */
987
- export function scanOpencodexDerivedCodexAgentRolesWithoutModelPin(codexHome = CODEX_HOME): string[] {
995
+ export function scanOpencodexDerivedCodexAgentRolesWithoutModelPin(
996
+ codexHome = CODEX_HOME,
997
+ onListError?: (cause: unknown) => void,
998
+ ): string[] {
988
999
  const findings: string[] = [];
989
- for (const role of listCodexAgentRoles(codexHome)) {
1000
+ let roles: string[];
1001
+ try {
1002
+ roles = listCodexAgentRoles(codexHome);
1003
+ } catch (cause) {
1004
+ onListError?.(cause);
1005
+ return [];
1006
+ }
1007
+ for (const role of roles) {
990
1008
  let content: string;
991
1009
  try {
992
1010
  content = readFileSync(join(codexHome, "agents", `${role}.toml`), "utf8");
@@ -399,6 +399,9 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co
399
399
  if (errorCode === "unsupported_value"
400
400
  && (e.param === "reasoning.effort" || e.param === "reasoning_effort")
401
401
  && e.message.startsWith("Unsupported value:") && e.message.includes("not supported")) return true;
402
+ if (e.code === null
403
+ && e.param === "reasoning_effort"
404
+ && /^Function tools with reasoning_effort are not supported for gpt-6-astra(?:-\d{4}-\d{2}-\d{2})? in \/v1\/chat\/completions\. To use function tools, use \/v1\/responses or set reasoning_effort to 'none'\.$/.test(e.message)) return true;
402
405
  return e.param === "input"
403
406
  && (errorCode === "" || errorCode === "invalid_request_error")
404
407
  && /^Model '[^']{1,256}' does not support image inputs\./.test(e.message);
@@ -0,0 +1,222 @@
1
+ import { readConfigAdmissionSnapshot } from "./diagnostics";
2
+ import { getConfigPath } from "./paths";
3
+ import { canonicalPlainData, copyPlainData, isPlainObject, ownDataKeys } from "../lib/plain-data";
4
+ import type { OcxConfig } from "../types";
5
+
6
+ /**
7
+ * A configuration a roster may be built from, detached from the object the caller holds.
8
+ *
9
+ * The detachment is the point. A gather suspends, and while it is suspended a management route can
10
+ * edit the resident configuration in place; gathering from one state and projecting from another
11
+ * would produce rows that belong to neither. Every authoritative pass uses this copy from
12
+ * beginning to end, so what the resident object does in the meantime becomes a question about
13
+ * whether the result may be retained rather than a question about what the result is.
14
+ *
15
+ * What this deliberately does NOT do is require the resident configuration to equal the file on
16
+ * disk. The proxy routes by the configuration it is holding, so that is the configuration a
17
+ * preview and the mutation it authorizes must both describe. A file the operator has edited and
18
+ * the process has not adopted is a supported state rather than a fault: live reconciliation
19
+ * (src/config/live-reconcile.ts) merges persisted state while deliberately retaining live changes
20
+ * and the active listener binding, and can persist a binding the resident object does not have.
21
+ * Demanding equality would make preview permanently unavailable on exactly those configurations
22
+ * while proving nothing about the rows, which come from the resident object either way.
23
+ */
24
+ export interface ExportConfigAdmission {
25
+ readonly config: OcxConfig;
26
+ }
27
+
28
+ /**
29
+ * What was true when an admission was captured, kept here rather than on the admission object.
30
+ *
31
+ * None of it is data a consumer has any business reading: the file term is a digest of the
32
+ * operator's configuration file and the canonical form contains their credentials. Holding it in a
33
+ * module WeakMap means an admission can be passed around, and even serialized by a careless
34
+ * caller, without carrying any of it.
35
+ */
36
+ interface AdmissionEvidence {
37
+ readonly path: string;
38
+ readonly file: string;
39
+ readonly data: string;
40
+ readonly executors: ReadonlyMap<string, unknown>;
41
+ }
42
+
43
+ const evidence = new WeakMap<ExportConfigAdmission, AdmissionEvidence>();
44
+
45
+ /**
46
+ * Detach the configuration a roster is about to be built from, and record what it was.
47
+ *
48
+ * Two things are recorded because two things can move independently. The resident configuration is
49
+ * what the rows are derived from, so its complete structure is captured: structurally rather than
50
+ * as a list of the fields that seemed to matter, because such a list is only as complete as
51
+ * whoever last thought about it and this one had already missed export-affecting configuration.
52
+ * The configuration file is recorded beside it, so a roster does not outlive an operator editing
53
+ * the configuration under a process that has not adopted it yet.
54
+ *
55
+ * Null when the file cannot be read, when it is there but the loader would have had to salvage it,
56
+ * and when the configuration object cannot be copied as plain data. Every caller fails closed on
57
+ * it: refusing a preview costs an ordinary load, and serving one that describes a configuration
58
+ * nobody has costs a file the operator did not ask for.
59
+ */
60
+ export function captureExportConfigAdmission(live: OcxConfig): ExportConfigAdmission | null {
61
+ const path = getConfigPath();
62
+ const file = admittedFileTerm();
63
+ if (file === null) return null;
64
+ const resident = detachConfig(live);
65
+ if (resident === null) return null;
66
+ const config = withExecutors(resident);
67
+ if (config === null) return null;
68
+ const admission: ExportConfigAdmission = { config };
69
+ evidence.set(admission, { path, file, data: canonicalPlainData(resident.data), executors: resident.executors });
70
+ return admission;
71
+ }
72
+
73
+ /**
74
+ * Whether an admission still describes the configuration in hand and the file it was taken beside.
75
+ *
76
+ * Three things are checked because three things can move: the file can be rewritten, the resident
77
+ * object can be edited in place, and a consumer of the detached copy can mutate what it was given.
78
+ * The last matters as much as the others, because a pass that edited its own input and then
79
+ * published would be retaining a roster under a state that no longer describes even that input.
80
+ *
81
+ * Passive: it reads the configuration file and nothing else. No credential is resolved, no
82
+ * provider is contacted, no path is hardened and nothing is written.
83
+ */
84
+ export function isExportConfigAdmissionCurrent(admission: ExportConfigAdmission, live: OcxConfig): boolean {
85
+ const captured = evidence.get(admission);
86
+ if (captured === undefined) return false;
87
+ // A different configuration home is a different question, not a stale answer to this one.
88
+ if (getConfigPath() !== captured.path) return false;
89
+ const file = admittedFileTerm();
90
+ if (file === null || file !== captured.file) return false;
91
+ const resident = detachConfig(live);
92
+ if (resident === null || canonicalPlainData(resident.data) !== captured.data) return false;
93
+ if (!sameExecutors(resident.executors, captured.executors)) return false;
94
+ const working = detachConfig(admission.config);
95
+ return working !== null
96
+ && canonicalPlainData(working.data) === captured.data
97
+ && sameExecutors(working.executors, captured.executors);
98
+ }
99
+
100
+ /**
101
+ * A plain-data copy of a configuration for a consumer that must not observe later edits, or null.
102
+ *
103
+ * The integration writer is the case this exists for. It freezes every other resolution seam
104
+ * before its first await and then held the configuration by reference, so a plan checked under one
105
+ * configuration could be written from another: the check and the document it authorized were
106
+ * reading the same object at two different moments. One copy taken before the await gives both of
107
+ * them the same configuration.
108
+ *
109
+ * Null rather than the caller's object when the copy cannot be made. Handing back the reference
110
+ * would have been a copy in name only, and the caller would have gone on to describe it as the
111
+ * configuration it checked.
112
+ */
113
+ export function detachedConfigSnapshot(config: OcxConfig): OcxConfig | null {
114
+ const detached = detachConfig(config);
115
+ return detached === null ? null : withExecutors(detached);
116
+ }
117
+
118
+ /**
119
+ * The configuration file as an opaque term: its exact bytes, or the distinguished absence of one.
120
+ *
121
+ * This is a byte observation and nothing more. It says the operator's configuration file has not
122
+ * been rewritten since a roster was built; it is not a claim about whether the configuration the
123
+ * process is holding agrees with that file.
124
+ *
125
+ * Null for a file that cannot be read, because then a later read cannot tell whether it changed.
126
+ * Null too for one that is there and does not load cleanly, which is the existing contract for a
127
+ * derived roster rather than an inference about the resident configuration. Before this, a digest
128
+ * was accepted ahead of any look at what the parse produced.
129
+ *
130
+ * Absence is a configuration rather than the lack of one. No file means defaults, which is an
131
+ * ordinary fresh install and the ordinary state in CI.
132
+ */
133
+ function admittedFileTerm(): string | null {
134
+ const snapshot = readConfigAdmissionSnapshot();
135
+ const { source, error } = snapshot.diagnostics;
136
+ if (snapshot.kind === "read") return source === "file" && error === null ? snapshot.contentSha256 : null;
137
+ return source === "default" && error === null ? "absent" : null;
138
+ }
139
+
140
+ interface DetachedConfig {
141
+ readonly data: Record<string, unknown>;
142
+ readonly executors: ReadonlyMap<string, unknown>;
143
+ }
144
+
145
+ /**
146
+ * A configuration as plain data, with the transport executors kept out of it.
147
+ *
148
+ * The copier refuses everything JSON could not have produced, so the one thing that needs handling
149
+ * here is a provider's fetch executor: a caller owns it and the gather uses it instead of the
150
+ * global transport. It is held by reference for the detached copy and compared by reference
151
+ * afterwards, so replacing it invalidates the binding while it is never serialized.
152
+ */
153
+ function detachConfig(live: OcxConfig): DetachedConfig | null {
154
+ const executors = new Map<string, unknown>();
155
+ const root = live as unknown;
156
+ if (!isPlainObject(root)) return null;
157
+ const data: Record<string, unknown> = {};
158
+ for (const key of ownDataKeys(root)) {
159
+ if (key === null) return null;
160
+ const value = root[key];
161
+ if (value === undefined) continue;
162
+ if (key !== "providers") {
163
+ const copied = copyPlainData(value);
164
+ if (!copied.ok) return null;
165
+ data[key] = copied.value;
166
+ continue;
167
+ }
168
+ if (!isPlainObject(value)) return null;
169
+ const providers: Record<string, unknown> = {};
170
+ for (const name of ownDataKeys(value)) {
171
+ if (name === null) return null;
172
+ const provider = value[name];
173
+ if (provider === undefined) continue;
174
+ if (!isPlainObject(provider)) return null;
175
+ const copiedProvider: Record<string, unknown> = {};
176
+ for (const field of ownDataKeys(provider)) {
177
+ if (field === null) return null;
178
+ const fieldValue = provider[field];
179
+ if (fieldValue === undefined) continue;
180
+ // The executor exception applies to an executor. A provider entry keeps unknown
181
+ // configuration keys, so a fetch value that is not a function is something an operator
182
+ // wrote into the file, and it is copied and compared as the data it is. Discovery reads it
183
+ // the same way: the outbound transport takes its built-in path unless the value is
184
+ // callable.
185
+ if (field === "fetch" && typeof fieldValue === "function") {
186
+ executors.set(name, fieldValue);
187
+ continue;
188
+ }
189
+ const copied = copyPlainData(fieldValue);
190
+ if (!copied.ok) return null;
191
+ copiedProvider[field] = copied.value;
192
+ }
193
+ providers[name] = copiedProvider;
194
+ }
195
+ data[key] = providers;
196
+ }
197
+ return { data, executors };
198
+ }
199
+
200
+ /** The copy a consumer runs against, with the executors put back by reference. */
201
+ function withExecutors(detached: DetachedConfig): OcxConfig | null {
202
+ // A second copy, so what is compared later is never the object handed to a consumer.
203
+ const copied = copyPlainData(detached.data);
204
+ if (!copied.ok) return null;
205
+ const config = copied.value;
206
+ const providers = config.providers;
207
+ if (isPlainObject(providers)) {
208
+ for (const [name, executor] of detached.executors) {
209
+ const provider = providers[name];
210
+ if (isPlainObject(provider)) provider.fetch = executor;
211
+ }
212
+ }
213
+ return config as unknown as OcxConfig;
214
+ }
215
+
216
+ function sameExecutors(left: ReadonlyMap<string, unknown>, right: ReadonlyMap<string, unknown>): boolean {
217
+ if (left.size !== right.size) return false;
218
+ for (const [name, executor] of left) {
219
+ if (!right.has(name) || right.get(name) !== executor) return false;
220
+ }
221
+ return true;
222
+ }
@@ -60,6 +60,7 @@ import {
60
60
  remoteGuiConfigSchema,
61
61
  runtimeRoleSchema,
62
62
  spendSchema,
63
+ compactionRoutingSchema,
63
64
  } from "./schema/leaf-validators";
64
65
 
65
66
  export type ConfigDiagnostics = {
@@ -561,7 +562,26 @@ function managementIngressConfigError(value: unknown): string | null {
561
562
  return null;
562
563
  }
563
564
 
565
+ /** Load degrades malformed metrics export config to off; live writes reject the same shape. */
566
+ export function metricsExportConfigError(value: unknown): string | null {
567
+ const raw = rawConfigRecord(value);
568
+ if (!raw || !Object.hasOwn(raw, "metricsExport") || raw.metricsExport === undefined) return null;
569
+ const metricsExport = rawConfigRecord(raw.metricsExport);
570
+ if (!metricsExport) return "schema_invalid: metricsExport: must be an object or omitted";
571
+ if (Object.keys(metricsExport).some(key => key !== "enabled")) {
572
+ return "schema_invalid: metricsExport: contains an unsupported field";
573
+ }
574
+ if (metricsExport.enabled !== undefined && typeof metricsExport.enabled !== "boolean") {
575
+ return "schema_invalid: metricsExport.enabled: must be a boolean";
576
+ }
577
+ return null;
578
+ }
579
+
564
580
  export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
581
+ const compactionRouting = rawConfigRecord(value)?.compactionRouting;
582
+ if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) {
583
+ return { ok: false, error: "schema_invalid: compactionRouting: requires a nonblank model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" };
584
+ }
565
585
  const boundaryError = configReasoningPinsConfigError(value)
566
586
  ?? blankHostnameError(value)
567
587
  ?? claudeSubagentEffortError(value)
@@ -586,7 +606,8 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
586
606
  ?? clientConnectionConfigError(value)
587
607
  ?? clientRolePairError(value)
588
608
  ?? loopbackListenerPortError(value)
589
- ?? managementIngressConfigError(value);
609
+ ?? managementIngressConfigError(value)
610
+ ?? metricsExportConfigError(value);
590
611
  if (boundaryError) return { ok: false, error: boundaryError };
591
612
  const result = configSchema.safeParse(value);
592
613
  if (result.success) {
@@ -12,6 +12,11 @@ export function ultraFastTierEnabled(config: Pick<OcxConfig, "ultraFastTier">):
12
12
  return config.ultraFastTier === true;
13
13
  }
14
14
 
15
+ /** Default-off aggregate request metrics; activation is fixed for one server process lifetime. */
16
+ export function metricsExportEnabled(config: Pick<OcxConfig, "metricsExport">): boolean {
17
+ return config.metricsExport?.enabled === true;
18
+ }
19
+
15
20
  /**
16
21
  * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass
17
22
  * per hour. Each pass spends a live /models call against every enabled provider, and