@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005

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 (353) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-CppMz-6w.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
  5. package/gui/dist/assets/index-DQt9srNi.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
  8. package/gui/dist/index.html +4 -3
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/antigravity-refusal.ts +28 -0
  15. package/src/adapters/cursor/protobuf-request.ts +6 -0
  16. package/src/adapters/cursor/request-builder.ts +3 -0
  17. package/src/adapters/cursor/structured-output.ts +13 -0
  18. package/src/adapters/cursor/types.ts +2 -0
  19. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  20. package/src/adapters/devin/trajectory.ts +49 -0
  21. package/src/adapters/devin.ts +13 -0
  22. package/src/adapters/ollama-native.ts +62 -19
  23. package/src/adapters/openai-responses/passthrough.ts +133 -103
  24. package/src/adapters/openai-responses/request-strips.ts +10 -4
  25. package/src/adapters/zed.ts +26 -4
  26. package/src/bridge/response-json.ts +14 -14
  27. package/src/bridge/sse.ts +15 -5
  28. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  29. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  30. package/src/claude/agents-inject.ts +4 -43
  31. package/src/claude/gateway-cache.ts +16 -13
  32. package/src/claude/inbound-model-options.ts +9 -2
  33. package/src/claude/intercept/cli-catalog.ts +27 -16
  34. package/src/claude/intercept/client-hello.ts +103 -0
  35. package/src/claude/intercept/model-bindings.ts +3 -2
  36. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  37. package/src/claude/intercept/picker-budget.ts +41 -0
  38. package/src/claude/intercept/picker-listener.ts +279 -66
  39. package/src/claude/outbound.ts +2 -0
  40. package/src/claude/subagent-force-status.ts +53 -0
  41. package/src/claude/subagent-model.ts +93 -0
  42. package/src/cli/access-audio-input.ts +84 -0
  43. package/src/cli/access-audio-live.ts +99 -0
  44. package/src/cli/access-audio.ts +123 -0
  45. package/src/cli/access-data-client.ts +127 -0
  46. package/src/cli/access-data-plane.ts +104 -0
  47. package/src/cli/access-data-response.ts +76 -0
  48. package/src/cli/access.ts +65 -2
  49. package/src/cli/account-api.ts +33 -6
  50. package/src/cli/account-auth.ts +111 -42
  51. package/src/cli/account-key-quota.ts +118 -0
  52. package/src/cli/account-main.ts +11 -4
  53. package/src/cli/account-next-actions.ts +63 -0
  54. package/src/cli/account-policy-dto.ts +68 -0
  55. package/src/cli/account-policy.ts +167 -0
  56. package/src/cli/account-target.ts +8 -0
  57. package/src/cli/account.ts +37 -9
  58. package/src/cli/agent-runtime-settings.ts +130 -0
  59. package/src/cli/agent-settings.ts +101 -0
  60. package/src/cli/agent.ts +29 -2
  61. package/src/cli/alias.ts +7 -2
  62. package/src/cli/aside-profiles.ts +2 -2
  63. package/src/cli/capabilities-access-remote.ts +147 -0
  64. package/src/cli/capabilities-accounts.ts +195 -0
  65. package/src/cli/capabilities-agents-routing.ts +414 -0
  66. package/src/cli/capabilities-base.ts +783 -0
  67. package/src/cli/capabilities-command.ts +1 -0
  68. package/src/cli/capabilities-integrations.ts +264 -0
  69. package/src/cli/capabilities-lab.ts +214 -0
  70. package/src/cli/capabilities-observe-system.ts +662 -0
  71. package/src/cli/capabilities-provider-models.ts +302 -0
  72. package/src/cli/capabilities.ts +30 -1190
  73. package/src/cli/capability-types.ts +54 -0
  74. package/src/cli/catalog-command-result.ts +57 -0
  75. package/src/cli/chatgpt-command.ts +35 -9
  76. package/src/cli/claude-agent-startup-sync.ts +17 -0
  77. package/src/cli/claude-desktop-profile.ts +100 -0
  78. package/src/cli/claude-desktop.ts +39 -6
  79. package/src/cli/claude.ts +22 -6
  80. package/src/cli/codex-shim-autorestore.ts +2 -1
  81. package/src/cli/codex-shim-readiness.ts +109 -0
  82. package/src/cli/combo-input.ts +88 -0
  83. package/src/cli/combo-result.ts +52 -0
  84. package/src/cli/combo-stats.ts +123 -0
  85. package/src/cli/combo.ts +37 -14
  86. package/src/cli/companion-timeline.ts +142 -0
  87. package/src/cli/companion-usage.ts +146 -0
  88. package/src/cli/companion.ts +12 -3
  89. package/src/cli/config-command.ts +24 -8
  90. package/src/cli/connect.ts +19 -0
  91. package/src/cli/dispatch.ts +55 -82
  92. package/src/cli/doctor.ts +22 -4
  93. package/src/cli/export-command.ts +20 -9
  94. package/src/cli/gui.ts +8 -3
  95. package/src/cli/help-catalog.ts +101 -0
  96. package/src/cli/help-models-context.ts +17 -0
  97. package/src/cli/help-navigation.ts +50 -0
  98. package/src/cli/help-recovery.ts +66 -0
  99. package/src/cli/help.ts +69 -11
  100. package/src/cli/index.ts +38 -27
  101. package/src/cli/injection-follow.ts +35 -0
  102. package/src/cli/inspect.ts +5 -0
  103. package/src/cli/integration-aside-sync.ts +69 -0
  104. package/src/cli/integration-cursor.ts +106 -0
  105. package/src/cli/integration-input.ts +60 -0
  106. package/src/cli/integration-journal.ts +49 -0
  107. package/src/cli/integration-plan-dto.ts +156 -0
  108. package/src/cli/integration-preview.ts +215 -0
  109. package/src/cli/integrations.ts +24 -18
  110. package/src/cli/json-input.ts +134 -0
  111. package/src/cli/link.ts +32 -4
  112. package/src/cli/local-sync-result.ts +41 -0
  113. package/src/cli/log-follow.ts +84 -0
  114. package/src/cli/log-view-filter.ts +146 -0
  115. package/src/cli/logout-command.ts +44 -0
  116. package/src/cli/model-picker-ordering.ts +137 -0
  117. package/src/cli/models-custom-input.ts +107 -0
  118. package/src/cli/models-custom-runtime.ts +112 -0
  119. package/src/cli/models-order.ts +194 -0
  120. package/src/cli/models-runtime-subcommands.ts +2 -0
  121. package/src/cli/models-runtime.ts +9 -1
  122. package/src/cli/models.ts +66 -81
  123. package/src/cli/observe-stream.ts +142 -0
  124. package/src/cli/observe.ts +75 -34
  125. package/src/cli/opencode.ts +54 -10
  126. package/src/cli/provider-batch.ts +77 -0
  127. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  128. package/src/cli/provider-result.ts +124 -0
  129. package/src/cli/provider-runtime.ts +15 -12
  130. package/src/cli/provider-settings.ts +143 -0
  131. package/src/cli/provider.ts +116 -64
  132. package/src/cli/registry.ts +90 -24
  133. package/src/cli/remote-workspace-hub.ts +83 -0
  134. package/src/cli/remote-workspace.ts +6 -1
  135. package/src/cli/restart-failure.ts +27 -0
  136. package/src/cli/root.ts +42 -9
  137. package/src/cli/route-policy-write.ts +137 -0
  138. package/src/cli/route-policy.ts +8 -0
  139. package/src/cli/runtime-api.ts +57 -80
  140. package/src/cli/secret-args.ts +149 -0
  141. package/src/cli/settings-result.ts +30 -0
  142. package/src/cli/storage.ts +77 -7
  143. package/src/cli/system-command.ts +60 -2
  144. package/src/cli/system-restart-client.ts +20 -3
  145. package/src/cli/system-settings-parity.ts +83 -0
  146. package/src/cli/tray-proxy.ts +3 -2
  147. package/src/cli/update-restart-candidate.ts +21 -0
  148. package/src/cli/update-restart-child.ts +81 -0
  149. package/src/cli/update-restart-home.ts +38 -0
  150. package/src/cli/update-restart-transport.ts +193 -0
  151. package/src/cli/update-restart.ts +260 -0
  152. package/src/cli/usage-model-search.ts +58 -0
  153. package/src/cli/usage-report.ts +13 -5
  154. package/src/cli/v2-input.ts +66 -0
  155. package/src/cli/v2-local-output.ts +37 -0
  156. package/src/cli/v2-runtime.ts +90 -0
  157. package/src/cli/v2.ts +102 -144
  158. package/src/clients/config-export/contracts.ts +100 -0
  159. package/src/clients/config-export/droid.ts +54 -2
  160. package/src/clients/config-export/dsh.ts +21 -0
  161. package/src/clients/config-export/kilo.ts +12 -2
  162. package/src/clients/config-export/model-metadata.ts +10 -4
  163. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  164. package/src/clients/config-export.ts +53 -67
  165. package/src/clients/omo-role-models.ts +17 -3
  166. package/src/codex/account-credit-use.ts +4 -2
  167. package/src/codex/account-runtime-state.ts +21 -0
  168. package/src/codex/account-store.ts +11 -47
  169. package/src/codex/account-usability.ts +11 -8
  170. package/src/codex/auth-api/account-list.ts +73 -63
  171. package/src/codex/auth-api/main-account-probe.ts +25 -12
  172. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  173. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  174. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  175. package/src/codex/auth-context.ts +30 -11
  176. package/src/codex/catalog/aggregation.ts +3 -0
  177. package/src/codex/catalog/build-entries.ts +1 -1
  178. package/src/codex/catalog/bundled.ts +9 -2
  179. package/src/codex/catalog/heal-observation.ts +66 -0
  180. package/src/codex/catalog/model-hints.ts +2 -1
  181. package/src/codex/catalog/model-visibility.ts +6 -3
  182. package/src/codex/catalog/parsing.ts +2 -0
  183. package/src/codex/catalog/provider-models.ts +13 -4
  184. package/src/codex/catalog/publication-observer.ts +19 -0
  185. package/src/codex/catalog/remote.ts +13 -15
  186. package/src/codex/catalog/restore.ts +19 -6
  187. package/src/codex/catalog/retained-sync.ts +60 -11
  188. package/src/codex/catalog/routed-removal.ts +116 -0
  189. package/src/codex/catalog/write-audit-contract.ts +21 -0
  190. package/src/codex/catalog/write-audit.ts +171 -0
  191. package/src/codex/catalog-self-heal.ts +284 -0
  192. package/src/codex/catalog-write-serialization.ts +71 -7
  193. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  194. package/src/codex/codex-home-owner.ts +124 -0
  195. package/src/codex/convergence.ts +57 -16
  196. package/src/codex/features.ts +8 -1
  197. package/src/codex/inject/restore.ts +46 -4
  198. package/src/codex/inject.ts +11 -1
  199. package/src/codex/internal/catalog-writer.ts +120 -13
  200. package/src/codex/journal.ts +49 -13
  201. package/src/codex/main-account.ts +144 -11
  202. package/src/codex/management-convergence.ts +3 -0
  203. package/src/codex/quota-refresh-outcome.ts +17 -2
  204. package/src/codex/quota-types.ts +36 -1
  205. package/src/codex/quota.ts +67 -16
  206. package/src/codex/refresh.ts +20 -9
  207. package/src/codex/routing/cooldown-math.ts +10 -4
  208. package/src/codex/routing/selection.ts +11 -4
  209. package/src/codex/routing.ts +9 -9
  210. package/src/codex/runtime.ts +7 -1
  211. package/src/codex/shim-diagnostics.ts +80 -0
  212. package/src/codex/shim-fingerprint.ts +7 -2
  213. package/src/codex/shim-migration.ts +153 -0
  214. package/src/codex/shim-overlay.ts +509 -0
  215. package/src/codex/shim-path-resolution.ts +145 -0
  216. package/src/codex/shim-probe.ts +4 -3
  217. package/src/codex/shim-state-file.ts +93 -7
  218. package/src/codex/shim.ts +54 -107
  219. package/src/codex/subagent-model-fallback.ts +2 -1
  220. package/src/codex/sync.ts +24 -4
  221. package/src/combos/failover.ts +101 -1
  222. package/src/combos/index.ts +1 -0
  223. package/src/combos/jev.ts +13 -3
  224. package/src/combos/resolve.ts +12 -0
  225. package/src/config/atomic-write.ts +13 -8
  226. package/src/config/diagnostics.ts +23 -1
  227. package/src/config/load-degrade.ts +7 -0
  228. package/src/config/provider-validation.ts +3 -0
  229. package/src/config/schema/chatgpt-desktop.ts +22 -0
  230. package/src/config/schema/compaction-triggers.ts +8 -0
  231. package/src/config/schema/config-schema.ts +43 -14
  232. package/src/config/schema/leaf-validators.ts +8 -3
  233. package/src/config/subagent-models.ts +12 -0
  234. package/src/generated/compatibility-version.json +645 -237
  235. package/src/grok/session-identity.ts +33 -0
  236. package/src/integrations/catalog-refresh.ts +1 -1
  237. package/src/integrations/merge.ts +13 -5
  238. package/src/integrations/mutation-plan.ts +50 -5
  239. package/src/integrations/omp-yaml-source.ts +181 -1
  240. package/src/integrations/ownership-policy.ts +66 -0
  241. package/src/integrations/registry.ts +50 -1
  242. package/src/integrations/state.ts +106 -19
  243. package/src/integrations/target.ts +25 -2
  244. package/src/integrations/writer.ts +107 -35
  245. package/src/lib/gui-pair-capability.ts +17 -0
  246. package/src/lib/package-tree-integrity.ts +42 -1
  247. package/src/lib/provider-client-headers.ts +62 -0
  248. package/src/lib/proxy-env.ts +3 -0
  249. package/src/lib/test-home-guard.ts +2 -1
  250. package/src/oauth/anthropic-account-refusal.ts +19 -8
  251. package/src/oauth/anthropic-continuity.ts +65 -0
  252. package/src/oauth/anthropic-identity.ts +75 -0
  253. package/src/oauth/anthropic-routing.ts +20 -15
  254. package/src/oauth/anthropic.ts +34 -3
  255. package/src/oauth/generic-account-failover.ts +21 -0
  256. package/src/oauth/health.ts +32 -5
  257. package/src/oauth/index.ts +42 -35
  258. package/src/oauth/local-token-detect.ts +7 -0
  259. package/src/oauth/pool-settings-capability.ts +9 -2
  260. package/src/oauth/refresh-policy.ts +2 -0
  261. package/src/oauth/store.ts +55 -6
  262. package/src/oauth/types.ts +9 -0
  263. package/src/protocols/encoders/messages.ts +4 -3
  264. package/src/protocols/plan-snapshot.ts +1 -1
  265. package/src/protocols/settings.ts +28 -4
  266. package/src/providers/antigravity-effort-families.ts +75 -0
  267. package/src/providers/antigravity-models.ts +54 -16
  268. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  269. package/src/providers/derive.ts +2 -0
  270. package/src/providers/model-rename-fields.ts +3 -0
  271. package/src/providers/new-model-policy.ts +48 -10
  272. package/src/providers/registry/entries-extended.ts +3 -0
  273. package/src/providers/registry/model-ids.ts +2 -0
  274. package/src/providers/registry/model-seeds.ts +5 -0
  275. package/src/providers/registry/types.ts +4 -0
  276. package/src/responses/compaction.ts +19 -0
  277. package/src/router.ts +6 -0
  278. package/src/server/auth-cors.ts +9 -0
  279. package/src/server/caller-session-identity.ts +32 -0
  280. package/src/server/chat-completions.ts +10 -0
  281. package/src/server/claude-messages.ts +29 -15
  282. package/src/server/droid-reasoning-default.ts +50 -0
  283. package/src/server/github-copilot-responses-repair.ts +3 -6
  284. package/src/server/grok-responses-control-frame.ts +2 -2
  285. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  286. package/src/server/gui-session.ts +33 -11
  287. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  288. package/src/server/index/serve-options.ts +13 -7
  289. package/src/server/index.ts +1 -1
  290. package/src/server/management/agent-settings-routes.ts +12 -134
  291. package/src/server/management/config-routes.ts +1 -1
  292. package/src/server/management/context.ts +8 -1
  293. package/src/server/management/cursor-integration-routes.ts +3 -3
  294. package/src/server/management/integration-routes.ts +69 -11
  295. package/src/server/management/link-routes.ts +20 -13
  296. package/src/server/management/model-routes.ts +3 -3
  297. package/src/server/management/model-row-export-metadata.ts +150 -0
  298. package/src/server/management/model-rows.ts +148 -9
  299. package/src/server/management/native-integration-routes.ts +2 -2
  300. package/src/server/management/oauth-account-routes.ts +96 -30
  301. package/src/server/management/route-registry.ts +29 -27
  302. package/src/server/management/shared.ts +19 -5
  303. package/src/server/management/subagent-model-routes.ts +214 -0
  304. package/src/server/messages-native-eligibility.ts +3 -6
  305. package/src/server/messages-native-oauth.ts +82 -35
  306. package/src/server/messages-native.ts +78 -16
  307. package/src/server/relay.ts +14 -44
  308. package/src/server/request-log-generation-window.ts +38 -0
  309. package/src/server/request-log.ts +13 -0
  310. package/src/server/responses/adapter-dispatch.ts +152 -28
  311. package/src/server/responses/codex-auth-error.ts +10 -2
  312. package/src/server/responses/combo-stream-preflight.ts +29 -3
  313. package/src/server/responses/compaction-routing.ts +10 -1
  314. package/src/server/responses/core-auth.ts +67 -30
  315. package/src/server/responses/core-codex-account.ts +6 -3
  316. package/src/server/responses/core-combo-failure.ts +5 -1
  317. package/src/server/responses/core-combo-native.ts +7 -1
  318. package/src/server/responses/core-combo.ts +64 -7
  319. package/src/server/responses/core-normalize.ts +7 -0
  320. package/src/server/responses/core-options.ts +5 -0
  321. package/src/server/responses/fetch-helpers.ts +9 -0
  322. package/src/server/responses/passthrough-delivery.ts +1 -1
  323. package/src/server/responses/policy-fallback.ts +1 -1
  324. package/src/server/responses/request-prepare.ts +5 -0
  325. package/src/server/responses/request-spend.ts +30 -30
  326. package/src/server/responses-custom-tool-repair.ts +2 -2
  327. package/src/server/responses-hosted-image-display.ts +70 -9
  328. package/src/server/responses-terminal-repair.ts +15 -2
  329. package/src/server/sse-frame-buffer.ts +38 -36
  330. package/src/server/sse-payload-rewrite.ts +78 -49
  331. package/src/server/ws-bridge.ts +1 -11
  332. package/src/service/cli.ts +6 -1
  333. package/src/service/ownership-mutation-lease.d.mts +26 -0
  334. package/src/service/ownership-mutation-lease.mjs +76 -1
  335. package/src/service/windows-taskxml.ts +1 -2
  336. package/src/service-manager-probe.ts +36 -2
  337. package/src/types/config.ts +6 -0
  338. package/src/types/provider.ts +28 -10
  339. package/src/update/index.ts +2 -1
  340. package/src/update/npm-invocation.mjs +35 -3
  341. package/src/update/proxy-liveness-probe.mjs +38 -8
  342. package/src/update/update-failure-guidance.d.mts +6 -0
  343. package/src/update/update-failure-guidance.mjs +30 -5
  344. package/src/usage/expected-prices.ts +9 -0
  345. package/src/usage/log.ts +7 -0
  346. package/src/usage/summary.ts +82 -1
  347. package/src/web-search/loop.ts +11 -8
  348. package/src/web-search/run-turn-loop.ts +10 -7
  349. package/gui/dist/assets/App-CddorHdA.js +0 -51
  350. package/gui/dist/assets/App-DMudtISW.css +0 -1
  351. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  352. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  353. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
package/src/codex/sync.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { currentExternalCodexModelProvider, injectCodexConfig } from "./inject";
2
+ import { FOREIGN_CODEX_HOME_OWNER_MESSAGE, UNKNOWN_CODEX_HOME_OWNER_MESSAGE, unbackedRoutedRemovalMessage } from "./catalog/routed-removal";
2
3
  import { printProjectCodexConfigWarnings, groupProjectCodexConfigWarningsByPath, type ProjectCodexConfigWarning } from "./project-config-warnings";
3
4
  import { refreshCodexModelCatalog } from "./refresh";
4
5
  import { applyProxyEnv, loadConfig } from "../config";
@@ -35,8 +36,8 @@ export interface CodexSyncResult {
35
36
  cacheSynced: boolean;
36
37
  /**
37
38
  * Whether the catalog owner committed a validated catalog or refused the
38
- * refresh. Only a `catalog-only` result carries it; `ok` already answers the
39
- * question for callers that do not care which half refused.
39
+ * refresh. Available on applied and catalog-only results; `ok` on applied
40
+ * describes config injection, so callers must also inspect catalog evidence.
40
41
  */
41
42
  refreshOutcome?: "committed" | "refused";
42
43
  message: string;
@@ -262,17 +263,27 @@ export async function syncModelsToCodex(
262
263
  let cacheSynced = false;
263
264
  let warning: string | undefined;
264
265
  let comboOmissions: ComboCatalogOmission[] = [];
266
+ let refreshOutcome: "committed" | "refused" | undefined;
265
267
 
266
268
  try {
267
269
  const cat = await deps.refreshCodexModelCatalog(config);
268
270
  added = cat.added;
271
+ refreshOutcome = cat.refreshOutcome;
269
272
  catalogExists = cat.catalogExists;
270
273
  catalogWritten = cat.catalogWritten;
271
274
  cacheSynced = cat.cacheSynced;
272
275
  catalogPathForInjection = cat.catalogExists ? cat.path : null;
273
276
  catalogPath = catalogPathForInjection;
274
277
  comboOmissions = cat.comboOmissions ?? [];
275
- if (cat.added > 0) {
278
+ if (cat.skippedReason === "unbacked_routed_removal" || cat.skippedReason === "foreign_owner" || cat.skippedReason === "owner_unknown") {
279
+ // The catalog on disk is kept as it was; say so, or a native-only refresh refused for safety
280
+ // reads exactly like a successful sync (#6529).
281
+ warning = cat.skippedReason === "foreign_owner"
282
+ ? FOREIGN_CODEX_HOME_OWNER_MESSAGE
283
+ : cat.skippedReason === "owner_unknown" ? UNKNOWN_CODEX_HOME_OWNER_MESSAGE
284
+ : unbackedRoutedRemovalMessage(cat.protectedRoutedNamespaces ?? 1);
285
+ log?.error(`[opencodex] ${warning}`);
286
+ } else if (cat.added > 0) {
276
287
  log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
277
288
  } else if (!cat.catalogExists) {
278
289
  warning = "catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.";
@@ -322,6 +333,7 @@ export async function syncModelsToCodex(
322
333
  status: "applied",
323
334
  ok: result.success,
324
335
  added,
336
+ ...(refreshOutcome ? { refreshOutcome } : {}),
325
337
  catalogPath,
326
338
  catalogExists,
327
339
  catalogWritten,
@@ -370,7 +382,15 @@ async function refreshCatalogForSync(
370
382
  cacheSynced = cat.cacheSynced;
371
383
  catalogPath = cat.catalogExists ? cat.path : null;
372
384
  comboOmissions = cat.comboOmissions ?? [];
373
- if (cat.added > 0) {
385
+ if (cat.skippedReason === "unbacked_routed_removal" || cat.skippedReason === "foreign_owner" || cat.skippedReason === "owner_unknown") {
386
+ // The catalog on disk is kept as it was; say so, or a native-only refresh refused for safety
387
+ // reads exactly like a successful sync (#6529).
388
+ warning = cat.skippedReason === "foreign_owner"
389
+ ? FOREIGN_CODEX_HOME_OWNER_MESSAGE
390
+ : cat.skippedReason === "owner_unknown" ? UNKNOWN_CODEX_HOME_OWNER_MESSAGE
391
+ : unbackedRoutedRemovalMessage(cat.protectedRoutedNamespaces ?? 1);
392
+ log?.error(`[opencodex] ${warning}`);
393
+ } else if (cat.added > 0) {
374
394
  log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
375
395
  } else if (!cat.catalogExists) {
376
396
  warning = "catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.";
@@ -11,6 +11,7 @@ import {
11
11
 
12
12
  interface TargetCooldown {
13
13
  cooldownUntil: number;
14
+ status?: number;
14
15
  }
15
16
 
16
17
  const DEFAULT_COOLDOWN_MS = 60_000;
@@ -195,6 +196,23 @@ export function remainingComboCooldownMs(comboId: string, now = Date.now()): num
195
196
  return soonest;
196
197
  }
197
198
 
199
+ /** Snapshot active quota cooldowns without inspecting request eligibility or changing state. */
200
+ export function snapshotComboQuotaCooldowns<T extends Pick<OcxComboTarget, "provider" | "model">>(
201
+ comboId: string,
202
+ targets: Iterable<T>,
203
+ now = Date.now(),
204
+ ): Array<{ target: T; cooldownUntil: number }> {
205
+ const snapshot: Array<{ target: T; cooldownUntil: number }> = [];
206
+ for (const target of targets) {
207
+ const key = cooldownMapKey(comboId, target);
208
+ const cooldown = targetCooldowns.get(key);
209
+ if (!cooldown || cooldown.cooldownUntil <= now) continue;
210
+ if (cooldown.status !== 429 && cooldown.status !== 402) continue;
211
+ snapshot.push({ target, cooldownUntil: cooldown.cooldownUntil });
212
+ }
213
+ return snapshot;
214
+ }
215
+
198
216
  export function comboCooldownRetryAfterSeconds(comboId: string, now = Date.now()): string | undefined {
199
217
  const remainingMs = remainingComboCooldownMs(comboId, now);
200
218
  if (remainingMs === undefined) return undefined;
@@ -244,6 +262,7 @@ export function coolComboTarget(
244
262
  targetCooldowns.set(cooldownMapKey(comboId, target), {
245
263
  // Local fallbacks are capped at ten minutes; explicit server delays at one day.
246
264
  cooldownUntil: now + (serverDelayMs ?? Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS)),
265
+ status: options?.status,
247
266
  });
248
267
  sweepExpiredOnWrite(now);
249
268
  return true;
@@ -670,10 +689,84 @@ function isDefiniteContextOverflow(status: number, message: string): boolean {
670
689
  return false;
671
690
  }
672
691
 
692
+ const CODEX_ACCOUNT_MODEL_REFUSAL = /^The '[^']{1,256}' model is not supported when using Codex with a ChatGPT account\.$/;
693
+
694
+ export type CodexAccountModelRefusal = "other" | "refusal" | "ambiguous";
695
+
696
+ /** Inspect only the original root and its own response record, before carrier selection. */
697
+ export function hasConflictingCodexModelRefusalEnvelopes(status: number, payload: unknown): boolean {
698
+ if (status !== 400 || !payload || typeof payload !== "object" || Array.isArray(payload)) return false;
699
+ const response = Object.hasOwn(payload, "response") ? (payload as Record<string, unknown>).response : undefined;
700
+ const nested = response && typeof response === "object" && !Array.isArray(response) ? response : undefined;
701
+ return (Object.hasOwn(payload, "detail") || !!nested && Object.hasOwn(nested, "detail"))
702
+ && (Object.hasOwn(payload, "error") || !!nested && Object.hasOwn(nested, "error"));
703
+ }
704
+
705
+ /** Inspect an already parsed, bounded error frame without copying or walking its body. */
706
+ export function codexAccountModelRefusalPayload(status: number, payload: unknown): CodexAccountModelRefusal {
707
+ if (status !== 400 || !payload || typeof payload !== "object" || Array.isArray(payload)) return "other";
708
+ if (Object.hasOwn(payload, "detail") && Object.hasOwn(payload, "error")) return "ambiguous";
709
+ const { detail, error } = payload as { detail?: unknown; error?: unknown };
710
+ const field = typeof detail === "string" ? detail
711
+ : error && typeof error === "object" && !Array.isArray(error)
712
+ && typeof (error as Record<string, unknown>).message === "string"
713
+ ? (error as { message: string }).message : undefined;
714
+ return field !== undefined && CODEX_ACCOUNT_MODEL_REFUSAL.test(field) ? "refusal" : "other";
715
+ }
716
+
717
+ /** One complete, bounded HTTP envelope; accept only a single exact status prefix. */
718
+ function parseCodexRefusalEnvelope(status: number, message: string): { text: string; payload: unknown } | undefined {
719
+ if (status !== 400 || message.length > 16_384) return undefined;
720
+ let text = message.trim();
721
+ if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length);
722
+ let payload: unknown;
723
+ try { payload = JSON.parse(text); } catch { /* Bare refusal text is also a supported carrier. */ }
724
+ return { text, payload };
725
+ }
726
+
727
+ /** Only allowlisted hard stops cross the same HTTP boundary as positive refusal evidence. */
728
+ export function codexAccountModelRefusalHardStopCode(status: number, message: string): string | undefined {
729
+ const payload = parseCodexRefusalEnvelope(status, message)?.payload;
730
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
731
+ const root = payload as Record<string, unknown>;
732
+ const response = Object.hasOwn(root, "response") ? root.response : undefined;
733
+ const records = response && typeof response === "object" && !Array.isArray(response)
734
+ ? [root, response as Record<string, unknown>] : [root];
735
+ for (const record of records) {
736
+ const error = record.error && typeof record.error === "object" && !Array.isArray(record.error)
737
+ ? record.error as Record<string, unknown> : undefined;
738
+ // Diagnostic type is never code evidence, even when a code is absent or malformed.
739
+ for (const code of [error?.code, record.code]) {
740
+ if (typeof code !== "string") continue;
741
+ if (isNonReplayableUpstreamCode(code) || isCyberPolicyCode(code)) return code;
742
+ if (normalizedFailureCode(code) === "origin_rejected") return "origin_rejected";
743
+ }
744
+ }
745
+ return undefined;
746
+ }
747
+
748
+ /** A bounded fallback signal, never credential or account-entitlement evidence. */
749
+ export function codexAccountModelRefusal(
750
+ status: number, message: string, options?: { allowNestedResponse?: boolean },
751
+ ): CodexAccountModelRefusal {
752
+ const envelope = parseCodexRefusalEnvelope(status, message);
753
+ if (!envelope) return "other";
754
+ if (CODEX_ACCOUNT_MODEL_REFUSAL.test(envelope.text)) return "refusal";
755
+ const payload = envelope.payload;
756
+ if (hasConflictingCodexModelRefusalEnvelopes(status, payload)) return "ambiguous";
757
+ // A present root carrier stays authoritative, even when malformed or nonmatching.
758
+ if (options?.allowNestedResponse !== false && payload && typeof payload === "object" && !Array.isArray(payload)
759
+ && !Object.hasOwn(payload, "detail") && !Object.hasOwn(payload, "error")
760
+ && Object.hasOwn(payload, "response")) {
761
+ return codexAccountModelRefusalPayload(status, (payload as Record<string, unknown>).response);
762
+ }
763
+ return codexAccountModelRefusalPayload(status, payload);
764
+ }
765
+
673
766
  export function comboFailureDecision(
674
767
  status: number,
675
768
  message: string,
676
- options?: { code?: string | null },
769
+ options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal },
677
770
  ): ComboFailureDecision {
678
771
  if (status === 499) return "stop";
679
772
  if (message.toLowerCase().includes("origin_rejected")) return "stop";
@@ -688,6 +781,12 @@ export function comboFailureDecision(
688
781
  // Cyber policy is a hard non-retryable refusal — honor structured code even when
689
782
  // classificationText was truncated before the JSON code field.
690
783
  if (isCyberPolicyCode(options?.code)) return "stop";
784
+ // HTTP consumers retain hard codes before truncation; SSE metadata owns its selected carrier.
785
+ if (options?.codexModelRefusal === undefined && codexAccountModelRefusalHardStopCode(status, message)) return "stop";
786
+ const modelRefusal = status === 400
787
+ ? options?.codexModelRefusal ?? codexAccountModelRefusal(status, message) : "other";
788
+ // Competing envelopes cannot grant a hop through an earlier structured-code rule either.
789
+ if (modelRefusal === "ambiguous") return "stop";
691
790
  // HTTP 410 is normally terminal. A model-specific lifecycle verdict is target-local,
692
791
  // however: another provider/model in the declared combo can still serve the request.
693
792
  // Require structured lifecycle code or explicit model+lifecycle prose so unrelated
@@ -738,6 +837,7 @@ export function comboFailureDecision(
738
837
  if (["model_not_found", "model_unavailable", "unsupported_model"].includes(failureCode)) {
739
838
  return "hop";
740
839
  }
840
+ if (modelRefusal === "refusal") return "hop";
741
841
  // `free_rate_limited` no longer routes through `isProviderScopedQuotaCap` (it is a
742
842
  // per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
743
843
  if (failureCode === "free_rate_limited") return "hop";
@@ -28,6 +28,7 @@ export {
28
28
  pickComboTarget,
29
29
  pickComboTargetWithWait,
30
30
  quotaInactiveReason,
31
+ snapshotComboQuotaCooldowns,
31
32
  tryPickComboModel,
32
33
  UnknownComboError,
33
34
  type ComboPick,
package/src/combos/jev.ts CHANGED
@@ -127,6 +127,8 @@ export interface ResolveJevDecisionOptions {
127
127
  * any other id names a configured `jev-decision` row (for example a self-hosted Ollama `tev1`).
128
128
  */
129
129
  decisionProvider?: string;
130
+ /** Request-local authorization of the concrete decision destination, before credential access. */
131
+ isDestinationAllowed?: (providerName: string, modelId: string) => boolean;
130
132
  /** Decision deadline; values outside 1000..120000 ms keep the four-second default. */
131
133
  timeoutMs?: number;
132
134
  signal?: AbortSignal;
@@ -674,14 +676,20 @@ function selfHostedApiKey(name: string, apiKey: string | undefined): string | un
674
676
  * custom destination. Any other id must be an enabled `jev-decision` row whose baseUrl is a
675
677
  * `/systemone` endpoint and which names its own model; only its own key may accompany it, so no
676
678
  * TypeSafe credential can reach a self-hosted service. `undefined` means no usable decision
677
- * service (reported through the existing `missing_key` gate).
679
+ * service (reported through the existing `missing_key` gate); `null` means the request's
680
+ * destination scope refused it before any credential access.
678
681
  */
679
- function jevDecisionEndpoint(config: OcxConfig, decisionProvider: string): JevDecisionEndpoint | undefined {
682
+ function jevDecisionEndpoint(
683
+ config: OcxConfig,
684
+ decisionProvider: string,
685
+ isDestinationAllowed?: ResolveJevDecisionOptions["isDestinationAllowed"],
686
+ ): JevDecisionEndpoint | undefined | null {
680
687
  const configured = Object.hasOwn(config.providers, decisionProvider)
681
688
  ? config.providers[decisionProvider]
682
689
  : undefined;
683
690
  if (configured?.disabled === true) return undefined;
684
691
  if (decisionProvider === JEV_PROVIDER_ID) {
692
+ if (isDestinationAllowed?.(JEV_PROVIDER_ID, JEV_MODEL) === false) return null;
685
693
  const configuredOwnsJev = configured
686
694
  && providerMatchesRegistryTransport(JEV_PROVIDER_ID, configured);
687
695
  const apiKey = (
@@ -704,6 +712,7 @@ function jevDecisionEndpoint(config: OcxConfig, decisionProvider: string): JevDe
704
712
  // `jev-latest` is TypeSafe's model name; a self-hosted host must name its own.
705
713
  const model = configured.defaultModel?.trim() || configured.models?.[0]?.trim();
706
714
  if (!model) return undefined;
715
+ if (isDestinationAllowed?.(decisionProvider, model) === false) return null;
707
716
  const apiKey = selfHostedApiKey(decisionProvider, configured.apiKey);
708
717
  if (apiKey === null) return undefined;
709
718
  return {
@@ -733,7 +742,8 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr
733
742
  if (options.candidates.length === 0) return failed("no_choices");
734
743
  if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid");
735
744
 
736
- const endpoint = jevDecisionEndpoint(options.config, options.decisionProvider ?? JEV_PROVIDER_ID);
745
+ const endpoint = jevDecisionEndpoint(options.config, options.decisionProvider ?? JEV_PROVIDER_ID, options.isDestinationAllowed);
746
+ if (endpoint === null) return failed("invalid");
737
747
  if (!endpoint) return failed("missing_key");
738
748
 
739
749
  let requestBody: string;
@@ -6,6 +6,7 @@ import {
6
6
  coolComboTarget,
7
7
  earliestComboCooldown,
8
8
  isComboTargetInCooldown,
9
+ snapshotComboQuotaCooldowns as snapshotTargetQuotaCooldowns,
9
10
  type ComboFailureCooldownScope,
10
11
  } from "./failover";
11
12
  import { quotaResetRemainingMs } from "./reset-window";
@@ -67,6 +68,17 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now:
67
68
  return !cachedProviderQuotaIsExhausted(getCachedProviderRoutingQuota(target.provider, provider, now), now, target.model);
68
69
  }
69
70
 
71
+ /** Capture usable providers' quota cooldowns; request eligibility is deferred to exhaustion. */
72
+ export function snapshotComboQuotaCooldowns(
73
+ config: OcxConfig,
74
+ comboId: string,
75
+ now = Date.now(),
76
+ ): Array<{ target: NormalizedComboTarget; cooldownUntil: number }> {
77
+ const targets = getCombo(config, comboId)?.targets.filter(target =>
78
+ targetProviderIsUsable(config, target, now)) ?? [];
79
+ return snapshotTargetQuotaCooldowns(comboId, targets, now);
80
+ }
81
+
70
82
  function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean {
71
83
  if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 100) return false;
72
84
  return typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt > now;
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs";
14
14
  import { basename, dirname, join } from "node:path";
15
15
  import { recordOwnedConfigPath } from "../lib/config-ownership";
16
- import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
16
+ import { assertNotRealCodexHomeUnderTest, assertNotRealHomeUnderTest } from "../lib/test-home-guard";
17
17
  import {
18
18
  forgetEphemeralSecretPath,
19
19
  hardenSecretPath,
@@ -114,14 +114,19 @@ export function resolveWriteTarget(path: string): string {
114
114
  }
115
115
 
116
116
  function assertResolvedTargetAllowed(path: string, target: string): void {
117
+ // Codex-home files (catalog, models cache, journal, config.toml, profile) are addressed through
118
+ // CODEX_HOME, which falls back to os.homedir() when a test's teardown deletes it, and os.homedir()
119
+ // ignores HOME. That is how test runs rewrote the real catalog (#6529), so every atomic write
120
+ // whose directory is the real Codex home is refused under an armed test process.
121
+ let realParent: string | null = null;
122
+ try {
123
+ realParent = realpathSync(dirname(target));
124
+ } catch {
125
+ realParent = null;
126
+ }
127
+ assertNotRealCodexHomeUnderTest(realParent ?? dirname(target));
117
128
  if (target === path) {
118
- let realParent: string;
119
- try {
120
- realParent = realpathSync(dirname(target));
121
- } catch {
122
- return;
123
- }
124
- if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
129
+ if (realParent !== null && realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
125
130
  return;
126
131
  }
127
132
  assertNotRealHomeUnderTest(dirname(target));
@@ -1,3 +1,5 @@
1
+ import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
2
+ import { protocolConfigSchema } from "./schema/config-schema";
1
3
  import { createHash } from "node:crypto";
2
4
  import { lstatSync, readFileSync } from "node:fs";
3
5
  import { join } from "node:path";
@@ -65,6 +67,7 @@ import {
65
67
  runtimeRoleSchema,
66
68
  spendSchema,
67
69
  chatgptDesktopSchema,
70
+ chatgptDesktopConfigIssue,
68
71
  compactionRoutingSchema,
69
72
  skillsConfigSchema,
70
73
  memoryModelsSchema,
@@ -100,12 +103,16 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
100
103
  // the entire config, which would hide unrelated providers/accounts. The next
101
104
  // ordinary save persists the normalized absence.
102
105
  const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed);
106
+ const rawForce = rawSubagentModelForce(rawParsed);
103
107
  const rawEffort = rawClaudeSubagentEffort(rawParsed);
104
108
  const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
105
109
  const warnings = configPlaceholderWarnings(normalized);
110
+ if (rawForce !== undefined && !isSubagentModelEntry(rawForce)) warnings.push("claudeCode.subagentModelForce ignored: expected a safe roster-style model id");
106
111
  if (normalized.chatgptDesktop?.appServerShim === true && process.platform !== "darwin") {
107
112
  warnings.push("chatgptDesktop.appServerShim is experimental and macOS only; ignored on this platform");
108
113
  }
114
+ const chatgptDesktopIssue = chatgptDesktopConfigIssue(rawParsed);
115
+ if (chatgptDesktopIssue) warnings.push(`${chatgptDesktopIssue}; the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
109
116
  warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning));
110
117
  warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
111
118
  warnings.push(...degradedListenerWarnings(rawParsed, normalized));
@@ -644,7 +651,19 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
644
651
  if (memoryModels !== undefined && !memoryModelsSchema.safeParse(memoryModels).success) {
645
652
  return { ok: false, error: "schema_invalid: memoryModels: requires a nonblank model and an optional declared reasoningEffort per configured phase, and no other fields" };
646
653
  }
647
- const routeValue = (rawConfigRecord(value)?.anthropicAccountPool as Record<string, unknown> | undefined)?.routes;
654
+ const protocols = rawConfigRecord(value)?.protocols;
655
+ if (protocols !== undefined && !protocolConfigSchema.safeParse(protocols).success) {
656
+ return { ok: false, error: "schema_invalid: protocols: expected valid protocol policy and boolean rollout flags" };
657
+ }
658
+ const rawAnthropicPool = rawConfigRecord(value)?.anthropicAccountPool;
659
+ const anthropicPool = rawConfigRecord(rawAnthropicPool);
660
+ if (rawAnthropicPool !== undefined && !anthropicPool) {
661
+ return { ok: false, error: "schema_invalid: anthropicAccountPool: must be an object" };
662
+ }
663
+ if (anthropicPool && Object.hasOwn(anthropicPool, "nativeMessages") && typeof anthropicPool.nativeMessages !== "boolean") {
664
+ return { ok: false, error: "schema_invalid: anthropicAccountPool.nativeMessages: must be a boolean" };
665
+ }
666
+ const routeValue = anthropicPool?.routes;
648
667
  if (routeValue !== undefined) {
649
668
  const parsed = parseAnthropicModelRoutes(routeValue);
650
669
  if (!parsed.ok) return { ok: false, error: `schema_invalid: anthropicAccountPool.routes: ${parsed.error}` };
@@ -652,6 +671,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
652
671
  const boundaryError = blockedModelRedirectsError(value)
653
672
  ?? compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value)
654
673
  ?? blankHostnameError(value)
674
+ ?? (rawSubagentModelForce(value) !== undefined && !isSubagentModelEntry(rawSubagentModelForce(value)) ? "schema_invalid: claudeCode.subagentModelForce: expected a safe roster-style model id" : null)
655
675
  ?? claudeSubagentEffortError(value)
656
676
  ?? appOwnedMemoryBudgetError(value)
657
677
  ?? upstreamHostCircuitThresholdError(value)
@@ -726,6 +746,8 @@ export function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
726
746
  if (salvaged) {
727
747
  const config = normalizeApiKeyIds(salvaged.parsed);
728
748
  const warnings = degradedListenerWarnings(parsed, config);
749
+ const chatgptDesktopIssue = chatgptDesktopConfigIssue(parsed);
750
+ if (chatgptDesktopIssue) warnings.push(`${chatgptDesktopIssue}; the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
729
751
  return {
730
752
  config,
731
753
  source: "fallback",
@@ -1,3 +1,4 @@
1
+ import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
1
2
  import { chmodSync, existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
@@ -40,6 +41,7 @@ import {
40
41
  transientRetryOn5xxPolicySchema,
41
42
  runtimeRoleSchema,
42
43
  spendSchema,
44
+ chatgptDesktopConfigIssue,
43
45
  } from "./schema/leaf-validators";
44
46
  import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo";
45
47
 
@@ -123,6 +125,8 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx
123
125
  export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void {
124
126
  if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved");
125
127
  if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available");
128
+ const chatgptDesktop = chatgptDesktopConfigIssue(rawParsed);
129
+ if (chatgptDesktop) console.warn(`⚠️ config.json ${chatgptDesktop} — the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
126
130
  warnDegradedStreamMode(rawParsed, validated);
127
131
  warnDegradedCompactionRouting(rawParsed, validated);
128
132
  warnDegradedMemoryModels(rawParsed, validated);
@@ -592,6 +596,7 @@ export function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["cl
592
596
  return claudeCode as OcxConfig["claudeCode"];
593
597
  }
594
598
  const normalized = { ...claudeCode } as Record<string, unknown>;
599
+ if (Object.hasOwn(normalized, "subagentModelForce") && !isSubagentModelEntry(normalized.subagentModelForce)) delete normalized.subagentModelForce;
595
600
  // A malformed hand edit must not arm CLI interception or discard the whole config.
596
601
  if (Object.hasOwn(normalized, "cliFirstParty") && typeof normalized.cliFirstParty !== "boolean") {
597
602
  delete normalized.cliFirstParty;
@@ -634,6 +639,8 @@ export function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unk
634
639
  }
635
640
 
636
641
  export function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
642
+ const force = rawSubagentModelForce(rawParsed);
643
+ if (force !== undefined && !isSubagentModelEntry(force)) console.warn("⚠️ config.json claudeCode.subagentModelForce is invalid — ignoring it. Other settings were preserved.");
637
644
  const rawEffort = rawClaudeSubagentEffort(rawParsed);
638
645
  if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
639
646
  console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
@@ -6,6 +6,7 @@ import {
6
6
  } from "../providers/model-discovery-limits";
7
7
  import { isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
8
8
  import { encodeRoutedModelId } from "../providers/slug-codec";
9
+ import { providerForwardClientHeadersConfigError } from "../lib/provider-client-headers";
9
10
  import {
10
11
  isWirePinnedModel,
11
12
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
@@ -125,6 +126,8 @@ export function providerHeadersConfigError(headers: unknown): string | null {
125
126
  return null;
126
127
  }
127
128
 
129
+ export { providerForwardClientHeadersConfigError };
130
+
128
131
  /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
129
132
  export function apiKeyTransportConfigError(
130
133
  provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
@@ -0,0 +1,22 @@
1
+ import * as z from "zod/v4";
2
+ import { redactSecretString } from "../../lib/redact";
3
+
4
+ /** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */
5
+ export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict();
6
+
7
+ /**
8
+ * Why a present `chatgptDesktop` block fails the schema, or null when it is valid or absent. The
9
+ * read path degrades a failing block to absent, so the integration reads as off while the file
10
+ * still says `appServerShim: true`; this names the field so that is never silent. A leftover key
11
+ * from an older or ported config (for example `unblockSend`) is the usual cause.
12
+ */
13
+ export function chatgptDesktopConfigIssue(rawParsed: unknown): string | null {
14
+ if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null;
15
+ const block = (rawParsed as Record<string, unknown>).chatgptDesktop;
16
+ if (block === undefined) return null;
17
+ const result = chatgptDesktopSchema.safeParse(block);
18
+ if (result.success) return null;
19
+ const issue = result.error.issues[0];
20
+ const field = issue?.path.join(".");
21
+ return redactSecretString(`chatgptDesktop${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`);
22
+ }
@@ -9,3 +9,11 @@
9
9
  * with "Cannot access 'runtimeRoleSchema' before initialization".
10
10
  */
11
11
  export const COMPACTION_TRIGGERS = ["manual", "auto"] as const;
12
+
13
+ /** Exact selectors or a provider-qualified trailing wildcard; never a global wildcard. */
14
+ export function validCompactionSourceModels(value: unknown): value is string[] {
15
+ return Array.isArray(value) && value.length > 0 && new Set(value).size === value.length
16
+ && value.every(entry => typeof entry === "string" && entry.length > 0
17
+ && entry === entry.trim() && !/\s/.test(entry)
18
+ && (!entry.includes("*") || /^[^/*]+\/\*$/.test(entry)));
19
+ }
@@ -45,6 +45,7 @@ import {
45
45
  positiveIntegerRecordConfigError,
46
46
  providerBaseUrlConfigError,
47
47
  providerHeadersConfigError,
48
+ providerForwardClientHeadersConfigError,
48
49
  reasoningSummaryDeliveryRecordConfigError,
49
50
  } from "../provider-validation";
50
51
  import {
@@ -73,6 +74,18 @@ import { parseDesktopProfile } from "../../claude/desktop-profile";
73
74
  import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/intercept/model-bindings";
74
75
  import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory";
75
76
 
77
+ /** Strict write contract; file-load recovery is applied only by the enclosing schema. */
78
+ export const protocolConfigSchema = z.object({
79
+ unrepresentable: z.enum(["legacy", "reject"]).optional(),
80
+ rollout: z.object({
81
+ nativeChatCombos: z.boolean().optional(),
82
+ managedMessagesNative: z.boolean().optional(),
83
+ managedMessagesNativeOAuth: z.boolean().optional(),
84
+ directEncoders: z.boolean().optional(),
85
+ shadowPlan: z.boolean().optional(),
86
+ }).strict().optional(),
87
+ }).strict();
88
+
76
89
  export const configSchema = z.object({
77
90
  chatgptDesktop: chatgptDesktopSchema.optional().catch(undefined),
78
91
  codexNativeSteering: z.boolean().optional().catch(false),
@@ -95,18 +108,23 @@ export const configSchema = z.object({
95
108
  // which can reopen a surface the operator meant to close. src/protocols/settings.ts parses it
96
109
  // and fails closed instead.
97
110
  apiSurfaces: z.unknown().optional(),
98
- // Every protocol default is the conservative one (legacy policy, rollout off), so a malformed
99
- // block dropping to undefined cannot widen behavior.
100
- protocols: z.object({
101
- unrepresentable: z.enum(["legacy", "reject"]).optional(),
102
- rollout: z.object({
103
- nativeChatCombos: z.boolean().optional(),
104
- managedMessagesNative: z.boolean().optional(),
105
- managedMessagesNativeOAuth: z.boolean().optional(),
106
- directEncoders: z.boolean().optional(),
107
- shadowPlan: z.boolean().optional(),
108
- }).strict().optional(),
109
- }).strict().optional().catch(undefined),
111
+ // Keep malformed native policy disabled even when an enabled pool supplies defaults.
112
+ protocols: protocolConfigSchema.optional().catch(ctx => {
113
+ const raw = ctx.input;
114
+ const protocols = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
115
+ const rollout = protocols.rollout;
116
+ const fields = rollout && typeof rollout === "object" && !Array.isArray(rollout) ? rollout as Record<string, unknown> : {};
117
+ return {
118
+ unrepresentable: protocols.unrepresentable === "reject" ? "reject" as const : "legacy" as const,
119
+ rollout: {
120
+ nativeChatCombos: fields.nativeChatCombos === true,
121
+ managedMessagesNative: false,
122
+ managedMessagesNativeOAuth: false,
123
+ directEncoders: fields.directEncoders === true,
124
+ shadowPlan: fields.shadowPlan === true,
125
+ },
126
+ };
127
+ }),
110
128
  // A malformed present client block must remain diagnosable from raw config and
111
129
  // fail closed through src/client/state.ts; unrelated provider state still loads.
112
130
  client: clientConnectionSchema.optional().catch(undefined),
@@ -307,8 +325,9 @@ export const configSchema = z.object({
307
325
  // path below and wipe providers/pool accounts. Warning emitted in loadConfig.
308
326
  streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
309
327
  blockedModelRedirects: blockedModelRedirectsSchema.optional().catch(undefined),
310
- // Preserve malformed hand edits for a local routing error; candidate writes use the shared parser.
311
- anthropicAccountPool: z.unknown().optional(),
328
+ // Degrade malformed hand edits locally; candidate writes reject them before parsing.
329
+ // An invalid native preference retains the legacy route instead of enabling native by default.
330
+ anthropicAccountPool: z.object({ nativeMessages: z.boolean().optional().catch(false) }).passthrough().optional().catch(undefined),
312
331
  // Same degrade-don't-reject rationale as the fields above: a hand-edited
313
332
  // non-string must not trip the backup-and-defaults repair path. Unset then
314
333
  // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).
@@ -506,6 +525,16 @@ export const configSchema = z.object({
506
525
  message: headersError,
507
526
  });
508
527
  }
528
+ const forwardClientHeadersError = providerForwardClientHeadersConfigError(
529
+ (provider as { forwardClientHeaders?: unknown }).forwardClientHeaders,
530
+ );
531
+ if (forwardClientHeadersError) {
532
+ ctx.addIssue({
533
+ code: "custom",
534
+ path: ["providers", redactSecretString(name), "forwardClientHeaders"],
535
+ message: forwardClientHeadersError,
536
+ });
537
+ }
509
538
  const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts);
510
539
  if (modelCostsError) {
511
540
  ctx.addIssue({
@@ -41,10 +41,11 @@ import {
41
41
  isHostedToolUnsupportedForModel,
42
42
  } from "../../responses/hosted-tool-policy";
43
43
  import { getConfigDir } from "../paths";
44
- import { COMPACTION_TRIGGERS } from "./compaction-triggers";
44
+ import { COMPACTION_TRIGGERS, validCompactionSourceModels } from "./compaction-triggers";
45
45
 
46
- /** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */
47
- export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict();
46
+ // The chatgptDesktop leaf lives in its own zod-only module so the `ocx chatgpt` command can explain
47
+ // a dropped block without loading this file's provider and account validators.
48
+ export { chatgptDesktopConfigIssue, chatgptDesktopSchema } from "./chatgpt-desktop";
48
49
 
49
50
  /** One definition of "usable secret", shared by the schema and the warnings. */
50
51
  export function isUsableApiKeySecret(value: unknown): value is string {
@@ -53,6 +54,8 @@ export function isUsableApiKeySecret(value: unknown): value is string {
53
54
 
54
55
  export const compactionRoutingSchema = z.object({
55
56
  model: z.string().trim().min(1),
57
+ sourceModels: z.array(z.string()).refine(validCompactionSourceModels,
58
+ "sourceModels requires unique exact selectors or provider/* patterns").optional(),
56
59
  reasoningEffort: z.string().refine(value => pinnedReasoningEffortConfigError(value) === null).optional(),
57
60
  triggers: z.array(z.enum(COMPACTION_TRIGGERS)).nonempty()
58
61
  .refine(values => new Set(values).size === values.length, "triggers must not repeat a value")
@@ -327,6 +330,8 @@ export const providerConfigSchema = z.object({
327
330
  modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
328
331
  modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(),
329
332
  preserveResponsesReasoningContent: z.boolean().optional(),
333
+ preserveResponsesInputItemIds: z.boolean().optional(),
334
+ preserveResponsesMessageMetadata: z.boolean().optional(),
330
335
  dropResponsesReasoningItems: z.boolean().optional(),
331
336
  modelReasoningEffortsAuthoritative: z.boolean().optional(),
332
337
  decodesNativeCompactionBlobs: z.boolean().optional(),
@@ -75,3 +75,15 @@ export function migrateSubagentModels(config: OcxConfig): boolean {
75
75
  config.subagentModelsVersion = SUBAGENT_MODELS_VERSION;
76
76
  return true;
77
77
  }
78
+
79
+ export const SAFE_AGENT_MODEL_ID = /^[a-z0-9][a-z0-9._:/@+\[\]~-]*$/i;
80
+
81
+ export function isSubagentModelEntry(value: unknown): value is string {
82
+ return typeof value === "string" && SAFE_AGENT_MODEL_ID.test(value);
83
+ }
84
+
85
+ export function rawSubagentModelForce(value: unknown): unknown {
86
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
87
+ const cc = (value as Record<string, unknown>).claudeCode;
88
+ return cc && typeof cc === "object" && !Array.isArray(cc) ? (cc as Record<string, unknown>).subagentModelForce : undefined;
89
+ }