@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DO8liQVL.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
  155. package/gui/dist/assets/index-DX0RGj3e.js +0 -112
@@ -20,12 +20,14 @@ import {
20
20
  getConfigDir,
21
21
  websocketsEnabled,
22
22
  } from "../config";
23
+ import { grokDefaultReasoningEffort } from "../grok/effort";
23
24
  import { reconcileOAuthProviders } from "../oauth";
24
25
  import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization";
25
26
  import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync";
26
27
  import { currentServiceHomes, serviceStatePathsForOpenCodexHome } from "../service";
27
28
  import { shouldSyncCodexOnStart } from "../codex/desired-state";
28
29
  import {
30
+ createWindowsTaskListingCache,
29
31
  inspectNativeCodexOwnership,
30
32
  type NativeCodexOwnership,
31
33
  type OwnershipInspection,
@@ -442,6 +444,37 @@ function attachLiveSidebandUpstream(
442
444
  // trackSseForRequestLog(
443
445
  // export function relaySseWithHeartbeat
444
446
 
447
+ const REQUEST_LOG_ID_RESPONSE_HEADER = "x-opencodex-request-id";
448
+
449
+ function withRequestLogId(response: Response, requestId: string): Response {
450
+ const headers = new Headers(response.headers);
451
+ headers.set(REQUEST_LOG_ID_RESPONSE_HEADER, requestId);
452
+ // A custom `x-` header is not CORS-safelisted, so cross-origin JavaScript gets null from
453
+ // `response.headers.get()` even though the header is on the wire. Naming it here is what
454
+ // makes the id readable by a browser client — the only caller that needs a correlation id
455
+ // it did not send itself.
456
+ //
457
+ // Appending to whatever `withCors` already set, rather than overwriting, keeps this
458
+ // independent of the CORS layer: if the data plane later exposes another header, both
459
+ // survive. Duplicate names are harmless, and the header stays absent from responses that
460
+ // never reach this wrapper, so no management or rejected-origin response is widened.
461
+ const exposed = headers.get("Access-Control-Expose-Headers");
462
+ const already = (exposed ?? "")
463
+ .split(",")
464
+ .some(name => name.trim().toLowerCase() === REQUEST_LOG_ID_RESPONSE_HEADER);
465
+ if (!already) {
466
+ headers.set(
467
+ "Access-Control-Expose-Headers",
468
+ exposed ? `${exposed}, ${REQUEST_LOG_ID_RESPONSE_HEADER}` : REQUEST_LOG_ID_RESPONSE_HEADER,
469
+ );
470
+ }
471
+ return new Response(response.body, {
472
+ status: response.status,
473
+ statusText: response.statusText,
474
+ headers,
475
+ });
476
+ }
477
+
445
478
  export interface StartServerDeps {
446
479
  /** Test-only seam; production always initializes its own management credential state. */
447
480
  managementAuthState?: ManagementAuthState;
@@ -467,6 +500,7 @@ function inspectStartupOwnership(
467
500
  deps: StartServerDeps,
468
501
  currentHomes: ReturnType<typeof currentServiceHomes> | null,
469
502
  statePaths: readonly string[] | null,
503
+ windowsTaskListingCache?: ReturnType<typeof createWindowsTaskListingCache>,
470
504
  ): OwnershipInspection {
471
505
  try {
472
506
  if (currentHomes === null || statePaths === null) {
@@ -476,9 +510,9 @@ function inspectStartupOwnership(
476
510
  };
477
511
  }
478
512
  if (deps.inspectNativeCodexOwnership) {
479
- return deps.inspectNativeCodexOwnership({ currentHomes, statePaths });
513
+ return deps.inspectNativeCodexOwnership({ currentHomes, statePaths, windowsTaskListingCache });
480
514
  }
481
- return inspectNativeCodexOwnership({ currentHomes, statePaths });
515
+ return inspectNativeCodexOwnership({ currentHomes, statePaths, windowsTaskListingCache });
482
516
  } catch {
483
517
  return {
484
518
  ownership: "unknown",
@@ -568,6 +602,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
568
602
  const resolveServiceHomes = deps.resolveServiceHomes ?? currentServiceHomes;
569
603
  let startupOwnershipHomes: ReturnType<typeof currentServiceHomes> | null = null;
570
604
  let startupOwnershipStatePaths: readonly string[] | null = null;
605
+ // #2923: both synchronous startup ownership decisions keep their fresh,
606
+ // race-sensitive targeted task query. Only the expensive fallback listing is
607
+ // shared, and only while that targeted result stays byte-for-byte unchanged.
608
+ // Runtime ownership retries below intentionally omit this startup-local memo.
609
+ const startupWindowsTaskListingCache = createWindowsTaskListingCache();
571
610
  try {
572
611
  const homes = resolveServiceHomes();
573
612
  const statePaths = serviceStatePathsForOpenCodexHome(homes.opencodexHome);
@@ -578,6 +617,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
578
617
  deps,
579
618
  startupOwnershipHomes,
580
619
  startupOwnershipStatePaths,
620
+ startupWindowsTaskListingCache,
581
621
  );
582
622
  // Startup cache invalidation is best-effort and must never block the server from
583
623
  // serving. It now takes K so it cannot race a convergence commit. Use the home
@@ -753,7 +793,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
753
793
  // clients; no Codex request can use this lifecycle in that state.
754
794
  // Re-probe here instead of trusting the earlier cache decision: startup work
755
795
  // between the two sites must not widen the service-install race.
756
- const nativeOwnership = inspectStartupOwnership(deps, startupOwnershipHomes, startupOwnershipStatePaths);
796
+ const nativeOwnership = inspectStartupOwnership(
797
+ deps,
798
+ startupOwnershipHomes,
799
+ startupOwnershipStatePaths,
800
+ startupWindowsTaskListingCache,
801
+ );
757
802
  const preparedNativeMainLifecycle = nativeOwnership.ownership !== "foreign"
758
803
  && startupOwnershipHomes !== null
759
804
  ? prepareNativeMainStartupLifecycle(
@@ -1024,7 +1069,81 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1024
1069
  return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
1025
1070
  }
1026
1071
 
1072
+ if (url.pathname === "/v1/catalog" && (req.method === "GET" || req.method === "HEAD")) {
1073
+ // #809: remote Codex clients need the model catalog, and the only prior source was
1074
+ // GET /api/catalog behind management auth — so operators had to hand out an admin
1075
+ // token to read a list of models. This route fixes that on the data plane instead of
1076
+ // widening /api/*, which stays exactly as restricted as before.
1077
+ //
1078
+ // resolveApiAuth (not resolveResponsesApiAuth) for the same reason /v1/models uses
1079
+ // it: nothing here forwards a caller credential upstream, so accepting the dedicated
1080
+ // header, a recognized bearer, or x-api-key is safe — and rejecting x-api-key would
1081
+ // 401 Anthropic-SDK clients holding a perfectly valid data credential.
1082
+ const admission = resolveApiAuth(req, policy);
1083
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1084
+ if (!isAllowedRequestOrigin(req, policy)) {
1085
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
1086
+ }
1087
+ const { serializePersistedCatalog, persistedCodexVersion, MAX_REMOTE_CATALOG_BYTES } = await import("./catalog-download");
1088
+ const serialized = await serializePersistedCatalog();
1089
+ if (serialized.body === null) {
1090
+ // Built directly rather than through formatErrorResponse: that helper derives
1091
+ // `code` from the status and message via classifyError, and these two need stable,
1092
+ // specific codes. `catalog_not_found` in particular is what lets a caller — and
1093
+ // tests/api-key-attribution.test.ts — tell "this route exists and has no catalog"
1094
+ // apart from "this route is gone", which is the difference between admission proof
1095
+ // and a vacuous pass.
1096
+ return withCors(
1097
+ new Response(JSON.stringify({
1098
+ error: { type: "invalid_request_error", code: "catalog_not_found", message: "no materialized catalog is available" },
1099
+ }), {
1100
+ status: 404,
1101
+ headers: { "content-type": "application/json" },
1102
+ }),
1103
+ req,
1104
+ policy,
1105
+ );
1106
+ }
1107
+ // Size policy belongs to this route, not the shared serializer: the management route
1108
+ // must keep its existing behavior for a catalog of any supported size.
1109
+ if (serialized.bytes !== undefined && serialized.bytes > MAX_REMOTE_CATALOG_BYTES) {
1110
+ return withCors(
1111
+ new Response(JSON.stringify({
1112
+ error: { type: "server_error", code: "catalog_too_large", message: "catalog exceeds the maximum served size" },
1113
+ }), {
1114
+ status: 507,
1115
+ headers: { "content-type": "application/json" },
1116
+ }),
1117
+ req,
1118
+ policy,
1119
+ );
1120
+ }
1121
+ const headers: Record<string, string> = {
1122
+ "content-type": "application/json",
1123
+ // Identity-varying content behind a credential: never let a shared cache keep it.
1124
+ "cache-control": "private, no-cache",
1125
+ };
1126
+ if (serialized.etag) headers.ETag = serialized.etag;
1127
+ const version = await persistedCodexVersion();
1128
+ if (version) headers["x-opencodex-codex-version"] = version;
1129
+ // Conditional GET: a client that already holds these bytes re-validates cheaply.
1130
+ const ifNoneMatch = req.headers.get("if-none-match")?.trim();
1131
+ if (serialized.etag && ifNoneMatch && ifNoneMatch === serialized.etag) {
1132
+ return withCors(new Response(null, { status: 304, headers }), req, policy);
1133
+ }
1134
+ if (serialized.bytes !== undefined) headers["content-length"] = String(serialized.bytes);
1135
+ // HEAD returns identical status and headers with no body.
1136
+ return withCors(
1137
+ new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }),
1138
+ req,
1139
+ policy,
1140
+ );
1141
+ }
1142
+
1027
1143
  if (url.pathname === "/v1/models" && req.method === "GET") {
1144
+ // #809: the catalog read sits immediately before model discovery because it shares
1145
+ // that route's admission rationale exactly. Keep them adjacent so a future change to
1146
+ // one is made in sight of the other.
1028
1147
  // Model discovery never forwards Authorization upstream, so the broader admission
1029
1148
  // set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by
1030
1149
  // remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version).
@@ -1038,7 +1157,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1038
1157
  try {
1039
1158
  [goModels, modelEntitlements] = await Promise.all([
1040
1159
  fetchAllModels(config),
1041
- resolveCodexModelEntitlements(config),
1160
+ // Codex sends its own client_version on this request, and upstream filters the
1161
+ // entitlement roster by it. Passing it through is what stops an entitled account
1162
+ // being told it cannot use models a newer client can (#2886).
1163
+ resolveCodexModelEntitlements(config, { clientVersion: url.searchParams.get("client_version") }),
1042
1164
  ]);
1043
1165
  } catch (error) {
1044
1166
  if (error instanceof CatalogGatherBusyError) {
@@ -1195,10 +1317,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1195
1317
  ...(isDefault ? { default: true } : {}),
1196
1318
  });
1197
1319
  const grokEffortFields = (efforts: string[], configuredDefault?: string) => {
1198
- if (efforts.length === 0) return {};
1199
- const defaultEffort = configuredDefault && efforts.includes(configuredDefault)
1200
- ? configuredDefault
1201
- : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
1320
+ const defaultEffort = grokDefaultReasoningEffort(efforts, configuredDefault);
1321
+ if (defaultEffort === undefined) return {};
1202
1322
  return {
1203
1323
  supports_reasoning_effort: true,
1204
1324
  reasoning_effort: defaultEffort,
@@ -1426,7 +1546,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1426
1546
  finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
1427
1547
  },
1428
1548
  });
1429
- return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy);
1549
+ return withRequestLogId(
1550
+ withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy),
1551
+ requestId,
1552
+ );
1430
1553
  });
1431
1554
  }
1432
1555
 
@@ -925,6 +925,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
925
925
  nativeContextLimits(latest),
926
926
  );
927
927
  if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500);
928
+ const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy");
929
+ const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({
930
+ platform: deps.platform ?? process.platform,
931
+ });
932
+ const policyWarning = claudeDesktopPolicyWarning(policyState);
928
933
  // Persist applied fingerprint + timestamp so GUI can show saved-vs-applied state.
929
934
  if (result.fingerprint) {
930
935
  // The Desktop write already landed, so a failed bookkeeping save is not
@@ -941,11 +946,22 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
941
946
  saved: false,
942
947
  path: result.path,
943
948
  fingerprint: result.fingerprint,
944
- warning: `Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`,
949
+ warning: [
950
+ `Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`,
951
+ policyWarning,
952
+ ].filter(Boolean).join(" "),
945
953
  });
946
954
  }
947
955
  }
948
- return jsonResponse({ ok: true, saved: true, applied: true, path: result.path, fingerprint: result.fingerprint });
956
+ return jsonResponse({
957
+ ok: true,
958
+ saved: true,
959
+ applied: true,
960
+ path: result.path,
961
+ fingerprint: result.fingerprint,
962
+ policyState,
963
+ ...(policyWarning ? { warning: policyWarning } : {}),
964
+ });
949
965
  } catch (error) {
950
966
  rethrowManagementBodyTooLarge(error);
951
967
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
@@ -967,7 +983,24 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
967
983
  // a stale apply the operator should refresh.
968
984
  const stale = desiredEnabled && observed.kind === "gateway_drifted";
969
985
  const { getDesktopHealth } = await import("../../claude/desktop-health");
970
- const health = getDesktopHealth();
986
+ const { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy");
987
+ const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({
988
+ platform: deps.platform ?? process.platform,
989
+ });
990
+ const policy = claudeDesktopPolicyHealth(policyState);
991
+ const health = {
992
+ ...getDesktopHealth(),
993
+ ok: policy.ok,
994
+ status: policy.status,
995
+ policy,
996
+ };
997
+ const policyConflict = desiredEnabled && !policy.ok;
998
+ const baseDrift = desiredEnabled ? !applied || stale : applied || observed.kind === "unsafe";
999
+ const driftReason = policyConflict
1000
+ ? policy.state === "present" ? "managed_policy_present" : "managed_policy_unknown"
1001
+ : desiredEnabled
1002
+ ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : null
1003
+ : applied ? "desired_off_gateway_selected" : null;
971
1004
  return jsonResponse({
972
1005
  desiredEnabled,
973
1006
  installed: observed.kind !== "not_installed",
@@ -982,8 +1015,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
982
1015
  // undeterminable (no/unreadable metadata or no appliedId); a readable
983
1016
  // appliedId with no owned entry is a KNOWN false. Predates the inspector.
984
1017
  activeProfile: observed.ownedProfileActive,
985
- drift: desiredEnabled ? !applied || stale : applied || observed.kind === "unsafe",
986
- driftReason: desiredEnabled ? (!applied ? "desired_on_not_current" : stale ? "profile_drift" : null) : (applied ? "desired_off_gateway_selected" : null),
1018
+ drift: baseDrift || policyConflict,
1019
+ driftReason,
987
1020
  health,
988
1021
  });
989
1022
  } catch (error) {
@@ -28,6 +28,7 @@ import {
28
28
  MAX_BASE_VARIANTS,
29
29
  adoptDeveloperInstructions,
30
30
  composeProjection,
31
+ computePromptProbeStateFingerprint,
31
32
  findInvalidCharacter,
32
33
  inspectOwnership,
33
34
  normalizeBody,
@@ -320,7 +321,12 @@ export async function handleCodexPromptRoutes(ctx: ManagementContext): Promise<R
320
321
  // A `cwd` parameter would have let any authenticated request read an arbitrary
321
322
  // folder's AGENTS.md through this endpoint.
322
323
  const { probePromptText } = await import("../../codex/prompt-text-probe");
323
- return jsonResponse(await probePromptText(), 200, req, ctx.config);
324
+ // A write can complete while an older probe is still running. Admission uses
325
+ // a prompt-state fingerprint rather than the transaction revision: editing
326
+ // the selected base variant changes its separate .md file without moving the
327
+ // optimistic-concurrency revision.
328
+ const promptStateFingerprint = computePromptProbeStateFingerprint(paths(ctx));
329
+ return jsonResponse(await probePromptText(15_000, req.signal, promptStateFingerprint), 200, req, ctx.config);
324
330
  }
325
331
 
326
332
  if (url.pathname === "/api/codex-prompt/toggle" && req.method === "PUT") {
@@ -65,6 +65,7 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR
65
65
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
66
66
  import type { ManagementContext } from "./context";
67
67
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
68
+ import { shadowCallTargetError } from "./shadow-call-validation";
68
69
 
69
70
 
70
71
  /** Management wire shape: omit default imageInput "auto" (persist/response sparse). */
@@ -169,7 +170,6 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
169
170
  const nextCombos = { ...(config.combos ?? {}) };
170
171
  if (renameFrom) delete nextCombos[renameFrom];
171
172
  nextCombos[id] = stored;
172
- config.combos = nextCombos;
173
173
  let shouldSyncClaudeAgentDefs = false;
174
174
  const migratedModels = new Map<string, string>();
175
175
  if (oldPublicModel && oldPublicModel !== newPublicModel && previous?.nativeAlias !== true) {
@@ -183,6 +183,15 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
183
183
  previous?.nativeAlias === true ? comboModelId(id) : newPublicModel,
184
184
  );
185
185
  }
186
+ const currentShadowTarget = config.shadowCallIntercept?.model;
187
+ const migratedShadowTarget = currentShadowTarget
188
+ ? migratedModels.get(currentShadowTarget)
189
+ : undefined;
190
+ if (migratedShadowTarget) {
191
+ const targetError = shadowCallTargetError({ ...config, combos: nextCombos }, migratedShadowTarget);
192
+ if (targetError) return jsonResponse({ error: targetError }, 400);
193
+ }
194
+ config.combos = nextCombos;
186
195
  if (migratedModels.size > 0) {
187
196
  const migrateReference = (model: string): string => migratedModels.get(model) ?? model;
188
197
  const migrateAgentReference = (model: string): string => {
@@ -38,7 +38,7 @@ import { deriveProviderPresets } from "../../providers/derive";
38
38
  import { providerCodexAccountMode } from "../../providers/registry";
39
39
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
40
40
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
41
- import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
41
+ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
42
42
  import { clearThreadAccountMap } from "../../codex/routing";
43
43
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
44
44
  import {
@@ -88,6 +88,7 @@ import {
88
88
  type DebugFlag,
89
89
  } from "../../lib/debug-settings";
90
90
  import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
91
+ import { shadowCallTargetError } from "./shadow-call-validation";
91
92
  import { drainAndShutdown } from "../lifecycle";
92
93
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
93
94
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
@@ -863,6 +864,13 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
863
864
  if (body.model !== undefined && typeof body.model !== "string") {
864
865
  return jsonResponse({ error: "model must be a string" }, 400);
865
866
  }
867
+ const candidateModel = typeof body.model === "string"
868
+ ? body.model
869
+ : body.enabled === true
870
+ ? config.shadowCallIntercept?.model
871
+ : undefined;
872
+ const targetError = shadowCallTargetError(config, candidateModel);
873
+ if (targetError) return jsonResponse({ error: targetError }, 400);
866
874
  config.shadowCallIntercept = { ...config.shadowCallIntercept };
867
875
  if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled;
868
876
  if (typeof body.model === "string") {
@@ -9,6 +9,7 @@ import type { CatalogModel } from "../../codex/catalog";
9
9
  import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers";
10
10
  import type { injectGrokConfig } from "../../grok/inject";
11
11
  import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
12
+ import type { probeClaudeDesktopPolicy } from "../../claude/desktop-policy";
12
13
  import type { RuntimePortState } from "../../config/process-state";
13
14
  import type { CatalogDisposition, ConvergeCodex } from "../../codex/convergence-types";
14
15
  import type {
@@ -22,6 +23,8 @@ export interface ManagementApiDeps {
22
23
  toggleCodexMultiAgentV2?: (enabled: boolean) => void;
23
24
  toggleDefaultModeRequestUserInput?: (enabled: boolean) => void;
24
25
  createManagementConvergeCodex?: (config: Readonly<OcxConfig>) => ConvergeCodex;
26
+ /** Test-only destination for best-effort Claude agent-definition sync. */
27
+ claudeAgentConfigDir?: string;
25
28
  /** Startup-health seam keeps route tests from launching platform probes. */
26
29
  getCachedStartupHealth?: (config: Pick<OcxConfig, "codexAutoStart">) => Promise<StartupHealth>;
27
30
  /**
@@ -47,6 +50,8 @@ export interface ManagementApiDeps {
47
50
  /** Desktop mutation seams keep route tests inside temporary config libraries. */
48
51
  removeDesktop3pStandardPivot?: typeof removeDesktop3pStandardPivot;
49
52
  writeDesktop3pConfig?: typeof writeDesktop3pConfig;
53
+ /** Read-only Windows MDM policy seam for status/apply tests. */
54
+ probeClaudeDesktopPolicy?: typeof probeClaudeDesktopPolicy;
50
55
  /**
51
56
  * Runtime-state seam: the fence must name the host/port the RUNNING process
52
57
  * bound (agent-settings-routes.ts:99-103 pattern), and a test must not depend
@@ -72,6 +72,7 @@ function readDefaultReasoningEffort(raw: unknown, efforts: string[] | undefined)
72
72
  import type { CatalogModel } from "../../codex/catalog";
73
73
  import { accountBoundNativeOpenAiSlugsBySelector, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
74
74
  import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch";
75
+ import { NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
75
76
  import { getProviderLiveModelCount } from "../../codex/model-cache";
76
77
  import {
77
78
  DEFAULT_SUBAGENT_MODELS,
@@ -332,17 +333,20 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
332
333
  }
333
334
 
334
335
  if (url.pathname === "/api/catalog" && req.method === "GET") {
335
- const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog");
336
- const catalog = readCatalog(readCodexCatalogPath());
337
- if (!catalog) return jsonResponse({ error: "catalog not found" }, 404, req, config);
336
+ // Shared with GET|HEAD /v1/catalog (#809) so both planes emit identical bytes.
337
+ const { serializePersistedCatalog, persistedCodexVersion } = await import("../catalog-download");
338
+ const serialized = await serializePersistedCatalog();
339
+ // No size ceiling here on purpose: this route's behavior predates the shared serializer
340
+ // and a 2,000-model catalog (~92 MB, within the supported bound) must keep working.
341
+ if (serialized.body === null) return jsonResponse({ error: "catalog not found" }, 404, req, config);
338
342
  const headers: Record<string, string> = {
339
343
  "Content-Type": "application/json",
340
344
  ...corsHeaders(req, config),
341
345
  };
342
- const { loadPersistedCodexRuntime } = await import("../../codex/runtime");
343
- const version = loadPersistedCodexRuntime()?.selectedVersion;
346
+ if (serialized.etag) headers.ETag = serialized.etag;
347
+ const version = await persistedCodexVersion();
344
348
  if (version) headers["x-opencodex-codex-version"] = version;
345
- return new Response(JSON.stringify(catalog), { status: 200, headers });
349
+ return new Response(serialized.body, { status: 200, headers });
346
350
  }
347
351
 
348
352
  if (url.pathname === "/api/models" && req.method === "GET") {
@@ -465,6 +469,12 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
465
469
  const supportedNative = new Set([
466
470
  ...nativeModelRows(config).map(row => row.slug),
467
471
  ...accountNativeQualified,
472
+ // A model suppressed by an unconfirmed entitlement roster is absent from
473
+ // nativeModelRows, so validating against those rows alone rejected a model this build
474
+ // knows perfectly well and left the operator with no way to clear its disable key
475
+ // (#2886). Accepting the target says "this build knows this model", not "this account
476
+ // may use it" — visibility only writes disabledModels and routing stays gated.
477
+ ...NATIVE_OPENAI_MODELS,
468
478
  ]);
469
479
  const targets: Array<{ id: string; native: boolean }> = [];
470
480
  const seen = new Set<string>();
@@ -19,11 +19,12 @@
19
19
  */
20
20
  import { loadConfig, saveConfigPreservingClaudeCode } from "../../config";
21
21
  import { readRuntimePort } from "../../config/process-state";
22
- import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog";
22
+ import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog";
23
23
  import { providerContextCap } from "../../providers/context-cap";
24
24
  import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
25
25
  import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
26
- import { injectGrokConfig, stripGrokConfig, type GrokInjectModel } from "../../grok/inject";
26
+ import { projectGrokCatalog } from "../../grok/catalog";
27
+ import { injectGrokConfig, stripGrokConfig } from "../../grok/inject";
27
28
  import { inspectGrokConfig } from "../../grok/inspect";
28
29
  import { grokConfigPath } from "../../grok/status";
29
30
  import { assertNativeTeardownOwned } from "../../integrations/native/ownership-preflight";
@@ -502,21 +503,10 @@ async function handleGrokToggle(ctx: ManagementContext): Promise<Response> {
502
503
  * synchronous from entry (012 §One preflight is not enough).
503
504
  */
504
505
  const fetchModels = deps.fetchAllModels ?? defaultFetchAllModels;
505
- let models: GrokInjectModel[];
506
+ let projection: ReturnType<typeof projectGrokCatalog>;
506
507
  try {
507
- const routed = filterCatalogVisibleModels(await fetchModels(config), config);
508
- models = [
509
- // Native slugs carry their context window: without it Grok falls back
510
- // to its own 200k default and understates a 372k model.
511
- ...visibleNativeSlugs(config).map(id => {
512
- const contextWindow = nativeOpenAiContextWindow(id, nativeContextLimits(config));
513
- return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) };
514
- }),
515
- ...routed.map(m => ({
516
- id: m.alias ?? `${m.provider}/${m.id}`,
517
- ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}),
518
- })),
519
- ];
508
+ const allRouted = await fetchModels(config);
509
+ projection = projectGrokCatalog(allRouted, config);
520
510
  } catch (error) {
521
511
  // A catalog failure must never write an empty fence (syncGrokConfig
522
512
  // guards this; the route inherits the rule). Nothing was written.
@@ -529,12 +519,17 @@ async function handleGrokToggle(ctx: ManagementContext): Promise<Response> {
529
519
  if (recheck.kind === "orphaned_marker") return postCommitRefusal(409, "grok", "orphaned_marker", ORPHANED_MARKER_MESSAGE, { desiredEnabled });
530
520
 
531
521
  const inject = deps.injectGrokConfig ?? injectGrokConfig;
532
- const result = inject(port, models, {
522
+ const result = inject(port, projection.models, {
533
523
  ...(hostname !== undefined ? { hostname } : {}),
534
524
  // The FULL list plus the exclusion set, never a pre-filtered list: the
535
525
  // writer allocates aliases over everything, so a model's alias never
536
526
  // depends on its neighbours' switches.
537
527
  excluded: new Set(config.grokExcludedModels ?? []),
528
+ // Visibility filters decide what to emit, not whether an owned pre-fence table is still
529
+ // current. Otherwise a hidden model is mistaken for retired state and survives outside.
530
+ catalogModelIds: projection.catalogModelIds,
531
+ disabledProviderNamespaces: projection.disabledProviderNamespaces,
532
+ comboPublicModelIds: projection.comboPublicModelIds,
538
533
  });
539
534
 
540
535
  if (result.skippedReason === "non-loopback") {
@@ -39,6 +39,7 @@ import {
39
39
  parseAccountPoolStickyLimit,
40
40
  parseAccountPoolStrategy,
41
41
  } from "../../codex/pool-rotation";
42
+ import { normalizeAccountPoolQuotaWindow, parseAccountPoolQuotaWindow } from "../../oauth/anthropic-routing";
42
43
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
43
44
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
44
45
  import { resolveCodexHomeDir } from "../../codex/home";
@@ -326,6 +327,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
326
327
  autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80,
327
328
  strategy: normalizeAccountPoolStrategy(pool.strategy),
328
329
  stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit),
330
+ quotaWindow: normalizeAccountPoolQuotaWindow(pool.quotaWindow),
329
331
  experimental: true,
330
332
  });
331
333
  }
@@ -340,6 +342,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
340
342
  autoSwitchThreshold?: unknown;
341
343
  strategy?: unknown;
342
344
  stickyLimit?: unknown;
345
+ quotaWindow?: unknown;
343
346
  };
344
347
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
345
348
  if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400);
@@ -376,11 +379,20 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
376
379
  }
377
380
  stickyLimit = parsed;
378
381
  }
382
+ let quotaWindow = config.anthropicAccountPool?.quotaWindow;
383
+ if (body.quotaWindow !== undefined) {
384
+ const parsed = parseAccountPoolQuotaWindow(body.quotaWindow);
385
+ if (parsed === null) {
386
+ return jsonResponse({ error: "quotaWindow must be one of: five-hour, weekly, max-utilization" }, 400);
387
+ }
388
+ quotaWindow = parsed;
389
+ }
379
390
  config.anthropicAccountPool = {
380
391
  enabled,
381
392
  autoSwitchThreshold: threshold,
382
393
  ...(strategy !== undefined ? { strategy } : {}),
383
394
  ...(stickyLimit !== undefined ? { stickyLimit } : {}),
395
+ ...(quotaWindow !== undefined ? { quotaWindow } : {}),
384
396
  };
385
397
  saveConfigPreservingClaudeCode(config);
386
398
  reconcileLiveStateStores();
@@ -391,6 +403,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
391
403
  autoSwitchThreshold: threshold,
392
404
  strategy: normalizeAccountPoolStrategy(strategy),
393
405
  stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit),
406
+ quotaWindow: normalizeAccountPoolQuotaWindow(quotaWindow),
394
407
  experimental: true,
395
408
  });
396
409
  }