@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/claude.ts CHANGED
@@ -18,12 +18,14 @@ import { isProxyAdmissionSecret } from "../server/auth-cors";
18
18
  import { findLiveProxy } from "../server/proxy-liveness";
19
19
  import type { OcxConfig } from "../types";
20
20
  import { configuredAdminToken } from "../lib/admin-secrets";
21
+ import { localAdmissionToken, localInferenceDestination, localLoopbackInferencePorts, localManagementOrigin } from "../lib/local-destinations";
21
22
  import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect";
22
23
  import { resolveClaudeAuthMode } from "../claude/auth-mode";
23
24
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
24
25
  import { selfLaunchArgv } from "../lib/self-launch-argv";
25
26
  import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context";
26
27
  import { readClientConnectionState, type ClientConnectionState } from "../client/state";
28
+ import { resolveHubState } from "../client/hub-state";
27
29
  import { readServiceApiTokenState, type ServiceApiTokenState } from "../lib/service-secrets";
28
30
  import { DEFAULT_CATALOG_PATH } from "../codex/paths";
29
31
  import { readFileSync } from "node:fs";
@@ -102,16 +104,34 @@ function isClaudeLoopbackHostname(hostname: string): boolean {
102
104
  || normalized === "[::1]";
103
105
  }
104
106
 
105
- function targetsLocalClaudeProxy(value: string | undefined, port: number): boolean {
107
+ /**
108
+ * Is this base URL one of OURS?
109
+ *
110
+ * Two ways to be ours (#4236), because a hub has two shapes of local destination:
111
+ *
112
+ * - a SET of loopback ports, not one port: with an unauthenticated loopback listener the
113
+ * public port and the listener's port are both addresses this proxy answers on at
114
+ * 127.0.0.1, so a URL naming either of them was written by us. Treating the one this launch
115
+ * did not pick as a foreign proxy would strip our own admission token out of the
116
+ * environment. On a tailnet bind with no listener that set is EMPTY, so a leftover
117
+ * `http://127.0.0.1:<port>` is correctly seen as stale rather than as ours.
118
+ * - the resolved destination origin itself, which on such a bind is the bind address. Without
119
+ * this arm the launch would write a base URL and then refuse to recognize it one line later.
120
+ */
121
+ function targetsLocalClaudeProxy(
122
+ value: string | undefined,
123
+ ports: readonly number[],
124
+ ownOrigin?: string,
125
+ ): boolean {
106
126
  if (!value) return false;
107
127
  try {
108
128
  const parsed = new URL(value);
129
+ if (parsed.username !== "" || parsed.password !== "") return false;
130
+ if (ownOrigin !== undefined && parsed.origin === ownOrigin) return true;
109
131
  const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
110
132
  return parsed.protocol === "http:"
111
133
  && isClaudeLoopbackHostname(parsed.hostname)
112
- && effectivePort === port
113
- && parsed.username === ""
114
- && parsed.password === "";
134
+ && ports.includes(effectivePort);
115
135
  } catch {
116
136
  return false;
117
137
  }
@@ -147,7 +167,16 @@ export function buildClaudeEnv(
147
167
  ): ClaudeLaunchEnv {
148
168
  const explicitTarget = typeof portOrTarget === "number" ? null : portOrTarget;
149
169
  const port = typeof portOrTarget === "number" ? portOrTarget : null;
150
- const managedBaseUrl = explicitTarget ? new URL(explicitTarget.baseUrl).origin : `http://127.0.0.1:${port}`;
170
+ // A local launch dials the unauthenticated loopback listener whenever one is enabled — the
171
+ // only credential-free local socket a tailnet-bound hub has (#4236). With the listener OFF
172
+ // the destination is the BIND address, which is reachable but demands data-plane admission;
173
+ // the resolver says which of the two this is instead of every caller guessing.
174
+ const destination = port === null ? null : localInferenceDestination(config, port);
175
+ const managedBaseUrl = explicitTarget
176
+ ? new URL(explicitTarget.baseUrl).origin
177
+ : destination!.origin;
178
+ // Every port this proxy answers on at 127.0.0.1, so a base URL naming any of them is ours.
179
+ const ownLocalPorts = port === null ? [] : localLoopbackInferencePorts(config, port);
151
180
  const env: ClaudeLaunchEnv = { ...base };
152
181
  // Step 1 — strip OUR OWN dummy from the inherited environment before anything reads
153
182
  // or writes the token slot. setDefault below preserves any non-empty value, so a
@@ -188,10 +217,14 @@ export function buildClaudeEnv(
188
217
  try {
189
218
  const parsed = new URL(existingBaseUrl);
190
219
  const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
220
+ // Stale means "a port no live local listener of ours owns". With a loopback listener
221
+ // enabled that is two ports, and rewriting one of them into the other would reject a
222
+ // destination we wrote ourselves.
191
223
  if (parsed.protocol === "http:"
192
224
  && isClaudeLoopbackHostname(parsed.hostname)
193
- && effectivePort !== port) {
194
- const replacement = `http://127.0.0.1:${port}`;
225
+ && !ownLocalPorts.includes(effectivePort)
226
+ && parsed.origin !== managedBaseUrl) {
227
+ const replacement = managedBaseUrl;
195
228
  console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${parsed.origin} with ${replacement}.`);
196
229
  env.ANTHROPIC_BASE_URL = replacement;
197
230
  // The credentials in this environment were paired with the destination we just
@@ -216,10 +249,19 @@ export function buildClaudeEnv(
216
249
  // the user's Claude login. Resolve the mode before adding any proxy-owned credential:
217
250
  // subscription launches must keep their OAuth, while proxy launches may use the
218
251
  // admission key or dummy marker (see server/claude-messages.ts).
219
- const ownTokens = explicitTarget ? [explicitTarget.admissionToken] : ownAdmissionTokens(config);
252
+ // A bind that demands admission needs a credential the machine can actually present, which
253
+ // is wider than `config.apiKeys`: the service installs its data-plane secret as
254
+ // `OPENCODEX_API_AUTH_TOKEN` / the hardened token file, and that is the ladder the Codex
255
+ // provider table already uses. Never the admin token (reviewer constraint on #4236).
256
+ const hostAdmissionToken = destination?.requiresAdmissionToken === true
257
+ ? localAdmissionToken(config)
258
+ : undefined;
259
+ const ownTokens = explicitTarget
260
+ ? [explicitTarget.admissionToken]
261
+ : [...new Set([...(hostAdmissionToken ? [hostAdmissionToken] : []), ...ownAdmissionTokens(config)])];
220
262
  const targetsLocalProxy = explicitTarget
221
263
  ? targetsClaudeRoutingTarget(env.ANTHROPIC_BASE_URL, explicitTarget)
222
- : targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port!);
264
+ : targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, ownLocalPorts, managedBaseUrl);
223
265
  const isOwnAdmissionToken = (value: string): boolean =>
224
266
  ownTokens.includes(value) || isProxyAdmissionSecret(value, config);
225
267
  const inheritedApiKey = env.ANTHROPIC_API_KEY;
@@ -265,6 +307,19 @@ export function buildClaudeEnv(
265
307
  if (!env.ANTHROPIC_AUTH_TOKEN && !hasUserApiKey && targetsLocalProxy && resolved.markerMode === "proxy") {
266
308
  env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER;
267
309
  }
310
+ // Degrade out loud rather than hand Claude Code a destination that 401s (#4236). A
311
+ // subscription launch deliberately carries no host token — asserting one logs a claude.ai
312
+ // subscriber out (#253) — so on a bind that demands admission the honest outcome is a
313
+ // warning naming the two fixes, not a silent refusal at the first request.
314
+ if (destination?.requiresAdmissionToken === true && targetsLocalProxy) {
315
+ const carried = env.ANTHROPIC_AUTH_TOKEN?.trim();
316
+ if (!hasUserApiKey && (!carried || carried === PROXY_MARKER)) {
317
+ console.error(
318
+ `⚠ ${managedBaseUrl} requires an opencodex data-plane credential and this launch carries none — `
319
+ + "requests will be refused. Enable `unauthenticatedLoopbackListener` or bind the proxy to loopback.",
320
+ );
321
+ }
322
+ }
268
323
  const finalAuthToken = env.ANTHROPIC_AUTH_TOKEN;
269
324
  const hostOwnsAuthentication = targetsLocalProxy
270
325
  && !hasUserApiKey
@@ -335,6 +390,12 @@ export function buildClaudeEnv(
335
390
  * Context-window map from the RUNNING proxy's management API (warm TTL cache; the
336
391
  * daemon registers every selector form — audit R3#1). 3s bound + management auth header.
337
392
  * (no [1m] marking, conservative).
393
+ *
394
+ * This is the MANAGEMENT destination, not the inference one (#4236): `/api/claude-code` is
395
+ * never served by the unauthenticated loopback listener, so it resolves through
396
+ * `localManagementOrigin` — a hub's loopback management ingress when it has one, otherwise the
397
+ * public bind — and keeps sending the local admin token. `enabled: false` is how `ocx claude`
398
+ * decides to launch natively, so a wrong destination here silently downgrades every launch.
338
399
  */
339
400
  export interface ClaudeCodeLiveState {
340
401
  contextWindows: Record<string, number>;
@@ -346,7 +407,7 @@ export async function fetchClaudeCodeState(config: OcxConfig, port: number, time
346
407
  const headers = new Headers();
347
408
  const token = configuredAdminToken();
348
409
  if (token) headers.set("x-opencodex-api-key", token);
349
- const res = await fetch(`http://127.0.0.1:${port}/api/claude-code`, {
410
+ const res = await fetch(`${localManagementOrigin(config, port)}/api/claude-code`, {
350
411
  headers,
351
412
  signal: AbortSignal.timeout(timeoutMs),
352
413
  });
@@ -525,7 +586,16 @@ export function buildNativeClaudeEnv(
525
586
  return Boolean(value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config)));
526
587
  });
527
588
  const baseUrl = env.ANTHROPIC_BASE_URL;
528
- if (hasOwnedAdmission && targetsLocalClaudeProxy(baseUrl, config.port)) {
589
+ // Shedding asks a DIFFERENT question than the stale-replacement branch above, so it uses a
590
+ // wider set (#4236). There the question is "is this inherited URL a live destination of
591
+ // ours?" and a port nothing answers on must be rewritten. Here it is "could we have written
592
+ // this?" — and the answer is yes for the public port on any topology, because an earlier
593
+ // config on this machine may have been loopback-bound. Leaving such a URL in place with its
594
+ // admission token stripped (the loop below always strips it) would point a native launch at a
595
+ // dead socket with no credential, which is strictly worse than shedding one port too many.
596
+ const nativeLocalPorts = [...new Set([config.port, ...localLoopbackInferencePorts(config, config.port)])];
597
+ const nativeOwnOrigin = localInferenceDestination(config, config.port).origin;
598
+ if (hasOwnedAdmission && targetsLocalClaudeProxy(baseUrl, nativeLocalPorts, nativeOwnOrigin)) {
529
599
  delete env.ANTHROPIC_BASE_URL;
530
600
  }
531
601
  for (const name of admissionSlots) {
@@ -594,6 +664,43 @@ export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string {
594
664
  return `⚠ Root --dangerously-skip-permissions requested: preserving user IS_SANDBOX=${env.IS_SANDBOX}; Claude Code's root guard remains in control.`;
595
665
  }
596
666
 
667
+ /**
668
+ * The hub's featured subagent roster, or undefined to fall back to local `subagentModels`.
669
+ *
670
+ * Best-effort by design: a launch must not fail because the hub is slow or old. But the
671
+ * fallback is ANNOUNCED (#4236) — a silently local roster is exactly how an operator came to
672
+ * believe a hub that serves grok could only delegate to five native models.
673
+ *
674
+ * An empty hub roster is honoured as empty, not treated as "no answer": an operator who cleared
675
+ * the hub's featured list meant it.
676
+ */
677
+ export async function resolveHubRosterForClaude(
678
+ connection: { serverUrl: string; apiKeyId: string; connectedAt: string },
679
+ token: string,
680
+ deps: { resolve?: typeof resolveHubState; warn?: (message: string) => void } = {},
681
+ ): Promise<readonly string[] | undefined> {
682
+ const warn = deps.warn ?? (message => console.error(message));
683
+ const resolve = deps.resolve ?? resolveHubState;
684
+ try {
685
+ const resolved = await resolve({
686
+ owner: { serverUrl: connection.serverUrl, apiKeyId: connection.apiKeyId, connectedAt: connection.connectedAt },
687
+ token,
688
+ });
689
+ if (!resolved.state) {
690
+ warn(`⚠ Hub roster unavailable (${resolved.reason ?? "unknown reason"}); using this machine's local subagentModels instead. The delegable agents below may not be what the hub can route.`);
691
+ return undefined;
692
+ }
693
+ if (resolved.stateSource === "cache") {
694
+ warn(`⚠ Hub roster came from a cached read ${resolved.ageSeconds ?? "?"}s old (${resolved.reason ?? "live read failed"}).`);
695
+ }
696
+ return resolved.state.subagentModels;
697
+ } catch (error) {
698
+ const message = error instanceof Error ? error.message : String(error);
699
+ warn(`⚠ Hub roster could not be read (${message}); using this machine's local subagentModels instead.`);
700
+ return undefined;
701
+ }
702
+ }
703
+
597
704
  export async function cmdClaude(args: string[]): Promise<number> {
598
705
  const config = loadConfig();
599
706
  const clientState = readClientConnectionState();
@@ -606,10 +713,13 @@ export async function cmdClaude(args: string[]): Promise<number> {
606
713
  if (preflight.kind === "native") return launchNativeClaude(config, args, preflight.notice);
607
714
  let route: number | ClaudeRoutingTarget;
608
715
  let contextWindows: Record<string, number>;
716
+ /** The hub's featured roster on a connected client; undefined means "use local config". */
717
+ let hubRoster: readonly string[] | undefined;
609
718
  if (clientState.kind === "connected") {
610
719
  if (tokenState?.kind !== "present") return 1;
611
720
  route = { baseUrl: clientState.value.serverUrl, admissionToken: tokenState.token };
612
721
  contextWindows = readConnectedClaudeContextWindows();
722
+ hubRoster = await resolveHubRosterForClaude(clientState.value, tokenState.token);
613
723
  } else {
614
724
  const port = await ensureProxyForClaude();
615
725
  if (!port) {
@@ -641,16 +751,24 @@ export async function cmdClaude(args: string[]): Promise<number> {
641
751
  console.error(`⚠ Gateway model cache could not be refreshed: ${message}`);
642
752
  }
643
753
  // Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md.
644
- if (typeof route === "number") {
645
- try {
646
- const written = injectClaudeAgentDefs(config, contextWindows);
647
- if (written === null) {
648
- console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
649
- }
650
- } catch (error) {
651
- const message = error instanceof Error ? error.message : String(error);
652
- console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
754
+ //
755
+ // This used to run only when `route` was a number — i.e. never on a connected client, where
756
+ // `route` is a ClaudeRoutingTarget (#4236). So `~/.claude/agents/ocx-*.md` on a client stayed
757
+ // whatever a previous standalone run had left, and the five delegable agents an operator saw
758
+ // were a frozen snapshot of a machine that no longer does the routing. Nothing in the output
759
+ // said so; the roster simply looked like the answer.
760
+ //
761
+ // On a client the roster comes from the hub, because the local `subagentModels` list is the
762
+ // one this machine had before it joined. The five-row cap stays: it is a Claude Code picker
763
+ // constraint, not the defect — sourcing the five from the wrong machine was.
764
+ try {
765
+ const written = injectClaudeAgentDefs(config, contextWindows, undefined, hubRoster);
766
+ if (written === null) {
767
+ console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
653
768
  }
769
+ } catch (error) {
770
+ const message = error instanceof Error ? error.message : String(error);
771
+ console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
654
772
  }
655
773
  return spawnClaude(args, env);
656
774
  }
@@ -4,6 +4,7 @@ import { getConfigPath, mutatePersistedConfig, readConfigDiagnostics, sanitizeMo
4
4
  import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort";
5
5
  import type { OcxConfig } from "../types";
6
6
  import { normalizeVisionReasoningForModel } from "../vision/reasoning";
7
+ import type { ClientConnectionStatus } from "./connect";
7
8
  import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";
8
9
 
9
10
  const USAGE = `Usage:
@@ -26,8 +27,55 @@ const USAGE = `Usage:
26
27
  const SECRET_KEYS = /^(apiKey|key|accessToken|refreshToken|idToken|token|password|clientSecret|webhookUrl)$/i;
27
28
  const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
28
29
 
30
+ /**
31
+ * The synthetic `_remoteHub` note printed by `ocx config show` on a client (#4236).
32
+ *
33
+ * `runtimeRole: "client"` and the `client` block were already printed, and were already ignored:
34
+ * an agent read a client's `config.json`, saw an empty `providers` map and no grok, and concluded
35
+ * the hub could not serve grok. Naming the situation in the config output costs one key.
36
+ *
37
+ * `connected` is OBSERVED, never assumed. It was briefly hardcoded `true` for any config with a
38
+ * `client` block, which is the same defect in miniature: the presence of configuration is not
39
+ * evidence that the connection works, and a machine whose data-plane token was revoked, rotated
40
+ * away or deleted would have been labelled `connected: true` while it could not reach the hub at
41
+ * all. `collectClientConnectionStatus` is the one reader that knows — it compares the token file's
42
+ * fingerprint against the connection record — so the caller passes its answer in and this stays
43
+ * pure and testable.
44
+ *
45
+ * Synthetic and NOT persisted, for two reasons. `clientConnectionSchema` is `.strict()`, so a
46
+ * `client.note` field would not validate; and persisted prose drifts from the behaviour it
47
+ * describes. The leading underscore marks it as an annotation rather than a setting, and
48
+ * `config export` emits the real config untouched so round-trips still validate.
49
+ */
50
+ export function remoteHubConfigNote(
51
+ config: OcxConfig,
52
+ readConnection: () => Pick<ClientConnectionStatus, "state" | "reason" | "token">,
53
+ ): { connected: boolean; origin: string; note: string } | null {
54
+ if (config.runtimeRole !== "client" || !config.client) return null;
55
+ // A thunk, so a standalone or hub install pays nothing: the guard above returns first and the
56
+ // connection probe (three file reads) never runs.
57
+ const connection = readConnection();
58
+ // Both halves are required: a settled connection record AND the token it recorded. Either one
59
+ // alone describes a machine that cannot read its hub, and `ocx status` is still the command
60
+ // that has the facts — so the note points there in every case, connected or not.
61
+ const connected = connection.state === "connected" && connection.token === "owned";
62
+ const note = connection.state !== "connected"
63
+ ? `this machine is configured as a client but its connection is ${connection.state}${connection.reason ? ` (${connection.reason})` : ""}; run ocx connect status`
64
+ : connection.token !== "owned"
65
+ ? `this machine is configured as a client but its hub data-plane token is ${connection.token}; run ocx connect status`
66
+ : "provider credentials and model availability live on the hub; run ocx status";
67
+ return { connected, origin: config.client.serverUrl, note };
68
+ }
69
+
29
70
  function redact(value: unknown, key = ""): unknown {
30
71
  if (SECRET_KEYS.test(key) && typeof value === "string") return value ? "********" : value;
72
+ // `client.priorCatalog` is the base64 catalog snapshot connect took before overwriting the
73
+ // local one — up to 64 MB of it (src/config.ts). Printed in full it buried `runtimeRole` and
74
+ // the `client` block under a wall of base64, which is how a reader came to miss that this
75
+ // machine is a client at all. Size only, mirroring sanitizeModelCostsForDisplay.
76
+ if (key === "priorCatalog" && typeof value === "string") {
77
+ return value ? `<omitted: ${Buffer.byteLength(value)} bytes>` : value;
78
+ }
31
79
  // modelCosts rows are keyed by model id; a pasted API key in a key position
32
80
  // must not be echoed back by config show/get (values are already redacted).
33
81
  if (key === "modelCosts") return sanitizeModelCostsForDisplay(value);
@@ -119,7 +167,25 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
119
167
  const source = takeFlag(args, "--source");
120
168
  rejectArgs(args, USAGE);
121
169
  const diagnostics = readConfigDiagnostics();
122
- const config = redact(diagnostics.config);
170
+ const redacted = redact(diagnostics.config);
171
+ // Imported here rather than at module scope: `./connect` pulls the whole client lifecycle
172
+ // in, and `ocx config get/set` has no use for it.
173
+ const { collectClientConnectionStatus } = await import("./connect");
174
+ // The readiness probe is declined explicitly. `collectClientConnectionStatus` observes the
175
+ // local Codex ladder for a connected client, and observing it spawns `codex debug models`
176
+ // under a 45s budget. `ocx config show` reads only `state`, `reason` and `token` from the
177
+ // result, so paying for a subprocess here would buy nothing and would quietly turn a
178
+ // read-only config dump into a runtime probe. Returning no ladder resolves readiness to
179
+ // `unverified`, which is the honest answer for a caller that never asked.
180
+ const note = remoteHubConfigNote(
181
+ diagnostics.config,
182
+ () => collectClientConnectionStatus(undefined, undefined, { supportedEfforts: () => null }),
183
+ );
184
+ // First key, not last: it has to be read before the empty `providers` map that misled a
185
+ // reader into concluding nothing was configured anywhere.
186
+ const config = note && redacted && typeof redacted === "object" && !Array.isArray(redacted)
187
+ ? { _remoteHub: note, ...redacted as Record<string, unknown> }
188
+ : redacted;
123
189
  const result = source ? { config, source: diagnostics.source, error: diagnostics.error, warnings: diagnostics.warnings ?? [] } : config;
124
190
  printData(result, true);
125
191
  return;
@@ -1,5 +1,13 @@
1
- import { existsSync, lstatSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import { DEFAULT_CATALOG_PATH } from "../codex/paths";
3
+ import { codexSupportedReasoningEfforts } from "../codex/catalog/effort";
4
+ import { resolveCodexRuntime } from "../codex/runtime";
5
+ import {
6
+ inspectClientCatalogReadiness,
7
+ type CatalogCompatibilityDeps,
8
+ type ClientCatalogFileState,
9
+ type ClientCatalogReadiness,
10
+ } from "../client/catalog-compatibility";
3
11
  import {
4
12
  disconnectClient,
5
13
  revokeConnectedClientKey,
@@ -26,6 +34,12 @@ import {
26
34
 
27
35
  export interface ClientCommandDeps extends RuntimeApiDeps {
28
36
  lifecycleLockDeps?: ClientLifecycleLockDeps;
37
+ catalogProbeDeps?: ClientCatalogProbeDeps;
38
+ }
39
+
40
+ export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps {
41
+ /** Injected in tests; defaults to reading the materialized client catalog off disk. */
42
+ readCatalogBody?: () => string | null;
29
43
  }
30
44
 
31
45
  export const CONNECT_USAGE = `Usage:
@@ -56,21 +70,96 @@ export type ClientConnectionStatus = {
56
70
  catalog: "present" | "missing" | "unsafe";
57
71
  token: "owned" | "missing" | "changed" | "unsafe";
58
72
  rotation: "clean" | "orphan-cleaned" | "recovery-required" | "unsafe";
73
+ /**
74
+ * Whether the selected local Codex CLI can actually launch against this connection (#4207).
75
+ *
76
+ * `state: "connected"` proves the hub answered and the credential works. It never proved the
77
+ * local runtime could consume what was downloaded, which is how a connection kept reporting
78
+ * itself healthy while `codex exec` exited on `unknown variant` before its first request.
79
+ *
80
+ * Reported only while connected, and reported as its own field rather than as a fourth
81
+ * `catalog` value: the status JSON is documented additive-only, so widening an existing
82
+ * field's value domain would change what `catalog: "present"` means for every consumer that
83
+ * already reads it.
84
+ */
85
+ readiness?: ClientCatalogReadiness["kind"];
86
+ /** Present whenever readiness is not `ready`; names the fault and the way out. */
87
+ readinessReason?: string;
59
88
  };
60
89
 
61
- export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDeps?: ClientLifecycleLockDeps): ClientConnectionStatus {
90
+ function readInstalledCatalogBody(): string | null {
91
+ try {
92
+ return readFileSync(DEFAULT_CATALOG_PATH, "utf8");
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The ladder the selected Codex CLI accepts, observed without persisting anything.
100
+ *
101
+ * `codexSupportedReasoningEfforts()` with no deps reaches `resolveAndPersistCodexRuntime`, which
102
+ * writes codex-runtime.json. `ocx status` deliberately resolves without persisting, and a
103
+ * read-only diagnostics command should not start writing runtime selection state because a
104
+ * readiness check was added to it. Handing the already-resolved command in as the only candidate
105
+ * skips that path and reuses the resolve cache `ocx status` has usually already filled.
106
+ */
107
+ function observeLocalCodexEffortLadder(): ReadonlySet<string> | null {
108
+ const command = resolveCodexRuntime().runtime.command;
109
+ return codexSupportedReasoningEfforts({ commandCandidates: () => [command] });
110
+ }
111
+
112
+ /**
113
+ * One observer per command, for the write-time gate and the readiness check alike. Built even
114
+ * when nothing was injected, so production does not silently fall back to the default inside
115
+ * {@link assertClientCatalogCompatible} — that default persists runtime selection state and
116
+ * would run its own probe, which is how the two checks could disagree about the ladder within
117
+ * a single `ocx connect`.
118
+ */
119
+ function catalogObserver(deps: ClientCatalogProbeDeps | undefined): CatalogCompatibilityDeps {
120
+ return { supportedEfforts: deps?.supportedEfforts ?? observeLocalCodexEffortLadder };
121
+ }
122
+
123
+ /** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */
124
+ function installedCatalogFileState(): ClientCatalogFileState {
125
+ if (!existsSync(DEFAULT_CATALOG_PATH)) return "missing";
126
+ try {
127
+ const stat = lstatSync(DEFAULT_CATALOG_PATH);
128
+ return !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe";
129
+ } catch {
130
+ return "unsafe";
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Observing the runtime spawns `codex debug models`, so this runs only for a connected client —
136
+ * the one configuration that installs hub bytes the local clamp never touched. A standalone or
137
+ * hub install pays nothing for it.
138
+ *
139
+ * Never throws. A status command that dies because a Codex probe failed would replace one wrong
140
+ * answer with a worse one.
141
+ */
142
+ function inspectInstalledCatalogReadiness(
143
+ file: ClientCatalogFileState,
144
+ deps: ClientCatalogProbeDeps,
145
+ ): ClientCatalogReadiness {
146
+ try {
147
+ const read = deps.readCatalogBody ?? readInstalledCatalogBody;
148
+ return inspectClientCatalogReadiness(file, file === "present" ? read() : null, catalogObserver(deps));
149
+ } catch {
150
+ return { kind: "unverified", reason: "the selected local Codex runtime could not be inspected" };
151
+ }
152
+ }
153
+
154
+ export function collectClientConnectionStatus(
155
+ now = Date.now(),
156
+ lifecycleLockDeps?: ClientLifecycleLockDeps,
157
+ catalogProbeDeps: ClientCatalogProbeDeps = {},
158
+ ): ClientConnectionStatus {
62
159
  const state = readClientConnectionState();
63
160
  const tokenState = readServiceApiTokenState();
64
161
  const rotation = inspectClientRotationRecoveryGate(state, lifecycleLockDeps).kind;
65
- let catalog: ClientConnectionStatus["catalog"] = "missing";
66
- if (existsSync(DEFAULT_CATALOG_PATH)) {
67
- try {
68
- const stat = lstatSync(DEFAULT_CATALOG_PATH);
69
- catalog = !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe";
70
- } catch {
71
- catalog = "unsafe";
72
- }
73
- }
162
+ const catalog = installedCatalogFileState();
74
163
  if (state.kind !== "connected") {
75
164
  return {
76
165
  state: state.kind,
@@ -88,6 +177,7 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep
88
177
  : tokenState.kind === "unsafe"
89
178
  ? "unsafe"
90
179
  : tokenState.fingerprint === state.value.tokenFingerprint ? "owned" : "changed";
180
+ const readiness = inspectInstalledCatalogReadiness(catalog, catalogProbeDeps);
91
181
  return {
92
182
  state: "connected",
93
183
  serverUrl: state.value.serverUrl,
@@ -102,6 +192,8 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep
102
192
  catalog,
103
193
  token,
104
194
  rotation,
195
+ readiness: readiness.kind,
196
+ ...(readiness.kind === "ready" ? {} : { readinessReason: readiness.reason }),
105
197
  };
106
198
  }
107
199
 
@@ -113,12 +205,74 @@ function parseClients(raw: string | undefined): OcxConnectedClientId[] {
113
205
  return values as OcxConnectedClientId[];
114
206
  }
115
207
 
208
+ /** Reads as a verdict, not a field dump: "ready" is the only word that means the client works. */
209
+ function readinessLine(status: ClientConnectionStatus): string {
210
+ const label = status.readiness === "ready"
211
+ ? "ready"
212
+ : status.readiness === "incompatible"
213
+ ? "not ready"
214
+ : "unverified";
215
+ return `Local Codex CLI: ${label}${status.readinessReason ? ` (${status.readinessReason})` : ""}`;
216
+ }
217
+
218
+ export type ConnectCompletionReport = {
219
+ readonly lines: readonly string[];
220
+ /** Non-null when `ocx connect` must exit non-zero rather than report success. */
221
+ readonly failure: string | null;
222
+ };
223
+
224
+ /**
225
+ * What `ocx connect` says once the hub and the credential are settled, and whether the command
226
+ * still fails (#4207).
227
+ *
228
+ * Pure so the fail-closed decision can be exercised without a hub. Two rules it encodes:
229
+ *
230
+ * On a proven incompatibility the verdict is printed FIRST and the `Connected to …` line is
231
+ * withheld, because a caller grepping for that phrase would otherwise read a catalog the local
232
+ * CLI cannot parse as a success. The connection really was saved, so the replacement line says
233
+ * where to see it.
234
+ *
235
+ * And that failure applies only when this connection selected Codex. A Claude-only connection
236
+ * never launches the Codex CLI, so an old binary somewhere on PATH is not a reason to fail the
237
+ * operator's Claude Desktop setup — it is still worth saying, which is why the line survives
238
+ * without the exit code.
239
+ */
240
+ export function connectCompletionReport(
241
+ connection: { serverUrl: string; apiKeyId: string },
242
+ selectedClients: readonly OcxConnectedClientId[],
243
+ readiness: ClientCatalogReadiness,
244
+ ): ConnectCompletionReport {
245
+ const connected = `Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`;
246
+ if (readiness.kind === "ready") {
247
+ return { lines: [connected, "Local Codex CLI: ready (it accepts every reasoning level in the installed catalog)."], failure: null };
248
+ }
249
+ if (readiness.kind === "unverified") {
250
+ // Not a failure. A client with no observable Codex CLI is a working configuration, and the
251
+ // write-time gate deliberately lets it through; saying so is the honest middle report.
252
+ return { lines: [connected, `Local Codex CLI: unverified (${readiness.reason}).`], failure: null };
253
+ }
254
+ const verdict = `Local Codex CLI: not ready (${readiness.reason})`;
255
+ if (!selectedClients.includes("codex")) {
256
+ return {
257
+ lines: [connected, `${verdict} This connection selected ${selectedClients.join(", ")}, so nothing here launches Codex.`],
258
+ failure: null,
259
+ };
260
+ }
261
+ return {
262
+ lines: [verdict, `The connection to ${connection.serverUrl} as key ${connection.apiKeyId} was saved; run 'ocx connect status' to see it.`],
263
+ failure: `client_not_ready: ${readiness.reason}`,
264
+ };
265
+ }
266
+
116
267
  function statusLines(status: ClientConnectionStatus): string[] {
117
268
  if (status.state !== "connected") {
118
269
  return [`Connection: ${status.state}${status.reason ? ` (${status.reason})` : ""}`];
119
270
  }
120
271
  return [
121
272
  "Connection: connected",
273
+ // Second line on purpose. The whole of #4207 is that a reader stopped at "connected" and
274
+ // believed the client was usable, so the local verdict has to arrive before the hub detail.
275
+ readinessLine(status),
122
276
  `Hub: ${status.serverUrl}`,
123
277
  `Management: ${status.managementUrl} (${status.managementTransport})`,
124
278
  `Protocol: ${status.protocolVersion}`,
@@ -183,8 +337,21 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise<void
183
337
  managementTransport,
184
338
  noSync,
185
339
  ...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }),
186
- }, { fetchImpl: deps.fetchImpl, lifecycleLockDeps: deps.lifecycleLockDeps });
187
- console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`);
340
+ }, {
341
+ fetchImpl: deps.fetchImpl,
342
+ lifecycleLockDeps: deps.lifecycleLockDeps,
343
+ // Same observer the readiness check below uses. Passed unconditionally: leaving it out in
344
+ // production would let the gate fall back to its own probing, persisting default, so one
345
+ // command could run two probes and act on two different ladders.
346
+ catalogCompatibility: catalogObserver(deps.catalogProbeDeps),
347
+ });
348
+ // The hub and the credential are proven at this point; the local runtime is not. Reporting
349
+ // only the first half is what #4207 was filed for, so the catalog now on disk is checked
350
+ // against the Codex CLI that will read it.
351
+ const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {});
352
+ const report = connectCompletionReport(connection, clients, readiness);
353
+ for (const line of report.lines) console.log(line);
354
+ if (report.failure) throw new Error(report.failure);
188
355
  }
189
356
 
190
357
  async function runRevoke(argv: string[], deps: ClientCommandDeps): Promise<void> {
@@ -204,7 +371,7 @@ export async function handleConnectCommand(argv: string[], deps: ClientCommandDe
204
371
  const args = argv.slice(1);
205
372
  const wantsJson = takeFlag(args, "--json");
206
373
  rejectArgs(args, CONNECT_USAGE, { redactValues: true });
207
- const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps);
374
+ const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps, deps.catalogProbeDeps ?? {});
208
375
  printData(status, wantsJson, statusLines(status));
209
376
  return;
210
377
  }