@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -7,6 +7,12 @@
7
7
  * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
8
8
  */
9
9
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
10
+ import {
11
+ admissionModelDeniedResponse,
12
+ AdmissionModelDeniedError,
13
+ assertRouteAllowedByScope,
14
+ resolveAdmissionModelScope,
15
+ } from "./admission-model-scope";
10
16
  import { jsonUtf8Bytes } from "../lib/json-byte-size";
11
17
  import { sseFieldValue } from "../lib/sse-decoder";
12
18
  import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard";
@@ -18,7 +24,14 @@ import { recordDesktopRequest } from "../claude/desktop-health";
18
24
  import { stripOneMillionMarker } from "../claude/context-windows";
19
25
  import { captureClaudeInbound } from "../claude/inbound-debug";
20
26
  import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility";
21
- import { isTransientUpstreamStatus } from "../lib/upstream-retry";
27
+ import {
28
+ applyReplayRefusalClientHeaders,
29
+ carryReplayRefusal,
30
+ isReplayRefusalResponse,
31
+ isTransientUpstreamStatus,
32
+ REPLAY_REFUSED_STATUS,
33
+ UPSTREAM_RESET_REPLAY_REFUSED_CODE,
34
+ } from "../lib/upstream-retry";
22
35
  import { resolveClientRetryAfter } from "../lib/retry-after";
23
36
  import {
24
37
  anthropicErrorBody,
@@ -815,6 +828,13 @@ async function handleClaudeMessagesWithBudget(
815
828
  // verified live 2026-07-11). Strip them for that route; routed providers keep them.
816
829
  try {
817
830
  const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
831
+ // Same reason as the native Chat lane: this route can be sent from here, so
832
+ // the key's scope is applied before the wire is settled.
833
+ assertRouteAllowedByScope(
834
+ resolveAdmissionModelScope(config, logIds?.admission),
835
+ String(internalBody.model ?? ""),
836
+ route,
837
+ );
818
838
  // Settle the wire once so the sampling decision below reads the effective
819
839
  // adapter rather than the provider-wide default (#404).
820
840
  route.staticPolicy = captureRouteStaticPolicy(
@@ -847,6 +867,11 @@ async function handleClaudeMessagesWithBudget(
847
867
  if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning;
848
868
  }
849
869
  } catch (err) {
870
+ if (err instanceof AdmissionModelDeniedError) {
871
+ logCtx.requestedModel = requestedModel;
872
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" });
873
+ return admissionModelDeniedResponse(err);
874
+ }
850
875
  if (err instanceof UnknownRoutingPolicyError) {
851
876
  logCtx.requestedModel = requestedModel;
852
877
  if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" });
@@ -961,6 +986,9 @@ async function handleClaudeMessagesWithBudget(
961
986
  const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream;
962
987
 
963
988
  if (!response.ok) {
989
+ // Read the shared provenance verdict before consuming and re-wrapping the body. A refusal
990
+ // and an ordinary provider rate limit are both 429, so the status cannot distinguish them.
991
+ const replayRefusal = isReplayRefusalResponse(response);
964
992
  // Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status.
965
993
  let message = `upstream error (${response.status})`;
966
994
  try {
@@ -975,14 +1003,16 @@ async function handleClaudeMessagesWithBudget(
975
1003
  }
976
1004
  } catch { /* keep fallback message */ }
977
1005
  const upstreamRetryAfter = response.headers.get("retry-after");
978
- const retryAfter = resolveClientRetryAfter({
979
- status: response.status,
980
- message,
981
- upstreamRetryAfter,
982
- })
983
- // Instant-retry "0" is a valid client directive but rejected by cooldown parsers.
984
- // Preserve it so it still wins over the transient "2" fallback (claude-529 mapping).
985
- ?? (upstreamRetryAfter?.trim() === "0" ? "0" : undefined);
1006
+ const retryAfter = replayRefusal
1007
+ ? undefined
1008
+ : resolveClientRetryAfter({
1009
+ status: response.status,
1010
+ message,
1011
+ upstreamRetryAfter,
1012
+ })
1013
+ // Instant-retry "0" is a valid client directive but rejected by cooldown parsers.
1014
+ // Preserve it so it still wins over the transient "2" fallback (claude-529 mapping).
1015
+ ?? (upstreamRetryAfter?.trim() === "0" ? "0" : undefined);
986
1016
  // Transient upstream 5xx (already retried pre-stream, 010): reclassify as Anthropic
987
1017
  // 529 overloaded_error so the Claude Code client applies its built-in backoff retry
988
1018
  // instead of dying on a fatal api_error (260716 sol-builder incident). The request
@@ -993,16 +1023,24 @@ async function handleClaudeMessagesWithBudget(
993
1023
  const nativeMainFence = response.status === 503
994
1024
  && upstreamRetryAfter?.trim() === "1"
995
1025
  && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE;
996
- const transient = !nativeMainFence && isTransientUpstreamStatus(response.status);
997
- const outStatus = nativeMainFence ? 503 : transient ? 529 : response.status;
998
- const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), {
1026
+ const transient = !replayRefusal && !nativeMainFence && isTransientUpstreamStatus(response.status);
1027
+ const outStatus = replayRefusal
1028
+ ? REPLAY_REFUSED_STATUS
1029
+ : nativeMainFence ? 503 : transient ? 529 : response.status;
1030
+ const outHeaders = new Headers({ "Content-Type": "application/json" });
1031
+ if (retryAfter) outHeaders.set("Retry-After", retryAfter);
1032
+ else if (transient) outHeaders.set("Retry-After", "2");
1033
+ if (replayRefusal) applyReplayRefusalClientHeaders(outHeaders);
1034
+ const out = new Response(JSON.stringify(anthropicErrorBody(
1035
+ outStatus,
1036
+ message,
1037
+ undefined,
1038
+ replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined,
1039
+ )), {
999
1040
  status: outStatus,
1000
- headers: {
1001
- "Content-Type": "application/json",
1002
- ...(retryAfter ? { "Retry-After": retryAfter } : (transient ? { "Retry-After": "2" } : {})),
1003
- },
1041
+ headers: outHeaders,
1004
1042
  });
1005
- return out;
1043
+ return carryReplayRefusal(response, out);
1006
1044
  }
1007
1045
 
1008
1046
  const contentType = response.headers.get("content-type") ?? "";
@@ -1,8 +1,13 @@
1
1
  /** Strict terminal reconstruction selected by the Grok compatibility marker. */
2
2
  import type { TranslatorBudget } from "../lib/translator-budget";
3
3
  import { MAX_COMPLETED_OUTPUT_ITEMS, MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES } from "./relay";
4
- import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
4
+ import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
5
5
  import { isPlainObject, jsonBlock, type RetainedOutputItem } from "./responses-snapshot-codec";
6
+ import {
7
+ requestToolScope,
8
+ type RequestToolScope,
9
+ type RequestToolScopeCorrespondence,
10
+ } from "./responses-request-tool-scope";
6
11
 
7
12
  type SparseTerminalOpenItem = {
8
13
  type: string;
@@ -16,6 +21,20 @@ type SparseTerminalCompletedItem = RetainedOutputItem & {
16
21
 
17
22
  const MAX_GROK_OPEN_ITEM_IDENTITY_BYTES = MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES;
18
23
 
24
+ /** Terminal event this repair publishes when it refused to reconstruct a call faithfully. */
25
+ export const GROK_REFUSED_TERMINAL_EVENT_TYPE = "response.incomplete";
26
+
27
+ /** `incomplete_details.reason` carried by that terminal. */
28
+ export const GROK_FORBIDDEN_TOOL_CALL_REASON = "forbidden_tool_call";
29
+
30
+ /** An upstream-supplied name reaches the terminal message; keep it bounded. */
31
+ const MAX_REPORTED_TOOL_NAME_CHARS = 100;
32
+
33
+ export function forbiddenToolCallMessage(name: string): string {
34
+ return `routed provider called "${name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS)}", `
35
+ + "which this request's tool selection excludes; the reconstructed output omits that call";
36
+ }
37
+
19
38
  const GROK_TERMINAL_OUTPUT_ITEM_TYPES = new Set([
20
39
  "message",
21
40
  "reasoning",
@@ -167,6 +186,45 @@ function plausibleGrokOpenItem(
167
186
  };
168
187
  }
169
188
 
189
+ /**
190
+ * Publish the refusal on the terminal itself rather than as a silent omission.
191
+ *
192
+ * The ordinary reconstruction replaces only the data payload's `output`, so its event name still
193
+ * describes the payload. A refusal does not: the turn no longer completed the way the upstream
194
+ * said it did, so the event line moves with the status instead of leaving a client to read a
195
+ * clean finish off an unchanged `event: response.completed`.
196
+ */
197
+ function refusedTerminalBlock(
198
+ block: string,
199
+ parsed: Record<string, unknown>,
200
+ response: Record<string, unknown>,
201
+ output: readonly Record<string, unknown>[],
202
+ refusedName: string | undefined,
203
+ ): string {
204
+ const payload = JSON.stringify({
205
+ ...parsed,
206
+ type: GROK_REFUSED_TERMINAL_EVENT_TYPE,
207
+ response: {
208
+ ...response,
209
+ status: "incomplete",
210
+ output,
211
+ incomplete_details: {
212
+ reason: GROK_FORBIDDEN_TOOL_CALL_REASON,
213
+ ...(refusedName === undefined ? {} : { message: forbiddenToolCallMessage(refusedName) }),
214
+ },
215
+ },
216
+ });
217
+ const rewritten = replaceSseDataPayload(block, payload);
218
+ const newline = block.includes("\r\n") ? "\r\n" : "\n";
219
+ let eventRewritten = false;
220
+ const lines = rewritten.split(/\r?\n/).map(line => {
221
+ if (eventRewritten || !line.startsWith("event:")) return line;
222
+ eventRewritten = true;
223
+ return `event: ${GROK_REFUSED_TERMINAL_EVENT_TYPE}`;
224
+ });
225
+ return lines.join(newline);
226
+ }
227
+
170
228
  /**
171
229
  * Narrow client repair for grok-build's Responses consumer.
172
230
  *
@@ -176,12 +234,26 @@ function plausibleGrokOpenItem(
176
234
  * empty output. Reconstruct only from real, unique, contiguous, bounded done
177
235
  * events whose raw semantics are already valid. Any ambiguity stays byte-level
178
236
  * fail-closed; the provider-opt-in snapshot repair above is unchanged.
237
+ *
238
+ * The terminal this publishes is one the upstream never sent, so it carries only what the final
239
+ * outbound request still authorized. A client call outside that request's tool selection is left
240
+ * out and the terminal says so explicitly. The declaration guard downstream answers the other
241
+ * half of the question — whether a name was declared at all — and keeps policing the raw stream,
242
+ * which this rewrite never edits.
179
243
  */
180
244
  export function createGrokResponsesSparseTerminalBlockRewrite(
181
245
  budget?: TranslatorBudget,
246
+ outboundRequestBody?: unknown,
247
+ toolIdentityCorrespondence?: RequestToolScopeCorrespondence,
182
248
  ): SseBlockRewrite {
249
+ const toolScope: RequestToolScope | undefined = requestToolScope(
250
+ outboundRequestBody,
251
+ toolIdentityCorrespondence,
252
+ );
183
253
  const openItems = new Map<number, SparseTerminalOpenItem>();
184
254
  const completedItems = new Map<number, SparseTerminalCompletedItem>();
255
+ const withheldIndices = new Set<number>();
256
+ let withheldToolName: string | undefined;
185
257
  let aggregateItemBytes = 0;
186
258
  let aggregateOpenItemBytes = 0;
187
259
  let tainted = false;
@@ -194,6 +266,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
194
266
  }
195
267
  openItems.clear();
196
268
  completedItems.clear();
269
+ withheldIndices.clear();
270
+ withheldToolName = undefined;
197
271
  aggregateItemBytes = 0;
198
272
  aggregateOpenItemBytes = 0;
199
273
  hasVisibleOutput = false;
@@ -217,7 +291,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
217
291
  if (tainted) return;
218
292
  const sourceBytes = Buffer.byteLength(JSON.stringify(item), "utf8");
219
293
  if (sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES
220
- || completedItems.size >= MAX_COMPLETED_OUTPUT_ITEMS
294
+ || completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS
221
295
  || aggregateItemBytes + sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) {
222
296
  taintAndRelease();
223
297
  return;
@@ -228,6 +302,24 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
228
302
  hasVisibleOutput = hasVisibleOutput || visibleToGrok;
229
303
  };
230
304
 
305
+ /**
306
+ * Record the position of a call this request forbade without retaining the item.
307
+ *
308
+ * Only the offending item is dropped. Tainting here instead would discard the assistant text
309
+ * that arrived in the same turn and leave the client the empty terminal this repair exists to
310
+ * fix, which punishes the caller for the provider's overreach. The index is kept so the
311
+ * contiguity proof below still covers the whole output.
312
+ */
313
+ const withholdForbiddenCall = (index: number, name: string): void => {
314
+ if (tainted) return;
315
+ if (completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS) {
316
+ taintAndRelease();
317
+ return;
318
+ }
319
+ withheldIndices.add(index);
320
+ withheldToolName ??= name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS);
321
+ };
322
+
231
323
  const closeOpenItem = (index: number): void => {
232
324
  const open = openItems.get(index);
233
325
  if (!open) return;
@@ -265,6 +357,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
265
357
  const open = isPlainObject(parsed.item) ? plausibleGrokOpenItem(parsed.item) : null;
266
358
  if (outputIndex === undefined || !open
267
359
  || openItems.has(outputIndex) || completedItems.has(outputIndex)
360
+ || withheldIndices.has(outputIndex)
268
361
  || openItems.size >= MAX_COMPLETED_OUTPUT_ITEMS) {
269
362
  taintAndRelease();
270
363
  } else if (!tainted) {
@@ -284,7 +377,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
284
377
  if (type === "response.output_item.done") {
285
378
  const item = isPlainObject(parsed.item) ? parsed.item : null;
286
379
  const proof = item ? trustedGrokCompletedItem(item) : null;
287
- if (outputIndex === undefined || !proof || completedItems.has(outputIndex)) {
380
+ if (outputIndex === undefined || !proof
381
+ || completedItems.has(outputIndex) || withheldIndices.has(outputIndex)) {
288
382
  taintAndRelease();
289
383
  return [block];
290
384
  }
@@ -295,7 +389,12 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
295
389
  return [block];
296
390
  }
297
391
  closeOpenItem(outputIndex);
298
- retainCompletedItem(outputIndex, item!, proof.visibleToGrok);
392
+ const forbidden = toolScope?.forbiddenClientToolCallName(item!);
393
+ if (forbidden === undefined) {
394
+ retainCompletedItem(outputIndex, item!, proof.visibleToGrok);
395
+ } else {
396
+ withholdForbiddenCall(outputIndex, forbidden);
397
+ }
299
398
  return [block];
300
399
  }
301
400
 
@@ -312,14 +411,18 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
312
411
  const outputIsAuthoritative = Array.isArray(output) && output.length > 0;
313
412
  const outputIsSparse = !("output" in response)
314
413
  || (Array.isArray(output) && output.length === 0);
414
+ // A withheld call is a reason to publish on its own: the refusal has to reach the client
415
+ // even when nothing visible survived it, or the turn ends as an ordinary empty finish.
416
+ const refused = withheldIndices.size > 0;
315
417
  if (!outputIsAuthoritative && outputIsSparse && terminalStatusConsistent
316
- && completedItems.size > 0 && openItems.size === 0 && hasVisibleOutput) {
418
+ && openItems.size === 0 && (hasVisibleOutput || refused)) {
317
419
  const ordered = [...completedItems.entries()].sort(([left], [right]) => left - right);
318
- if (ordered.every(([index], position) => index === position)) {
319
- out = jsonBlock({
320
- ...parsed,
321
- response: { ...response, output: ordered.map(([, retained]) => retained.item) },
322
- });
420
+ const positions = [...completedItems.keys(), ...withheldIndices].sort((left, right) => left - right);
421
+ if (positions.length > 0 && positions.every((index, position) => index === position)) {
422
+ const rebuilt = ordered.map(([, retained]) => retained.item);
423
+ out = refused
424
+ ? refusedTerminalBlock(block, parsed, response, rebuilt, withheldToolName)
425
+ : jsonBlock({ ...parsed, response: { ...response, output: rebuilt } });
323
426
  }
324
427
  }
325
428
  }
@@ -330,4 +433,3 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
330
433
  rewrite.dispose = reset;
331
434
  return rewrite;
332
435
  }
333
-
@@ -0,0 +1,103 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Whether the served dashboard bundle predates the sources it was built from.
6
+ *
7
+ * The dashboard is a build artifact: `AGENTS.md` records that `gui/` is served from `gui/dist`,
8
+ * so a checkout that moves forward without `bun run build:gui` keeps serving the previous bundle.
9
+ * Nothing fails when that happens. The proxy answers, the page loads, and every feature added since
10
+ * the last build is simply absent — which reads as the feature being broken rather than unbuilt.
11
+ * A five-day-old bundle hid the entire menu-bar and widget section of the Usage page this way.
12
+ *
13
+ * This reports; it never rebuilds. A proxy that compiled a frontend while starting would trade a
14
+ * silent staleness for a slow, surprising start, and the rebuild belongs to whoever moved the
15
+ * checkout.
16
+ */
17
+ export interface GuiBundleFreshness {
18
+ /** Absolute path of the served bundle, or null when no bundle was found. */
19
+ bundlePath: string | null;
20
+ /** Newest mtime under the bundle, in epoch milliseconds. */
21
+ bundleModifiedMs: number | null;
22
+ /** Newest mtime under the GUI sources, in epoch milliseconds. */
23
+ sourceModifiedMs: number | null;
24
+ /** True only when both sides are known and the sources are strictly newer. */
25
+ stale: boolean;
26
+ }
27
+
28
+ /** Directory entries that never carry meaningful build input or output timestamps. */
29
+ const IGNORED_DIRECTORIES = new Set(["node_modules", ".git", ".vite", ".cache"]);
30
+
31
+ /**
32
+ * Newest mtime beneath `root`, or null when the tree is missing or empty.
33
+ *
34
+ * Walking is bounded by `maxEntries` because this runs on a status path: a pathological tree must
35
+ * cost a predictable amount rather than stalling the command that reports on it. Hitting the bound
36
+ * yields the newest value seen so far, which can only make the comparison more conservative.
37
+ */
38
+ export function newestModifiedMs(root: string, maxEntries = 20_000): number | null {
39
+ if (!existsSync(root)) return null;
40
+ let newest: number | null = null;
41
+ let seen = 0;
42
+ const queue: string[] = [root];
43
+ while (queue.length > 0) {
44
+ const current = queue.pop()!;
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(current, { withFileTypes: true });
48
+ } catch {
49
+ continue;
50
+ }
51
+ for (const entry of entries) {
52
+ if (seen >= maxEntries) return newest;
53
+ if (entry.isSymbolicLink()) continue;
54
+ const full = join(current, entry.name);
55
+ if (entry.isDirectory()) {
56
+ if (IGNORED_DIRECTORIES.has(entry.name)) continue;
57
+ queue.push(full);
58
+ continue;
59
+ }
60
+ if (!entry.isFile()) continue;
61
+ seen += 1;
62
+ try {
63
+ const mtime = statSync(full).mtimeMs;
64
+ if (newest === null || mtime > newest) newest = mtime;
65
+ } catch {
66
+ // A file that vanished mid-walk cannot make the bundle look fresher than it is.
67
+ }
68
+ }
69
+ }
70
+ return newest;
71
+ }
72
+
73
+ /**
74
+ * Compare the built bundle against its sources.
75
+ *
76
+ * Unknown is not stale. A packaged install has no `gui/src` beside it, and a missing bundle is a
77
+ * different condition with its own message elsewhere; neither should produce a rebuild warning.
78
+ */
79
+ export function inspectGuiBundleFreshness(input: {
80
+ bundlePath: string | null;
81
+ sourcePath: string;
82
+ }): GuiBundleFreshness {
83
+ const bundleModifiedMs = input.bundlePath === null ? null : newestModifiedMs(input.bundlePath);
84
+ const sourceModifiedMs = newestModifiedMs(input.sourcePath);
85
+ const stale = bundleModifiedMs !== null
86
+ && sourceModifiedMs !== null
87
+ && sourceModifiedMs > bundleModifiedMs;
88
+ return {
89
+ bundlePath: input.bundlePath,
90
+ bundleModifiedMs,
91
+ sourceModifiedMs,
92
+ stale,
93
+ };
94
+ }
95
+
96
+ /** Operator-facing lines for a stale bundle; empty when the bundle is current or unknown. */
97
+ export function staleGuiBundleLines(freshness: GuiBundleFreshness): string[] {
98
+ if (!freshness.stale) return [];
99
+ return [
100
+ "Dashboard bundle is older than the GUI sources, so the page is missing everything built since.",
101
+ "Rebuild it with: bun run build:gui",
102
+ ];
103
+ }
@@ -2,15 +2,11 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { browserSecurityHeaders } from "./auth-cors";
4
4
  import type { GuiSessionBootstrap } from "./gui-session";
5
+ import { packageVersion } from "../lib/package-version";
6
+ import { isStandaloneBinary, standaloneRoot } from "../lib/standalone";
5
7
 
6
8
  /** opencodex version, read from the packaged package.json (same source as the server bootstrap). */
7
- const VERSION = (() => {
8
- try {
9
- return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string;
10
- } catch {
11
- return "0.0.0";
12
- }
13
- })();
9
+ const VERSION = packageVersion("0.0.0");
14
10
 
15
11
  const MIME_TYPES: Record<string, string> = {
16
12
  ".html": "text/html", ".js": "application/javascript", ".css": "text/css",
@@ -24,11 +20,13 @@ const MIME_TYPES: Record<string, string> = {
24
20
  */
25
21
  const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/;
26
22
 
27
- function findGuiDist(): string | null {
23
+ export function findGuiDist(): string | null {
28
24
  const candidates = [
25
+ process.env.OPENCODEX_GUI_DIST,
26
+ ...(isStandaloneBinary() ? [join(standaloneRoot(), "gui", "dist")] : []),
29
27
  join(import.meta.dir, "..", "..", "gui", "dist"),
30
28
  join(import.meta.dir, "..", "..", "..", "gui", "dist"),
31
- ];
29
+ ].filter((candidate): candidate is string => Boolean(candidate));
32
30
  for (const c of candidates) {
33
31
  if (existsSync(join(c, "index.html"))) return c;
34
32
  }
@@ -33,6 +33,8 @@ import { selectProactiveApiKeyTransport } from "../providers/key-failover";
33
33
  import { getProviderRegistryEntry } from "../providers/registry";
34
34
  import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
35
35
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
36
+ import type { DataPlaneAdmission } from "./auth-cors";
37
+ import { admissionScopeDenial } from "./admission-model-scope";
36
38
  import type { RequestLogContext } from "./request-log";
37
39
  import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
38
40
  import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index";
@@ -115,6 +117,18 @@ export async function readImageResponseBytes(
115
117
  }
116
118
 
117
119
  const CCA_IMAGE_MODEL = "gemini-3.1-flash-image";
120
+ const XAI_IMAGE_BRIDGE_MODEL = "grok-imagine-image-quality";
121
+
122
+ /**
123
+ * The model this request names, or undefined when it names none.
124
+ *
125
+ * An absent `model` is relayed as an absent `model`: the upstream picks, so
126
+ * there is no destination to echo and none a model list can allow.
127
+ */
128
+ function requestedImageSelector(body: unknown): string | undefined {
129
+ const model = (body as { model?: unknown } | null)?.model;
130
+ return typeof model === "string" && model.trim() ? model : undefined;
131
+ }
118
132
 
119
133
  /**
120
134
  * Google Gemini finishReasons that indicate a permanent content/safety block.
@@ -207,11 +221,21 @@ async function tryCcaImageGeneration(
207
221
  logCtx: RequestLogContext,
208
222
  signal: AbortSignal,
209
223
  endpoint: ImagesEndpoint,
224
+ admission: DataPlaneAdmission | undefined,
210
225
  ): Promise<Response | undefined> {
211
226
  if (endpoint !== "generations") return undefined;
212
227
  const provider = config.providers?.["google-antigravity"];
213
228
  if (!provider || provider.disabled) return undefined;
214
229
 
230
+ // The destination is decided here, not by the caller: this branch always
231
+ // bills Antigravity for CCA_IMAGE_MODEL whatever the body named. That is the
232
+ // pair a scoped key is held to.
233
+ const denial = admissionScopeDenial(config, admission, requestedImageSelector(body), {
234
+ providerName: "google-antigravity",
235
+ modelId: CCA_IMAGE_MODEL,
236
+ });
237
+ if (denial) return denial;
238
+
215
239
  const prompt = (body as { prompt?: unknown })?.prompt;
216
240
  if (typeof prompt !== "string" || !prompt.trim()) {
217
241
  return formatErrorResponse(400, "invalid_request_error", "prompt is required and must not be empty");
@@ -446,10 +470,20 @@ async function tryXaiImageRelay(
446
470
  logCtx: RequestLogContext,
447
471
  signal: AbortSignal | undefined,
448
472
  endpoint: ImagesEndpoint,
473
+ admission: DataPlaneAdmission | undefined,
449
474
  ): Promise<Response | undefined> {
450
475
  if (config.images?.bridgeEnabled !== true) return undefined;
451
476
  const found = findXaiProvider(config);
452
477
  if (!found) return undefined;
478
+ const bridgeModel = config.images?.bridgeModel ?? XAI_IMAGE_BRIDGE_MODEL;
479
+ // The bridge sends this request to the configured xAI provider and the bridge
480
+ // model regardless of the selector in the body, so that is what the key is
481
+ // checked against.
482
+ const denial = admissionScopeDenial(config, admission, requestedImageSelector(body), {
483
+ providerName: found.name,
484
+ modelId: bridgeModel,
485
+ });
486
+ if (denial) return denial;
453
487
  const obj = body && typeof body === "object" && !Array.isArray(body)
454
488
  ? body as Record<string, unknown>
455
489
  : {};
@@ -496,11 +530,11 @@ async function tryXaiImageRelay(
496
530
  }
497
531
  if (!token) return xaiImageAuthMissing();
498
532
  logCtx.provider = "xai";
499
- logCtx.model = config.images?.bridgeModel ?? "grok-imagine-image-quality";
533
+ logCtx.model = bridgeModel;
500
534
  const result = await callXaiImages(
501
535
  {
502
536
  prompt,
503
- model: logCtx.model,
537
+ model: bridgeModel,
504
538
  n,
505
539
  size,
506
540
  quality,
@@ -602,6 +636,7 @@ export async function handleImages(
602
636
  endpoint: ImagesEndpoint,
603
637
  logCtx: RequestLogContext,
604
638
  turnAdmissionLease?: AdmissionLease,
639
+ admission?: DataPlaneAdmission,
605
640
  ): Promise<Response> {
606
641
  let body: unknown;
607
642
  try {
@@ -616,7 +651,7 @@ export async function handleImages(
616
651
  // Explicit images.provider owns the route, including its validation errors.
617
652
  // Do not divert that selection to the xAI Imagine relay.
618
653
  if (config.images?.provider === undefined) {
619
- const xaiRelay = await tryXaiImageRelay(body, config, logCtx, req.signal, endpoint);
654
+ const xaiRelay = await tryXaiImageRelay(body, config, logCtx, req.signal, endpoint, admission);
620
655
  if (xaiRelay) return xaiRelay;
621
656
  }
622
657
  if (candidates.error) {
@@ -641,7 +676,7 @@ export async function handleImages(
641
676
  const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0;
642
677
 
643
678
  if (!canUseOpenAiForward && !candidates.keyed) {
644
- const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint);
679
+ const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission);
645
680
  if (ccaResponse) return ccaResponse;
646
681
  // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent
647
682
  // configuration state that must surface on the first attempt.
@@ -688,7 +723,20 @@ export async function handleImages(
688
723
 
689
724
  const headers: Record<string, string> = { "content-type": "application/json" };
690
725
  let url: string;
726
+ // Both relay branches copy the body upstream, so the destination is the
727
+ // provider chosen in that branch and the model the caller named. Each branch
728
+ // is checked as it is entered, before it resolves a credential or commits a
729
+ // key rotation, so a refused request spends nothing.
730
+ const relaySelector = requestedImageSelector(body);
691
731
  if (forward) {
732
+ const denial = admissionScopeDenial(config, admission, relaySelector, {
733
+ providerName: forward.providerName,
734
+ modelId: relaySelector,
735
+ });
736
+ if (denial) {
737
+ forward.releaseProbeLease?.();
738
+ return denial;
739
+ }
692
740
  const { provider } = forward;
693
741
  if (provider.headers) Object.assign(headers, provider.headers);
694
742
  for (const [name, value] of forward.headers) headers[name] = value;
@@ -697,13 +745,18 @@ export async function handleImages(
697
745
  } else if (forwardAuthError) {
698
746
  // Before surfacing the OpenAI auth failure, try CCA — the user may have a
699
747
  // valid Google Antigravity login even though their OpenAI pool is broken.
700
- const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint);
748
+ const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission);
701
749
  if (ccaResponse) return ccaResponse;
702
750
  // No CCA either: a configured OpenAI pool mode owns its authentication failure.
703
751
  // Do not hide a broken/expired pool behind separately billed API-key image generation.
704
752
  return forwardAuthError;
705
753
  } else if (candidates.keyed) {
706
754
  const { providerName } = candidates.keyed;
755
+ const denial = admissionScopeDenial(config, admission, relaySelector, {
756
+ providerName,
757
+ modelId: relaySelector,
758
+ });
759
+ if (denial) return denial;
707
760
  // The keyed image path builds its own URL and Authorization header and never enters
708
761
  // handleResponses, so the pre-dispatch key pick happens here.
709
762
  //
@@ -737,7 +790,7 @@ export async function handleImages(
737
790
  url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`;
738
791
  } else {
739
792
  // No usable OpenAI credential — try CCA before giving up.
740
- const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint);
793
+ const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission);
741
794
  if (ccaResponse) return ccaResponse;
742
795
  return formatErrorResponse(
743
796
  401,