@bitkyc08/opencodex 2.50.0 → 2.52.0-preview.20260911

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 (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/qoder/adapter.ts +69 -1
  6. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  7. package/src/claude/agents-inject.ts +29 -5
  8. package/src/claude/desktop-3p.ts +31 -3
  9. package/src/claude/gateway-cache.ts +12 -21
  10. package/src/cli/capabilities.ts +28 -0
  11. package/src/cli/claude-agent-startup-sync.ts +26 -1
  12. package/src/cli/claude.ts +138 -20
  13. package/src/cli/config-command.ts +67 -1
  14. package/src/cli/connect.ts +181 -14
  15. package/src/cli/dispatch.ts +53 -9
  16. package/src/cli/doctor.ts +9 -2
  17. package/src/cli/ensure-desired-integrations.ts +10 -0
  18. package/src/cli/gui-pair-client.ts +1 -12
  19. package/src/cli/help.ts +4 -1
  20. package/src/cli/hub.ts +367 -0
  21. package/src/cli/index.ts +94 -30
  22. package/src/cli/launcher-context.ts +1 -1
  23. package/src/cli/registry.ts +43 -3
  24. package/src/cli/status.ts +325 -5
  25. package/src/cli/version-skew.ts +4 -1
  26. package/src/cli.ts +2 -2
  27. package/src/client/catalog-compatibility.ts +192 -0
  28. package/src/client/connect.ts +31 -0
  29. package/src/client/hub-client.ts +52 -0
  30. package/src/client/hub-state.ts +214 -0
  31. package/src/codex/account-usability.ts +48 -12
  32. package/src/codex/auth-api.ts +49 -5
  33. package/src/codex/catalog/effort.ts +67 -8
  34. package/src/codex/catalog/sync.ts +85 -0
  35. package/src/codex/codex-write-lock.ts +11 -2
  36. package/src/codex/desired-state.ts +47 -1
  37. package/src/codex/inject-coordination.ts +10 -5
  38. package/src/codex/inject.ts +26 -10
  39. package/src/codex/loopback-target.ts +45 -0
  40. package/src/codex/routing.ts +48 -1
  41. package/src/codex/runtime.ts +37 -3
  42. package/src/codex/sync.ts +29 -9
  43. package/src/codex/warmup.ts +21 -4
  44. package/src/config/pending-teardown.ts +1 -1
  45. package/src/config.ts +126 -12
  46. package/src/generated/compatibility-version.json +136 -76
  47. package/src/grok/status.ts +9 -1
  48. package/src/integrations/config-io.ts +54 -1
  49. package/src/lib/bun-runtime.ts +1 -1
  50. package/src/lib/gui-pair-capability.ts +27 -0
  51. package/src/lib/local-destinations.ts +162 -0
  52. package/src/lib/package-tree-integrity.ts +1 -1
  53. package/src/lib/process-control.ts +130 -20
  54. package/src/lib/service-secrets.ts +28 -0
  55. package/src/lib/test-home-guard.ts +49 -0
  56. package/src/providers/opencode-go-transport.ts +9 -1
  57. package/src/providers/quota.ts +5 -1
  58. package/src/providers/registry.ts +34 -5
  59. package/src/remote/hub-state.ts +182 -0
  60. package/src/server/auth-cors.ts +5 -0
  61. package/src/server/chat-completions.ts +6 -3
  62. package/src/server/claude-messages.ts +7 -1
  63. package/src/server/hub-state.ts +98 -0
  64. package/src/server/index.ts +124 -6
  65. package/src/server/management/api-access.ts +14 -3
  66. package/src/server/management/config-routes.ts +2 -2
  67. package/src/server/management/cursor-integration-routes.ts +13 -4
  68. package/src/server/proxy-liveness.ts +7 -1
  69. package/src/server/request-log-conversation.ts +41 -1
  70. package/src/server/responses/codex-auth-error.ts +18 -1
  71. package/src/server/responses/codex-ws-exchange.ts +36 -4
  72. package/src/server/responses/codex-ws-wire.ts +75 -4
  73. package/src/server/responses/compact.ts +20 -9
  74. package/src/server/responses/core.ts +57 -10
  75. package/src/server/responses/policy-fallback.ts +7 -1
  76. package/src/server/system-env-shell.ts +14 -2
  77. package/src/server/system-env.ts +106 -14
  78. package/src/service.ts +906 -94
  79. package/src/types/config.ts +57 -4
  80. package/src/update/badge.ts +3 -2
  81. package/src/update/index.ts +317 -64
  82. package/src/update/install-detection.d.mts +6 -0
  83. package/src/update/install-detection.mjs +73 -0
  84. package/src/update/job.ts +101 -49
  85. package/src/update/pnpm-global-install.d.mts +144 -0
  86. package/src/update/pnpm-global-install.mjs +591 -0
  87. package/src/update/pnpm-invocation.d.mts +43 -0
  88. package/src/update/pnpm-invocation.mjs +141 -0
  89. package/src/update/registry-integrity.d.mts +16 -0
  90. package/src/update/registry-integrity.mjs +37 -0
  91. package/src/update/transactional-install.d.mts +1 -1
  92. package/src/update/transactional-install.mjs +101 -7
  93. package/src/update/tray-update-plan.mjs +1 -1
  94. package/src/vision/plan.ts +13 -3
  95. package/src/vision/routed-describe.ts +51 -20
package/src/cli/index.ts CHANGED
@@ -48,7 +48,7 @@ import {
48
48
  pendingTeardownPathFor,
49
49
  quarantinePendingTeardown,
50
50
  } from "../config/pending-teardown";
51
- import { collectStatus, unusedProxyWarningLines } from "./status";
51
+ import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status";
52
52
  import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
53
53
  import { takeFlag } from "./runtime-api";
54
54
 
@@ -66,10 +66,11 @@ import { dispatchCommand , decideStartWithLiveOwner } from "./dispatch";
66
66
  import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
67
67
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
68
68
  import { createReadinessGate } from "../server/readiness";
69
+ import { isApiAuthRequired } from "../server/auth-cors";
69
70
  import { runReady, type ReadyArgs } from "./ready";
70
71
  import { runCli } from "./root";
71
- import { isProcessAlive, ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control";
72
- import { loadServiceTokenFromFile } from "../lib/service-secrets";
72
+ import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control";
73
+ import { startupDataPlaneToken } from "../lib/service-secrets";
73
74
  import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service";
74
75
  import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
75
76
  import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env";
@@ -81,8 +82,11 @@ import { scheduleCatalogPrewarm } from "./catalog-prewarm";
81
82
  import { maybeShowUpdatePrompt } from "../update/notify";
82
83
  import { syncModelsToCodex } from "../codex/sync";
83
84
  import {
85
+ HUB_GATED_SKIP_MESSAGE,
86
+ localClientSkipReason,
84
87
  shouldSyncGrokOnStart,
85
88
  syncCodexOnStartIfEnabled,
89
+ type LocalClientSkipReason,
86
90
  } from "../codex/desired-state";
87
91
  import {
88
92
  reconcileClientStartupBeforeReady,
@@ -174,6 +178,19 @@ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
174
178
  return null;
175
179
  }
176
180
 
181
+ /**
182
+ * The one startup line for "nothing was written to Codex".
183
+ *
184
+ * Two very different facts reached it: the user's own OFF switch, and a hub declining to
185
+ * rewrite its own local clients. Printing the toggle's wording for the gate is what made
186
+ * operators hunt for a switch they never set (#4236).
187
+ */
188
+ function startupLeftCodexNativeLine(reason: LocalClientSkipReason): string {
189
+ return reason === "hub-gated"
190
+ ? ` ${HUB_GATED_SKIP_MESSAGE} Startup left Codex native.`
191
+ : " Codex integration OFF; startup left Codex native.";
192
+ }
193
+
177
194
  /** Argv for detached `start`, optionally hard-pinning the listen port. */
178
195
  function startArgv(port?: number): string[] {
179
196
  const args = ["start"];
@@ -190,6 +207,10 @@ async function chooseListenPort(
190
207
  const config = loadConfig();
191
208
  const preferred = requestedPort ?? config.port ?? 10100;
192
209
  const hardPin = requestedPort !== undefined && requestedPort > 0;
210
+ // Only an EXPLICITLY ported listener reserves a port. The companion form (`{enabled:true}`
211
+ // with no port) deliberately shares the public port on 127.0.0.1, so treating it as a
212
+ // reservation — via `effectiveLoopbackListenerPort` — would refuse every start on a
213
+ // one-port hub and hop the public listener onto an ephemeral port (#4236).
193
214
  const reservedLoopbackPort = config.unauthenticatedLoopbackListener?.enabled
194
215
  ? config.unauthenticatedLoopbackListener.port
195
216
  : undefined;
@@ -271,10 +292,16 @@ async function findProxyOwnerBeforeJournalRecovery(
271
292
  }
272
293
 
273
294
  async function handleStart(options: { block?: boolean } = {}) {
274
- // Native (WinSW) service mode has no batch wrapper to read the service token file
275
- // into the environment, so the app loads it here before the server binds. The server
295
+ // Native (WinSW) service mode has no batch wrapper to read the service token file into
296
+ // the environment, and a FOREGROUND `ocx start` has no wrapper at all — so the app loads
297
+ // the token here, before the server binds, with the same precedence the launchd plist and
298
+ // the systemd unit use when they cat the file into the environment. Without the second
299
+ // source, `ocx start` refused to bind a non-loopback hostname (assertServerAuthConfig)
300
+ // that the installed service on the same machine was serving happily (#4236). The server
276
301
  // auth path reads OPENCODEX_API_AUTH_TOKEN from the environment.
277
- const serviceToken = loadServiceTokenFromFile(process.env);
302
+ const serviceToken = startupDataPlaneToken(process.env, {
303
+ authRequired: isApiAuthRequired(loadConfig()),
304
+ });
278
305
  if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken;
279
306
  // The service wrapper (and WinSW via OCX_API_TOKEN_FILE) can still export a colliding
280
307
  // token that install now refuses to write. Refuse it here too, before bind, so an
@@ -513,7 +540,7 @@ async function handleStart(options: { block?: boolean } = {}) {
513
540
  }
514
541
  },
515
542
  );
516
- if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native.");
543
+ if (!startupSync.ran) console.log(startupLeftCodexNativeLine(localClientSkipReason(config)));
517
544
  await refreshOwnedRaycastCatalog(config, port);
518
545
  // #1046: one warning per startup, after BOTH writes. The server's cache
519
546
  // invalidation happens first and the catalog sync second, so the mtime is only
@@ -579,7 +606,9 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
579
606
  console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
580
607
  return null;
581
608
  });
582
- if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
609
+ if (synced?.status === "skipped") {
610
+ console.log(startupLeftCodexNativeLine(synced.skippedReason ?? "desired_disabled"));
611
+ }
583
612
  // Do not refresh Raycast from saved config here: live bind/admission and
584
613
  // secondary-listener settings may differ. Explicit sync or server startup
585
614
  // owns catalog refresh; ensure must not overwrite a working destination.
@@ -626,7 +655,9 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
626
655
  console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
627
656
  return null;
628
657
  });
629
- if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
658
+ if (synced?.status === "skipped") {
659
+ console.log(startupLeftCodexNativeLine(synced.skippedReason ?? "desired_disabled"));
660
+ }
630
661
  // The child performs Raycast refresh with its actual startup config. The
631
662
  // parent's pre-spawn snapshot is not authoritative for a client-file write.
632
663
  // The child opens /healthz before its best-effort roster reconcile. Await the same idempotent
@@ -952,7 +983,12 @@ async function handleStop() {
952
983
  if (detail) console.error(` ${detail}`);
953
984
  if (err instanceof ProxyOwnershipRefusedError) {
954
985
  ownershipBlocked = true;
955
- console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running.");
986
+ // Every refusal `POST /api/stop` produces is written for an API client, so it
987
+ // recommends `ocx stop` — the command printing it. Following that advice returns
988
+ // the operator to this exact message, which is the loop #4169 was filed for. The
989
+ // service manager was already asked to stop above, so name what is actually left.
990
+ console.error(` ${refusalNextStep(err.code)}`);
991
+ console.error(" Skipping shared teardown (native Codex restore, Grok config): the refusing proxy is still running.");
956
992
  }
957
993
  }
958
994
  } else {
@@ -979,7 +1015,9 @@ async function handleStop() {
979
1015
  if (detail) console.error(` ${detail}`);
980
1016
  if (err instanceof ProxyOwnershipRefusedError) {
981
1017
  ownershipBlocked = true;
982
- console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running.");
1018
+ // Same loop as the tracked-pid path above: the refusal recommends this command.
1019
+ console.error(` ${refusalNextStep(err.code)}`);
1020
+ console.error(" Skipping shared teardown (native Codex restore, Grok config): the refusing proxy is still running.");
983
1021
  }
984
1022
  }
985
1023
  } else if (live) {
@@ -1345,12 +1383,22 @@ async function handleStatus() {
1345
1383
  return;
1346
1384
  }
1347
1385
 
1386
+ // First line of the report, above the proxy line, deliberately (#4236): on a connected client
1387
+ // the provider/login/model lines describe the HUB, and the lines that describe this machine
1388
+ // are tagged `(local)`. A reader who sees neither draws the wrong conclusion from a correct
1389
+ // report — an agent read `xai ✗ not logged in` off a client and decided the hub could not
1390
+ // serve grok.
1391
+ const remoteHubBanner = remoteHubBannerLine(status.json.remoteHub);
1392
+ if (remoteHubBanner) console.log(remoteHubBanner);
1393
+ // `(local)` only while connected: on a standalone install every line is local, and tagging
1394
+ // them all would be noise that trains the reader to skip the tag.
1395
+ const local = status.json.remoteHub.connected ? " (local)" : "";
1348
1396
  if (status.json.proxy.pid || status.json.proxy.health.ok) {
1349
- console.log(`✅ Proxy: ${status.proxyLabel}`);
1397
+ console.log(`✅ Proxy: ${status.proxyLabel}${local}`);
1350
1398
  } else {
1351
- console.log(`❌ Proxy: ${status.proxyLabel}`);
1399
+ console.log(`❌ Proxy: ${status.proxyLabel}${local}`);
1352
1400
  }
1353
- console.log(` Health: ${status.healthLabel}`);
1401
+ console.log(` Health: ${status.healthLabel}${local}`);
1354
1402
  if (status.json.claudeDesktop.desiredEnabled && !status.json.claudeDesktop.policy.ok) {
1355
1403
  console.log(` ⚠️ Claude Desktop 3P health: ${status.json.claudeDesktop.policy.status}`);
1356
1404
  console.log(` ${status.json.claudeDesktop.policy.message}`);
@@ -1390,25 +1438,31 @@ async function handleStatus() {
1390
1438
  ? " Restart with 'ocx start', or refresh the installed service: 'ocx service repair'."
1391
1439
  : " Restart with 'ocx start', or install the persistent service: 'ocx service install'.");
1392
1440
  }
1393
- console.log(` Dashboard: ${status.json.dashboard.url}`);
1394
- console.log(` Config: ${status.json.paths.config}`);
1395
- console.log(` PID file: ${status.json.paths.pid}`);
1396
- console.log(` Runtime: ${status.json.paths.runtime}`);
1397
- console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}`);
1398
- console.log(` Default provider: ${status.json.defaultProvider}`);
1441
+ console.log(` Dashboard: ${status.json.dashboard.url}${local}`);
1442
+ console.log(` Config: ${status.json.paths.config}${local}`);
1443
+ console.log(` PID file: ${status.json.paths.pid}${local}`);
1444
+ console.log(` Runtime: ${status.json.paths.runtime}${local}`);
1445
+ console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}${local}`);
1446
+ // On a client this is the local default, which routing does not use — the hub applies its own.
1447
+ console.log(` Default provider: ${status.json.defaultProvider}${local}`);
1448
+ // One block rather than six scattered lines, and only on a hub: `hubStatusLines` owns the
1449
+ // sentences so they are testable without spawning the CLI. It prints no token value.
1450
+ if (status.json.hub) {
1451
+ for (const line of hubStatusLines(status.json.hub)) console.log(` ${line}`);
1452
+ }
1399
1453
  console.log(` Remote hub: ${status.json.connection.state}${status.json.connection.serverUrl ? ` (${status.json.connection.serverUrl})` : ""}`);
1400
1454
  if (status.json.connection.state === "invalid" || status.json.connection.state === "mismatched") {
1401
1455
  console.log(` ⚠️ ${status.json.connection.reason}`);
1402
1456
  }
1403
- console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`);
1404
- console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`);
1405
- console.log(` ${formatStartupRoutingDetail(status.json.startup)}`);
1406
- console.log(` Service: ${status.json.service.summary}`);
1407
- console.log(` ${status.json.codexShim.summary}`);
1408
- console.log(` Codex runtime: ${status.json.codexRuntime.path}`);
1409
- console.log(` Codex version: ${status.json.codexRuntime.version ?? "unknown"}`);
1410
- console.log(` Codex source: ${status.json.codexRuntime.source}`);
1411
- console.log(` Codex home: ${status.json.codexHome.effectiveCodexHome}`);
1457
+ console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}${local}`);
1458
+ console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}${local}`);
1459
+ console.log(` ${formatStartupRoutingDetail(status.json.startup)}${local}`);
1460
+ console.log(` Service: ${status.json.service.summary}${local}`);
1461
+ console.log(` ${status.json.codexShim.summary}${local}`);
1462
+ console.log(` Codex runtime: ${status.json.codexRuntime.path}${local}`);
1463
+ console.log(` Codex version: ${status.json.codexRuntime.version ?? "unknown"}${local}`);
1464
+ console.log(` Codex source: ${status.json.codexRuntime.source}${local}`);
1465
+ console.log(` Codex home: ${status.json.codexHome.effectiveCodexHome}${local}`);
1412
1466
  if (status.json.codexHome.warning) {
1413
1467
  console.log(` ⚠️ ${status.json.codexHome.warning}`);
1414
1468
  console.log(` Action: ${status.json.codexHome.action}`);
@@ -1430,7 +1484,17 @@ async function handleStatus() {
1430
1484
  const { collectOAuthHealthEntriesForCli, oauthLoginSummary } = await import("../oauth");
1431
1485
  const { emailMaskingEnabled } = await import("../lib/privacy");
1432
1486
  const { formatOAuthHealthForStatus } = await import("./status-oauth");
1433
- console.log(` OAuth logins:`);
1487
+ // On a connected client the HUB's providers and logins come first, because they are the ones
1488
+ // that decide what a request can route to. The local block still prints — an operator debugging
1489
+ // a half-migrated machine needs to see it — but under a heading that says it is not in use, and
1490
+ // below the hub's, so the hub's is what a reader (or an agent) encounters first (#4236).
1491
+ for (const line of remoteHubStatusLines(status.json.remoteHub)) console.log(` ${line}`);
1492
+ if (status.json.remoteHub.connected) {
1493
+ console.log(status.json.remoteHub.stateSource === "unavailable"
1494
+ ? " Local-only credential state (this is NOT the hub's; the hub's state could not be read):"
1495
+ : " Local-only (not used for routing while connected):");
1496
+ }
1497
+ console.log(` OAuth logins${local}:`);
1434
1498
  // The operator's own `privacy.maskEmails` decision applies to the CLI too: `ocx status` is not
1435
1499
  // the dashboard, but it reads the same stored addresses, and a flag that only moved one of the
1436
1500
  // two would leave the operator unable to tell which surface they had configured.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Trusted facts captured by the plain-Node npm launcher before Bun auto-loads
2
+ * Trusted facts captured by the plain-Node package launcher before Bun auto-loads
3
3
  * project dotenv files. The random proof travels in argv while the context
4
4
  * travels in the environment, so a project `.env` cannot forge the pair during
5
5
  * an ordinary `ocx ...` invocation.
@@ -64,9 +64,17 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
64
64
  usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]",
65
65
  summary: "Run as a background service.",
66
66
  details: [
67
- "With no subcommand, installs when absent or repairs/restarts an existing service.",
68
- "`restart` aliases `repair`; healthy Windows tasks are reused, while stale definitions may re-register and elevate.",
69
- "Use `ocx service status` to see diagnostics and log paths.",
67
+ "With no subcommand, installs when absent or repairs an existing service.",
68
+ "`repair` refreshes the definition and reloads the manager only when something changed, so repairing a healthy service is not an outage.",
69
+ "`restart` is the same refresh but always restarts: on macOS an unchanged, already-loaded job is kickstarted in place. Healthy Windows tasks are reused, while stale definitions may re-register and elevate.",
70
+ "Use `ocx service status` to see diagnostics and log paths. On macOS it reports four states:",
71
+ "loaded from the current plist, loaded from an OLDER plist (repair), not loaded (repair), or",
72
+ "`launchd state could not be verified` -- which is an unanswerable probe, NOT a down service.",
73
+ "Data-plane token: nothing has to be exported by hand. On a non-loopback hostname install/repair uses",
74
+ "OPENCODEX_API_AUTH_TOKEN when set, otherwise reuses the existing owner-only ~/.opencodex/service-api-token,",
75
+ "otherwise generates one; the launch wrapper reads that file at start and the value never enters a plist,",
76
+ "a unit file, or argv. An ADMIN token in OPENCODEX_API_AUTH_TOKEN is refused -- unset it and rerun.",
77
+ "Repair and restart never ask for the environment variable again once the token file exists.",
70
78
  ],
71
79
  },
72
80
  {
@@ -155,6 +163,38 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
155
163
  "The printed grant is secret, single-use, short-lived, and must not be persisted.",
156
164
  ],
157
165
  },
166
+ {
167
+ name: "hub",
168
+ usage: "ocx hub invite [--json] [--data-url <origin>] [--management-url <origin>] [--clients codex,claude]",
169
+ summary: "Hub-side commands. `invite` prints a ready-to-run `ocx connect` line for one more machine.",
170
+ details: [
171
+ "Topology: a hub serves ONE port. Remote machines dial `hostname:port` with their own per-client",
172
+ "key; the hub's own local processes dial `127.0.0.1:<same port>` with no credential, through the",
173
+ "loopback companion listener enabled by `unauthenticatedLoopbackListener: {\"enabled\": true}`.",
174
+ "The browser-facing management plane is separate: a loopback-only ingress published by an",
175
+ "operator-owned HTTPS frontend (Tailscale Serve) and advertised as hub.managementPublicOrigin.",
176
+ "",
177
+ "Nothing needs to be exported by hand. `ocx service install` provisions an owner-only data-plane",
178
+ "token at ~/.opencodex/service-api-token and the launch wrapper reads it at start; the value never",
179
+ "enters a plist, a unit file, argv, or the environment you typed in. Never copy that file to another",
180
+ "machine -- every client gets its own revocable key from the pairing exchange.",
181
+ "",
182
+ "invite requires a running hub and mints a single-use, short-lived pairing code through the same",
183
+ "attested local route `ocx gui pair` uses, then prints the exact command to run on the other",
184
+ "machine. The code is bound to hub.managementPublicOrigin and to the connecting machine's local",
185
+ "browser origin (`http://localhost:10100` unless corsAllowOrigins names another loopback origin);",
186
+ "the bound origin is always printed, and a different one names the port the client needs.",
187
+ "--data-url overrides the advertised data origin; hub.dataPublicOrigin is the persistent form, and",
188
+ "the fallback is http://<bind address>:<port>. A loopback or wildcard bind has no such address, so",
189
+ "invite refuses instead of advertising http://localhost:<port>, which would tell the other machine",
190
+ "to dial itself and spend the code.",
191
+ "--management-url is a CONFIRMATION, not an override: the grant is bound to",
192
+ "hub.managementPublicOrigin, so a differing value is refused instead of printed.",
193
+ "--clients chooses which client configs the printed ocx connect line will point at the hub.",
194
+ "--json emits { code, expiresAt, dataUrl, managementUrl, command }. Do not persist the code.",
195
+ "Hub state, including the data token and the companion listener, is reported by `ocx status`.",
196
+ ],
197
+ },
158
198
  {
159
199
  name: "update",
160
200
  usage: "ocx update [--tag latest|preview]",