@bitkyc08/opencodex 2.58.0 → 2.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -360,6 +360,25 @@ export const CAPABILITIES: readonly Capability[] = [
360
360
  "`--quota` shows cached Codex windows (including 5h); `--refresh` bypasses the server TTL.",
361
361
  ],
362
362
  },
363
+ {
364
+ command: ["account", "import-orca"],
365
+ summary: "Preview or register read-only links to Orca-managed Codex accounts without another login.",
366
+ routes: [],
367
+ flags: [
368
+ { name: "--source", value: "string", required: true, summary: "Orca data directory containing codex-accounts." },
369
+ { name: "--registry", value: "string", required: true, summary: "The chosen Orca profile's orca-data.json account registry." },
370
+ { name: "--apply", value: "boolean", summary: "Register new accounts; requires a stopped proxy. Default is preview." },
371
+ { name: "--json", value: "boolean", summary: "Emit counts and fixed invalid-reason codes without credentials or source paths." },
372
+ ],
373
+ mutates: true,
374
+ json: "envelope",
375
+ details: [
376
+ "Local files only; never copies refresh tokens or changes Orca authentication files.",
377
+ "Skips existing ChatGPT identities. New accounts remain pending until dashboard validation.",
378
+ "Orca must keep the source login available and refreshed; a missing or expired source fails closed.",
379
+ "Mixed eligible and invalid entries exit successfully; an all-invalid result exits nonzero.",
380
+ ],
381
+ },
363
382
  {
364
383
  command: ["account", "refresh"],
365
384
  summary: "Refresh account quotas without model validation; pending Codex accounts require dashboard consent.",
@@ -708,6 +727,27 @@ export const CAPABILITIES: readonly Capability[] = [
708
727
  "Does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.",
709
728
  ],
710
729
  },
730
+ {
731
+ command: ["system", "codex-cli-update", "attest"],
732
+ summary: "Observe the selected or explicitly named Windows npm Codex installation files without enabling updates.",
733
+ routes: [],
734
+ flags: [
735
+ { name: "--candidate", value: "string", summary: "Absolute npm codex.cmd or package bin/codex.js path; all four paths are all-or-none." },
736
+ { name: "--npm-prefix", value: "string", summary: "Absolute prefix containing node_modules/@openai/codex." },
737
+ { name: "--npm-cli", value: "string", summary: "Absolute node_modules/npm/bin/npm-cli.js path." },
738
+ { name: "--node", value: "string", summary: "Absolute node.exe path; observed, never executed." },
739
+ { name: "--json", value: "boolean", summary: "Emit the path-free installation identity observation." },
740
+ ],
741
+ mutates: false,
742
+ json: "envelope",
743
+ details: [
744
+ "Opt-in Windows x64 local-volume inspection using held native file handles; refuses reparse points, active writers and unsupported layouts.",
745
+ "Without explicit paths, the proof-bound launcher snapshot identifies the selected candidate: the configured CODEX_CLI_PATH or the first codex on the captured PATH, with an OpenCodex wrapper resolving to its codex.opencodex-real backing. Discovery only proposes paths; the held-handle observation remains the authority.",
746
+ "Success binds observed file identities and bytes, not selected-runtime admission or installer ownership.",
747
+ "selectionAttested, managed and applyAllowed remain false. The digest is an observation, not a durable update permit.",
748
+ "Does not run the named Codex/npm/Node files, query a registry, install software, control processes or persist state.",
749
+ ],
750
+ },
711
751
  {
712
752
  command: ["system", "codex-restart"],
713
753
  summary: "Restart the Codex desktop app and app-servers.",
package/src/cli/claude.ts CHANGED
@@ -11,7 +11,7 @@ import { spawn } from "node:child_process";
11
11
  import { loadConfig } from "../config";
12
12
  import { injectClaudeAgentDefs } from "../claude/agents-inject";
13
13
  import { CLAUDE_ALIAS_PREFIX_V1, CLAUDE_ALIAS_PREFIX_V2 } from "../claude/alias";
14
- import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows";
14
+ import { claudeToolSearchEnv, effectiveModelEnv, resolveAutoContext } from "../claude/context-windows";
15
15
  import { claudeConfigDir, refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
16
16
  import { commandInvocation } from "../lib/win-exec";
17
17
  import { isProxyAdmissionSecret } from "../server/auth-cors";
@@ -357,6 +357,22 @@ export function buildClaudeEnv(
357
357
  if (config.claudeCode?.alwaysEnableEffort === true) {
358
358
  setDefault("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1");
359
359
  }
360
+ // Tool-search deferral (#4838). Claude Code disables MCP tool deferral whenever
361
+ // ANTHROPIC_BASE_URL is not a first-party Anthropic host — keyed on the host, not
362
+ // the model — so every routed session inlines all MCP tool schemas. Its own log
363
+ // line states the precondition: "Set ENABLE_TOOL_SEARCH=true (or auto / auto:N)
364
+ // if your proxy forwards tool_reference blocks."
365
+ //
366
+ // We forward them on the native Anthropic passthrough route only. A translated
367
+ // route cannot honour the shape: deferred tools still carry input_schema on the
368
+ // wire (deferral is a server-side context optimisation, not a smaller request),
369
+ // toolsToResponses drops the tool_search server tool and ignores defer_loading,
370
+ // and compatibility.ts lists tool_search/tool_reference/deferred_tools as
371
+ // unsupported. Turning it on there leaves the routed provider holding every
372
+ // schema while Claude Code stops counting them and stops compacting, which is
373
+ // worse than the problem. So this stays opt-in per config rather than
374
+ // unconditional, and setDefault keeps an operator's own export.
375
+ setDefault("ENABLE_TOOL_SEARCH", claudeToolSearchEnv(config.claudeCode?.toolSearch));
360
376
  // Context-window override: the official pair — MAX_CONTEXT_TOKENS alone is ignored
361
377
  // for recognized claude-shaped ids unless DISABLE_COMPACT=1 rides along (devlog 135).
362
378
  const maxCtx = config.claudeCode?.maxContextTokens;
@@ -542,6 +558,18 @@ export function claudeLaunchPreflight(
542
558
  : { kind: "native", notice: CLAUDE_NATIVE_ROUTING_OFF };
543
559
  }
544
560
 
561
+ /**
562
+ * Levers a native (non-routed) launch must shed. The loop below deletes them
563
+ * unconditionally, because each one either points Claude Code at a gateway that
564
+ * is not there or asserts host ownership that a native session does not have.
565
+ *
566
+ * ENABLE_TOOL_SEARCH is deliberately NOT in this list (#4838). It is the only
567
+ * one of these whose value is meaningful to a native session: natively the base
568
+ * URL is first-party, where deferral is already Claude Code's default and the
569
+ * variable is the user's own tuning knob (auto:N, force). Stripping it would
570
+ * delete a preference that works, to protect a session that does not need
571
+ * protecting.
572
+ */
545
573
  const NATIVE_STRIPPED_LEVERS = [
546
574
  "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
547
575
  "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST",
@@ -3,18 +3,53 @@ import {
3
3
  type CodexCliInstallProvenanceDeps,
4
4
  type CodexCliInstallReport,
5
5
  } from "../codex/cli-install-provenance";
6
+ import type {
7
+ CodexCliInstallationIdentityInput,
8
+ CodexCliInstallationIdentityReport,
9
+ } from "../codex/cli-installation-identity";
10
+ import type {
11
+ CodexCliInstallationSnapshot,
12
+ CodexCliInstallationTargetDerivation,
13
+ } from "../codex/cli-installation-targets";
6
14
  import { CliUsageError, isJsonOption, printData, runCliAction } from "./runtime-api";
7
15
  import { trustedNodeLauncherContext } from "./launcher-context";
8
16
 
9
17
  export const CODEX_CLI_UPDATE_USAGE = `Usage:
10
- ocx system codex-cli-update check [--json]`;
18
+ ocx system codex-cli-update check [--json]
19
+ ocx system codex-cli-update attest [--json]
20
+ ocx system codex-cli-update attest --candidate <absolute-path> --npm-prefix <absolute-path> --npm-cli <absolute-path> --node <absolute-path> [--json]`;
11
21
 
12
22
  export type ParsedCodexCliUpdateArgs = Readonly<{
13
23
  json: boolean;
24
+ }> | Readonly<{
25
+ json: boolean;
26
+ attest: CodexCliInstallationIdentityInput | "selected";
14
27
  }>;
15
28
 
16
29
  export interface CodexCliUpdateCommandDeps {
17
30
  readonly inspectInstall?: (deps: CodexCliInstallProvenanceDeps) => Promise<CodexCliInstallReport>;
31
+ readonly inspectIdentity?: (input: CodexCliInstallationIdentityInput) => Promise<CodexCliInstallationIdentityReport>;
32
+ readonly deriveInstallationInput?: (
33
+ snapshot: CodexCliInstallationSnapshot,
34
+ ) => CodexCliInstallationTargetDerivation;
35
+ }
36
+
37
+ function identitySummary(report: CodexCliInstallationIdentityReport): string[] {
38
+ return [
39
+ `status: ${report.status}`,
40
+ `reason: ${report.reason}`,
41
+ `candidate-source: ${report.candidateSource}`,
42
+ `installation-identity-observed: ${report.installationIdentityObserved ? "yes" : "no"}`,
43
+ `selection-attested: ${report.selectionAttested ? "yes" : "no"}`,
44
+ `managed: ${report.managed ? "yes" : "no"}`,
45
+ `apply-allowed: ${report.applyAllowed ? "yes" : "no"}`,
46
+ `package-version: ${report.packageVersion ?? "unavailable"}`,
47
+ `npm-version: ${report.npmVersion ?? "unavailable"}`,
48
+ `identity-digest: ${report.identityDigest ?? "unavailable"}`,
49
+ `proof: ${report.proof ?? "unavailable"}`,
50
+ `toolchain: ${report.toolchain}`,
51
+ "scope: installation identity only; runtime selection and update ownership are not attested",
52
+ ];
18
53
  }
19
54
 
20
55
  function installSummary(report: CodexCliInstallReport): string[] {
@@ -47,8 +82,38 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli
47
82
  }
48
83
  positional.push(token);
49
84
  }
85
+ if (positional[0] === "attest") {
86
+ // No options: attest the selected candidate identified from the proof-bound
87
+ // launcher snapshot. The four explicit paths remain all-or-none.
88
+ if (positional.length === 1) {
89
+ return Object.freeze({ json, attest: "selected" as const });
90
+ }
91
+ const options = new Map<string, keyof CodexCliInstallationIdentityInput>([
92
+ ["--candidate", "candidate"], ["--npm-prefix", "npmPrefix"],
93
+ ["--npm-cli", "npmCli"], ["--node", "node"],
94
+ ]);
95
+ const input: Partial<Record<keyof CodexCliInstallationIdentityInput, string>> = {};
96
+ for (let index = 1; index < positional.length; index += 2) {
97
+ const key = options.get(positional[index]!);
98
+ if (!key || input[key] !== undefined) {
99
+ throw new CliUsageError("unsupported or duplicate attest option", CODEX_CLI_UPDATE_USAGE);
100
+ }
101
+ const value = positional[index + 1];
102
+ if (!value || !value.trim() || /[\0\r\n]/.test(value)
103
+ || !(value.startsWith("/") || /^[a-z]:[\\/]/i.test(value))) {
104
+ throw new CliUsageError("attest options require explicit absolute paths", CODEX_CLI_UPDATE_USAGE);
105
+ }
106
+ input[key] = value;
107
+ }
108
+ if (!input.candidate || !input.npmPrefix || !input.npmCli || !input.node) {
109
+ throw new CliUsageError("attest requires --candidate, --npm-prefix, --npm-cli and --node", CODEX_CLI_UPDATE_USAGE);
110
+ }
111
+ return Object.freeze({ json, attest: Object.freeze({
112
+ candidate: input.candidate, npmPrefix: input.npmPrefix, npmCli: input.npmCli, node: input.node,
113
+ }) });
114
+ }
50
115
  if (positional[0] !== "check") {
51
- throw new CliUsageError("codex-cli-update action must be check", CODEX_CLI_UPDATE_USAGE);
116
+ throw new CliUsageError("codex-cli-update action must be check or attest", CODEX_CLI_UPDATE_USAGE);
52
117
  }
53
118
  if (positional.length > 1) {
54
119
  throw new CliUsageError("unsupported codex-cli-update argument", CODEX_CLI_UPDATE_USAGE);
@@ -72,6 +137,36 @@ export async function handleCodexCliUpdateCommand(
72
137
  throw error;
73
138
  }
74
139
  return runCliAction(async () => {
140
+ if ("attest" in parsed) {
141
+ let report: CodexCliInstallationIdentityReport;
142
+ try {
143
+ const identityModule = await import("../codex/cli-installation-identity");
144
+ const inspectIdentity = deps.inspectIdentity ?? identityModule.inspectCodexCliInstallationIdentity;
145
+ if (parsed.attest === "selected") {
146
+ // The proof-bound launcher snapshot is the only trusted source for the
147
+ // selected candidate; a direct Bun/source launch has none and refuses.
148
+ const snapshot = trustedNodeLauncherContext()?.codexCliInspectionEnv;
149
+ const derive = deps.deriveInstallationInput
150
+ ?? (await import("../codex/cli-installation-targets")).deriveCodexCliInstallationInput;
151
+ const derived = derive({
152
+ codexCliPath: snapshot?.codexCliPath ?? null,
153
+ path: snapshot?.path ?? null,
154
+ pathExt: snapshot?.pathExt ?? null,
155
+ });
156
+ report = derived.kind === "derived"
157
+ ? await inspectIdentity(derived.input)
158
+ : identityModule.codexCliInstallationRefusal(derived.reason, "selected");
159
+ } else {
160
+ report = await inspectIdentity(parsed.attest);
161
+ }
162
+ } catch {
163
+ // Filesystem/native errors can contain the explicit private paths. The
164
+ // read-only inspector normally returns a refusal; unexpected errors stay redacted.
165
+ throw new Error("Installation identity inspection failed");
166
+ }
167
+ printData(report, parsed.json, identitySummary(report));
168
+ return;
169
+ }
75
170
  const trustedInspectionEnv = trustedNodeLauncherContext()?.codexCliInspectionEnv;
76
171
  const inspectionDeps: CodexCliInstallProvenanceDeps = trustedInspectionEnv
77
172
  && trustedInspectionEnv.managerRoots !== null ? {
@@ -945,9 +945,9 @@ export type StartOwnerDecision = "refuse" | "service-stay-out" | "sibling";
945
945
  *
946
946
  * The #3106 guard exists so a bare `start` cannot shadow a healthy configured-port
947
947
  * proxy with an ephemeral-port copy. An interactive `--port X` naming a DIFFERENT
948
- * port than the live proxy's is an explicit sibling request, not that shadow — and
949
- * refusing it also broke every spawned-launcher test on a machine running a real
950
- * proxy, because the probe reaches the machine-global port across sandbox homes.
948
+ * port than the live proxy's is an explicit sibling request, not that shadow. The
949
+ * state-directory spend-ledger lease makes the final same-home refusal; keeping this
950
+ * decision allows isolated homes on one machine to remain independent.
951
951
  * The service wrapper always passes the configured port and keeps its exact
952
952
  * stay-out-of-the-way semantics: it never takes the sibling path.
953
953
  */
@@ -965,6 +965,60 @@ export function decideStartWithLiveOwner(input: {
965
965
  return input.ocxService === "1" ? "service-stay-out" : "refuse";
966
966
  }
967
967
 
968
+ /** What `chooseListenPort` does when the preferred port stayed busy through prefer-retry. */
969
+ export type BusyPreferredPortDecision =
970
+ | "hop"
971
+ | "refuse-live-proxy"
972
+ | "service-stay-out"
973
+ | "refuse-unidentified-holder";
974
+
975
+ /**
976
+ * Pure decision for a soft `start` whose preferred port is busy and whose only remaining
977
+ * option is an ephemeral port.
978
+ *
979
+ * The hop exists so a first start is not defeated by a port this machine happens to be
980
+ * using. What it must never be is a silent answer to "someone is already here": a start
981
+ * that hops takes over this home's pid and runtime-port records and re-points Codex at
982
+ * itself, so hopping past a live opencodex leaves two proxies running and the editor
983
+ * talking to the one the user did not mean (#5004). The hop path never asked who held the
984
+ * port, and `findLiveProxy` returning null — a stale record, a probe that lost a race, a
985
+ * loopback family split — was enough to reach it.
986
+ *
987
+ * So the decision is made from the holder's own answer rather than from this home's
988
+ * bookkeeping, and both outcomes stop the start. An opencodex answer is the duplicate this
989
+ * closes. A holder that does not answer as opencodex is deliberately NOT called foreign:
990
+ * an identity probe returns the same nothing for a foreign server, an unreachable one, and
991
+ * one that lost a race, so all the start can honestly say is that the port it was told to
992
+ * use is taken by something it could not identify — and moving to an arbitrary port is the
993
+ * one response that hides that from the user while re-pointing Codex. An explicit
994
+ * `--port` never reaches here (`findAvailablePort` refuses the fallback instead), and a
995
+ * configured port of 0 is a request for an ephemeral port, not a collision.
996
+ *
997
+ * Service-wrapper context keeps the semantics `decideStartWithLiveOwner` gives it: a
998
+ * healthy proxy on the port means the port is served, and the wrapper's
999
+ * `if %ERRORLEVEL% NEQ 0` loop must see a zero exit rather than respawn every 5 seconds.
1000
+ */
1001
+ export function decideBusyPreferredPort(input: {
1002
+ preferredPort: number;
1003
+ selectedPort: number;
1004
+ hardPin: boolean;
1005
+ holderIsOpencodex: boolean;
1006
+ ocxService: string | undefined;
1007
+ }): BusyPreferredPortDecision {
1008
+ // Port 0 (or an unusable preference) asked the OS to choose; nothing was taken away.
1009
+ if (input.preferredPort <= 0) return "hop";
1010
+ // The preferred port was obtained — no hop happened, nothing to decide.
1011
+ if (input.selectedPort === input.preferredPort) return "hop";
1012
+ // Defensive: a hard pin cannot reach a different port, and if it ever did, the pin is
1013
+ // the user's explicit instruction and not something to answer with a refusal here.
1014
+ if (input.hardPin) return "hop";
1015
+ if (input.holderIsOpencodex) {
1016
+ // Same sentinel rule as decideStartWithLiveOwner: only the exact "1" is service context.
1017
+ return input.ocxService === "1" ? "service-stay-out" : "refuse-live-proxy";
1018
+ }
1019
+ return "refuse-unidentified-holder";
1020
+ }
1021
+
968
1022
  export function resolveDispatchCommand(command: string | undefined): string | undefined {
969
1023
  if (command === undefined) return undefined;
970
1024
  if (Object.prototype.hasOwnProperty.call(commandRunners, command)) return command;
package/src/cli/doctor.ts CHANGED
@@ -13,7 +13,7 @@ import { dirname, join } from "node:path";
13
13
  import { getConfigDir, getConfigPath, readConfigDiagnostics } from "../config";
14
14
  import { readPid } from "../config/process-state";
15
15
  import { probeUncleanExitState } from "./status";
16
- import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness";
16
+ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
17
17
  import { BUN_RUNTIME_SOURCES } from "../lib/bun-runtime";
18
18
  import type { BunRuntimeSource } from "../lib/bun-runtime";
19
19
  import { maskAccountId } from "../lib/privacy";
@@ -26,7 +26,11 @@ import { withNativeMainSharedClaim } from "../codex/native-main-claim";
26
26
  import { probeNativeProfileRecoveryState, resolveNativeProfileContext } from "../codex/native-profile-store";
27
27
  import { NativeProfileError } from "../codex/native-profile-types";
28
28
  import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
29
- import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback";
29
+ import {
30
+ scanCodexAgentRolesWithTomlModelFallback,
31
+ scanOpencodexDerivedCodexAgentRolesWithoutModelPin,
32
+ } from "../codex/subagent-model-fallback";
33
+ import { readCatalog, readCodexCatalogPath, readConfiguredDefaultModel } from "../codex/catalog/parsing";
30
34
  import { diagnoseCodexShim, findCodexOnPath, isWindowsInteropDir, type CodexShimDiagnostic } from "../codex/shim";
31
35
  import { providerTableString, rootTomlString } from "../codex/injected-marker";
32
36
  import { countPendingOpencodexHistory } from "../codex/history-provider";
@@ -1040,6 +1044,161 @@ export function chatgptPublicEndpointHint(
1040
1044
  return "ChatGPT-family requests use the public ChatGPT endpoint through this proxy, in both Pool and Direct modes. Eligible streaming turns dial the ChatGPT websocket transport (the same responses_websockets lane Codex CLI defaults to) and fall back to SSE over HTTP when a turn is not eligible - an unsupported Bun runtime, an oversized create frame, or a proxy route that cannot carry the socket - and local provider pacing can hold a request before it is dispatched at all. This hint classifies configuration only and measures nothing, so upstream queueing is one possible contributor to a slow first output: compare actual transport, pacing, network, and provider observations before concluding. service_tier=priority is a request preference: this backend can echo service_tier \"default\" even on turns it scheduled as priority (#2558), so the echoed response tier in request logs stays an observation with confirmation \"assumed\" and cannot confirm or deny the granted tier.";
1041
1045
  }
1042
1046
 
1047
+ /**
1048
+ * Bound for the doctor-side `/v1/models` read (#4646). A diagnostic must not hang on a proxy
1049
+ * that is listening but wedged mid-gather; when the read does not land in time the on-disk
1050
+ * catalog answers instead, and if that is unreadable too the verdict is "could not determine"
1051
+ * rather than a guess.
1052
+ */
1053
+ const EXPOSED_MODELS_TIMEOUT_MS = 8000;
1054
+
1055
+ /**
1056
+ * Whether Codex's pinned default model is one this proxy exposes (#4646).
1057
+ *
1058
+ * Three states, not two. Reporting "not exposed" when the exposed set could not be read would
1059
+ * be a fabricated failure on exactly the installs least able to check it (proxy down, catalog
1060
+ * never synced), so an unreadable set is its own verdict.
1061
+ */
1062
+ export type DefaultModelExposureStatus = "not_configured" | "exposed" | "not_exposed" | "undeterminable";
1063
+
1064
+ export interface DefaultModelExposure {
1065
+ status: DefaultModelExposureStatus;
1066
+ /** The configured pin, or null when Codex's config.toml has no root `model`. */
1067
+ model: string | null;
1068
+ /** Which surface answered; null when neither could be read. */
1069
+ source: "proxy" | "catalog" | null;
1070
+ detail: string;
1071
+ action?: string;
1072
+ }
1073
+
1074
+ /** Exactly the catalog's own `RawEntry` shape, so an on-disk row needs no conversion. */
1075
+ type CatalogVisibilityRow = Record<string, unknown>;
1076
+
1077
+ export interface DefaultModelExposureDeps {
1078
+ readConfiguredModelFn?: () => string | null;
1079
+ /** The live proxy doctor already resolved, or null/absent when none is running. */
1080
+ live?: LiveProxy | null;
1081
+ fetchFn?: typeof fetch;
1082
+ readCatalogModelsFn?: () => readonly CatalogVisibilityRow[] | null;
1083
+ }
1084
+
1085
+ /**
1086
+ * Ids the running proxy advertises, or null when the read did not produce a usable answer.
1087
+ *
1088
+ * Null is deliberately indistinguishable across transport failure, a non-200, and a malformed
1089
+ * body, because every one of them means the same thing to the caller: this surface did not
1090
+ * answer, ask the next one. The 401 case is real rather than theoretical — `/v1/models` requires
1091
+ * data-plane admission on a non-loopback bind (`isApiAuthRequired`), and doctor deliberately
1092
+ * holds no data-plane key, so a remote-bound proxy always falls through to the catalog.
1093
+ */
1094
+ async function fetchExposedModelIds(live: LiveProxy, fetchFn: typeof fetch): Promise<Set<string> | null> {
1095
+ try {
1096
+ const res = await fetchFn(`http://${probeHostname(live.hostname)}:${live.port}/v1/models`, {
1097
+ signal: AbortSignal.timeout(EXPOSED_MODELS_TIMEOUT_MS),
1098
+ });
1099
+ if (!res.ok) return null;
1100
+ const body = await res.json() as { data?: unknown };
1101
+ if (!Array.isArray(body?.data)) return null;
1102
+ const ids = new Set<string>();
1103
+ for (const row of body.data) {
1104
+ const id = (row as { id?: unknown } | null)?.id;
1105
+ if (typeof id === "string" && id.length > 0) ids.add(id);
1106
+ }
1107
+ return ids;
1108
+ } catch {
1109
+ return null;
1110
+ }
1111
+ }
1112
+
1113
+ /** Picker-visible catalog slugs, or null when the catalog is absent or unparseable. */
1114
+ function catalogExposedModelIds(rows: readonly CatalogVisibilityRow[] | null): Set<string> | null {
1115
+ if (rows === null) return null;
1116
+ const ids = new Set<string>();
1117
+ for (const row of rows) {
1118
+ // `visibility: "hide"` rows are retained on purpose (see the native-toggle contract in
1119
+ // structure/catalog.md); they are exactly the rows a pin must not resolve to.
1120
+ if (!row || row.visibility !== "list") continue;
1121
+ const slug = row.slug;
1122
+ if (typeof slug === "string" && slug.length > 0) ids.add(slug);
1123
+ }
1124
+ return ids;
1125
+ }
1126
+
1127
+ function defaultCatalogModels(): readonly CatalogVisibilityRow[] | null {
1128
+ const models = readCatalog(readCodexCatalogPath())?.models;
1129
+ return Array.isArray(models) ? models : null;
1130
+ }
1131
+
1132
+ /**
1133
+ * Compare Codex's root `model` pin against the models this install actually exposes (#4646).
1134
+ *
1135
+ * The exposed set is read, never recomputed. Reproducing the live assembly in the CLI would mean
1136
+ * duplicating an entitlements snapshot, a provider gather and account-selector expansion, and the
1137
+ * duplicate would drift — the same failure `formatStartupRoutingDetail` and `computeVersionSkew`
1138
+ * were extracted to prevent. So the running proxy answers when there is one, the on-disk catalog
1139
+ * answers otherwise, and neither is reconstructed here.
1140
+ *
1141
+ * Both surfaces are consulted before any negative verdict. They name a routed row through the
1142
+ * same `<provider>/<id>` slug space, but they are built by different code at different times, so
1143
+ * requiring both to disagree is what keeps an encoding or staleness difference from being
1144
+ * reported to the operator as a broken pin.
1145
+ */
1146
+ export async function collectDefaultModelExposure(
1147
+ deps: DefaultModelExposureDeps = {},
1148
+ ): Promise<DefaultModelExposure> {
1149
+ const configured = (deps.readConfiguredModelFn ?? readConfiguredDefaultModel)();
1150
+ const model = typeof configured === "string" ? configured.trim() : "";
1151
+ if (!model) {
1152
+ return {
1153
+ status: "not_configured",
1154
+ model: null,
1155
+ source: null,
1156
+ detail: "Codex config.toml pins no root `model`, so Codex picks from the exposed catalog",
1157
+ };
1158
+ }
1159
+
1160
+ const live = deps.live ?? null;
1161
+ const proxyIds = live ? await fetchExposedModelIds(live, deps.fetchFn ?? fetch) : null;
1162
+ const catalogIds = catalogExposedModelIds((deps.readCatalogModelsFn ?? defaultCatalogModels)());
1163
+ if (proxyIds === null && catalogIds === null) {
1164
+ return {
1165
+ status: "undeterminable",
1166
+ model,
1167
+ source: null,
1168
+ detail: `could not read the exposed model set, so Codex \`model = "${model}"\` was not checked`,
1169
+ action: "Start the proxy with 'ocx start', or run 'ocx sync' to write the Codex catalog, then re-run 'ocx doctor'",
1170
+ };
1171
+ }
1172
+
1173
+ const source = proxyIds !== null ? "proxy" as const : "catalog" as const;
1174
+ // `source` reports which surface produced the verdict, so a match names the surface that
1175
+ // matched rather than the one we happened to read first.
1176
+ const matched = proxyIds?.has(model) === true
1177
+ ? "proxy" as const
1178
+ : catalogIds?.has(model) === true ? "catalog" as const : null;
1179
+ if (matched !== null) {
1180
+ return {
1181
+ status: "exposed",
1182
+ model,
1183
+ source: matched,
1184
+ detail: `Codex \`model = "${model}"\` is exposed by this install`,
1185
+ };
1186
+ }
1187
+ // Name only the surfaces that actually answered: claiming a check that did not happen is the
1188
+ // same defect as claiming an exposure verdict we could not reach.
1189
+ const checked = [
1190
+ ...(proxyIds !== null ? ["the running proxy's /v1/models"] : []),
1191
+ ...(catalogIds !== null ? ["the on-disk Codex catalog"] : []),
1192
+ ].join(" and ");
1193
+ return {
1194
+ status: "not_exposed",
1195
+ model,
1196
+ source,
1197
+ detail: `Codex \`model = "${model}"\` is NOT exposed by this install (checked ${checked}), so every new Codex session starts on a model this proxy does not serve`,
1198
+ action: "Expose that model (enable it in the dashboard or drop it from 'disabledModels') and run 'ocx sync', or pin an exposed id as 'model' in CODEX_HOME/config.toml",
1199
+ };
1200
+ }
1201
+
1043
1202
  export async function runDoctor(args: string[] = []): Promise<void> {
1044
1203
  if (args.includes("--fix-codex-runtime")) {
1045
1204
  const resolved = resolveCodexRuntime();
@@ -1325,14 +1484,63 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1325
1484
  console.log(line);
1326
1485
  }
1327
1486
 
1487
+ // Adjacent to the section above because both read Codex's config.toml, and an operator
1488
+ // debugging "Codex config" wants the pinned model checked in the same place.
1489
+ console.log("\nCodex default model exposure");
1490
+ const defaultModelExposure = await collectDefaultModelExposure({ live });
1491
+ if (defaultModelExposure.status === "not_exposed") {
1492
+ console.log(` !! ${defaultModelExposure.detail}`);
1493
+ console.log(` Action: ${defaultModelExposure.action}`);
1494
+ } else if (defaultModelExposure.status === "undeterminable") {
1495
+ // Not `!!`: nothing is known to be wrong. The one thing this must never do is report an
1496
+ // unread set as a broken pin.
1497
+ console.log(` -- ${defaultModelExposure.detail}`);
1498
+ console.log(` Action: ${defaultModelExposure.action}`);
1499
+ } else {
1500
+ console.log(` ok ${defaultModelExposure.detail}`);
1501
+ }
1502
+ // Deliberately no `recordDoctorFailure()` and no `process.exitCode` write. A pin that is not
1503
+ // exposed is a degraded install, not an unusable one — the operator can still pick another
1504
+ // model in the session — and the rule above reserves FAIL for an unusable surface so a warning
1505
+ // cannot break a legitimately green pipeline.
1506
+
1328
1507
  console.log("\nCodex agent role files");
1329
- const tomlFallbackRoles = scanCodexAgentRolesWithTomlModelFallback(resolveCodexHomeDirImpl());
1330
- if (tomlFallbackRoles.length === 0) {
1508
+ let roleScanError: unknown;
1509
+ const tomlFallbackRoles = scanCodexAgentRolesWithTomlModelFallback(
1510
+ resolveCodexHomeDirImpl(),
1511
+ cause => {
1512
+ roleScanError = cause;
1513
+ },
1514
+ );
1515
+ if (roleScanError) {
1516
+ console.log(` [WARN] unable to scan $CODEX_HOME/agents/*.toml: ${String(roleScanError)}`);
1517
+ } else if (tomlFallbackRoles.length === 0) {
1331
1518
  console.log(" ok no per-role model_fallback fields in $CODEX_HOME/agents/*.toml");
1332
1519
  } else {
1333
1520
  console.log(` [WARN] ${tomlFallbackRoles.length} agent role file${tomlFallbackRoles.length === 1 ? "" : "s"} contain${tomlFallbackRoles.length === 1 ? "s" : ""} \`model_fallback\`: ${tomlFallbackRoles.join(", ")}`);
1334
1521
  console.log(" Codex >= 0.146 rejects that field as unknown and skips the whole role. Move the chains to opencodex config `subagentModelFallbackByModel` (keyed by primary model) and remove the field from the TOML files.");
1335
1522
  }
1523
+ // opencodex does not write these files; the Codex desktop external-agent import does, and it
1524
+ // drops the model pin on the way in. Observe-only: doctor never repairs or removes them.
1525
+ // Both role scans share the same directory listing, so a failure above already reported the
1526
+ // cause; skip the second scan rather than warn twice or print a false "ok".
1527
+ if (!roleScanError) {
1528
+ const unpinnedDerivedRoles = scanOpencodexDerivedCodexAgentRolesWithoutModelPin(
1529
+ resolveCodexHomeDirImpl(),
1530
+ cause => {
1531
+ roleScanError = cause;
1532
+ },
1533
+ );
1534
+ if (roleScanError) {
1535
+ console.log(` [WARN] unable to scan $CODEX_HOME/agents/*.toml: ${String(roleScanError)}`);
1536
+ } else if (unpinnedDerivedRoles.length === 0) {
1537
+ console.log(" ok every opencodex-derived role file in $CODEX_HOME/agents/*.toml pins a model");
1538
+ } else {
1539
+ console.log(` [WARN] ${unpinnedDerivedRoles.length} opencodex-derived role file${unpinnedDerivedRoles.length === 1 ? "" : "s"} without a \`model\` pin: ${unpinnedDerivedRoles.map(role => `${role}.toml`).join(", ")}`);
1540
+ console.log(" Codex runs these roles on the parent model, so a spawn records one role and another model. The `ocx-route` directive in the file cannot pin them: it is honoured only on the Claude Code `/v1/messages` path and is inert on `/v1/responses`.");
1541
+ console.log(" Add `model = \"<id>\"` to each file, or remove them. They usually come from the Codex desktop external-agent import of ~/.claude/agents/ocx-*.md; set `[desktop] external-agent-import-sync-item-types` with `SUBAGENTS = false` to stop it recreating them.");
1542
+ }
1543
+ }
1336
1544
 
1337
1545
  const dual = collectWslDualInstall();
1338
1546
  if (dual.wsl) {
@@ -1397,6 +1605,12 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1397
1605
  hints.push(`${row.detail}. Set ${row.envName} in the shell that starts the proxy, or store a literal key in config (value hidden here).`);
1398
1606
  }
1399
1607
  if (codexEnvKeyReadiness) hints.push(`${codexEnvKeyReadiness.detail}. ${codexEnvKeyReadiness.action}.`);
1608
+ // Only the negative verdict becomes a hint. "Could not determine" is usually just a proxy that
1609
+ // is not running, which `proxyDownRestartHint` already reports; repeating it here would put a
1610
+ // second line in the hint list for one fact.
1611
+ if (defaultModelExposure.status === "not_exposed") {
1612
+ hints.push(`${defaultModelExposure.detail}. ${defaultModelExposure.action}.`);
1613
+ }
1400
1614
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
1401
1615
  const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
1402
1616
  if (!startup.rebootSafe) {
package/src/cli/help.ts CHANGED
@@ -27,7 +27,8 @@ export function printUsage(): void {
27
27
 
28
28
  Usage:
29
29
  ocx setup Interactive setup (alias: init)
30
- ocx start [--port <port>] Start the proxy server (auto-syncs models to Codex)
30
+ ocx start [--port <port>] [--socks5 [host:port] | --socks5-off]
31
+ Start the proxy; SOCKS5 defaults to 127.0.0.1:10808
31
32
  ocx stop Stop the proxy AND restore native Codex (plain codex works again)
32
33
  ocx restore Restore native Codex without stopping (alias: eject)
33
34
  ocx restore back Re-point codex at the running proxy (undo restore)
@@ -102,6 +103,8 @@ Examples:
102
103
  ocx init Set up provider and inject into Codex
103
104
  ocx start Start on default port (10100)
104
105
  ocx start --port 8080 Start on custom port
106
+ ocx start --socks5 Outbound via SOCKS5 at 127.0.0.1:10808 (saved)
107
+ ocx start --socks5-off Clear a saved SOCKS5 outbound proxy
105
108
  ocx help service Show service command help
106
109
  ocx help hub Explain the hub topology, token file, and invites
107
110
  ocx sync Sync available models to Codex`);
package/src/cli/hub.ts CHANGED
@@ -228,8 +228,9 @@ export function hubInviteCommand(
228
228
  managementUrl: string,
229
229
  clients: OcxConnectedClientId[],
230
230
  ): string {
231
- const clientsFlag = clients.length > 0 ? ` --clients ${clients.join(",")}` : "";
232
- return `echo '${code}' | ocx connect ${dataUrl} --management-url ${managementUrl}${clientsFlag} --pairing-code-stdin`;
231
+ const shellQuote = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`;
232
+ const clientsFlag = clients.length > 0 ? ` --clients ${shellQuote(clients.join(","))}` : "";
233
+ return `echo ${shellQuote(code)} | ocx connect ${shellQuote(dataUrl)} --management-url ${shellQuote(managementUrl)}${clientsFlag} --pairing-code-stdin`;
233
234
  }
234
235
 
235
236
  async function runInvite(args: string[], deps: HubCommandDeps): Promise<number> {