@bitkyc08/opencodex 2.58.0 → 2.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
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,11 @@ 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 { SpendLedgerOwnerError } from "../lib/spend-ledger-owner";
82
+ import { redactUrlForLog } from "../lib/redact";
83
+ import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch";
80
84
  import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
81
- import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
85
+ import { findLiveProxy, probeHostname, probePortOwner, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness";
82
86
  import { createReadinessGate } from "../server/readiness";
83
87
  import { isApiAuthRequired } from "../server/auth-cors";
84
88
  import { runReady, type ReadyArgs } from "./ready";
@@ -169,21 +173,13 @@ const head = await runCli(process.argv.slice(2));
169
173
  const args = head.args;
170
174
  const command = head.command;
171
175
 
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");
176
+ function parseStartCliOptions(): ReturnType<typeof parseStartOptions> {
177
+ try {
178
+ return parseStartOptions(args.slice(1));
179
+ } catch (error) {
180
+ console.error(error instanceof StartArgsError ? error.message : String(error));
184
181
  process.exit(1);
185
182
  }
186
- return port;
187
183
  }
188
184
 
189
185
  async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
@@ -272,8 +268,41 @@ async function chooseListenPort(
272
268
  // ever a config collision.
273
269
  ...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}),
274
270
  });
275
- if (preferred > 0 && selected !== preferred) {
276
- console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
271
+ if (selected !== preferred) {
272
+ // The hop used to be automatic, and that is how a bare `start` beside a healthy
273
+ // proxy produced a second one (#5004): nothing on this path ever asked who held the
274
+ // preferred port. Ask the holder itself — not this home's pid/runtime bookkeeping,
275
+ // which is exactly what was wrong when the duplicate happened — and give the
276
+ // question a budget that cannot mistake one lost probe for an empty port.
277
+ const holder = preferred > 0 && !hardPin
278
+ ? await probePortOwner(preferred, { hostname: config.hostname }, START_OWNERSHIP_LIVENESS)
279
+ : null;
280
+ const decision = decideBusyPreferredPort({
281
+ preferredPort: preferred,
282
+ selectedPort: selected,
283
+ hardPin,
284
+ holderIsOpencodex: holder !== null,
285
+ ocxService: process.env.OCX_SERVICE,
286
+ });
287
+ if (decision === "service-stay-out") {
288
+ // Same contract as the pre-bind owner check: the wrapper's retry loop terminates
289
+ // on a zero exit, and the port it was asked to serve is already served.
290
+ console.log(`Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}); service wrapper staying out of the way.`);
291
+ process.exit(0);
292
+ }
293
+ if (decision === "refuse-live-proxy") {
294
+ console.error(`⚠️ Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}). Use 'ocx stop' first.`);
295
+ process.exit(1);
296
+ }
297
+ if (decision === "refuse-unidentified-holder") {
298
+ console.error(`❌ Port ${preferred} is busy and its holder did not identify as opencodex.`);
299
+ console.error(" Starting on another port would leave Codex pointed at a proxy you did not ask for.");
300
+ console.error(" Stop whatever holds that port, or start on a free one with 'ocx start --port <port>'.");
301
+ process.exit(1);
302
+ }
303
+ if (preferred > 0) {
304
+ console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
305
+ }
277
306
  }
278
307
  if (shouldPersistSelectedPort(config.port, selected, preferred, options)) {
279
308
  config.port = selected;
@@ -296,7 +325,12 @@ async function findProxyOwnerBeforeJournalRecovery(
296
325
  const pidSnapshot = readPidFileValue();
297
326
  const hasRuntimeOwner = readRuntimePort() !== null;
298
327
  const shouldProbe = pidSnapshot !== null || hasRuntimeOwner || options.probeConfiguredPort === true;
299
- const live = shouldProbe ? await findLiveProxy() : null;
328
+ // A negative answer here is acted on twice over: the caller walks past a proxy it was
329
+ // supposed to find, and the lines below delete this home's pid record and reconcile the
330
+ // journal. One 750ms probe is not enough evidence for either (#5004) — a transport
331
+ // failure is indistinguishable from an empty port, and the reported Windows duplicate
332
+ // came from exactly that answer on a proxy the previous command had just found healthy.
333
+ const live = shouldProbe ? await findLiveProxy(START_OWNERSHIP_LIVENESS) : null;
300
334
  if (live) return { live, pidSnapshot };
301
335
 
302
336
  // The probe established that the snapshotted owner is stale. Compare before
@@ -328,7 +362,26 @@ async function handleStart(options: { block?: boolean } = {}) {
328
362
  // already-broken file cannot fence /api/* closed at boot (#2696).
329
363
  const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
330
364
  if (present) assertNotAdminToken(present);
331
- const requestedPort = parsePortOption();
365
+ const startOpts = parseStartCliOptions();
366
+ if (startOpts.socks5 !== undefined || startOpts.socks5Off) {
367
+ const proxyConfig = loadConfig();
368
+ if (startOpts.socks5Off) {
369
+ if (proxyConfig.proxy && !/^socks5h?:\/\//i.test(proxyConfig.proxy.trim())) {
370
+ console.error("Cannot use --socks5-off: config.proxy is not a SOCKS5 URL; it was left unchanged.");
371
+ process.exit(1);
372
+ }
373
+ if (proxyConfig.proxy) {
374
+ delete proxyConfig.proxy;
375
+ saveConfig(proxyConfig);
376
+ console.log("Cleared config.proxy (outbound SOCKS5 proxy off).");
377
+ }
378
+ } else {
379
+ proxyConfig.proxy = startOpts.socks5!;
380
+ saveConfig(proxyConfig);
381
+ console.log(`Outbound SOCKS5: ${redactUrlForLog(startOpts.socks5!)} (saved to config.proxy)`);
382
+ }
383
+ }
384
+ const requestedPort = startOpts.port;
332
385
  // Always probe the configured port, even when both state files are absent. A
333
386
  // fallback-port sibling overwrites the pid/runtime records when it starts and
334
387
  // removes them on its own shutdown, so their absence proves nothing about the
@@ -366,8 +419,8 @@ async function handleStart(options: { block?: boolean } = {}) {
366
419
  // to bake the service (observed: a probe on 10198 left the service pinned there).
367
420
  siblingStart = true;
368
421
  console.warn(
369
- `Proxy already running on port ${owner.live.port}; starting a second instance on requested port ${requestedPort}. `
370
- + `The new instance takes over this home's pid/runtime records and Codex config while it runs.`,
422
+ `Proxy already running on port ${owner.live.port}; requested a second instance on port ${requestedPort}. `
423
+ + `Startup continues only for an independent OPENCODEX_HOME; one state directory has one spend-ledger writer.`,
371
424
  );
372
425
  }
373
426
 
@@ -411,6 +464,10 @@ async function handleStart(options: { block?: boolean } = {}) {
411
464
  scheduleCatalogPrewarm();
412
465
  break;
413
466
  } catch (err) {
467
+ if (err instanceof SpendLedgerOwnerError) {
468
+ console.error(`❌ ${err.message}`);
469
+ process.exit(1);
470
+ }
414
471
  if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err;
415
472
  if (requestedPort !== undefined) {
416
473
  console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`);
@@ -927,8 +984,24 @@ async function handleStop() {
927
984
  // `inheritedTeardowns` is the inverse case: PREVIOUS stops that left obligations
928
985
  // unfinished. Snapshot them BEFORE this run claims anything, so this run's own receipt
929
986
  // is never mistaken for one it inherited.
987
+ //
988
+ // Ownership is decided by IDENTITY, not by bare liveness. A receipt records a number, and
989
+ // the OS reuses numbers: once the owner exits, an unrelated process can be handed its PID,
990
+ // and `isProcessAlive` alone then answers "that stop is still running" for as long as the
991
+ // new process lives. The receipt is filtered out, so no run ever recovers it, quarantines
992
+ // it or even mentions it — while both updater gates keep seeing an outstanding obligation
993
+ // and refuse. That is the permanent fail-closed reported in #4897: no proxy running, a
994
+ // dead owner, and `ocx update` aborting on `teardown-outstanding` every time.
995
+ //
996
+ // Requiring the live PID to be an opencodex process is the narrowing that costs the safety
997
+ // intent nothing: a stop that really is in flight is still left strictly alone, because its
998
+ // process is one of ours. Recognizing the receipt as abandoned only admits it to the
999
+ // recovery loop below, which still has to prove the recorded endpoint is down before
1000
+ // anything is restored.
1001
+ const teardownOwnerStillRunning = (ownerPid: number): boolean =>
1002
+ isProcessAlive(ownerPid) && isLikelyOcxProcess(ownerPid);
930
1003
  const inheritedTeardowns = listPendingTeardowns()
931
- .filter(read => isPendingTeardownAbandoned(read, isProcessAlive));
1004
+ .filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning));
932
1005
  let teardownNonce: string | undefined;
933
1006
  const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => {
934
1007
  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
 
@@ -41,7 +41,7 @@ import type {
41
41
  } from "../clients/config-export";
42
42
  import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog";
43
43
  import { commandInvocation } from "../lib/win-exec";
44
- import { configuredAdminToken } from "../lib/admin-secrets";
44
+ import { configuredAdminToken, opencodeCatalogToken } from "../lib/admin-secrets";
45
45
  import { localManagementOrigin } from "../lib/local-destinations";
46
46
  import { directLocalHttpFetch } from "../server/direct-local-http";
47
47
  import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
@@ -684,7 +684,7 @@ export async function cmdOpencode(args: string[]): Promise<number> {
684
684
  try {
685
685
  const managementToken = configuredAdminToken();
686
686
  if (!managementToken) throw new Error("No local admin token is available; check the running proxy's home.");
687
- proxyModels = await fetchOpencodeProxyModels(live, managementToken, {
687
+ proxyModels = await fetchOpencodeProxyModels(live, opencodeCatalogToken(managementToken), {
688
688
  managementOrigin: localManagementOrigin({ ...startupConfig, hostname: live.hostname }, live.port),
689
689
  });
690
690
  } catch (error) {
@@ -139,7 +139,7 @@ function handleList(args: string[]): void {
139
139
  // provider add
140
140
  // ---------------------------------------------------------------------------
141
141
 
142
- const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--api-key-transport <x-api-key|bearer>] [--default-model <model>] [--model <id> --text-only] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
142
+ const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--api-key-transport <x-api-key|bearer>] [--default-model <model>] [--model <id> --text-only] [--google-tool-schema-policy <compatible|reject-lossy>] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
143
143
 
144
144
  async function handleAdd(args: string[]): Promise<void> {
145
145
  const name = args[0];
@@ -164,6 +164,7 @@ async function handleAdd(args: string[]): Promise<void> {
164
164
  const adapter = consumeFlagValue(restArgs, "--adapter");
165
165
  const baseUrl = consumeFlagValue(restArgs, "--base-url");
166
166
  const defaultModel = consumeFlagValue(restArgs, "--default-model");
167
+ const googleToolSchemaPolicy = consumeFlagValue(restArgs, "--google-tool-schema-policy");
167
168
  const textOnly = consumeFlag(restArgs, "--text-only");
168
169
  const capabilityModel = consumeFlagValue(restArgs, "--model");
169
170
  rejectUnknownArgs(restArgs, ADD_USAGE);
@@ -228,6 +229,17 @@ async function handleAdd(args: string[]): Promise<void> {
228
229
  }
229
230
  provConfig.apiKeyTransport = apiKeyTransport;
230
231
  }
232
+ if (googleToolSchemaPolicy !== undefined) {
233
+ if (googleToolSchemaPolicy !== "compatible" && googleToolSchemaPolicy !== "reject-lossy") {
234
+ console.error('Error: --google-tool-schema-policy must be "compatible" or "reject-lossy".');
235
+ process.exit(1);
236
+ }
237
+ if (provConfig.adapter !== "google") {
238
+ console.error("Error: --google-tool-schema-policy requires the google adapter.");
239
+ process.exit(1);
240
+ }
241
+ provConfig.googleToolSchemaPolicy = googleToolSchemaPolicy;
242
+ }
231
243
 
232
244
  const existingProvider = config.providers[name];
233
245
  if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) {
@@ -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);
@@ -0,0 +1,94 @@
1
+ export const START_USAGE = "Usage: ocx start [--port <port>] [--socks5 [host:port] | --socks5-off]";
2
+ export const START_USAGE_LINE = "ocx start [--port <port>] [--socks5 [host:port] | --socks5-off]";
3
+ export const DEFAULT_SOCKS5_PROXY = "socks5://127.0.0.1:10808";
4
+
5
+ export class StartArgsError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = "StartArgsError";
9
+ }
10
+ }
11
+
12
+ export type StartOptions = {
13
+ port?: number;
14
+ /** SOCKS5 URL to persist. */
15
+ socks5?: string;
16
+ socks5Off?: boolean;
17
+ };
18
+
19
+ export function isSocksProxyUrl(proxy: string): boolean {
20
+ return /^socks5h?:\/\//i.test(proxy.trim());
21
+ }
22
+
23
+ export function normalizeSocks5(raw: string): string {
24
+ const trimmed = raw.trim();
25
+ if (!trimmed) return DEFAULT_SOCKS5_PROXY;
26
+ if (isSocksProxyUrl(trimmed)) {
27
+ try {
28
+ const parsed = new URL(trimmed);
29
+ const port = Number(parsed.port);
30
+ if (!parsed.hostname || !parsed.port || !Number.isInteger(port) || port <= 0 || port > 65535) {
31
+ throw new Error("invalid host or port");
32
+ }
33
+ return trimmed;
34
+ } catch {
35
+ throw new StartArgsError("Invalid SOCKS5 address; expected socks5://host:port");
36
+ }
37
+ }
38
+ if (/^socks4a?:\/\//i.test(trimmed)) {
39
+ throw new StartArgsError("Only SOCKS5 proxy URLs are supported");
40
+ }
41
+ if (/^https?:\/\//i.test(trimmed)) {
42
+ throw new StartArgsError("SOCKS5 proxy must be socks5://host:port, not an HTTP URL");
43
+ }
44
+ if (/^\d+$/.test(trimmed)) {
45
+ const port = Number(trimmed);
46
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
47
+ throw new StartArgsError("Invalid SOCKS5 port number");
48
+ }
49
+ return `socks5://127.0.0.1:${port}`;
50
+ }
51
+ const hostPort = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(trimmed);
52
+ if (hostPort) {
53
+ const port = Number(hostPort[2]);
54
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
55
+ throw new StartArgsError("Invalid SOCKS5 port number");
56
+ }
57
+ return `socks5://${trimmed}`;
58
+ }
59
+ throw new StartArgsError("Invalid SOCKS5 address; expected socks5://host:port");
60
+ }
61
+
62
+ export function parseStartOptions(argv: string[]): StartOptions {
63
+ const options: StartOptions = {};
64
+ for (let i = 0; i < argv.length; i++) {
65
+ const arg = argv[i]!;
66
+ if (arg === "--port") {
67
+ const value = argv[++i];
68
+ const port = value && /^\d+$/.test(value) ? Number(value) : NaN;
69
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
70
+ throw new StartArgsError("Invalid port number");
71
+ }
72
+ options.port = port;
73
+ continue;
74
+ }
75
+ if (arg === "--socks5") {
76
+ if (options.socks5Off) throw new StartArgsError("--socks5 and --socks5-off cannot be used together");
77
+ const next = argv[i + 1];
78
+ if (next && !next.startsWith("-")) {
79
+ options.socks5 = normalizeSocks5(next);
80
+ i += 1;
81
+ } else {
82
+ options.socks5 = DEFAULT_SOCKS5_PROXY;
83
+ }
84
+ continue;
85
+ }
86
+ if (arg === "--socks5-off") {
87
+ if (options.socks5 !== undefined) throw new StartArgsError("--socks5 and --socks5-off cannot be used together");
88
+ options.socks5Off = true;
89
+ continue;
90
+ }
91
+ throw new StartArgsError(START_USAGE);
92
+ }
93
+ return options;
94
+ }
@@ -21,6 +21,8 @@ const USAGE = `Usage:
21
21
  ocx system codex-app-server [--json]
22
22
  ocx system codex-restart --yes [--json]
23
23
  ocx system codex-cli-update check [--json]
24
+ ocx system codex-cli-update attest [--json]
25
+ ocx system codex-cli-update attest --candidate <absolute-path> --npm-prefix <absolute-path> --npm-cli <absolute-path> --node <absolute-path> [--json]
24
26
  ocx system update check [--channel <latest|preview>] [--json]
25
27
  ocx system update run [--channel <latest|preview>] [--restart <on|off>] --yes [--json]
26
28
  ocx system update status <job-id> [--json]
@@ -23,7 +23,7 @@ export interface MachineStatusV1 {
23
23
  export interface MachineApiDeps {
24
24
  sync: typeof syncConnectedClient;
25
25
  disconnect: typeof disconnectClient;
26
- scheduleStandaloneRecycle: () => void;
26
+ scheduleStandaloneRecycle: (disconnectedTokenFingerprint: string) => void;
27
27
  hubReachability?: () => HubReachability;
28
28
  setHubReachability?: (value: HubReachability) => void;
29
29
  }
@@ -74,10 +74,11 @@ export async function handleMachineApi(
74
74
  injected: MachineApiDeps = defaultDeps,
75
75
  ): Promise<Response | null> {
76
76
  const deps = { ...defaultDeps, ...injected };
77
- if (url.pathname === "/api/machine/status" && req.method === "GET") {
77
+ const readMethod = req.method === "GET" || req.method === "HEAD";
78
+ if (url.pathname === "/api/machine/status" && readMethod) {
78
79
  return Response.json(statusPayload(req, state, deps), { headers: { "Cache-Control": "no-store" } });
79
80
  }
80
- if (url.pathname === "/api/machine/clients" && req.method === "GET") {
81
+ if (url.pathname === "/api/machine/clients" && readMethod) {
81
82
  return Response.json({
82
83
  selectedClients: [...state.selectedClients],
83
84
  journalOwner: journalOwner(),
@@ -103,7 +104,7 @@ export async function handleMachineApi(
103
104
  return Response.json({ success: false, error: message }, { status: 502 });
104
105
  }
105
106
  }
106
- if (url.pathname === "/api/machine/shim" && req.method === "GET") {
107
+ if (url.pathname === "/api/machine/shim" && readMethod) {
107
108
  return Response.json(diagnoseCodexShim(), { headers: { "Cache-Control": "no-store" } });
108
109
  }
109
110
  if (url.pathname === "/api/machine/shim" && req.method === "POST") {
@@ -129,7 +130,7 @@ export async function handleMachineApi(
129
130
  }
130
131
  try {
131
132
  const result = await deps.disconnect(input.keepCatalog === undefined ? {} : { keepCatalog: input.keepCatalog });
132
- deps.scheduleStandaloneRecycle();
133
+ deps.scheduleStandaloneRecycle(state.tokenFingerprint);
133
134
  return Response.json({ success: true, ...result }, { status: 202 });
134
135
  } catch (error) {
135
136
  return Response.json({ success: false, error: error instanceof Error ? error.message : "disconnect failed" }, { status: 409 });
@@ -46,9 +46,14 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea
46
46
  if (req.headers.get("upgrade")) return false;
47
47
  const path = url.pathname;
48
48
  if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true;
49
- if (req.method === "GET" && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
49
+ if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
50
50
  if (req.method === "POST" && (path === "/api/machine/sync" || path === "/api/machine/shim" || path === "/api/machine/disconnect")) return true;
51
51
  if (relayEnabled && path.startsWith("/api/machine/hub-relay/")) return true;
52
+ // Known machine endpoints are admitted for every method so an unsupported
53
+ // method reaches the authenticated method restriction (403) instead of a
54
+ // bare 404 that hides the endpoint entirely.
55
+ if (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim"
56
+ || path === "/api/machine/sync" || path === "/api/machine/disconnect") return true;
52
57
  if (req.method !== "GET" || path.startsWith("/api/") || path.startsWith("/v1/")) return false;
53
58
  return GUI_SPA_PATHS.has(path)
54
59
  || path.startsWith("/integrations/")
@@ -70,8 +75,8 @@ export function startMachineListener(
70
75
  const machineApiDeps: MachineApiDeps = {
71
76
  sync: deps.machineApi?.sync ?? syncConnectedClient,
72
77
  disconnect: deps.machineApi?.disconnect ?? disconnectClient,
73
- scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (() => {
74
- void import("./runtime").then(module => module.scheduleStandaloneRecycle());
78
+ scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (tokenFingerprint => {
79
+ void import("./runtime").then(module => module.scheduleStandaloneRecycle(tokenFingerprint));
75
80
  }),
76
81
  hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability),
77
82
  setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
@@ -111,6 +116,14 @@ export function startMachineListener(
111
116
  if (managementPrincipal(req, managementAuth, config) !== "gui-session") {
112
117
  return Response.json({ error: "opencodex machine GUI session required" }, { status: 401 });
113
118
  }
119
+ // A loopback dashboard session proves possession, not user presence: any local
120
+ // process can fetch the dashboard bootstrap and replay its token and CSRF value.
121
+ // Keep the connected listener useful for status/diagnostics, but never let that
122
+ // credentialless bootstrap authorize durable machine changes. Those operations
123
+ // remain available through the explicit CLI commands.
124
+ if (req.method !== "GET" && req.method !== "HEAD") {
125
+ return Response.json({ error: "opencodex machine changes require the local CLI" }, { status: 403 });
126
+ }
114
127
  return await handleMachineApi(req, url, connection, machineApiDeps) ?? json404(req);
115
128
  }
116
129
 
@@ -4,6 +4,7 @@ import { loadConfig } from "../config";
4
4
  import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state";
5
5
  import { installCrashGuards } from "../lib/crash-guard";
6
6
  import { selfLaunchArgv } from "../lib/self-launch-argv";
7
+ import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets";
7
8
  import { findAvailablePort } from "../server/ports";
8
9
  import { startMachineListener } from "./machine-listener";
9
10
  import { readClientConnectionState } from "./state";
@@ -17,7 +18,30 @@ function cleanup(): void {
17
18
  removeRuntimePort(process.pid);
18
19
  }
19
20
 
20
- export function scheduleStandaloneRecycle(): void {
21
+ export function standaloneRecycleEnv(
22
+ env: NodeJS.ProcessEnv,
23
+ disconnectedTokenFingerprint: string,
24
+ ): NodeJS.ProcessEnv {
25
+ const childEnv = { ...env };
26
+ const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim();
27
+ if (admissionToken && serviceApiTokenFingerprint(admissionToken) !== disconnectedTokenFingerprint) {
28
+ // A surviving env token shadows OCX_API_TOKEN_FILE entirely, so nothing below can
29
+ // reintroduce the disconnected token.
30
+ return childEnv;
31
+ }
32
+ if (admissionToken) delete childEnv.OPENCODEX_API_AUTH_TOKEN;
33
+ // The respawned `ocx start` loads OCX_API_TOKEN_FILE into OPENCODEX_API_AUTH_TOKEN when the
34
+ // env token is absent, so vet the file by content: drop the pointer when it is unreadable or
35
+ // holds the disconnected token, keep it when it holds a different operator credential.
36
+ if (!childEnv.OCX_API_TOKEN_FILE?.trim()) return childEnv;
37
+ const fileToken = loadServiceTokenFromFile(childEnv);
38
+ if (fileToken === null || serviceApiTokenFingerprint(fileToken) === disconnectedTokenFingerprint) {
39
+ delete childEnv.OCX_API_TOKEN_FILE;
40
+ }
41
+ return childEnv;
42
+ }
43
+
44
+ export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string): void {
21
45
  if (recycleScheduled) return;
22
46
  recycleScheduled = true;
23
47
  const timer = setTimeout(() => {
@@ -46,7 +70,7 @@ export function scheduleStandaloneRecycle(): void {
46
70
  detached: true,
47
71
  stdio: "ignore",
48
72
  windowsHide: true,
49
- env: { ...process.env },
73
+ env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint),
50
74
  });
51
75
  child.unref();
52
76
  }
@@ -37,9 +37,8 @@ export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`;
37
37
  export const LOOPBACK_API_KEY_PLACEHOLDER = "opencodex-loopback";
38
38
 
39
39
  /**
40
- * Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey`
41
- * falls back to treating the literal text as the token when the variable is
42
- * unset, which would silently ship a bogus credential — so we never emit it.
40
+ * Legacy name retained for existing consumers. Gajae exports now use the
41
+ * non-secret loopback placeholder and do not emit an environment reference.
43
42
  */
44
43
  export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY";
45
44