@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
@@ -16,7 +16,12 @@ import type { LivenessIo, LiveProxy } from "../server/proxy-liveness";
16
16
  import type { OcxConfig } from "../types";
17
17
  import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh";
18
18
  import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help";
19
- import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state";
19
+ import {
20
+ HUB_GATED_SKIP_MESSAGE,
21
+ localClientSkipMessage,
22
+ setIntegrationEnabled,
23
+ shouldSyncCodexOnStart,
24
+ } from "../codex/desired-state";
20
25
  import { syncModelsToCodex } from "../codex/sync";
21
26
  import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
22
27
  import { restoreNativeCodexAsync } from "../codex/inject";
@@ -54,6 +59,24 @@ export interface CliDispatchDeps {
54
59
 
55
60
  type CommandRunner = (deps: CliDispatchDeps) => Promise<number>;
56
61
 
62
+ /**
63
+ * The hub's management ingress is deliberately loopback-only. Prefer it for
64
+ * a browser opened on the hub itself: the proxy listener may be restricted to
65
+ * a Tailscale address, while the ingress is the local authenticated dashboard.
66
+ */
67
+ export function selectDefaultGuiUrl(
68
+ config: Pick<OcxConfig, "port" | "hostname" | "runtimeRole" | "hub">,
69
+ live: Pick<LiveProxy, "port" | "hostname"> | null,
70
+ probeHostname: (hostname: string | undefined) => string,
71
+ ): string {
72
+ const ingress = config.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
73
+ if (ingress?.enabled) return `http://localhost:${ingress.port}`;
74
+
75
+ const guiHost = probeHostname(live?.hostname ?? config.hostname);
76
+ const hostname = guiHost === "127.0.0.1" ? "localhost" : guiHost;
77
+ return `http://${hostname}:${live?.port ?? config.port ?? 10100}`;
78
+ }
79
+
57
80
  const commandRunners: Record<string, CommandRunner> = {
58
81
  init: async () => {
59
82
  const { runInit } = await import("./init");
@@ -105,7 +128,17 @@ const commandRunners: Record<string, CommandRunner> = {
105
128
  }
106
129
  const synced = await syncModelsToCodex(live.port);
107
130
  if (synced.status === "skipped") {
108
- return emitBack(false, "Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes.", 2);
131
+ // `setIntegrationEnabled` above just committed ON, so a skip here is NOT the toggle and
132
+ // is not a competing writer either — on a hub it is the role gate. Telling the operator
133
+ // to "retry after the competing integration change finishes" sent them waiting for a
134
+ // writer that does not exist (#4236).
135
+ return emitBack(
136
+ false,
137
+ synced.skippedReason === "hub-gated"
138
+ ? `${HUB_GATED_SKIP_MESSAGE} restore back did not change Codex.`
139
+ : "Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes.",
140
+ 2,
141
+ );
109
142
  }
110
143
  if (!synced.ok) {
111
144
  return emitBack(false, "Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry.", 1);
@@ -372,7 +405,9 @@ const commandRunners: Record<string, CommandRunner> = {
372
405
  );
373
406
  let code = 0;
374
407
  if (synced.status === "skipped") {
375
- console.log("Codex integration is OFF; sync skipped and no Codex files changed.");
408
+ console.log(synced.skippedReason === "hub-gated"
409
+ ? `${HUB_GATED_SKIP_MESSAGE} sync skipped and no Codex files changed.`
410
+ : "Codex integration is OFF; sync skipped and no Codex files changed.");
376
411
  } else if (synced.status === "catalog-only") {
377
412
  // Explicit sync with the integration OFF still refreshes the catalog/cache
378
413
  // for side profiles that consume the proxy without injection.
@@ -448,7 +483,8 @@ const commandRunners: Record<string, CommandRunner> = {
448
483
  const { readCodexCatalogPathForHome } = await import("../codex/catalog/parsing");
449
484
  const { existsSync } = await import("node:fs");
450
485
  const owningCodexHome = getCodexHome();
451
- const desiredDisabled = !shouldSyncCodexOnStart(deps.loadConfig());
486
+ const cacheGateSnapshot = deps.loadConfig();
487
+ const desiredDisabled = !shouldSyncCodexOnStart(cacheGateSnapshot);
452
488
  const invalidated = withCatalogWriteSerialization(owningCodexHome, permit =>
453
489
  invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true }));
454
490
  const cacheJson = cacheArgs.includes("--json");
@@ -462,7 +498,11 @@ const commandRunners: Record<string, CommandRunner> = {
462
498
  } else if (desiredDisabled && !cacheJson) {
463
499
  // Worth saying in the human path, because it explains why nothing was written.
464
500
  // Under --json this belongs on the envelope, not as a second stdout line.
465
- console.log("Codex integration is OFF; no catalog or cache write resulted.");
501
+ console.log(localClientSkipMessage(
502
+ cacheGateSnapshot,
503
+ "Codex integration is OFF; no catalog or cache write resulted.",
504
+ "No catalog or cache write resulted.",
505
+ ));
466
506
  }
467
507
  // `completed` with a falsy value means the cache was NOT rewritten. Previously every
468
508
  // outcome exited 0, so a script could not tell a refreshed cache from a skipped one.
@@ -535,10 +575,7 @@ const commandRunners: Record<string, CommandRunner> = {
535
575
  return 1;
536
576
  }
537
577
  }
538
- // Open the host the proxy actually binds — `localhost` only answers for
539
- // loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
540
- const guiHost = deps.probeHostname(live?.hostname ?? config.hostname);
541
- const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`;
578
+ const guiUrl = selectDefaultGuiUrl(config, live, deps.probeHostname);
542
579
  console.log(`Opening ${guiUrl}`);
543
580
  const { openUrl } = await import("../lib/open-url");
544
581
  openUrl(guiUrl);
@@ -546,6 +583,13 @@ const commandRunners: Record<string, CommandRunner> = {
546
583
  },
547
584
  });
548
585
  },
586
+ hub: async deps => {
587
+ const { runHubCommand } = await import("./hub");
588
+ return runHubCommand(deps.args.slice(1), {
589
+ loadConfig: deps.loadConfig,
590
+ findLiveProxy: deps.findLiveProxy,
591
+ });
592
+ },
549
593
  service: async deps => {
550
594
  process.exitCode = 0;
551
595
  await deps.serviceCommand(...deps.args.slice(1));
package/src/cli/doctor.ts CHANGED
@@ -56,6 +56,8 @@ import {
56
56
  import { collectStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
57
57
  import {
58
58
  displayCodexRuntimePath,
59
+ effortClampAppliesToRuntime,
60
+ liveRemovedEfforts,
59
61
  loadLastEffortClamp,
60
62
  persistCodexRuntime,
61
63
  resolveAndPersistCodexRuntime,
@@ -1177,9 +1179,14 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1177
1179
  console.log(" Suggested: set CODEX_CLI_PATH to the desired binary and run ocx sync.");
1178
1180
  console.log(" Optional: ocx doctor --fix-codex-runtime");
1179
1181
  }
1182
+ // Doctor used to warn on any non-empty `removedEfforts`, while `ocx status` asked
1183
+ // `effortClampAppliesToRuntime` — so the two could disagree about the same file, and doctor
1184
+ // would tell an operator to install a newer Codex while the resolved runtime was already
1185
+ // newer than the one the diagnostic described. Both surfaces now read the same predicate.
1180
1186
  const lastClamp = loadLastEffortClamp();
1181
- if (lastClamp && lastClamp.removedEfforts.length > 0) {
1182
- console.log(` !! ${lastClamp.removedEfforts.join(" and ")} were removed during catalog sync.`);
1187
+ if (effortClampAppliesToRuntime(lastClamp, resolved.runtime)) {
1188
+ const live = liveRemovedEfforts(lastClamp);
1189
+ console.log(` !! ${live.join(" and ")} were removed during catalog sync.`);
1183
1190
  console.log(" Suggested: set CODEX_CLI_PATH to a newer Codex binary and run ocx sync.");
1184
1191
  }
1185
1192
  }
@@ -13,6 +13,8 @@ import { stripGrokConfig, type GrokInjectResult } from "../grok/inject";
13
13
  import { removeDesktop3pStandardPivot } from "../claude/desktop-3p";
14
14
  import {
15
15
  claudeDesktopIntegrationEnabled,
16
+ grokIntegrationEnabled,
17
+ HUB_GATED_SKIP_MESSAGE,
16
18
  shouldSyncGrokOnStart,
17
19
  } from "../codex/desired-state";
18
20
  import type { OcxConfig } from "../types";
@@ -78,6 +80,14 @@ export async function ensureGrokFenceMatchesDesired(
78
80
  ): Promise<void> {
79
81
  const config = deps.loadConfig();
80
82
  const { log, error } = io(deps);
83
+ // A hub-gated skip is NOT "the user turned Grok off" (#4236). Stripping the managed block
84
+ // there deleted a fence the operator still wants — and `ocx ensure` reported it as the
85
+ // Grok toggle doing its job. Only an explicit OFF authorizes the strip; the gate just
86
+ // declines to write, and says which key would let it.
87
+ if (!shouldSyncGrokOnStart(config) && grokIntegrationEnabled(config)) {
88
+ log(` ${HUB_GATED_SKIP_MESSAGE} ~/.grok/config.toml was left exactly as it is.`);
89
+ return;
90
+ }
81
91
  if (!shouldSyncGrokOnStart(config)) {
82
92
  try {
83
93
  const grok = deps.stripGrokConfig();
@@ -17,6 +17,7 @@ import {
17
17
  GUI_PAIR_NONCE_HEADER,
18
18
  GUI_PAIR_PATH,
19
19
  canonicalGuiBrowserOrigin,
20
+ canonicalHttpOrigin,
20
21
  createGuiPairCapability,
21
22
  } from "../lib/gui-pair-capability";
22
23
  import { directLocalHttpFetch } from "../server/direct-local-http";
@@ -52,18 +53,6 @@ function sameRuntime(left: RuntimePortState, right: RuntimePortState | null): bo
52
53
  && timingSafeEqual(leftSecret, rightSecret);
53
54
  }
54
55
 
55
- function canonicalHttpOrigin(value: unknown): string | null {
56
- if (typeof value !== "string") return null;
57
- try {
58
- const parsed = new URL(value);
59
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
60
- if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
61
- return parsed.origin;
62
- } catch {
63
- return null;
64
- }
65
- }
66
-
67
56
  function parseCreatedResult(value: unknown, browserOrigin: string): GuiPairRequestResult | null {
68
57
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
69
58
  const record = value as Record<string, unknown>;
package/src/cli/help.ts CHANGED
@@ -45,7 +45,7 @@ Usage:
45
45
  ocx sync [--restart-codex] Fetch models from providers and inject into Codex config
46
46
  ocx sync-cache [--restart-codex]
47
47
  Refresh Codex's model cache from the active catalog
48
- ocx status Check proxy server status
48
+ ocx status Check proxy server status (on a hub: one block with its ports and token source)
49
49
  ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
50
50
  ocx doctor --reclaim-response-temps
51
51
  Reclaim abandoned response-state temp files (works without a running proxy)
@@ -56,6 +56,8 @@ Usage:
56
56
  ocx logout <provider> Remove a stored OAuth login
57
57
  ocx gui [pair --origin <browser-origin> [--json]]
58
58
  Open the dashboard or create a single-use remote pairing grant
59
+ ocx hub invite [--json] Print a ready-to-run \`ocx connect\` line for one more machine
60
+ (hub only; see \`ocx help hub\` for the one-port topology)
59
61
  ocx update [--tag <tag>] Update opencodex (keeps preview installs on @preview)
60
62
  ocx restart Stop and restart the proxy
61
63
  ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|keep-native-v1|threads|mode-hint)
@@ -99,6 +101,7 @@ Examples:
99
101
  ocx start Start on default port (10100)
100
102
  ocx start --port 8080 Start on custom port
101
103
  ocx help service Show service command help
104
+ ocx help hub Explain the hub topology, token file, and invites
102
105
  ocx sync Sync available models to Codex`);
103
106
  }
104
107
 
package/src/cli/hub.ts ADDED
@@ -0,0 +1,367 @@
1
+ import type { OcxConfig, OcxConnectedClientId } from "../types";
2
+ import { canonicalGuiBrowserOrigin, canonicalHttpOrigin } from "../lib/gui-pair-capability";
3
+ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
4
+ import {
5
+ requestBoundGuiPairingGrant,
6
+ type GuiPairClientDeps,
7
+ type GuiPairRequestResult,
8
+ } from "./gui-pair-client";
9
+ import type { RuntimeApiDeps } from "./runtime-api";
10
+
11
+ export const HUB_USAGE =
12
+ "ocx hub invite [--json] [--data-url <origin>] [--management-url <origin>] [--clients codex,claude]";
13
+
14
+ const PAIRING_WARNING = "Pairing codes are secret, single-use, and expire quickly. Do not save them.";
15
+
16
+ /**
17
+ * The default browser origin a connecting machine presents.
18
+ *
19
+ * `ocx connect --pairing-code-stdin` exchanges the code with `Origin:` set by `localGuiOrigin()`
20
+ * in `src/client/connect.ts` — `http://localhost:<that machine's configured port>`, which on a
21
+ * fresh client is the default 10100. The hub cannot observe the other machine's port, so the
22
+ * grant is bound to this origin unless `corsAllowOrigins` names a different loopback one.
23
+ */
24
+ const DEFAULT_CLIENT_BROWSER_ORIGIN = "http://localhost:10100";
25
+
26
+ export interface HubCommandDeps extends RuntimeApiDeps {
27
+ loadConfig: () => OcxConfig;
28
+ findLiveProxy?: () => Promise<LiveProxy | null>;
29
+ requestPairingGrant?: (
30
+ target: LiveProxy,
31
+ browserOrigin: string,
32
+ deps?: GuiPairClientDeps,
33
+ ) => Promise<GuiPairRequestResult>;
34
+ }
35
+
36
+ export interface HubInviteOptions {
37
+ json: boolean;
38
+ dataUrl?: string;
39
+ managementUrl?: string;
40
+ clients?: string;
41
+ }
42
+
43
+ export interface HubInvitePayload {
44
+ code: string;
45
+ expiresAt: string;
46
+ dataUrl: string;
47
+ managementUrl: string;
48
+ command: string;
49
+ }
50
+
51
+ function isLoopbackOrigin(origin: string): boolean {
52
+ try {
53
+ const host = new URL(origin).hostname.toLowerCase();
54
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** Loopback or HTTPS, the rule `consumeGuiPairingGrant` enforces on the hub side. */
61
+ export function pairingOriginUsable(origin: string): boolean {
62
+ try {
63
+ return new URL(origin).protocol === "https:" || isLoopbackOrigin(origin);
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ export function parseHubInviteArgs(args: string[]): HubInviteOptions | null {
70
+ const options: HubInviteOptions = { json: false };
71
+ for (let index = 0; index < args.length; index++) {
72
+ const arg = args[index]!;
73
+ if (arg === "--json" && !options.json) {
74
+ options.json = true;
75
+ continue;
76
+ }
77
+ if (arg === "--data-url" || arg === "--management-url" || arg === "--clients") {
78
+ const value = args[++index];
79
+ if (!value || value.startsWith("--")) return null;
80
+ const key = arg === "--data-url" ? "dataUrl" : arg === "--management-url" ? "managementUrl" : "clients";
81
+ if (options[key] !== undefined) return null;
82
+ options[key] = value;
83
+ continue;
84
+ }
85
+ return null;
86
+ }
87
+ return options;
88
+ }
89
+
90
+ export function parseInviteClients(raw: string | undefined): OcxConnectedClientId[] | null {
91
+ if (raw === undefined) return [];
92
+ const values = raw.split(",").map(value => value.trim()).filter(Boolean);
93
+ if (values.length < 1 || values.some(value => value !== "codex" && value !== "claude")) return null;
94
+ return values as OcxConnectedClientId[];
95
+ }
96
+
97
+ /**
98
+ * The browser origin the grant is bound to, or null when the hub admits none.
99
+ *
100
+ * `createGuiPairingGrant` accepts only `hub.managementPublicOrigin` itself or an entry of
101
+ * `corsAllowOrigins`, so this picks from exactly that set rather than guessing: the default
102
+ * client origin when it is admitted, otherwise the first admitted loopback origin. A hub whose
103
+ * allow-list names no loopback origin cannot pair a remote `ocx connect` at all, and saying so
104
+ * here is better than minting a code the exchange will reject.
105
+ */
106
+ export function selectInviteBrowserOrigin(config: OcxConfig): string | null {
107
+ const allowed = [
108
+ canonicalGuiBrowserOrigin(config.hub?.managementPublicOrigin ?? ""),
109
+ ...(config.corsAllowOrigins ?? []).map(value => canonicalGuiBrowserOrigin(value)),
110
+ ].filter((value): value is string => Boolean(value));
111
+ if (allowed.includes(DEFAULT_CLIENT_BROWSER_ORIGIN)) return DEFAULT_CLIENT_BROWSER_ORIGIN;
112
+ return allowed.find(origin => isLoopbackOrigin(origin)) ?? null;
113
+ }
114
+
115
+ /** `http://<bind address>:<port>` — right for a plain tailnet/LAN bind with no TLS frontend. */
116
+ export function derivedHubDataOrigin(hostname: string | undefined, port: number): string {
117
+ const host = probeHostname(hostname);
118
+ return `http://${host === "127.0.0.1" ? "localhost" : host}:${port}`;
119
+ }
120
+
121
+ /**
122
+ * The `ocx config set` lines that will actually work on THIS config.
123
+ *
124
+ * `setPath` in `src/cli/config-command.ts` walks only parents that already exist, so
125
+ * `ocx config set hub.<field> …` exits with `config parent path not found: hub` on a config
126
+ * that has no `hub` object yet — which is exactly the config that needs the advice. Create the
127
+ * parent first, the way `guides/remote-hub.md` does, and only when it is actually missing, so
128
+ * the operator can paste the lines verbatim either way.
129
+ */
130
+ export function configSetHubLines(
131
+ config: Pick<OcxConfig, "hub">,
132
+ field: "managementPublicOrigin" | "dataPublicOrigin",
133
+ example: string,
134
+ ): string[] {
135
+ const set = `ocx config set hub.${field} '${JSON.stringify(example)}'`;
136
+ return config.hub ? [set] : ["ocx config set hub '{}'", set];
137
+ }
138
+
139
+ /**
140
+ * A `corsAllowOrigins` line that ADDS an origin instead of replacing the list.
141
+ *
142
+ * `ocx config set corsAllowOrigins '[…]'` overwrites the array, so printing a one-element
143
+ * literal tells an operator with an existing allow-list to delete it. The already-configured
144
+ * entries are known here, so the suggested value carries them.
145
+ */
146
+ export function appendCorsAllowOriginsCommand(
147
+ config: Pick<OcxConfig, "corsAllowOrigins">,
148
+ origin: string,
149
+ ): string {
150
+ const current = config.corsAllowOrigins ?? [];
151
+ const next = current.includes(origin) ? current : [...current, origin];
152
+ return `ocx config set corsAllowOrigins '${JSON.stringify(next)}'`;
153
+ }
154
+
155
+ export type HubDataOriginResolution =
156
+ | { kind: "usable"; dataUrl: string; source: "flag" | "config" | "derived" }
157
+ /** The bind address can only be spelled as loopback, so there is nothing to advertise. */
158
+ | { kind: "loopback-derived"; dataUrl: string; bindHostname: string };
159
+
160
+ /**
161
+ * The data origin to advertise, or a refusal.
162
+ *
163
+ * `derivedHubDataOrigin` maps a loopback bind AND every wildcard spelling to
164
+ * `http://localhost:<port>` (via `probeHostname`), which on the other machine means "dial
165
+ * yourself". Printing it burns the single-use code on a connect that cannot succeed, so a
166
+ * derived loopback origin is a refusal rather than a value. A wildcard bind is refused the same
167
+ * way on purpose: nothing in this repo derives a tailnet or LAN address, and guessing one from
168
+ * `os.networkInterfaces()` would advertise an interface the operator never chose.
169
+ *
170
+ * An explicit `--data-url` or `hub.dataPublicOrigin` is never second-guessed — a loopback data
171
+ * origin is legitimate when the "other machine" is reached through an SSH tunnel.
172
+ */
173
+ export function resolveHubDataOrigin(
174
+ override: string | null,
175
+ configured: string | undefined,
176
+ bindHostname: string | undefined,
177
+ port: number,
178
+ ): HubDataOriginResolution {
179
+ if (override) return { kind: "usable", dataUrl: override, source: "flag" };
180
+ const fromConfig = canonicalHttpOrigin(configured);
181
+ if (fromConfig) return { kind: "usable", dataUrl: fromConfig, source: "config" };
182
+ const derived = derivedHubDataOrigin(bindHostname, port);
183
+ if (!isLoopbackOrigin(derived)) return { kind: "usable", dataUrl: derived, source: "derived" };
184
+ const trimmed = (bindHostname ?? "").trim();
185
+ return { kind: "loopback-derived", dataUrl: derived, bindHostname: trimmed || "127.0.0.1" };
186
+ }
187
+
188
+ /** Wildcards and loopback fail for different reasons; say which one this hub has. */
189
+ function bindAddressPhrase(bindHostname: string): string {
190
+ return bindHostname === "0.0.0.0" || bindHostname === "::" || bindHostname === "[::]"
191
+ ? `the bind address ${bindHostname} is a wildcard, which names no address another machine can dial`
192
+ : `the bind address ${bindHostname} is loopback-only`;
193
+ }
194
+
195
+ /**
196
+ * What an operator has to know about the origin the grant actually got bound to.
197
+ *
198
+ * `selectInviteBrowserOrigin` falls back to the first admitted loopback origin when
199
+ * `http://localhost:10100` is not admitted, and a remote `ocx connect` sends
200
+ * `Origin: http://localhost:<its own configured port>` — so a grant bound to anything else is
201
+ * refused at the exchange and the single-use code is spent with nothing printed to explain it.
202
+ * Always stating the bound origin, and naming the port the client needs when it differs, is the
203
+ * difference between a fixable failure and a mystery.
204
+ */
205
+ export function inviteBoundOriginNotes(
206
+ browserOrigin: string,
207
+ config: Pick<OcxConfig, "corsAllowOrigins">,
208
+ ): string[] {
209
+ const notes = [`Bound browser origin: ${browserOrigin} — the connecting machine must present exactly this.`];
210
+ if (browserOrigin === DEFAULT_CLIENT_BROWSER_ORIGIN) return notes;
211
+ const parsed = new URL(browserOrigin);
212
+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
213
+ notes.push(
214
+ `That is NOT ${DEFAULT_CLIENT_BROWSER_ORIGIN}, which is what an unconfigured client sends: the other machine `
215
+ + `must already be running on port ${port} ('ocx config set port ${port}' there) before it runs the line `
216
+ + "below, or the hub refuses the exchange and the code is spent.",
217
+ );
218
+ notes.push(
219
+ "To accept a default client instead, admit its origin on this hub: "
220
+ + appendCorsAllowOriginsCommand(config, DEFAULT_CLIENT_BROWSER_ORIGIN),
221
+ );
222
+ return notes;
223
+ }
224
+
225
+ export function hubInviteCommand(
226
+ code: string,
227
+ dataUrl: string,
228
+ managementUrl: string,
229
+ clients: OcxConnectedClientId[],
230
+ ): string {
231
+ const clientsFlag = clients.length > 0 ? ` --clients ${clients.join(",")}` : "";
232
+ return `echo '${code}' | ocx connect ${dataUrl} --management-url ${managementUrl}${clientsFlag} --pairing-code-stdin`;
233
+ }
234
+
235
+ async function runInvite(args: string[], deps: HubCommandDeps): Promise<number> {
236
+ const options = parseHubInviteArgs(args);
237
+ if (!options) {
238
+ console.error(`Usage: ${HUB_USAGE}`);
239
+ return 1;
240
+ }
241
+ const clients = parseInviteClients(options.clients);
242
+ if (!clients) {
243
+ console.error("--clients must contain codex and/or claude.");
244
+ return 1;
245
+ }
246
+ const config = deps.loadConfig();
247
+ if (config.runtimeRole !== "hub") {
248
+ console.error(
249
+ `ocx hub invite runs on a hub; this machine's runtimeRole is "${config.runtimeRole ?? "standalone"}". `
250
+ + "A client machine runs 'ocx connect' with the code its hub printed.",
251
+ );
252
+ return 1;
253
+ }
254
+ // The grant's server origin IS hub.managementPublicOrigin (createGuiPairingGrant reads it,
255
+ // and the exchange compares the request's management origin against it), so an invite that
256
+ // advertised anything else would hand out a code the hub then refuses.
257
+ const managementPublic = canonicalHttpOrigin(config.hub?.managementPublicOrigin);
258
+ if (!managementPublic) {
259
+ console.error(
260
+ "hub.managementPublicOrigin is not set, so there is no origin to pair against. Set the exact "
261
+ + "browser-visible HTTPS origin:",
262
+ );
263
+ for (const line of configSetHubLines(config, "managementPublicOrigin", "https://hub.tailnet.ts.net")) {
264
+ console.error(` ${line}`);
265
+ }
266
+ return 1;
267
+ }
268
+ const managementOverride = options.managementUrl === undefined ? null : canonicalHttpOrigin(options.managementUrl);
269
+ if (options.managementUrl !== undefined && !managementOverride) {
270
+ console.error("--management-url must be a bare http(s) origin with no path, query, or credentials.");
271
+ return 1;
272
+ }
273
+ if (managementOverride && managementOverride !== managementPublic) {
274
+ console.error(
275
+ `--management-url ${managementOverride} does not match hub.managementPublicOrigin ${managementPublic}. `
276
+ + "The pairing code is bound to the configured origin, so the other machine would be refused.",
277
+ );
278
+ return 1;
279
+ }
280
+ if (!pairingOriginUsable(managementPublic)) {
281
+ console.error(
282
+ `hub.managementPublicOrigin ${managementPublic} is non-loopback plain HTTP, which cannot carry a `
283
+ + "pairing code. Put management behind an HTTPS frontend (Tailscale Serve) and set that origin.",
284
+ );
285
+ return 1;
286
+ }
287
+ const dataOverride = options.dataUrl === undefined ? null : canonicalHttpOrigin(options.dataUrl);
288
+ if (options.dataUrl !== undefined && !dataOverride) {
289
+ console.error("--data-url must be a bare http(s) origin with no path, query, or credentials.");
290
+ return 1;
291
+ }
292
+ const browserOrigin = selectInviteBrowserOrigin(config);
293
+ if (!browserOrigin) {
294
+ // `ocx config set corsAllowOrigins` REPLACES the array, so the suggested value carries the
295
+ // entries this hub already has -- a one-element literal would tell the operator to drop them.
296
+ console.error(
297
+ "No loopback browser origin is admitted for pairing. Add the connecting machine's local origin "
298
+ + "(this keeps the entries already configured; 'ocx config get corsAllowOrigins' shows them): "
299
+ + appendCorsAllowOriginsCommand(config, DEFAULT_CLIENT_BROWSER_ORIGIN),
300
+ );
301
+ return 1;
302
+ }
303
+ const target = await (deps.findLiveProxy ?? findLiveProxy)();
304
+ if (!target) {
305
+ console.error("No running attested OpenCodex hub was found. Check 'ocx service status', then 'ocx service repair'.");
306
+ return 1;
307
+ }
308
+ const resolved = resolveHubDataOrigin(
309
+ dataOverride,
310
+ config.hub?.dataPublicOrigin,
311
+ target.hostname ?? config.hostname,
312
+ target.port,
313
+ );
314
+ if (resolved.kind === "loopback-derived") {
315
+ console.error(
316
+ `The advertised data origin would be ${resolved.dataUrl} — this machine's own loopback — because `
317
+ + `${bindAddressPhrase(resolved.bindHostname)}, and nothing here guesses a tailnet or LAN address. `
318
+ + "The other machine would dial itself and the single-use code would be spent for nothing. Name the "
319
+ + "origin remote machines reach this hub's data plane on:",
320
+ );
321
+ for (const line of configSetHubLines(config, "dataPublicOrigin", "https://hub.tailnet.ts.net:8443")) {
322
+ console.error(` ${line}`);
323
+ }
324
+ console.error(" ...or, for this invite only: ocx hub invite --data-url https://hub.tailnet.ts.net:8443");
325
+ return 1;
326
+ }
327
+ const dataUrl = resolved.dataUrl;
328
+ const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, browserOrigin, {
329
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
330
+ });
331
+ if (result.kind !== "created") {
332
+ console.error(`Minting a pairing code failed (${result.reason}).`);
333
+ return 1;
334
+ }
335
+ const payload: HubInvitePayload = {
336
+ code: result.grant,
337
+ expiresAt: new Date(result.expiresAt).toISOString(),
338
+ dataUrl,
339
+ managementUrl: managementPublic,
340
+ command: hubInviteCommand(result.grant, dataUrl, managementPublic, clients),
341
+ };
342
+ // Always, in both modes: the grant is bound to ONE browser origin and the operator cannot
343
+ // see which from the printed command (#4236 review).
344
+ for (const note of inviteBoundOriginNotes(browserOrigin, config)) console.error(note);
345
+ if (options.json) {
346
+ console.log(JSON.stringify(payload));
347
+ console.error(PAIRING_WARNING);
348
+ return 0;
349
+ }
350
+ // Remaining time, not the constant TTL: the number an operator reads has to be the one
351
+ // they actually have left by the time the line is printed.
352
+ const ttlSeconds = Math.max(0, Math.round((result.expiresAt - Date.now()) / 1000));
353
+ console.log(`Pairing code for one machine — single-use, expires in ${ttlSeconds}s (${payload.expiresAt}).`);
354
+ console.log("");
355
+ console.log("# Run on the other machine:");
356
+ console.log(payload.command);
357
+ console.error(PAIRING_WARNING);
358
+ return 0;
359
+ }
360
+
361
+ export async function runHubCommand(args: string[], deps: HubCommandDeps): Promise<number> {
362
+ if (args[0] !== "invite") {
363
+ console.error(`Usage: ${HUB_USAGE}`);
364
+ return 1;
365
+ }
366
+ return runInvite(args.slice(1), deps);
367
+ }