@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
@@ -139,7 +139,7 @@ function handleList(args: string[]): void {
139
139
  // provider add
140
140
  // ---------------------------------------------------------------------------
141
141
 
142
- const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--api-key-transport <x-api-key|bearer>] [--default-model <model>] [--model <id> --text-only] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
142
+ const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--api-key-transport <x-api-key|bearer>] [--default-model <model>] [--model <id> --text-only] [--google-tool-schema-policy <compatible|reject-lossy>] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
143
143
 
144
144
  async function handleAdd(args: string[]): Promise<void> {
145
145
  const name = args[0];
@@ -164,6 +164,7 @@ async function handleAdd(args: string[]): Promise<void> {
164
164
  const adapter = consumeFlagValue(restArgs, "--adapter");
165
165
  const baseUrl = consumeFlagValue(restArgs, "--base-url");
166
166
  const defaultModel = consumeFlagValue(restArgs, "--default-model");
167
+ const googleToolSchemaPolicy = consumeFlagValue(restArgs, "--google-tool-schema-policy");
167
168
  const textOnly = consumeFlag(restArgs, "--text-only");
168
169
  const capabilityModel = consumeFlagValue(restArgs, "--model");
169
170
  rejectUnknownArgs(restArgs, ADD_USAGE);
@@ -228,6 +229,17 @@ async function handleAdd(args: string[]): Promise<void> {
228
229
  }
229
230
  provConfig.apiKeyTransport = apiKeyTransport;
230
231
  }
232
+ if (googleToolSchemaPolicy !== undefined) {
233
+ if (googleToolSchemaPolicy !== "compatible" && googleToolSchemaPolicy !== "reject-lossy") {
234
+ console.error('Error: --google-tool-schema-policy must be "compatible" or "reject-lossy".');
235
+ process.exit(1);
236
+ }
237
+ if (provConfig.adapter !== "google") {
238
+ console.error("Error: --google-tool-schema-policy requires the google adapter.");
239
+ process.exit(1);
240
+ }
241
+ provConfig.googleToolSchemaPolicy = googleToolSchemaPolicy;
242
+ }
231
243
 
232
244
  const existingProvider = config.providers[name];
233
245
  if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) {
@@ -23,7 +23,7 @@ export interface MachineStatusV1 {
23
23
  export interface MachineApiDeps {
24
24
  sync: typeof syncConnectedClient;
25
25
  disconnect: typeof disconnectClient;
26
- scheduleStandaloneRecycle: () => void;
26
+ scheduleStandaloneRecycle: (disconnectedTokenFingerprint: string) => void;
27
27
  hubReachability?: () => HubReachability;
28
28
  setHubReachability?: (value: HubReachability) => void;
29
29
  }
@@ -130,7 +130,7 @@ export async function handleMachineApi(
130
130
  }
131
131
  try {
132
132
  const result = await deps.disconnect(input.keepCatalog === undefined ? {} : { keepCatalog: input.keepCatalog });
133
- deps.scheduleStandaloneRecycle();
133
+ deps.scheduleStandaloneRecycle(state.tokenFingerprint);
134
134
  return Response.json({ success: true, ...result }, { status: 202 });
135
135
  } catch (error) {
136
136
  return Response.json({ success: false, error: error instanceof Error ? error.message : "disconnect failed" }, { status: 409 });
@@ -75,8 +75,8 @@ export function startMachineListener(
75
75
  const machineApiDeps: MachineApiDeps = {
76
76
  sync: deps.machineApi?.sync ?? syncConnectedClient,
77
77
  disconnect: deps.machineApi?.disconnect ?? disconnectClient,
78
- scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (() => {
79
- void import("./runtime").then(module => module.scheduleStandaloneRecycle());
78
+ scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (tokenFingerprint => {
79
+ void import("./runtime").then(module => module.scheduleStandaloneRecycle(tokenFingerprint));
80
80
  }),
81
81
  hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability),
82
82
  setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
@@ -4,6 +4,7 @@ import { loadConfig } from "../config";
4
4
  import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state";
5
5
  import { installCrashGuards } from "../lib/crash-guard";
6
6
  import { selfLaunchArgv } from "../lib/self-launch-argv";
7
+ import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets";
7
8
  import { findAvailablePort } from "../server/ports";
8
9
  import { startMachineListener } from "./machine-listener";
9
10
  import { readClientConnectionState } from "./state";
@@ -17,7 +18,30 @@ function cleanup(): void {
17
18
  removeRuntimePort(process.pid);
18
19
  }
19
20
 
20
- export function scheduleStandaloneRecycle(): void {
21
+ export function standaloneRecycleEnv(
22
+ env: NodeJS.ProcessEnv,
23
+ disconnectedTokenFingerprint: string,
24
+ ): NodeJS.ProcessEnv {
25
+ const childEnv = { ...env };
26
+ const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim();
27
+ if (admissionToken && serviceApiTokenFingerprint(admissionToken) !== disconnectedTokenFingerprint) {
28
+ // A surviving env token shadows OCX_API_TOKEN_FILE entirely, so nothing below can
29
+ // reintroduce the disconnected token.
30
+ return childEnv;
31
+ }
32
+ if (admissionToken) delete childEnv.OPENCODEX_API_AUTH_TOKEN;
33
+ // The respawned `ocx start` loads OCX_API_TOKEN_FILE into OPENCODEX_API_AUTH_TOKEN when the
34
+ // env token is absent, so vet the file by content: drop the pointer when it is unreadable or
35
+ // holds the disconnected token, keep it when it holds a different operator credential.
36
+ if (!childEnv.OCX_API_TOKEN_FILE?.trim()) return childEnv;
37
+ const fileToken = loadServiceTokenFromFile(childEnv);
38
+ if (fileToken === null || serviceApiTokenFingerprint(fileToken) === disconnectedTokenFingerprint) {
39
+ delete childEnv.OCX_API_TOKEN_FILE;
40
+ }
41
+ return childEnv;
42
+ }
43
+
44
+ export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string): void {
21
45
  if (recycleScheduled) return;
22
46
  recycleScheduled = true;
23
47
  const timer = setTimeout(() => {
@@ -46,7 +70,7 @@ export function scheduleStandaloneRecycle(): void {
46
70
  detached: true,
47
71
  stdio: "ignore",
48
72
  windowsHide: true,
49
- env: { ...process.env },
73
+ env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint),
50
74
  });
51
75
  child.unref();
52
76
  }
@@ -370,6 +370,23 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
370
370
  return !!record?.credential && record.deletedAt == null && record.generation === generation;
371
371
  }
372
372
 
373
+ /**
374
+ * The same verdict as {@link isCodexAccountGenerationLive}, over ONE store load.
375
+ *
376
+ * `readCodexAccountRecord` is `loadCodexAccountRecordStore()[id]`, so asking it per row
377
+ * reloads, reparses and renormalizes the whole file per row — the exact shape
378
+ * {@link loadCodexAccountRecordSnapshot} was added to avoid. The denial reader resolves
379
+ * several accounts in one synchronous pass on the request path, so it opens one checker and
380
+ * closes over the snapshot instead.
381
+ */
382
+ export function beginCodexAccountGenerationLiveCheck(): (id: string, generation: number) => boolean {
383
+ const snapshot = loadCodexAccountRecordSnapshot();
384
+ return (id, generation) => {
385
+ const record = snapshot[id];
386
+ return !!record?.credential && record.deletedAt == null && record.generation === generation;
387
+ };
388
+ }
389
+
373
390
  export function saveCodexAccountCredentialIfGeneration(
374
391
  id: string,
375
392
  generation: number,
@@ -596,6 +613,41 @@ function withCredentialMutationLockSync<T>(fn: () => T): T {
596
613
  }
597
614
 
598
615
  type CodexTokenResult = { accessToken: string; chatgptAccountId: string; generation: number };
616
+ type CodexRefreshGenerationHandoff = (accountId: string, fromGeneration: number, toGeneration: number) => void;
617
+ // var (hoisted, initialized to undefined) rather than const: a registrar reached
618
+ // through an import cycle can register while this module body is still evaluating,
619
+ // and the lazily created Set must be reachable rather than in the temporal dead zone.
620
+ var refreshGenerationHandoffs: Set<CodexRefreshGenerationHandoff> | undefined;
621
+ function refreshHandoffs(): Set<CodexRefreshGenerationHandoff> {
622
+ return refreshGenerationHandoffs ??= new Set();
623
+ }
624
+
625
+ /** Register process-local state that must follow a credential refresh generation. */
626
+ export function registerCodexRefreshGenerationHandoff(handoff: CodexRefreshGenerationHandoff): () => void {
627
+ const set = refreshHandoffs();
628
+ set.add(handoff);
629
+ return () => set.delete(handoff);
630
+ }
631
+
632
+ /**
633
+ * Invoke every registered handoff for one committed `fromGeneration` to `toGeneration` move.
634
+ *
635
+ * Each listener runs independently and a throw is contained: by the time handoffs run the
636
+ * rotated credential is already persisted, so a failing listener must not reject the shared
637
+ * refresh promise (surviving waiters would see a refresh failure that never happened), must
638
+ * not starve the remaining listeners, and must not block plan reconciliation. The warning
639
+ * carries no account id or token material — the same scrub refresh error messages get.
640
+ */
641
+ function dispatchRefreshGenerationHandoffs(accountId: string, fromGeneration: number, toGeneration: number): void {
642
+ for (const handoff of refreshHandoffs()) {
643
+ try {
644
+ handoff(accountId, fromGeneration, toGeneration);
645
+ } catch (error) {
646
+ console.warn("[codex-auth] a refresh generation handoff listener failed", error);
647
+ }
648
+ }
649
+ }
650
+
599
651
  type CodexRefreshResult = CodexTokenResult & {
600
652
  credential?: CodexAccountCredentials;
601
653
  /**
@@ -1377,6 +1429,19 @@ async function resolveCodexToken(
1377
1429
  * committed result, for every waiter, including none.
1378
1430
  */
1379
1431
  const refreshPromise = fetchPromise.then(async (result): Promise<CodexRefreshResult> => {
1432
+ // Generation-dependent completion belongs to the flight, not to its initiating
1433
+ // request. The owner may stop waiting after a disconnect while this detached work
1434
+ // still commits G+1; advance process-local affinities before any waiter observes
1435
+ // the result (and even when there are no surviving waiters).
1436
+ if (result.selfRefreshed) {
1437
+ dispatchRefreshGenerationHandoffs(id, result.generation - 1, result.generation);
1438
+ // A propagated alias committed at its OWN generation, so its affinities sit at
1439
+ // alias.generation - 1: without this handoff they fail the exact-generation
1440
+ // liveness check on the very next request that reads them.
1441
+ for (const alias of result.propagatedAliases ?? []) {
1442
+ dispatchRefreshGenerationHandoffs(alias.id, alias.generation - 1, alias.generation);
1443
+ }
1444
+ }
1380
1445
  await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation);
1381
1446
  // One settlement path for the whole flight: the refreshing account, then any dormant alias that
1382
1447
  // adopted the same rotated JWT. An alias holds the identical access token, so a changed
@@ -82,9 +82,9 @@ export function mainQuotaWithCarriedResetCredits(
82
82
  /**
83
83
  * Why an account needs the operator. `missing_credential`, `refresh_failed`, and
84
84
  * `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized`
85
- * and `forbidden` exist because the shared health projection may return them; today
86
- * `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union
87
- * keeps this field correct if that projection widens rather than silently dropping a reason.
85
+ * and `forbidden` come from the shared health projection: `projectCodexAccountHealth` maps a
86
+ * stored verification failure's `http_status:401`/`http_status:403` to them, so the union has
87
+ * to accept every reason the projection can emit rather than silently dropping one.
88
88
  */
89
89
  export type CodexAccountReauthReason =
90
90
  | "missing_credential"
@@ -105,18 +105,26 @@ export function poolAccountDto(
105
105
  const plan = codexPlanValue(account.plan);
106
106
  const quota = quotaForPlan(quotaResult.quota, plan);
107
107
  const runtimeReauth = isAccountNeedsReauth(account.id);
108
+ const rawReauthReason: CodexAccountReauthReason | undefined = !hasCredential
109
+ ? "missing_credential"
110
+ : quotaResult.reauthReason;
108
111
  const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth;
109
- const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
112
+ // An in-memory reauth mark carries no cause of its own, so it must not name one: passing
113
+ // a caller reason here would outrank the stored verdict's http_status inside the
114
+ // projection and hide unauthorized/forbidden until the mark is gone. With no caller
115
+ // reason the projection falls back to the persisted cause, then to refresh_failed.
116
+ const healthReason = rawReauthReason === "quota_unauthorized" || rawReauthReason === "missing_credential"
117
+ ? "unauthorized"
118
+ : rawReauthReason;
119
+ const health = projectCodexAccountHealth({
120
+ accountId: account.id,
121
+ needsReauth,
122
+ reauthReason: needsReauth ? healthReason : undefined,
123
+ });
110
124
  // `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the
111
125
  // health projection. Emitting only the boolean is what left #4212's reporter guessing which
112
126
  // account took their model away and why, so name the cause they actually have to act on.
113
- const reauthReason: CodexAccountReauthReason | undefined = !hasCredential
114
- ? "missing_credential"
115
- : runtimeReauth
116
- ? "refresh_failed"
117
- : quotaResult.needsReauth
118
- ? "quota_unauthorized"
119
- : health.status === "reauth_required" ? health.reason : undefined;
127
+ const reauthReason: CodexAccountReauthReason | undefined = rawReauthReason ?? (health.status === "reauth_required" ? health.reason : undefined);
120
128
  return {
121
129
  id: account.id,
122
130
  email: projectEmail(account.email, maskEmails) ?? account.email,
@@ -1,4 +1,4 @@
1
- import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store";
1
+ import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, isTerminalCodexPoolRefreshFailure, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store";
2
2
  import type { PoolQuotaWriter } from "../quota-types";
3
3
  import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
4
4
  import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
@@ -46,6 +46,8 @@ export interface PoolQuotaResult {
46
46
  resetRefreshLineage?: ManualResetRefreshLineage;
47
47
  quota: StoredAccountQuota | null;
48
48
  needsReauth: boolean;
49
+ /** Failure source observed while obtaining the quota, when reauthentication is required. */
50
+ reauthReason?: "refresh_failed" | "quota_unauthorized";
49
51
  /** Credential generation whose cache or network result this DTO state belongs to. */
50
52
  credentialGeneration?: number;
51
53
  /** Present only when this call freshly parsed a WHAM usage response. */
@@ -178,7 +180,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
178
180
  // the credential it condemned, so a late terminal response arriving after the operator
179
181
  // re-authenticated would quarantine the replacement.
180
182
  markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
181
- return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
183
+ return { quota: existing ?? null, needsReauth: true, reauthReason: "quota_unauthorized", credentialGeneration: rejectedGeneration };
182
184
  }
183
185
 
184
186
  const claim = claimQuotaRecovery(accountId, rejectedGeneration);
@@ -186,7 +188,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
186
188
  // A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the
187
189
  // budget being used would make the next bare 401 report a dead credential as healthy.
188
190
  if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) {
189
- return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
191
+ return { quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: rejectedGeneration };
190
192
  }
191
193
  // Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a
192
194
  // transient failure is backing off. Report transient and let the next poll try —
@@ -225,8 +227,15 @@ export async function recoverPoolQuotaFrom401(ctx: {
225
227
  // A refresh that failed terminally is the one case where the credential really is gone.
226
228
  // Everything else is unknown, and unknown is not proof.
227
229
  if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
230
+ // Persist the same verdict the token guardian writes: the in-memory mark dies with
231
+ // this process, and only the stored terminal failure keeps a cached listing from
232
+ // calling the dead grant healthy after a restart.
233
+ markCodexAccountValidationFailed(accountId, `refresh_${e.reason}`, {
234
+ expectedGeneration: rejectedGeneration,
235
+ terminal: true,
236
+ });
228
237
  markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
229
- return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
238
+ return { quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: rejectedGeneration };
230
239
  }
231
240
  return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
232
241
  }
@@ -258,7 +267,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
258
267
  // let the next poll call a dead credential healthy. The evidence is about the
259
268
  // REFRESHED credential, which is what the replay used.
260
269
  markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
261
- return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
270
+ return { quota: existing ?? null, needsReauth: true, reauthReason: "quota_unauthorized", credentialGeneration: refreshed.generation };
262
271
  }
263
272
  return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
264
273
  }
@@ -399,9 +408,23 @@ export async function fetchFreshPoolAccountQuota(
399
408
  quotaProbeSkipped: true,
400
409
  }, quotaProbeEvidence);
401
410
  }
402
- if (e instanceof TokenRefreshError) {
411
+ // Terminal means the grant itself is dead or missing; an `unknown` refresh failure (a
412
+ // token-endpoint 5xx, a transport blip) may clear, so it reports transient like the
413
+ // 401-recovery path instead of quarantining a healthy account (#2887). A revoked or
414
+ // expired grant is also written to the record as a terminal validation failure, the same
415
+ // verdict the token guardian persists: the in-memory mark dies with this process, and
416
+ // the stored verdict is what keeps a cached listing from calling the dead grant healthy
417
+ // after a restart.
418
+ if (isTerminalCodexPoolRefreshFailure(e)) {
419
+ if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
420
+ markCodexAccountValidationFailed(accountId, `refresh_${e.reason}`, {
421
+ expectedGeneration: requestCredentialGeneration,
422
+ terminal: true,
423
+ });
424
+ }
425
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), requestCredentialGeneration);
403
426
  return withQuotaProbeEvidence(
404
- { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration },
427
+ { quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: requestCredentialGeneration },
405
428
  quotaProbeEvidence,
406
429
  );
407
430
  }
@@ -100,8 +100,9 @@ import type {
100
100
  CatalogSourceEvidence,
101
101
  CatalogTrustedOpenAiApiPolicySnapshot,
102
102
  } from "../convergence-types";
103
- import { applyRegistryCapabilitySeedFill, modelCapabilities, modelInputModalities } from "./model-hints";
103
+ import { modelCapabilities, modelInputModalities } from "./model-hints";
104
104
  import { configuredComboTargetModelsByProvider } from "./combo-member";
105
+ import { resolveModelPolicy } from "../../providers/resolved-model-policy";
105
106
 
106
107
  /** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery.
107
108
  * Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */
@@ -344,8 +345,26 @@ export function captureProviderGather(
344
345
  ): CapturedProviderGather {
345
346
  const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured));
346
347
  enrichProviderFromRegistry(name, enriched);
347
- applyRegistryCapabilitySeedFill(name, enriched);
348
348
  const registryTransportMatch = providerMatchesRegistryTransport(name, enriched);
349
+ const registryEntry = registryTransportMatch ? getProviderRegistryEntry(name) : undefined;
350
+ const staticProvider = resolveModelPolicy({
351
+ providerName: name,
352
+ modelId: enriched.defaultModel ?? "__catalog_capture__",
353
+ provider: enriched,
354
+ registryEntry,
355
+ transportMatchedRegistry: registryTransportMatch,
356
+ ...(enriched.authMode ? { effectiveAuth: { authMode: enriched.authMode } } : {}),
357
+ }).provider;
358
+ for (const key of [
359
+ "modelContextWindows", "modelInputModalities", "modelMaxInputTokens", "modelMaxOutputTokens",
360
+ "modelReasoningEfforts", "modelSupportsReasoningSummaries",
361
+ "modelSupportsVerbosity", "modelSupportsServiceTier",
362
+ ] as const) {
363
+ if (staticProvider[key] !== undefined) enriched[key] = detachedClone(staticProvider[key]) as never;
364
+ }
365
+ // A present modelDefaultReasoningEfforts object is a whole-map catalog authority, including
366
+ // explicit {}. enrichProviderFromRegistry already preserves/fills that contract; replacing it
367
+ // with the resolver's per-key registry fill would turn "no configured default" into a seed default.
349
368
  const provider = recursivelyFreeze(enriched);
350
369
  const fastPolicyAuthority = captureFastPolicyAuthority(
351
370
  name,
@@ -90,6 +90,7 @@ import type { CatalogModel } from "./parsing";
90
90
  import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
91
91
  import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
92
92
  import type { ComboCatalogOmission } from "./aggregation";
93
+ import { clampObservedModelLimits, resolveModelPolicy } from "../../providers/resolved-model-policy";
93
94
  import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
94
95
  import type {
95
96
  CatalogAdmissionSnapshot,
@@ -246,22 +247,6 @@ export function configuredReasoningSummarySupport(prov: OcxProviderConfig | unde
246
247
  return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined;
247
248
  }
248
249
 
249
- function configuredVerbositySupport(name: string, prov: OcxProviderConfig | undefined, id: string): boolean | undefined {
250
- const explicit = prov ? modelRecordValue(prov.modelSupportsVerbosity, id) : undefined;
251
- if (explicit !== undefined) return explicit;
252
- if (!prov) return undefined;
253
- void name;
254
- // Provider-wide fallback for ids the per-model map does not enumerate — a live-discovered
255
- // model would otherwise re-advertise a control the upstream accepts and ignores.
256
- //
257
- // Read from the PROVIDER CONFIG, never from PROVIDER_REGISTRY. A gather flight captures its
258
- // registry authority up front and forbids any later registry read, so consulting the registry
259
- // here made a custom-destination flight fall back to "configured" instead of serving its own
260
- // discovery result (tests/codex-integration/codex-gather-authority.test.ts). `applyVerbosityDefaults` in
261
- // providers/derive.ts materializes the registry default into the config at seed/enrich time.
262
- return prov.supportsVerbosity;
263
- }
264
-
265
250
  export function applyProviderConfigHints(
266
251
  name: string,
267
252
  prov: OcxProviderConfig,
@@ -270,6 +255,15 @@ export function applyProviderConfigHints(
270
255
  metadataModelIdCaseFold?: boolean,
271
256
  effectiveAlias?: string | null,
272
257
  ): CatalogModel {
258
+ const staticPolicy = resolveModelPolicy({
259
+ providerName: name,
260
+ modelId: model.id,
261
+ provider: prov,
262
+ transportMatchedRegistry: false,
263
+ modelCapabilities: prov.modelCapabilities?.[model.id],
264
+ ...(prov.authMode ? { effectiveAuth: { authMode: prov.authMode } } : {}),
265
+ ...(effectiveAlias !== undefined ? { effectiveAlias } : {}),
266
+ });
273
267
  const displayName = configuredModelDisplayName(prov, model.id);
274
268
  // The alias decision is resolved once at flight admission (captureProviderGather) and threaded
275
269
  // through as `effectiveAlias`. Re-deriving it here would read PROVIDER_REGISTRY after admission,
@@ -279,11 +273,15 @@ export function applyProviderConfigHints(
279
273
  const providerAlias = typeof effectiveAlias === "string" || effectiveAlias === null
280
274
  ? effectiveAlias
281
275
  : model.providerAlias;
282
- const configuredCap = configuredContextWindow(prov, model.id);
283
- const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
276
+ const configuredCap = staticPolicy.model.contextWindow ?? configuredContextWindow(prov, model.id);
277
+ const configuredMaxInput = staticPolicy.model.maxInputTokens;
284
278
  const maxOutputTokens = routedMaxOutputTokens(name, prov, model, model.id, metadataModelIdCaseFold);
285
279
  const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id);
286
- let inputModalities = configuredInputModalities(prov, model.id);
280
+ // The resolver owns exact capability precedence and legacy exact/colon-family/case-fold fallback.
281
+ // Removing an exact capability row therefore returns this projection to legacy-map inference.
282
+ let inputModalities = staticPolicy.model.inputModalities
283
+ ? [...staticPolicy.model.inputModalities]
284
+ : undefined;
287
285
  // The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time
288
286
  // planning aligned. The catalog must still advertise image input — the Codex app
289
287
  // gates attachments client-side on input_modalities, and a text-only entry would block images
@@ -297,10 +295,12 @@ export function applyProviderConfigHints(
297
295
  }
298
296
  const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
299
297
  const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) === true;
300
- const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
301
- const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id);
302
- const supportsVerbosity = configuredVerbositySupport(name, prov, model.id);
298
+ const defaultReasoningEffort = staticPolicy.model.defaultReasoningEffort ?? model.defaultReasoningEffort;
299
+ const supportsReasoningSummaries = staticPolicy.model.supportsReasoningSummaries;
300
+ const supportsVerbosity = staticPolicy.model.supportsVerbosity;
303
301
  const fastPolicy = fastPolicyForModel(prov, model.id, name);
302
+ // Frozen gather providers retain the captured Fast authority. That late/captured eligibility
303
+ // owns catalog publication, including an explicit provider-level false.
304
304
  const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy);
305
305
  const {
306
306
  supportsServiceTier: _staleServiceTier,
@@ -313,9 +313,12 @@ export function applyProviderConfigHints(
313
313
  const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0
314
314
  ? model.contextWindow
315
315
  : undefined;
316
- const hintedWindow = discoveredWindow !== undefined
317
- ? (configuredCap !== undefined ? Math.min(discoveredWindow, configuredCap) : discoveredWindow)
318
- : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined));
316
+ const projectedLimits = clampObservedModelLimits(staticPolicy.model, {
317
+ ...(discoveredWindow !== undefined ? { contextWindow: discoveredWindow } : {}),
318
+ ...(typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 ? { maxInputTokens: model.maxInputTokens } : {}),
319
+ });
320
+ const hintedWindow = projectedLimits.contextWindow
321
+ ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined);
319
322
  const hinted = {
320
323
  ...modelWithoutServiceTier,
321
324
  ...(displayName !== undefined ? { displayName } : {}),
@@ -326,9 +329,7 @@ export function applyProviderConfigHints(
326
329
  ...(suppressSyntheticMax ? { suppressSyntheticMax: true } : {}),
327
330
  ...(configuredMaxInput !== undefined
328
331
  ? {
329
- maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0
330
- ? Math.min(model.maxInputTokens, configuredMaxInput)
331
- : configuredMaxInput,
332
+ maxInputTokens: projectedLimits.maxInputTokens ?? configuredMaxInput,
332
333
  }
333
334
  : {}),
334
335
  ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
@@ -803,6 +803,13 @@ export function normalizeRoutedCatalogEntry(
803
803
  delete entry.multi_agent_reasoning_effort;
804
804
  delete entry.use_responses_lite;
805
805
  delete entry.supports_websockets;
806
+ // Routed rows cloned from native templates must not inherit OpenAI-only experimental context
807
+ // delivery. Codex reads the flag as "this model accepts experimental context history" and drives
808
+ // its context-management cadence from it, so a third-party provider that never negotiated it gets
809
+ // a compact-after-every-step loop instead (observed on a routed DeepSeek row: 1,600+ compactions
810
+ // in a single thread). Nothing re-applies the field from provider metadata during this step, so
811
+ // the row leaves this normalization without it.
812
+ delete entry.supports_experimental_context;
806
813
  /*
807
814
  * Tier metadata is stripped from routed rows because a row cloned from a native template
808
815
  * would otherwise hand a third-party provider OpenAI's tiers.
@@ -11,6 +11,7 @@ import {
11
11
  clearModelCache,
12
12
  clearProviderDiscoveryStatus,
13
13
  captureModelCacheGeneration,
14
+ observeModelCacheRevision,
14
15
  DEFAULT_MODEL_CACHE_TTL_MS,
15
16
  getFreshCached,
16
17
  getStaleCached,
@@ -108,6 +109,14 @@ import { captureProviderGather, materializeCapturedHeaders } from "./gather-capt
108
109
  export interface ProviderModelsResult {
109
110
  readonly models: CatalogModel[];
110
111
  readonly outcome: CatalogGatherProviderModelOutcome;
112
+ /**
113
+ * The provider's cache content revision as it stood when these rows were chosen.
114
+ *
115
+ * Sampled here rather than by the caller because only this point is synchronous with the
116
+ * choice. A caller reading it after its own await can observe a revision another flight
117
+ * published in between, and would then record these rows under that flight's identity.
118
+ */
119
+ readonly contentRevision: string;
111
120
  }
112
121
  export const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" };
113
122
 
@@ -145,7 +154,11 @@ export async function fetchProviderModelsWithAuth(
145
154
  const observed = (
146
155
  models: CatalogModel[],
147
156
  state: CatalogGatherProviderModelOutcome["state"],
148
- ): ProviderModelsResult => ({ models, outcome: { provider: name, state } });
157
+ ): ProviderModelsResult => ({
158
+ models,
159
+ outcome: { provider: name, state },
160
+ contentRevision: observeModelCacheRevision(name),
161
+ });
149
162
  // Capture before any credential refresh or outbound await. OAuth account changes clear this
150
163
  // generation, so a request started with the former account cannot later publish its result.
151
164
  const cacheGeneration = captureModelCacheGeneration(name);
@@ -372,7 +385,11 @@ export async function fetchProviderModelsWithAuth(
372
385
  ...(cursorFetch ? { fetch: cursorFetch } : {}),
373
386
  });
374
387
  if (liveResult.ok) {
375
- const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
388
+ const available = filterCursorConfiguredModelsByLiveDiscovery(
389
+ configured,
390
+ liveResult.models,
391
+ liveResult.maxModeModels ?? [],
392
+ );
376
393
  const result = available.length > 0 ? available : configured;
377
394
  // Cache the discovery-filtered roster without combo retention so a later
378
395
  // gather can re-apply the current capture's retain set on read.
@@ -59,10 +59,12 @@ import {
59
59
  type CatalogWritePermit,
60
60
  } from "../catalog-write-serialization";
61
61
  import {
62
+ preparedBytesDifferFromDisk,
62
63
  publishHashedCodexCatalogBackup,
63
64
  publishLegacyCodexCatalogBackup,
64
65
  replaceActiveCodexCatalog,
65
66
  replaceCodexModelsCache,
67
+ type PreparedCatalogFileWrite,
66
68
  } from "../internal/catalog-writer";
67
69
  import { visibleCodexAccountSelectors } from "./account-models";
68
70
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
@@ -234,24 +236,6 @@ function revalidateRetainedCatalogSync(
234
236
  };
235
237
  }
236
238
 
237
- /**
238
- * Exact bytes currently on disk at `path`, or null when unreadable/absent.
239
- *
240
- * Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")`
241
- * substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes
242
- * equal to prepared content holding a legitimately encoded U+FFFD. Comparing the
243
- * decoded strings would then classify a malformed catalog as identical, skip the
244
- * atomic repair write, and leave the corruption on disk while reporting
245
- * `catalogWritten: false`.
246
- */
247
- function currentCatalogFileContent(path: string): Buffer | null {
248
- try {
249
- return readFileSync(path);
250
- } catch {
251
- return null;
252
- }
253
- }
254
-
255
239
  function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null {
256
240
  if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) {
257
241
  try {
@@ -558,15 +542,12 @@ function writeRetainedCatalogSync({
558
542
  // nothing about the catalog changed. Skipping the no-op write keeps both the mtime
559
543
  // and `catalogWritten` honest; `added` still reports the routed rows the catalog
560
544
  // carries, because they are on disk either way.
561
- const onDiskBytes = currentCatalogFileContent(catalogPath);
562
- if (onDiskBytes !== null && onDiskBytes.equals(Buffer.from(content, "utf8"))) {
545
+ const preparedCatalog: PreparedCatalogFileWrite = { path: catalogPath, content };
546
+ if (!preparedBytesDifferFromDisk(preparedCatalog)) {
563
547
  return { added, path: catalogPath, catalogWritten: false, comboOmissions };
564
548
  }
565
549
 
566
- replaceActiveCodexCatalog(permit, owningCodexHome, {
567
- path: catalogPath,
568
- content,
569
- });
550
+ replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
570
551
  return {
571
552
  added,
572
553
  path: catalogPath,
@@ -713,10 +694,25 @@ export function invalidateCodexModelsCacheWithPermit(
713
694
  client_version: "0.0.0",
714
695
  models: [...models, ...observedAccountModels],
715
696
  };
716
- replaceCodexModelsCache(permit, owningCodexHome, {
697
+ const preparedCache: PreparedCatalogFileWrite = {
717
698
  path: cachePath,
718
699
  content: `${JSON.stringify(wrapper, null, 2)}\n`,
719
- });
700
+ };
701
+ // The same no-op rule the active catalog already applies (#1459), for the same
702
+ // reason and at the second writer that has to obey it.
703
+ //
704
+ // This function is what `refreshCodexModelCatalog` reports as `cacheSynced`, and
705
+ // `handleStart` ORs that into the stale-app-server warning. Rewriting identical
706
+ // bytes bumped this file's mtime and returned `true`, so on a start where the
707
+ // catalog reproduced byte-identically — the settled case — the warning still
708
+ // claimed "Disk catalog/cache were updated" and told the operator their Codex
709
+ // model list might be stale, when nothing on disk had changed and Codex held the
710
+ // same model set the file already described. Returning `false` here makes
711
+ // `cacheSynced` mean what its name and its consumers already assume, and what
712
+ // `pullRemoteCatalog` and the early returns in `refreshCodexModelCatalog`
713
+ // already assert: a write happened.
714
+ if (!preparedBytesDifferFromDisk(preparedCache)) return false;
715
+ replaceCodexModelsCache(permit, owningCodexHome, preparedCache);
720
716
  return true;
721
717
  } catch {
722
718
  return false;