@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  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 +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
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,6 +1484,26 @@ 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
1508
  const tomlFallbackRoles = scanCodexAgentRolesWithTomlModelFallback(resolveCodexHomeDirImpl());
1330
1509
  if (tomlFallbackRoles.length === 0) {
@@ -1333,6 +1512,16 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1333
1512
  console.log(` [WARN] ${tomlFallbackRoles.length} agent role file${tomlFallbackRoles.length === 1 ? "" : "s"} contain${tomlFallbackRoles.length === 1 ? "s" : ""} \`model_fallback\`: ${tomlFallbackRoles.join(", ")}`);
1334
1513
  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
1514
  }
1515
+ // opencodex does not write these files; the Codex desktop external-agent import does, and it
1516
+ // drops the model pin on the way in. Observe-only: doctor never repairs or removes them.
1517
+ const unpinnedDerivedRoles = scanOpencodexDerivedCodexAgentRolesWithoutModelPin(resolveCodexHomeDirImpl());
1518
+ if (unpinnedDerivedRoles.length === 0) {
1519
+ console.log(" ok every opencodex-derived role file in $CODEX_HOME/agents/*.toml pins a model");
1520
+ } else {
1521
+ console.log(` [WARN] ${unpinnedDerivedRoles.length} opencodex-derived role file${unpinnedDerivedRoles.length === 1 ? "" : "s"} without a \`model\` pin: ${unpinnedDerivedRoles.map(role => `${role}.toml`).join(", ")}`);
1522
+ 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`.");
1523
+ 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.");
1524
+ }
1336
1525
 
1337
1526
  const dual = collectWslDualInstall();
1338
1527
  if (dual.wsl) {
@@ -1397,6 +1586,12 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1397
1586
  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
1587
  }
1399
1588
  if (codexEnvKeyReadiness) hints.push(`${codexEnvKeyReadiness.detail}. ${codexEnvKeyReadiness.action}.`);
1589
+ // Only the negative verdict becomes a hint. "Could not determine" is usually just a proxy that
1590
+ // is not running, which `proxyDownRestartHint` already reports; repeating it here would put a
1591
+ // second line in the hint list for one fact.
1592
+ if (defaultModelExposure.status === "not_exposed") {
1593
+ hints.push(`${defaultModelExposure.detail}. ${defaultModelExposure.action}.`);
1594
+ }
1400
1595
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
1401
1596
  const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
1402
1597
  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/index.ts CHANGED
@@ -13,7 +13,20 @@ try {
13
13
  /* best-effort */
14
14
  }
15
15
  }
16
- import { currentExternalCodexModelProvider, restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader } from "../codex/inject";
16
+ import {
17
+ currentExternalCodexModelProvider,
18
+ restoreNativeCodex,
19
+ restoreNativeCodexAsync,
20
+ shouldInjectApiAuthHeader,
21
+ } from "../codex/inject";
22
+ // Straight from the owning modules rather than the facade: these are teardown-reporting
23
+ // helpers, not part of the injection surface, and `inject.ts` sits under a size cap that
24
+ // exists to stop it collecting exactly this kind of passthrough.
25
+ import { readOcxProviderTableBlock } from "../codex/inject/remove";
26
+ import {
27
+ describeRetainedCodexProviderTable,
28
+ type RetainedCodexProviderTable,
29
+ } from "../codex/inject/restore";
17
30
  import { stripGrokConfig } from "../grok/inject";
18
31
  import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs";
19
32
  import {
@@ -30,6 +43,7 @@ import {
30
43
  saveConfig,
31
44
  } from "../config";
32
45
  import {
46
+ isLikelyOcxProcess,
33
47
  readPid,
34
48
  readPidFileValue,
35
49
  readRuntimePort,
@@ -52,6 +66,7 @@ import {
52
66
  import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status";
53
67
  import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
54
68
  import { takeFlag } from "./runtime-api";
69
+ import { parseStartOptions, StartArgsError } from "./start-args";
55
70
 
56
71
  import {
57
72
  discoverStableProxyForRestart,
@@ -63,9 +78,10 @@ import {
63
78
  } from "./tray-proxy";
64
79
  import { requestBoundSystemRestart } from "./system-restart-client";
65
80
  import { installCrashGuards } from "../lib/crash-guard";
66
- import { dispatchCommand , decideStartWithLiveOwner } from "./dispatch";
81
+ import { redactUrlForLog } from "../lib/redact";
82
+ import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch";
67
83
  import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
68
- import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
84
+ import { findLiveProxy, probeHostname, probePortOwner, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness";
69
85
  import { createReadinessGate } from "../server/readiness";
70
86
  import { isApiAuthRequired } from "../server/auth-cors";
71
87
  import { runReady, type ReadyArgs } from "./ready";
@@ -119,6 +135,12 @@ function reportShellHookFailure(result: { state: "installed" | "absent" | "faile
119
135
  console.warn(" Check ~/.zshrc for the '# opencodex claude-env hook' block.");
120
136
  }
121
137
 
138
+ function reportRetainedCodexProviderTable(retained: RetainedCodexProviderTable): void {
139
+ console.log(` ${describeRetainedCodexProviderTable(retained)}`);
140
+ console.log(" Retained config lines:");
141
+ for (const line of retained.lines) console.log(` ${line}`);
142
+ }
143
+
122
144
  async function refreshOwnedRaycastCatalog(
123
145
  config: ReturnType<typeof loadConfig>,
124
146
  port: number,
@@ -150,21 +172,13 @@ const head = await runCli(process.argv.slice(2));
150
172
  const args = head.args;
151
173
  const command = head.command;
152
174
 
153
- function parsePortOption(): number | undefined {
154
- if (args.length === 1) return undefined;
155
- if (args.length !== 3 || args[1] !== "--port") {
156
- console.error("Usage: ocx start [--port <port>]");
157
- process.exit(1);
158
- }
159
- const portIdx = args.indexOf("--port");
160
- if (portIdx === -1) return undefined;
161
- const value = args[portIdx + 1];
162
- const port = value && /^\d+$/.test(value) ? Number(value) : NaN;
163
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
164
- console.error("Invalid port number");
175
+ function parseStartCliOptions(): ReturnType<typeof parseStartOptions> {
176
+ try {
177
+ return parseStartOptions(args.slice(1));
178
+ } catch (error) {
179
+ console.error(error instanceof StartArgsError ? error.message : String(error));
165
180
  process.exit(1);
166
181
  }
167
- return port;
168
182
  }
169
183
 
170
184
  async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
@@ -253,8 +267,41 @@ async function chooseListenPort(
253
267
  // ever a config collision.
254
268
  ...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}),
255
269
  });
256
- if (preferred > 0 && selected !== preferred) {
257
- console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
270
+ if (selected !== preferred) {
271
+ // The hop used to be automatic, and that is how a bare `start` beside a healthy
272
+ // proxy produced a second one (#5004): nothing on this path ever asked who held the
273
+ // preferred port. Ask the holder itself — not this home's pid/runtime bookkeeping,
274
+ // which is exactly what was wrong when the duplicate happened — and give the
275
+ // question a budget that cannot mistake one lost probe for an empty port.
276
+ const holder = preferred > 0 && !hardPin
277
+ ? await probePortOwner(preferred, { hostname: config.hostname }, START_OWNERSHIP_LIVENESS)
278
+ : null;
279
+ const decision = decideBusyPreferredPort({
280
+ preferredPort: preferred,
281
+ selectedPort: selected,
282
+ hardPin,
283
+ holderIsOpencodex: holder !== null,
284
+ ocxService: process.env.OCX_SERVICE,
285
+ });
286
+ if (decision === "service-stay-out") {
287
+ // Same contract as the pre-bind owner check: the wrapper's retry loop terminates
288
+ // on a zero exit, and the port it was asked to serve is already served.
289
+ console.log(`Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}); service wrapper staying out of the way.`);
290
+ process.exit(0);
291
+ }
292
+ if (decision === "refuse-live-proxy") {
293
+ console.error(`⚠️ Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}). Use 'ocx stop' first.`);
294
+ process.exit(1);
295
+ }
296
+ if (decision === "refuse-unidentified-holder") {
297
+ console.error(`❌ Port ${preferred} is busy and its holder did not identify as opencodex.`);
298
+ console.error(" Starting on another port would leave Codex pointed at a proxy you did not ask for.");
299
+ console.error(" Stop whatever holds that port, or start on a free one with 'ocx start --port <port>'.");
300
+ process.exit(1);
301
+ }
302
+ if (preferred > 0) {
303
+ console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
304
+ }
258
305
  }
259
306
  if (shouldPersistSelectedPort(config.port, selected, preferred, options)) {
260
307
  config.port = selected;
@@ -277,7 +324,12 @@ async function findProxyOwnerBeforeJournalRecovery(
277
324
  const pidSnapshot = readPidFileValue();
278
325
  const hasRuntimeOwner = readRuntimePort() !== null;
279
326
  const shouldProbe = pidSnapshot !== null || hasRuntimeOwner || options.probeConfiguredPort === true;
280
- const live = shouldProbe ? await findLiveProxy() : null;
327
+ // A negative answer here is acted on twice over: the caller walks past a proxy it was
328
+ // supposed to find, and the lines below delete this home's pid record and reconcile the
329
+ // journal. One 750ms probe is not enough evidence for either (#5004) — a transport
330
+ // failure is indistinguishable from an empty port, and the reported Windows duplicate
331
+ // came from exactly that answer on a proxy the previous command had just found healthy.
332
+ const live = shouldProbe ? await findLiveProxy(START_OWNERSHIP_LIVENESS) : null;
281
333
  if (live) return { live, pidSnapshot };
282
334
 
283
335
  // The probe established that the snapshotted owner is stale. Compare before
@@ -309,7 +361,26 @@ async function handleStart(options: { block?: boolean } = {}) {
309
361
  // already-broken file cannot fence /api/* closed at boot (#2696).
310
362
  const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
311
363
  if (present) assertNotAdminToken(present);
312
- const requestedPort = parsePortOption();
364
+ const startOpts = parseStartCliOptions();
365
+ if (startOpts.socks5 !== undefined || startOpts.socks5Off) {
366
+ const proxyConfig = loadConfig();
367
+ if (startOpts.socks5Off) {
368
+ if (proxyConfig.proxy && !/^socks5h?:\/\//i.test(proxyConfig.proxy.trim())) {
369
+ console.error("Cannot use --socks5-off: config.proxy is not a SOCKS5 URL; it was left unchanged.");
370
+ process.exit(1);
371
+ }
372
+ if (proxyConfig.proxy) {
373
+ delete proxyConfig.proxy;
374
+ saveConfig(proxyConfig);
375
+ console.log("Cleared config.proxy (outbound SOCKS5 proxy off).");
376
+ }
377
+ } else {
378
+ proxyConfig.proxy = startOpts.socks5!;
379
+ saveConfig(proxyConfig);
380
+ console.log(`Outbound SOCKS5: ${redactUrlForLog(startOpts.socks5!)} (saved to config.proxy)`);
381
+ }
382
+ }
383
+ const requestedPort = startOpts.port;
313
384
  // Always probe the configured port, even when both state files are absent. A
314
385
  // fallback-port sibling overwrites the pid/runtime records when it starts and
315
386
  // removes them on its own shutdown, so their absence proves nothing about the
@@ -799,7 +870,12 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole
799
870
  let other = false;
800
871
  try {
801
872
  const result = await restoreNativeCodexAsync();
802
- if (result.success) console.log(`↩️ ${result.message}`);
873
+ if (result.success) {
874
+ console.log(`↩️ ${result.message}`);
875
+ if (result.retainedCodexProviderTable) {
876
+ reportRetainedCodexProviderTable(result.retainedCodexProviderTable);
877
+ }
878
+ }
803
879
  else {
804
880
  // Codex history is the one restore whose failure leaves the runtime consistent: the
805
881
  // manifest is retained and the routed metadata is untouched. Config and catalog are
@@ -810,6 +886,9 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole
810
886
  // attempted. Reading the states alone cannot tell that apart from an ownership
811
887
  // refusal, so the structured reason carries it and the states are still required to
812
888
  // agree — a refusal that somehow reports a failed artifact is not this case.
889
+ // A degraded restore has no refusal reason and reports config as partial, so it cannot
890
+ // enter this branch: its config obligation was discharged and the stop receipt must be
891
+ // released rather than preserved.
813
892
  const preflightRefused = result.historyPreflightRefusal !== undefined
814
893
  && artifacts.config.state === "skipped"
815
894
  && artifacts.catalog.state === "skipped"
@@ -900,8 +979,24 @@ async function handleStop() {
900
979
  // `inheritedTeardowns` is the inverse case: PREVIOUS stops that left obligations
901
980
  // unfinished. Snapshot them BEFORE this run claims anything, so this run's own receipt
902
981
  // is never mistaken for one it inherited.
982
+ //
983
+ // Ownership is decided by IDENTITY, not by bare liveness. A receipt records a number, and
984
+ // the OS reuses numbers: once the owner exits, an unrelated process can be handed its PID,
985
+ // and `isProcessAlive` alone then answers "that stop is still running" for as long as the
986
+ // new process lives. The receipt is filtered out, so no run ever recovers it, quarantines
987
+ // it or even mentions it — while both updater gates keep seeing an outstanding obligation
988
+ // and refuse. That is the permanent fail-closed reported in #4897: no proxy running, a
989
+ // dead owner, and `ocx update` aborting on `teardown-outstanding` every time.
990
+ //
991
+ // Requiring the live PID to be an opencodex process is the narrowing that costs the safety
992
+ // intent nothing: a stop that really is in flight is still left strictly alone, because its
993
+ // process is one of ours. Recognizing the receipt as abandoned only admits it to the
994
+ // recovery loop below, which still has to prove the recorded endpoint is down before
995
+ // anything is restored.
996
+ const teardownOwnerStillRunning = (ownerPid: number): boolean =>
997
+ isProcessAlive(ownerPid) && isLikelyOcxProcess(ownerPid);
903
998
  const inheritedTeardowns = listPendingTeardowns()
904
- .filter(read => isPendingTeardownAbandoned(read, isProcessAlive));
999
+ .filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning));
905
1000
  let teardownNonce: string | undefined;
906
1001
  const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => {
907
1002
  if (teardownNonce) return;
@@ -1357,6 +1452,9 @@ async function handleUninstall() {
1357
1452
  await runStep("native Codex restored", async () => {
1358
1453
  const r = await restoreNativeCodexAsync();
1359
1454
  if (!r.success) throw new Error(r.message);
1455
+ if (r.retainedCodexProviderTable) {
1456
+ reportRetainedCodexProviderTable(r.retainedCodexProviderTable);
1457
+ }
1360
1458
  });
1361
1459
 
1362
1460
  await runStep("Grok Build config restored", () => {
@@ -1503,6 +1601,18 @@ async function handleStatus() {
1503
1601
  console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}${local}`);
1504
1602
  console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}${local}`);
1505
1603
  console.log(` ${formatStartupRoutingDetail(status.json.startup)}${local}`);
1604
+ if (status.json.startup.routingKind === "native") {
1605
+ let retainedProviderTable = false;
1606
+ try {
1607
+ retainedProviderTable = readOcxProviderTableBlock() !== null;
1608
+ } catch {
1609
+ // The routing snapshot owns unreadable-config reporting. A later read race must not
1610
+ // turn this diagnostic command into a teardown failure.
1611
+ }
1612
+ if (retainedProviderTable) {
1613
+ console.log(` ⚠️ Codex provider table retained${local}: [model_providers.opencodex] remains while root routing is native. Remove with 'ocx restore --remove-codex-provider-table'; tagged conversations will stop opening.`);
1614
+ }
1615
+ }
1506
1616
  console.log(` Service: ${status.json.service.summary}${local}`);
1507
1617
  console.log(` ${status.json.codexShim.summary}${local}`);
1508
1618
  console.log(` Codex runtime: ${status.json.codexRuntime.path}${local}`);
@@ -4,6 +4,7 @@ import {
4
4
  printData,
5
5
  rejectArgs,
6
6
  runCliAction,
7
+ RuntimeApiError,
7
8
  runtimeRequest,
8
9
  summaryLines,
9
10
  takeBooleanOption,
@@ -165,6 +166,22 @@ async function priceRequest(write: boolean, argv: string[], deps: RuntimeApiDeps
165
166
  [auto ? `${selector}: automatic pricing restored.` : `${selector}: manual pricing saved.`]);
166
167
  }
167
168
 
169
+ /**
170
+ * True for the management handler's own unknown-id 404, and only that.
171
+ *
172
+ * Two different listeners answer 404 on this route. `src/server/management/model-routes.ts`
173
+ * means "no custom model with that id"; a listener that does not route the request at all
174
+ * reports `{error, method, path}` (src/client/machine-listener.ts), and runtime-api.ts already
175
+ * renders that shape as a routing statement. Narrowing on the absence of `method`/`path` keeps
176
+ * this rewrite from relabelling a not-served-here 404 as a missing record — the exact confusion
177
+ * #4662 was reported as.
178
+ */
179
+ function unknownCustomModelId(body: unknown): boolean {
180
+ if (!body || typeof body !== "object") return false;
181
+ const record = body as Record<string, unknown>;
182
+ return record.method === undefined && record.path === undefined;
183
+ }
184
+
168
185
  async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
169
186
  const args = [...argv];
170
187
  const id = args.shift()?.trim();
@@ -204,10 +221,22 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
204
221
  }
205
222
  if (defaultEffortRaw !== undefined) patch.defaultReasoningEffort = defaultEffortRaw === "-" ? null : defaultEffortRaw;
206
223
  if (Object.keys(patch).length === 0) throw new CliUsageError("at least one edit option is required", USAGE);
207
- const result = await runtimeRequest(`/api/custom-models/${encodeURIComponent(id)}`, {
208
- method: "PUT",
209
- body: JSON.stringify(patch),
210
- }, deps);
224
+ let result: unknown;
225
+ try {
226
+ result = await runtimeRequest(`/api/custom-models/${encodeURIComponent(id)}`, {
227
+ method: "PUT",
228
+ body: JSON.stringify(patch),
229
+ }, deps);
230
+ } catch (error) {
231
+ if (error instanceof RuntimeApiError && error.status === 404 && unknownCustomModelId(error.body)) {
232
+ throw new RuntimeApiError(
233
+ `No custom model has id ${id}. Edits address the custom-model id, not the provider/model slug; list the ids with: ocx models list-custom`,
234
+ 404,
235
+ error.body,
236
+ );
237
+ }
238
+ throw error;
239
+ }
211
240
  printData(result, wantsJson, [`Updated custom model ${id}.`]);
212
241
  }
213
242
 
@@ -20,7 +20,15 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
20
20
  usage: "ocx setup",
21
21
  summary: "Interactive setup for providers and Codex config injection (alias of init).",
22
22
  },
23
- { name: "start", usage: "ocx start [--port <port>]", summary: "Start the proxy server and sync models to Codex." },
23
+ {
24
+ name: "start",
25
+ usage: "ocx start [--port <port>] [--socks5 [host:port] | --socks5-off]",
26
+ summary: "Start the proxy server and sync models to Codex.",
27
+ details: [
28
+ "--socks5 [host:port] Route outbound provider traffic through SOCKS5 (default 127.0.0.1:10808). Saved as config.proxy.",
29
+ "--socks5-off Clear a saved SOCKS5 outbound proxy from config.proxy.",
30
+ ],
31
+ },
24
32
  { name: "stop", usage: "ocx stop", summary: "Stop the proxy and restore native Codex config." },
25
33
  {
26
34
  name: "restore",
@@ -390,6 +398,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
390
398
  details: [
391
399
  "system update manages OpenCodex itself.",
392
400
  "ocx system codex-cli-update check [--json]",
401
+ "ocx system codex-cli-update attest [--json]",
402
+ "ocx system codex-cli-update attest --candidate <absolute-path> --npm-prefix <absolute-path> --npm-cli <absolute-path> --node <absolute-path> [--json]",
393
403
  "The Codex CLI inspection command makes no package-registry request, does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.",
394
404
  ],
395
405
  },