@bitkyc08/opencodex 2.58.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 (188) 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 +1 -1
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/cursor/catalog.ts +11 -0
  10. package/src/adapters/cursor/effort-map.ts +16 -2
  11. package/src/adapters/cursor/envelope-echo.ts +55 -2
  12. package/src/adapters/cursor/message-mapper.ts +3 -2
  13. package/src/adapters/cursor/protobuf-request.ts +8 -5
  14. package/src/adapters/cursor/request-builder.ts +14 -3
  15. package/src/adapters/cursor/thread-continuity.ts +105 -31
  16. package/src/adapters/cursor/tool-guidance.ts +5 -4
  17. package/src/adapters/cursor.ts +42 -1
  18. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  19. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  20. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  21. package/src/adapters/devin.ts +75 -13
  22. package/src/adapters/google-antigravity-wire.ts +29 -2
  23. package/src/adapters/google-http.ts +8 -1
  24. package/src/adapters/google.ts +23 -4
  25. package/src/adapters/openai-chat/response-events.ts +61 -0
  26. package/src/adapters/openai-chat.ts +5 -10
  27. package/src/adapters/openai-responses/passthrough.ts +10 -1
  28. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  29. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  30. package/src/adapters/responses-tool-schema.ts +76 -46
  31. package/src/adapters/run-turn-queue.ts +17 -4
  32. package/src/bridge/response-json.ts +1 -1
  33. package/src/bridge/sse.ts +165 -24
  34. package/src/claude/context-windows.ts +22 -0
  35. package/src/claude/outbound.ts +35 -4
  36. package/src/cli/account-api.ts +4 -3
  37. package/src/cli/account-extended.ts +22 -2
  38. package/src/cli/account-orca-import.ts +63 -0
  39. package/src/cli/account.ts +32 -4
  40. package/src/cli/capabilities.ts +40 -0
  41. package/src/cli/claude.ts +29 -1
  42. package/src/cli/codex-cli-update.ts +97 -2
  43. package/src/cli/dispatch.ts +54 -0
  44. package/src/cli/doctor.ts +197 -2
  45. package/src/cli/help.ts +4 -1
  46. package/src/cli/index.ts +88 -20
  47. package/src/cli/models-runtime.ts +33 -4
  48. package/src/cli/registry.ts +11 -1
  49. package/src/cli/runtime-api.ts +44 -0
  50. package/src/cli/start-args.ts +94 -0
  51. package/src/cli/system-command.ts +2 -0
  52. package/src/client/machine-api.ts +4 -3
  53. package/src/client/machine-listener.ts +14 -1
  54. package/src/clients/config-export/constants.ts +2 -3
  55. package/src/clients/config-export.ts +5 -5
  56. package/src/codex/account-store.ts +81 -5
  57. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  58. package/src/codex/auth-api/routes.ts +17 -2
  59. package/src/codex/auth-context.ts +16 -12
  60. package/src/codex/catalog/build-entries.ts +25 -4
  61. package/src/codex/catalog/derive-entry.ts +8 -1
  62. package/src/codex/catalog/effort.ts +10 -6
  63. package/src/codex/catalog/gather-capture.ts +1 -0
  64. package/src/codex/catalog/model-hints.ts +37 -5
  65. package/src/codex/catalog/parsing.ts +83 -5
  66. package/src/codex/catalog/reserve-warn.ts +96 -0
  67. package/src/codex/catalog/retained-sync.ts +19 -0
  68. package/src/codex/catalog/routed-gather.ts +42 -3
  69. package/src/codex/cli-installation-identity.ts +210 -0
  70. package/src/codex/cli-installation-targets.ts +158 -0
  71. package/src/codex/convergence.ts +5 -0
  72. package/src/codex/history-provider.ts +4 -1
  73. package/src/codex/history-state-open.ts +105 -0
  74. package/src/codex/inject/config-toml.ts +44 -2
  75. package/src/codex/inject.ts +3 -2
  76. package/src/codex/lineage.ts +83 -32
  77. package/src/codex/loopback-target.ts +31 -0
  78. package/src/codex/main-account-hard-lock.ts +2 -1
  79. package/src/codex/main-account.ts +10 -3
  80. package/src/codex/main-device-reauth.ts +17 -9
  81. package/src/codex/model-entitlements.ts +60 -1
  82. package/src/codex/observed-model-denials.ts +137 -0
  83. package/src/codex/orca-auth-source.ts +94 -0
  84. package/src/codex/orca-import.ts +219 -0
  85. package/src/codex/prompt-text-probe.ts +282 -12
  86. package/src/codex/quota-401-recovery.ts +12 -0
  87. package/src/codex/quota-types.ts +65 -0
  88. package/src/codex/quota.ts +24 -19
  89. package/src/codex/routing/cooldown-math.ts +8 -47
  90. package/src/codex/routing/pin-drain.ts +57 -0
  91. package/src/codex/routing.ts +13 -15
  92. package/src/codex/subagent-model-fallback.ts +94 -0
  93. package/src/codex/windows-installation-files.ts +224 -0
  94. package/src/combos/failover.ts +122 -5
  95. package/src/config/diagnostics.ts +21 -0
  96. package/src/config/load-degrade.ts +15 -0
  97. package/src/config/pending-teardown.ts +8 -0
  98. package/src/config/process-state.ts +36 -3
  99. package/src/config/provider-relative-send-path.ts +16 -0
  100. package/src/config/proxy-env.ts +23 -5
  101. package/src/config/schema/config-schema.ts +21 -0
  102. package/src/config/schema/leaf-validators.ts +64 -17
  103. package/src/generated/compatibility-version.json +235 -163
  104. package/src/generated/model-metadata.ts +1 -1
  105. package/src/lib/bounded-body.ts +4 -2
  106. package/src/lib/destination-policy.ts +48 -6
  107. package/src/lib/errors.ts +3 -15
  108. package/src/lib/local-destinations.ts +32 -5
  109. package/src/lib/provider-outbound.ts +3 -3
  110. package/src/lib/proxy-env.ts +70 -3
  111. package/src/lib/request-execution-budget.ts +11 -3
  112. package/src/lib/response-body-inactivity.ts +193 -0
  113. package/src/lib/retry-delay.ts +69 -0
  114. package/src/lib/socks5-fetch.ts +631 -0
  115. package/src/lib/spend-reservation-ledger.ts +115 -9
  116. package/src/lib/workflow-budget.ts +145 -8
  117. package/src/oauth/account-quota-rank.ts +72 -15
  118. package/src/oauth/generic-account-failover.ts +40 -27
  119. package/src/oauth/orcarouter.ts +15 -2
  120. package/src/oauth/store.ts +8 -0
  121. package/src/providers/codex-capacity.ts +9 -0
  122. package/src/providers/devin-provider-merge-migration.ts +33 -12
  123. package/src/providers/free-directory.ts +20 -2
  124. package/src/providers/key-failover.ts +261 -7
  125. package/src/providers/model-rename-migration.ts +1 -0
  126. package/src/providers/openai-sidecar.ts +4 -0
  127. package/src/providers/opencode-go-transport.ts +14 -5
  128. package/src/providers/quota/report-cache.ts +3 -0
  129. package/src/providers/registry/entries-extended.ts +96 -0
  130. package/src/providers/registry/model-seeds.ts +78 -21
  131. package/src/responses/apply-patch-envelope.ts +44 -11
  132. package/src/responses/bridge-search-replay-cache.ts +152 -0
  133. package/src/responses/code-mode-helper-compat.ts +26 -16
  134. package/src/responses/custom-tool-compat.ts +1 -1
  135. package/src/responses/hosted-tool-policy.ts +85 -2
  136. package/src/responses/schema.ts +9 -2
  137. package/src/server/auth-cors.ts +26 -0
  138. package/src/server/chat-completions.ts +9 -4
  139. package/src/server/chat-native-sse.ts +26 -9
  140. package/src/server/chat-native.ts +10 -4
  141. package/src/server/claude-messages.ts +24 -2
  142. package/src/server/gui-static.ts +36 -2
  143. package/src/server/inbound-body-admission.ts +187 -0
  144. package/src/server/index.ts +15 -19
  145. package/src/server/management/api-access.ts +3 -4
  146. package/src/server/management/config-routes.ts +31 -6
  147. package/src/server/management/provider-capability-config.ts +35 -7
  148. package/src/server/management/provider-routes.ts +70 -18
  149. package/src/server/proxy-liveness.ts +97 -2
  150. package/src/server/relay.ts +17 -24
  151. package/src/server/request-log.ts +25 -1
  152. package/src/server/responses/adapter-continuation.ts +71 -27
  153. package/src/server/responses/adapter-delivery.ts +39 -8
  154. package/src/server/responses/adapter-dispatch.ts +52 -24
  155. package/src/server/responses/compact.ts +60 -11
  156. package/src/server/responses/core-codex-account.ts +83 -22
  157. package/src/server/responses/core-normalize.ts +12 -5
  158. package/src/server/responses/fetch-helpers.ts +68 -2
  159. package/src/server/responses/passthrough-delivery.ts +10 -1
  160. package/src/server/responses/passthrough-dispatch.ts +113 -48
  161. package/src/server/responses/passthrough-execution.ts +11 -1
  162. package/src/server/responses/request-prepare.ts +29 -0
  163. package/src/server/responses/request-send-budget.ts +84 -7
  164. package/src/server/responses/request-sidecar-auth.ts +16 -8
  165. package/src/server/responses/request-spend.ts +38 -9
  166. package/src/server/responses/request-transport.ts +13 -10
  167. package/src/server/responses/run-turn-execution.ts +20 -5
  168. package/src/server/responses/sidecar-execution.ts +2 -0
  169. package/src/server/responses/ws-upstream.ts +2 -1
  170. package/src/server/responses-custom-tool-repair.ts +2 -2
  171. package/src/server/sse-frame-buffer.ts +12 -10
  172. package/src/server/sse-payload-rewrite.ts +36 -9
  173. package/src/server/system-env-shell.ts +5 -1
  174. package/src/server/system-env.ts +7 -1
  175. package/src/server/workflow-refusal.ts +56 -2
  176. package/src/service/cli.ts +16 -6
  177. package/src/service/guards.ts +10 -0
  178. package/src/service/health.ts +43 -0
  179. package/src/service/state.ts +7 -2
  180. package/src/types/accounts.ts +4 -0
  181. package/src/types/config.ts +100 -3
  182. package/src/types/provider.ts +19 -0
  183. package/src/types/request.ts +7 -1
  184. package/src/types/wire.ts +9 -1
  185. package/src/usage/expected-prices.ts +28 -0
  186. package/src/usage/log.ts +87 -4
  187. package/src/web-search/passthrough-bridge.ts +39 -5
  188. package/gui/dist/assets/index-BbrHOIY0.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
@@ -43,6 +43,7 @@ import {
43
43
  saveConfig,
44
44
  } from "../config";
45
45
  import {
46
+ isLikelyOcxProcess,
46
47
  readPid,
47
48
  readPidFileValue,
48
49
  readRuntimePort,
@@ -65,6 +66,7 @@ import {
65
66
  import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status";
66
67
  import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
67
68
  import { takeFlag } from "./runtime-api";
69
+ import { parseStartOptions, StartArgsError } from "./start-args";
68
70
 
69
71
  import {
70
72
  discoverStableProxyForRestart,
@@ -76,9 +78,10 @@ import {
76
78
  } from "./tray-proxy";
77
79
  import { requestBoundSystemRestart } from "./system-restart-client";
78
80
  import { installCrashGuards } from "../lib/crash-guard";
79
- import { dispatchCommand , decideStartWithLiveOwner } from "./dispatch";
81
+ import { redactUrlForLog } from "../lib/redact";
82
+ import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch";
80
83
  import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
81
- import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
84
+ import { findLiveProxy, probeHostname, probePortOwner, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness";
82
85
  import { createReadinessGate } from "../server/readiness";
83
86
  import { isApiAuthRequired } from "../server/auth-cors";
84
87
  import { runReady, type ReadyArgs } from "./ready";
@@ -169,21 +172,13 @@ const head = await runCli(process.argv.slice(2));
169
172
  const args = head.args;
170
173
  const command = head.command;
171
174
 
172
- function parsePortOption(): number | undefined {
173
- if (args.length === 1) return undefined;
174
- if (args.length !== 3 || args[1] !== "--port") {
175
- console.error("Usage: ocx start [--port <port>]");
176
- process.exit(1);
177
- }
178
- const portIdx = args.indexOf("--port");
179
- if (portIdx === -1) return undefined;
180
- const value = args[portIdx + 1];
181
- const port = value && /^\d+$/.test(value) ? Number(value) : NaN;
182
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
183
- 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));
184
180
  process.exit(1);
185
181
  }
186
- return port;
187
182
  }
188
183
 
189
184
  async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
@@ -272,8 +267,41 @@ async function chooseListenPort(
272
267
  // ever a config collision.
273
268
  ...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}),
274
269
  });
275
- if (preferred > 0 && selected !== preferred) {
276
- 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
+ }
277
305
  }
278
306
  if (shouldPersistSelectedPort(config.port, selected, preferred, options)) {
279
307
  config.port = selected;
@@ -296,7 +324,12 @@ async function findProxyOwnerBeforeJournalRecovery(
296
324
  const pidSnapshot = readPidFileValue();
297
325
  const hasRuntimeOwner = readRuntimePort() !== null;
298
326
  const shouldProbe = pidSnapshot !== null || hasRuntimeOwner || options.probeConfiguredPort === true;
299
- 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;
300
333
  if (live) return { live, pidSnapshot };
301
334
 
302
335
  // The probe established that the snapshotted owner is stale. Compare before
@@ -328,7 +361,26 @@ async function handleStart(options: { block?: boolean } = {}) {
328
361
  // already-broken file cannot fence /api/* closed at boot (#2696).
329
362
  const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
330
363
  if (present) assertNotAdminToken(present);
331
- 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;
332
384
  // Always probe the configured port, even when both state files are absent. A
333
385
  // fallback-port sibling overwrites the pid/runtime records when it starts and
334
386
  // removes them on its own shutdown, so their absence proves nothing about the
@@ -927,8 +979,24 @@ async function handleStop() {
927
979
  // `inheritedTeardowns` is the inverse case: PREVIOUS stops that left obligations
928
980
  // unfinished. Snapshot them BEFORE this run claims anything, so this run's own receipt
929
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);
930
998
  const inheritedTeardowns = listPendingTeardowns()
931
- .filter(read => isPendingTeardownAbandoned(read, isProcessAlive));
999
+ .filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning));
932
1000
  let teardownNonce: string | undefined;
933
1001
  const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => {
934
1002
  if (teardownNonce) return;
@@ -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
  },
@@ -42,10 +42,39 @@ export class RuntimeApiError extends Error {
42
42
  }
43
43
  }
44
44
 
45
+ /**
46
+ * Refusal for a management request that resolved to a connected-client machine listener.
47
+ *
48
+ * A connected client runs `src/client/machine-listener.ts`, which binds the SAME address the
49
+ * standalone proxy would (`port ?? config.port ?? 10100`) and answers `/healthz` as opencodex.
50
+ * Liveness therefore finds it — correctly, it is our process — but it serves only
51
+ * `/api/machine/*` and returns a JSON 404 for every other `/api/*` path. Before this refusal
52
+ * every management-backed subcommand on such a machine died on that opaque 404:
53
+ * `{"error":"not_found","method":"PUT","path":"/api/custom-models/<id>"}`, one character away
54
+ * from the real handler's unknown-id `{"error":"not found"}` and indistinguishable from it
55
+ * (#4662). The role was already on the wire; only the parser was throwing it away.
56
+ *
57
+ * 503 rather than 404: the management plane is unavailable here, the resource is not missing,
58
+ * and `runCliAction` maps 404 to exit 4 ("no such thing") — the wrong answer to give a script.
59
+ */
60
+ function clientRoleManagementRefusal(port: number): string {
61
+ return [
62
+ `The opencodex listener on port ${port} is running in the client role. It serves only the machine routes (/api/machine/*), so this machine has no management API to call.`,
63
+ "Custom-model edits and other management changes are made on the hub this machine is connected to: run the command there, or use the hub dashboard (the dashboard on this machine relays to it when the connection uses the relay transport).",
64
+ "To change this machine's own configuration instead: edit customModels in config.json, then run: ocx sync",
65
+ ].join("\n");
66
+ }
67
+
45
68
  export async function runtimeBaseUrl(deps: RuntimeApiDeps = {}): Promise<string> {
46
69
  if (deps.baseUrl) return deps.baseUrl.replace(/\/$/, "");
47
70
  const live = await (deps.findLiveProxy ?? findLiveProxy)();
48
71
  if (!live) throw new RuntimeApiError("Proxy is not running. Start it with: ocx start", 503, null);
72
+ // The role comes from the same identity-checked /healthz body liveness already parsed, so
73
+ // this costs no extra request. Only the client role is refused: an absent role is a
74
+ // standalone or hub proxy (or a legacy body that predates the field), and both serve /api/*.
75
+ if (live.role === "client") {
76
+ throw new RuntimeApiError(clientRoleManagementRefusal(live.port), 503, null);
77
+ }
49
78
  return `http://${probeHostname(live.hostname)}:${live.port}`;
50
79
  }
51
80
 
@@ -62,11 +91,26 @@ function stringField(record: Record<string, unknown>, key: string): string | und
62
91
  * is unavailable). Both were dropped here, so a fenced management plane was
63
92
  * indistinguishable from a generic failure and an operator had no way to tell a port
64
93
  * collision from an ACL refusal from a stopped proxy (#2698).
94
+ *
95
+ * A 404 body that carries both `method` and `path` is a different statement again: some
96
+ * opencodex listener answered, and it does not route that request at all. Only the
97
+ * connected-client machine listener emits that shape today (`json404` in
98
+ * src/client/machine-listener.ts), and printing its bare `not_found` token read as though the
99
+ * resource were missing — a real handler's unknown-id 404 says `not found`, one space apart
100
+ * (#4662). Name the route instead, so any listener that does not serve a path stays legible
101
+ * even if another one starts answering this way.
65
102
  */
66
103
  function responseMessage(body: unknown, status: number): string {
67
104
  if (typeof body === "string" && body.trim()) return body.trim().slice(0, 400);
68
105
  if (!body || typeof body !== "object") return `Management request failed (${status})`;
69
106
  const record = body as Record<string, unknown>;
107
+ if (status === 404) {
108
+ const method = stringField(record, "method");
109
+ const path = stringField(record, "path");
110
+ if (method && path) {
111
+ return `This opencodex listener does not serve ${method.slice(0, 16)} ${path.slice(0, 200)}, so the request was refused before any handler ran (it is not a missing record). Check that the command is pointed at a proxy that serves the management API.`;
112
+ }
113
+ }
70
114
  let primary: string | undefined;
71
115
  for (const key of ["error", "message", "detail"]) {
72
116
  primary = stringField(record, key);