@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.
- package/bin/ocx.mjs +222 -71
- package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/cli/capabilities.ts +28 -0
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +94 -30
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/codex/account-usability.ts +48 -12
- package/src/codex/auth-api.ts +49 -5
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/sync.ts +85 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +26 -10
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +126 -12
- package/src/generated/compatibility-version.json +136 -76
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry.ts +34 -5
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +5 -0
- package/src/server/chat-completions.ts +6 -3
- package/src/server/claude-messages.ts +7 -1
- package/src/server/hub-state.ts +98 -0
- package/src/server/index.ts +124 -6
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +75 -4
- package/src/server/responses/compact.ts +20 -9
- package/src/server/responses/core.ts +57 -10
- package/src/server/responses/policy-fallback.ts +7 -1
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +906 -94
- package/src/types/config.ts +57 -4
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- package/src/vision/routed-describe.ts +51 -20
package/src/cli/dispatch.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
1182
|
-
|
|
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
|
+
}
|