@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
package/src/cli/help.ts CHANGED
@@ -38,6 +38,8 @@ Usage:
38
38
  ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall|remove)
39
39
  ocx tray <sub> Windows status tray (install|start|stop|status|uninstall)
40
40
  ocx ensure Ensure the proxy is running and Codex config/cache are current
41
+ ocx connect <url> Connect this machine to a remote OpenCodex hub (credential via stdin)
42
+ ocx disconnect Restore local state and clear the hub connection
41
43
  ocx sync [--restart-codex] Fetch models from providers and inject into Codex config
42
44
  ocx sync-cache [--restart-codex]
43
45
  Refresh Codex's model cache from the active catalog
@@ -50,7 +52,8 @@ Usage:
50
52
  ocx debug <scope> provider/usage/injection/claude on|off|status|reset
51
53
  ocx login <provider> OAuth or API-key provider login
52
54
  ocx logout <provider> Remove a stored OAuth login
53
- ocx gui Open the opencodex dashboard
55
+ ocx gui [pair --origin <browser-origin> [--json]]
56
+ Open the dashboard or create a single-use remote pairing grant
54
57
  ocx update [--tag <tag>] Update opencodex (keeps preview installs on @preview)
55
58
  ocx restart Stop and restart the proxy
56
59
  ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|keep-native-v1|threads|mode-hint)
package/src/cli/index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  runCodexHistoryJob,
10
10
  } from "../codex/history-job";
11
11
  import { reconcileJournal } from "../codex/journal";
12
+ import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../client/state";
12
13
  import {
13
14
  codexAutoStartEnabled,
14
15
  getConfigDir,
@@ -48,7 +49,7 @@ import {
48
49
  } from "./tray-proxy";
49
50
  import { requestBoundSystemRestart } from "./system-restart-client";
50
51
  import { installCrashGuards } from "../lib/crash-guard";
51
- import { dispatchCommand } from "./dispatch";
52
+ import { dispatchCommand , decideStartWithLiveOwner } from "./dispatch";
52
53
  import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
53
54
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
54
55
  import { createReadinessGate } from "../server/readiness";
@@ -58,7 +59,6 @@ import { isProcessAlive, ProxyOwnershipRefusedError, stopProxy } from "../lib/pr
58
59
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
59
60
  import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service";
60
61
  import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
61
- import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
62
62
  import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env";
63
63
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
64
64
  import { startTokenGuardian } from "../oauth/token-guardian";
@@ -149,7 +149,10 @@ function startArgv(port?: number): string[] {
149
149
  return selfLaunchArgv(args);
150
150
  }
151
151
 
152
- async function chooseListenPort(requestedPort?: number): Promise<number> {
152
+ async function chooseListenPort(
153
+ requestedPort?: number,
154
+ options: { sibling?: boolean } = {},
155
+ ): Promise<number> {
153
156
  const config = loadConfig();
154
157
  const preferred = requestedPort ?? config.port ?? 10100;
155
158
  const hardPin = requestedPort !== undefined && requestedPort > 0;
@@ -197,7 +200,7 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
197
200
  if (preferred > 0 && selected !== preferred) {
198
201
  console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
199
202
  }
200
- if (shouldPersistSelectedPort(config.port, selected, preferred)) {
203
+ if (shouldPersistSelectedPort(config.port, selected, preferred, options)) {
201
204
  config.port = selected;
202
205
  saveConfig(config);
203
206
  }
@@ -224,7 +227,12 @@ async function findProxyOwnerBeforeJournalRecovery(
224
227
  // The probe established that the snapshotted owner is stale. Compare before
225
228
  // deleting so a concurrent start that rewrote the PID file keeps its state.
226
229
  removePidIfValueIs(pidSnapshot);
227
- if (!currentExternalCodexModelProvider()) reconcileJournal();
230
+ if (!currentExternalCodexModelProvider()) {
231
+ const clientState = readClientConnectionState();
232
+ reconcileJournal(clientState.kind === "connected"
233
+ ? { activeClientApiKeyId: clientState.value.apiKeyId }
234
+ : undefined);
235
+ }
228
236
  return { live: null, pidSnapshot };
229
237
  }
230
238
 
@@ -248,20 +256,52 @@ async function handleStart(options: { block?: boolean } = {}) {
248
256
  // shutdown then left no runtime record for discovery at all. `handleEnsure`
249
257
  // already passes this; `handleStart` is the path that did not.
250
258
  const owner = await findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true });
259
+ let siblingStart = false;
251
260
  if (owner.live) {
252
- // Service-wrapper context (opencodex-service.cmd `:loop`): a healthy proxy from
253
- // ANY source means the requested port is already served. Exit 0 so the wrapper's
254
- // `if %ERRORLEVEL% NEQ 0` retry loop terminates instead of respawning every 5s
255
- // against a listener it can never claim (observed as an endless
256
- // "Proxy already running" service.log loop).
257
- // Only the exact "1" sentinel takes this path — the same check syncCleanup
258
- // uses so an env value like "0" or "false" cannot bypass the conflict error.
259
- if (process.env.OCX_SERVICE === "1") {
261
+ // Rationale and the full decision table live on `decideStartWithLiveOwner`.
262
+ const decision = decideStartWithLiveOwner({
263
+ livePort: owner.live.port,
264
+ requestedPort,
265
+ ocxService: process.env.OCX_SERVICE,
266
+ });
267
+ if (decision === "service-stay-out") {
268
+ // Service-wrapper context (opencodex-service.cmd `:loop`): a healthy proxy from
269
+ // ANY source means the requested port is already served. Exit 0 so the wrapper's
270
+ // `if %ERRORLEVEL% NEQ 0` retry loop terminates instead of respawning every 5s
271
+ // against a listener it can never claim (observed as an endless
272
+ // "Proxy already running" service.log loop).
260
273
  console.log(`Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}); service wrapper staying out of the way.`);
261
274
  process.exit(0);
262
275
  }
263
- console.error(`⚠️ Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}). Use 'ocx stop' first.`);
264
- process.exit(1);
276
+ if (decision === "refuse") {
277
+ console.error(`⚠️ Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}). Use 'ocx stop' first.`);
278
+ process.exit(1);
279
+ }
280
+ // Sibling path. Honest about the side effects it shares with any start in this home:
281
+ // the new instance takes over this home's ocx.pid / runtime-port.json while it runs,
282
+ // and re-points this home's Codex config at the new port when injection applies.
283
+ // What it must NOT do is persist its port into config.port: the configured-port
284
+ // proxy is still the owner of this home, and a later `ocx service` reads config.port
285
+ // to bake the service (observed: a probe on 10198 left the service pinned there).
286
+ siblingStart = true;
287
+ console.warn(
288
+ `Proxy already running on port ${owner.live.port}; starting a second instance on requested port ${requestedPort}. `
289
+ + `The new instance takes over this home's pid/runtime records and Codex config while it runs.`,
290
+ );
291
+ }
292
+
293
+ const clientState = readClientConnectionState();
294
+ if (clientState.kind === "invalid" || clientState.kind === "mismatched") {
295
+ throw new Error(`client startup refused: ${clientState.reason}`);
296
+ }
297
+ const rotationGate = inspectClientRotationRecoveryGate(clientState);
298
+ if (clientState.kind === "connected") {
299
+ if (rotationGate.kind === "recovery-required" || rotationGate.kind === "unsafe") {
300
+ throw new Error(`client startup refused: ${rotationGate.reason}`);
301
+ }
302
+ const { startClientRuntime } = await import("../client/runtime");
303
+ await startClientRuntime({ port: requestedPort, block: options.block });
304
+ return;
265
305
  }
266
306
 
267
307
  // Interactive-only update prompt. Must run BEFORE we bind a port / write a
@@ -272,7 +312,8 @@ async function handleStart(options: { block?: boolean } = {}) {
272
312
  // Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port
273
313
  // between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries
274
314
  // the same port only (never hop — that was the remaining PR #152 gap).
275
- let port = await chooseListenPort(requestedPort);
315
+ let port = await chooseListenPort(requestedPort, { sibling: siblingStart });
316
+ const { drainAndShutdown, isRecyclingForExit, startServer } = await import("../server");
276
317
  // One private readiness gate for this startServer invocation, captured by the
277
318
  // listener's closure. handleStart owns it and transitions it after the
278
319
  // post-startup sync settles. A second startServer in the same process would
@@ -301,7 +342,7 @@ async function handleStart(options: { block?: boolean } = {}) {
301
342
  continue;
302
343
  }
303
344
  console.log(`⚠️ Port ${port} was taken while starting; picking another...`);
304
- port = await chooseListenPort(requestedPort);
345
+ port = await chooseListenPort(requestedPort, { sibling: siblingStart });
305
346
  }
306
347
  }
307
348
  // A single request's streaming error must never crash the daemon serving every
@@ -737,6 +778,7 @@ async function handleStop() {
737
778
  // restart-window wait; launchd, systemd and WinSW are down when they say so.
738
779
  let schedulerCanRespawn = false;
739
780
  let stoppedService = false;
781
+ let nativeRestoreHandledByProxy = false;
740
782
  // An ownership mismatch means the service manager was never even contacted: the installed
741
783
  // service is still live and will respawn the proxy. Tearing down SHARED state in that
742
784
  // situation (native Codex config, the Grok fence) removes config out from under a running
@@ -785,17 +827,22 @@ async function handleStop() {
785
827
  * guessed one fails closed into manual recovery rather than letting a later probe read
786
828
  * "the configured port refuses" as proof that the right proxy is down.
787
829
  */
788
- const stopWithDeferral = async (pid: number, discovered?: { hostname: string; port: number } | null): Promise<void> => {
830
+ const stopWithDeferral = async (pid: number, discovered?: { hostname: string; port: number } | null): Promise<boolean> => {
789
831
  // Resolve ONCE. Reading the runtime record twice let the receipt name the configured
790
832
  // guess while the request went to a runtime endpoint that appeared in between.
791
833
  const exact = discovered ?? endpointOf(readRuntimePort(pid));
792
834
  claimTeardown(exact ?? configuredEndpoint(), exact ? "exact" : "guessed");
793
- await stopProxy(pid, {
835
+ const graceful = await stopProxy(pid, {
794
836
  deferSharedTeardownNonce: teardownNonce,
795
837
  // Only an exact endpoint may direct the request; the configured fallback is a guess
796
838
  // good enough to record an obligation against, not to POST a stop to.
797
839
  runtimeEndpoint: exact ?? undefined,
798
840
  });
841
+ // A valid receipt means the proxy deferred shared teardown to this process. If the
842
+ // receipt could not be written, the proxy restores it itself and the caller must not
843
+ // attempt a second restore after a graceful stop. A hard-kill always leaves restore
844
+ // to this process.
845
+ return graceful && !teardownNonce;
799
846
  };
800
847
  try {
801
848
  const serviceStop = stopServiceIfInstalledDetailed();
@@ -838,7 +885,7 @@ async function handleStop() {
838
885
  // verification below, so a survivor does not get its client config pulled first.
839
886
  // The receipt goes down first — the proxy honours the deferral only when it can
840
887
  // see one, so an unrecordable claim degrades to the child doing its own teardown.
841
- await stopWithDeferral(pid);
888
+ nativeRestoreHandledByProxy = await stopWithDeferral(pid);
842
889
  console.log(`✅ Proxy (PID ${pid}) stopped.`);
843
890
  removePid(pid);
844
891
  removeRuntimePort(pid);
@@ -868,7 +915,10 @@ async function handleStop() {
868
915
  try {
869
916
  // The probe already found where it answers, and on this path the runtime record is
870
917
  // typically what went missing in the first place.
871
- await stopWithDeferral(live.pid, { hostname: live.hostname ?? "127.0.0.1", port: live.port });
918
+ nativeRestoreHandledByProxy = await stopWithDeferral(
919
+ live.pid,
920
+ { hostname: live.hostname ?? "127.0.0.1", port: live.port },
921
+ );
872
922
  console.log(`✅ Proxy (PID ${live.pid}) stopped.`);
873
923
  } catch (err) {
874
924
  stopFailed = true;
@@ -986,7 +1036,7 @@ async function handleStop() {
986
1036
  console.error(" The obligation is preserved; retry once the proxy is confirmed stopped.");
987
1037
  }
988
1038
  }
989
- const restoreBlocked = ownershipBlocked || inheritedBlocks;
1039
+ const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy;
990
1040
  if (!restoreBlocked) {
991
1041
  if (recoveredNonces.length > 0) {
992
1042
  // A previous deferred stop died before restoring, and the probe says its endpoint is
@@ -1294,6 +1344,10 @@ async function handleStatus() {
1294
1344
  console.log(` Runtime: ${status.json.paths.runtime}`);
1295
1345
  console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}`);
1296
1346
  console.log(` Default provider: ${status.json.defaultProvider}`);
1347
+ console.log(` Remote hub: ${status.json.connection.state}${status.json.connection.serverUrl ? ` (${status.json.connection.serverUrl})` : ""}`);
1348
+ if (status.json.connection.state === "invalid" || status.json.connection.state === "mismatched") {
1349
+ console.log(` ⚠️ ${status.json.connection.reason}`);
1350
+ }
1297
1351
  console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`);
1298
1352
  console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`);
1299
1353
  console.log(` ${formatStartupRoutingDetail(status.json.startup)}`);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The `ocx models` subcommands that live in `models-runtime` and talk to the
3
+ * management API, rather than editing `config.json` directly.
4
+ *
5
+ * This list is shared rather than duplicated on purpose. `handleModels` in
6
+ * `models.ts` decides which names to hand to the runtime module, and
7
+ * `handleModelsRuntimeCommand` decides which names it answers. When those two
8
+ * lists were written out separately, `new-policy` and `new-arrivals` were
9
+ * implemented and documented but never routed, so both failed with
10
+ * "Unexpected argument(s)" (#3094).
11
+ *
12
+ * It lives in its own leaf module so `models.ts` can read the set without
13
+ * statically importing `models-runtime` — that import is deliberately dynamic
14
+ * to keep the management-API client off the `ocx models add` path.
15
+ */
16
+ export const MODELS_RUNTIME_SUBCOMMANDS = [
17
+ "live",
18
+ "edit",
19
+ "enable",
20
+ "disable",
21
+ "provider",
22
+ "selected",
23
+ "preset",
24
+ "new-policy",
25
+ "new-arrivals",
26
+ "context",
27
+ "shadow",
28
+ ] as const;
29
+
30
+ export type ModelsRuntimeSubcommand = (typeof MODELS_RUNTIME_SUBCOMMANDS)[number];
31
+
32
+ export function isModelsRuntimeSubcommand(value: string | undefined): value is ModelsRuntimeSubcommand {
33
+ return MODELS_RUNTIME_SUBCOMMANDS.includes(value as ModelsRuntimeSubcommand);
34
+ }
@@ -12,6 +12,7 @@ import {
12
12
  takeOption,
13
13
  type RuntimeApiDeps,
14
14
  } from "./runtime-api";
15
+ import { isModelsRuntimeSubcommand } from "./models-runtime-subcommands";
15
16
 
16
17
  const USAGE = `Usage:
17
18
  ocx models live [--provider <name>] [--json]
@@ -321,6 +322,9 @@ async function shadow(argv: string[], deps: RuntimeApiDeps): Promise<void> {
321
322
  }
322
323
 
323
324
  export async function handleModelsRuntimeCommand(sub: string, argv: string[], deps: RuntimeApiDeps = {}): Promise<number | null> {
325
+ // The dispatch below and MODELS_RUNTIME_SUBCOMMANDS must name the same set;
326
+ // tests/cli-models-runtime-dispatch.test.ts fails if they drift (#3094).
327
+ if (!isModelsRuntimeSubcommand(sub)) return null;
324
328
  let action: (() => Promise<void>) | undefined;
325
329
  if (sub === "live") action = () => live(argv, deps);
326
330
  else if (sub === "edit") action = () => edit(argv, deps);
package/src/cli/models.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  modelRecordValue,
13
13
  } from "../reasoning-effort";
14
14
  import { encodedModelIdCollides, resolveSlugSelection, routedSlug } from "../providers/slug-codec";
15
+ import { isModelsRuntimeSubcommand } from "./models-runtime-subcommands";
15
16
  import { knownModelIdsForProvider } from "../router";
16
17
  import { findLiveProxy } from "../server/proxy-liveness";
17
18
  import { modelInList, type OcxConfig, type OcxCustomModel } from "../types";
@@ -445,7 +446,7 @@ export async function handleModels(args: string[]): Promise<void> {
445
446
  handleCustomList(rest);
446
447
  return;
447
448
  }
448
- if (["live", "edit", "enable", "disable", "provider", "selected", "preset", "context", "shadow"].includes(subcommand ?? "")) {
449
+ if (isModelsRuntimeSubcommand(subcommand)) {
449
450
  const { handleModelsRuntimeCommand } = await import("./models-runtime");
450
451
  const code = await handleModelsRuntimeCommand(subcommand!, rest);
451
452
  if (code !== null) process.exitCode = code;
@@ -94,6 +94,7 @@ export interface OpencodeProxyModelRow {
94
94
  native?: boolean;
95
95
  disabled?: boolean;
96
96
  displayName?: string;
97
+ displayNameSource?: "operator" | "provider" | "fallback";
97
98
  contextWindow?: number;
98
99
  /** Declared effort ladder from `/api/models`; carried into opencode model variants. */
99
100
  reasoningEfforts?: string[];
@@ -387,7 +388,7 @@ export function opencodeCatalogFromProxyRows(
387
388
  provider: row.provider,
388
389
  id: row.id,
389
390
  contextWindow: row.contextWindow,
390
- displayName: row.displayName,
391
+ displayName: row.displayNameSource === "fallback" ? undefined : row.displayName,
391
392
  ...(Array.isArray(row.reasoningEfforts) && row.reasoningEfforts.length > 0
392
393
  ? { reasoningEfforts: [...row.reasoningEfforts] }
393
394
  : {}),
@@ -24,12 +24,14 @@ const USAGE = `Usage:
24
24
  [--auth-mode <key|forward|oauth|local|->] [--note <text|->]
25
25
  [--api-key-transport <x-api-key|bearer|->]
26
26
  [--headers <json>] [--enabled <on|off>] [--live-models <on|off>]
27
+ [--retain-models <id,id|->]
27
28
  [--allow-private-network <on|off>] [--json]
28
29
  ocx provider test <name> [--json]
29
30
  ocx provider quota [--refresh] [--json]
30
31
  ocx provider presets [--json]
31
32
  ocx provider account-mode <pool|direct> [--json]
32
- ocx provider selected <name> [--set <model,model...>] [--clear] [--json]`;
33
+ ocx provider selected <name> [--set <model,model...>] [--clear] [--json]
34
+ ocx provider keychain <name> [status|store|restore] [--json]`;
33
35
 
34
36
  function cleared(value: string | undefined): string | undefined {
35
37
  return value === "-" ? "" : value;
@@ -48,6 +50,7 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
48
50
  const note = cleared(takeOption(args, "--note"));
49
51
  const apiKeyTransport = cleared(takeOption(args, "--api-key-transport"));
50
52
  const headers = takeOption(args, "--headers");
53
+ const retainModelsRaw = takeOption(args, "--retain-models");
51
54
  const enabled = takeBooleanOption(args, "--enabled");
52
55
  const liveModels = takeBooleanOption(args, "--live-models");
53
56
  const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network");
@@ -74,6 +77,10 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
74
77
  }
75
78
  }
76
79
  if (enabled !== undefined) patch.disabled = !enabled;
80
+ if (retainModelsRaw !== undefined) {
81
+ // `-` clears, matching the other `edit` scalars; test before csv() or it becomes ["-"].
82
+ patch.retainModels = retainModelsRaw.trim() === "-" ? null : csv(retainModelsRaw);
83
+ }
77
84
  if (liveModels !== undefined) patch.liveModels = liveModels;
78
85
  if (allowPrivateNetwork !== undefined) patch.allowPrivateNetwork = allowPrivateNetwork;
79
86
  if (Object.keys(patch).length === 0) throw new CliUsageError("at least one edit option is required", USAGE);
@@ -175,6 +182,28 @@ async function selected(argv: string[], deps: RuntimeApiDeps): Promise<void> {
175
182
  printData(result, wantsJson, [`${name}: ${models.length ? models.join(", ") : "all models"}`]);
176
183
  }
177
184
 
185
+ async function keychain(argv: string[], deps: RuntimeApiDeps): Promise<void> {
186
+ const args = [...argv];
187
+ const name = args.shift()?.trim();
188
+ const wantsJson = takeFlag(args, "--json");
189
+ const action = (args.shift() ?? "status").toLowerCase();
190
+ if (!name) throw new CliUsageError("provider name is required", USAGE);
191
+ if (!["status", "store", "restore"].includes(action)) throw new CliUsageError(`unknown keychain action ${action}`, USAGE);
192
+ rejectArgs(args, USAGE);
193
+ if (action === "status") {
194
+ const result = await runtimeRequest<Record<string, unknown>>(`/api/providers/keychain?name=${encodeURIComponent(name)}`, {}, deps);
195
+ printData(result, wantsJson, summaryLines(result));
196
+ return;
197
+ }
198
+ const result = await runtimeRequest<Record<string, unknown>>("/api/providers/keychain", {
199
+ method: "POST",
200
+ body: JSON.stringify({ name, action }),
201
+ }, deps);
202
+ printData(result, wantsJson, [action === "store"
203
+ ? `${name}: API key moved to the OS keychain; config.json now holds a keychain: reference.`
204
+ : `${name}: API key restored to config.json; keychain entries removed.`]);
205
+ }
206
+
178
207
  export async function handleProviderRuntimeCommand(sub: string, argv: string[], deps: RuntimeApiDeps = {}): Promise<number | null> {
179
208
  const handlers: Record<string, (args: string[], deps: RuntimeApiDeps) => Promise<void>> = {
180
209
  edit,
@@ -184,6 +213,7 @@ export async function handleProviderRuntimeCommand(sub: string, argv: string[],
184
213
  presets,
185
214
  "account-mode": accountMode,
186
215
  selected,
216
+ keychain,
187
217
  };
188
218
  const handler = handlers[sub];
189
219
  if (!handler) return null;
@@ -86,6 +86,24 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
86
86
  ],
87
87
  },
88
88
  { name: "ensure", usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." },
89
+ {
90
+ name: "connect",
91
+ usage: "ocx connect <url> [--management-url <url>] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--no-sync]",
92
+ summary: "Connect this machine to a remote OpenCodex hub without persisting the one-time authority.",
93
+ details: [
94
+ "Status: ocx connect status [--json]",
95
+ "Rotate or recover: ocx connect rotate (--pairing-code-stdin | --admin-token-stdin) [--json]",
96
+ "Revoke while connected: ocx connect revoke --admin-token-stdin [--json]",
97
+ "Machine resources: /api/machine/status, /api/machine/shim, /api/machine/clients, /api/machine/sync, /api/machine/disconnect, and the fixed /api/machine/hub-relay namespace.",
98
+ "Remote browser self-logout uses /api/session/logout from the GUI; it is distinct from client disconnect and key revocation.",
99
+ "Credentials are accepted only through stdin; argv and environment credential forms are not supported.",
100
+ ],
101
+ },
102
+ {
103
+ name: "disconnect",
104
+ usage: "ocx disconnect [--keep-catalog] [--json]",
105
+ summary: "Restore local client state offline and clear the remote-hub connection.",
106
+ },
89
107
  {
90
108
  name: "sync",
91
109
  usage: "ocx sync [--restart-codex] [--restart-desktop-app]",
@@ -128,7 +146,15 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
128
146
  },
129
147
  { name: "login", usage: "ocx login <provider>", summary: "OAuth or API-key login for a provider." },
130
148
  { name: "logout", usage: "ocx logout <provider>", summary: "Remove a stored provider login." },
131
- { name: "gui", usage: "ocx gui", summary: "Open the opencodex dashboard." },
149
+ {
150
+ name: "gui",
151
+ usage: "ocx gui [pair --origin <browser-origin> [--json]]",
152
+ summary: "Open the opencodex dashboard or create a secret single-use remote pairing grant.",
153
+ details: [
154
+ "Pairing requires an explicit allowed --origin; there is no localhost or config-derived default.",
155
+ "The printed grant is secret, single-use, short-lived, and must not be persisted.",
156
+ ],
157
+ },
132
158
  {
133
159
  name: "update",
134
160
  usage: "ocx update [--tag latest|preview]",
@@ -240,8 +266,12 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
240
266
  name: "access",
241
267
  usage: "ocx access <key|endpoints|models|test> ...",
242
268
  summary: "Manage OpenCodex admission API keys and inspect external endpoints.",
269
+ details: [
270
+ "Key rotation start uses POST /api/keys/rotate and returns the replacement secret once.",
271
+ "Commit uses POST /api/keys/rotate/commit; abort uses DELETE /api/keys/rotate with the returned rotation id.",
272
+ ],
243
273
  },
244
- { name: "api-key", usage: "ocx api-key <list|create|remove> ...", summary: "Alias of ocx access key." },
274
+ { name: "api-key", usage: "ocx api-key <list|create|rotate|remove> ...", summary: "Alias of ocx access key." },
245
275
  {
246
276
  name: "export",
247
277
  usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime|aside> [--json] [--out <path>] [--force]",
@@ -182,7 +182,16 @@ export function csv(value: string | undefined): string[] | undefined {
182
182
  * `--code=https://…?code=SECRET` that writes the authorization code to stderr,
183
183
  * which is the exact exposure the stdin path exists to avoid.
184
184
  */
185
- const SECRET_OPTIONS = ["--code", "--headers"];
185
+ const SECRET_OPTIONS = [
186
+ "--code",
187
+ "--headers",
188
+ "--token",
189
+ "--admin-token",
190
+ "--pairing-code",
191
+ "--credential-env",
192
+ "--admin-token-env",
193
+ "--pairing-code-env",
194
+ ];
186
195
 
187
196
  /**
188
197
  * Replace credential values before they are reported back.
package/src/cli/status.ts CHANGED
@@ -18,6 +18,7 @@ import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "..
18
18
  import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status";
19
19
  import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
20
20
  import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy";
21
+ import { collectClientConnectionStatus } from "./connect";
21
22
 
22
23
  type HealthCheck = {
23
24
  ok: boolean;
@@ -63,6 +64,18 @@ export type CliStatusJson = {
63
64
  source: "default" | "file" | "fallback";
64
65
  error: string | null;
65
66
  };
67
+ connection: {
68
+ state: "disconnected" | "connected" | "invalid" | "mismatched";
69
+ reason?: string;
70
+ serverUrl?: string;
71
+ managementUrl?: string;
72
+ protocolVersion?: number;
73
+ apiKeyId?: string;
74
+ selectedClients?: string[];
75
+ catalog?: "present" | "missing" | "unsafe";
76
+ catalogAgeSeconds?: number;
77
+ credentialFile: "owned" | "missing" | "changed" | "unsafe";
78
+ };
66
79
  service: { summary: string };
67
80
  codexShim: { summary: string };
68
81
  codexPlugins: CodexPluginsDiagnostic;
@@ -309,6 +322,7 @@ export async function collectStatus(): Promise<CliStatusView> {
309
322
  desiredEnabled: claudeDesktopIntegrationEnabled(config),
310
323
  policy: claudeDesktopPolicyHealth(probeClaudeDesktopPolicy()),
311
324
  };
325
+ const clientConnection = collectClientConnectionStatus();
312
326
  // Prefer identity-verified liveness (runtime-port + /healthz) over ocx.pid alone (#618).
313
327
  // Pass the already-resolved diagnostics config so findLiveProxy does not re-load and
314
328
  // warn on malformed config.json (status --json must stay stderr-clean).
@@ -491,6 +505,18 @@ export async function collectStatus(): Promise<CliStatusView> {
491
505
  source: configDiagnostics.source,
492
506
  error: configDiagnostics.error,
493
507
  },
508
+ connection: {
509
+ state: clientConnection.state,
510
+ ...(clientConnection.reason ? { reason: clientConnection.reason } : {}),
511
+ ...(clientConnection.serverUrl ? { serverUrl: clientConnection.serverUrl } : {}),
512
+ ...(clientConnection.managementUrl ? { managementUrl: clientConnection.managementUrl } : {}),
513
+ ...(clientConnection.protocolVersion ? { protocolVersion: clientConnection.protocolVersion } : {}),
514
+ ...(clientConnection.apiKeyId ? { apiKeyId: clientConnection.apiKeyId } : {}),
515
+ ...(clientConnection.selectedClients ? { selectedClients: [...clientConnection.selectedClients] } : {}),
516
+ catalog: clientConnection.catalog,
517
+ ...(clientConnection.catalogAgeSeconds !== undefined ? { catalogAgeSeconds: clientConnection.catalogAgeSeconds } : {}),
518
+ credentialFile: clientConnection.token,
519
+ },
494
520
  service: { summary: serviceSummary },
495
521
  codexShim: { summary: codexShimSummary },
496
522
  codexPlugins,
@@ -13,7 +13,8 @@ import {
13
13
 
14
14
  const USAGE = `Usage:
15
15
  ocx system [status] [--json]
16
- ocx system settings [--auto-start <on|off>] [--stream-mode <auto|legacy-tee|eager-relay>] [--json]
16
+ ocx system settings [--auto-start <on|off>] [--stream-mode <auto|legacy-tee|eager-relay>]
17
+ [--desktop-authless <on|off>] [--json]
17
18
  ocx system startup <health|install-service|install-shim> [--json]
18
19
  ocx system diagnostics [--json]
19
20
  ocx system sync [--json]
@@ -42,13 +43,18 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
42
43
  const wantsJson = takeFlag(args, "--json");
43
44
  const autoStart = takeBooleanOption(args, "--auto-start");
44
45
  const streamMode = takeOption(args, "--stream-mode");
46
+ const desktopAuthless = takeBooleanOption(args, "--desktop-authless");
45
47
  rejectArgs(args, USAGE);
46
- if (autoStart === undefined && streamMode === undefined) {
48
+ if (autoStart === undefined && streamMode === undefined && desktopAuthless === undefined) {
47
49
  const result = await runtimeRequest("/api/settings", {}, deps);
48
50
  printData(result, wantsJson, summaryLines(result));
49
51
  return;
50
52
  }
51
- const body = { ...(autoStart !== undefined ? { codexAutoStart: autoStart } : {}), ...(streamMode !== undefined ? { streamMode } : {}) };
53
+ const body = {
54
+ ...(autoStart !== undefined ? { codexAutoStart: autoStart } : {}),
55
+ ...(streamMode !== undefined ? { streamMode } : {}),
56
+ ...(desktopAuthless !== undefined ? { codexDesktopAuthless: desktopAuthless } : {}),
57
+ };
52
58
  const result = await runtimeRequest("/api/settings", { method: "PUT", body: JSON.stringify(body) }, deps);
53
59
  printData(result, wantsJson, ["System settings updated."]);
54
60
  }