@bitkyc08/opencodex 2.49.0 → 2.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -264,6 +264,7 @@ function refreshLine(row: FamilyRows["rows"][number]): string {
264
264
  const quotaText = row.quota ? quotaParts(row.quota).join(" ") : "";
265
265
  parts.push(quotaText.length > 0 ? quotaText : "quota: unknown");
266
266
  if (row.needsReauth) parts.push("needs-reauth");
267
+ if (row.validationPending) parts.push("validation-pending (routing disabled; open 'ocx gui' and click Refresh quotas after recovery)");
267
268
  return parts.filter(Boolean).join(" ");
268
269
  }
269
270
 
@@ -339,7 +340,7 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
339
340
  } else console.log(`no quota report available for ${name}`);
340
341
  return 0;
341
342
  }
342
- const result = await fetchCodexRows(deps, baseUrl, true);
343
+ const result = await fetchCodexRows(deps, baseUrl, true, true, { refreshAction: true });
343
344
  const failed = familyFailure(result, `failed to refresh ${name}`);
344
345
  if (failed !== null) return failed;
345
346
  if (wantsJson) console.log(JSON.stringify({ accounts: result.rows }, null, 2));
@@ -99,6 +99,7 @@ function statusText(row: AccountRow): string {
99
99
  if (row.paused) parts.push("paused");
100
100
  if (row.active) parts.push(row.type === "codex" ? "selected" : "active");
101
101
  if (row.needsReauth) parts.push("needs-reauth");
102
+ if (row.validationPending) parts.push("validation-pending");
102
103
  return parts.join(" ");
103
104
  }
104
105
 
@@ -132,6 +132,34 @@ export const CAPABILITIES: readonly Capability[] = [
132
132
  json: "envelope",
133
133
  details: ["Reads /healthz plus local config; drives no management API route."],
134
134
  },
135
+ {
136
+ command: ["hub", "invite"],
137
+ summary: "Mint a single-use pairing code on a hub and print the exact `ocx connect` line for one more machine.",
138
+ // Deliberately empty. The command DOES drive `POST /api/gui/pairing-grants` -- the attested
139
+ // local mint route `ocx gui pair` uses, authorized by a capability HMAC'd with the running
140
+ // proxy's own attestation secret rather than by the admin token, which is why it needs
141
+ // nothing exported in the shell. That route is answered in the composition root, ahead of
142
+ // `handleManagementAPI`, so it is not in MANAGEMENT_ROUTES; declaring it here would fail the
143
+ // capability/registry reconciliation rather than inform anyone. Widening the registry's scope
144
+ // to `src/server/index.ts` is its own change.
145
+ routes: [],
146
+ flags: [
147
+ { name: "--json", value: "boolean", summary: "Emit code, expiresAt, dataUrl, managementUrl, and command." },
148
+ { name: "--data-url", value: "string", summary: "Advertise this data origin instead of hub.dataPublicOrigin or the bind address." },
149
+ { name: "--management-url", value: "string", summary: "Confirm the management origin; it must equal hub.managementPublicOrigin." },
150
+ { name: "--clients", value: "string", summary: "Pre-select codex and/or claude in the printed connect command." },
151
+ ],
152
+ mutates: true,
153
+ json: "envelope",
154
+ details: [
155
+ "Hub only: refuses when runtimeRole is not hub, and requires a running attested proxy.",
156
+ "The code is secret, single-use and short-lived; it is bound to hub.managementPublicOrigin and to the connecting machine's loopback browser origin.",
157
+ "The bound browser origin is always printed; when it is not http://localhost:10100 the warning names the port the connecting machine must use.",
158
+ "Refuses when the advertised data origin would be loopback (a loopback or wildcard bind with no hub.dataPublicOrigin and no --data-url) rather than printing a line that dials the other machine itself.",
159
+ "Prints no data-plane token. Remote machines receive their own revocable per-client key from the exchange.",
160
+ "Mints through the attested local pairing-grant route, the same one ocx gui pair uses; no admin token is read.",
161
+ ],
162
+ },
135
163
  {
136
164
  command: ["connect", "rotate"],
137
165
  summary: "Rotate the connected client's data key against the hub, with commit and abort.",
@@ -219,6 +247,18 @@ export const CAPABILITIES: readonly Capability[] = [
219
247
  "`--quota` shows cached Codex windows (including 5h); `--refresh` bypasses the server TTL.",
220
248
  ],
221
249
  },
250
+ {
251
+ command: ["account", "refresh"],
252
+ summary: "Refresh account quotas without model validation; pending Codex accounts require dashboard consent.",
253
+ routes: [
254
+ { method: "POST", path: "/api/codex-auth/accounts/refresh" },
255
+ { method: "GET", path: "/api/provider-quotas" },
256
+ ],
257
+ flags: [{ name: "--json", value: "boolean", summary: "Emit the refresh result as JSON." }],
258
+ mutates: true,
259
+ json: "payload",
260
+ details: ["CLI/admin-token refreshes only observe usage. After quota recovery, a human must click Refresh quotas in the dashboard to authorize model validation. Do not mint a GUI session to work around this consent boundary."],
261
+ },
222
262
  {
223
263
  command: ["usage"],
224
264
  summary: "Token and estimated-cost report over a time range.",
@@ -307,12 +347,13 @@ export const CAPABILITIES: readonly Capability[] = [
307
347
  },
308
348
  {
309
349
  command: ["logs"],
310
- summary: "Recent request log rows, filterable by provider, model, conversation, and status.",
350
+ summary: "Recent request log rows, filterable by provider, model, conversation, account, and status.",
311
351
  routes: [{ method: "GET", path: "/api/logs" }],
312
352
  flags: [
313
353
  { name: "--provider", value: "string", summary: "Restrict to one provider, matching failover attempts too." },
314
354
  { name: "--model", value: "string", summary: "Restrict to one model id, matching failover attempts too." },
315
355
  { name: "--conversation", value: "string", summary: "Restrict to one conversation id (`--conversationId` is accepted too)." },
356
+ { name: "--account", value: "string", summary: "Restrict to one account log label (`main`, `p<hex6>`, `o<hex6>`), matching failover attempts too." },
316
357
  { name: "--status", value: "string", summary: "An exact code (429) or a class (5xx)." },
317
358
  { name: "--limit", value: "number", summary: "Row cap; defaults to 200." },
318
359
  { name: "--follow", value: "boolean", summary: "Poll for new rows; add --jsonl to emit JSONL." },
@@ -324,6 +365,7 @@ export const CAPABILITIES: readonly Capability[] = [
324
365
  details: [
325
366
  "`--provider` and `--model` both match a failover attempt, so a request is findable by what actually served it, not only by what was asked for.",
326
367
  "Rows print `conv=<id>` when the entry carries one, so a conversation filter can be told apart from an empty result.",
368
+ "Rows print `acct=<label>` when the account is known, so an `--account` filter can be told apart from an empty result.",
327
369
  "`--follow` deduplicates by row id and cannot be combined with `--json`.",
328
370
  ],
329
371
  },
@@ -1,5 +1,6 @@
1
1
  import type { OcxConfig } from "../types";
2
2
  import { injectClaudeAgentDefs } from "../claude/agents-inject";
3
+ import { readCachedHubState } from "../client/hub-state";
3
4
  import { fetchClaudeContextWindows } from "./claude";
4
5
  import type { ReadinessGate } from "../server/readiness";
5
6
 
@@ -7,6 +8,30 @@ export interface ClaudeAgentStartupSyncDeps {
7
8
  fetchContextWindows?: typeof fetchClaudeContextWindows;
8
9
  injectAgentDefs?: typeof injectClaudeAgentDefs;
9
10
  warn?: (message: string) => void;
11
+ /** Seam for the hub roster lookup; the default reads only the on-disk cache. */
12
+ readHubRoster?: (config: OcxConfig) => readonly string[] | undefined;
13
+ }
14
+
15
+ /**
16
+ * The hub's roster for a connected client, from the CACHE only (#4236).
17
+ *
18
+ * Startup deliberately makes no network call for this. The roster is a convenience here — `ocx
19
+ * claude` does the live read on the path where it matters — and a hub round trip on every proxy
20
+ * start would put an offline hub in the way of a local launch. Undefined falls back to local
21
+ * `subagentModels`, which is what this path has always used.
22
+ */
23
+ function cachedHubRoster(config: OcxConfig): readonly string[] | undefined {
24
+ if (config.runtimeRole !== "client" || !config.client) return undefined;
25
+ try {
26
+ const cached = readCachedHubState({
27
+ serverUrl: config.client.serverUrl,
28
+ apiKeyId: config.client.apiKeyId,
29
+ connectedAt: config.client.connectedAt,
30
+ });
31
+ return cached?.state.subagentModels;
32
+ } catch {
33
+ return undefined;
34
+ }
10
35
  }
11
36
 
12
37
  /**
@@ -70,7 +95,7 @@ export async function syncClaudeAgentDefsAtProxyStartup(
70
95
  // Startup remains best-effort. The next management mutation or `ocx claude` launch can
71
96
  // restore context markers after a transient catalog/Management API failure.
72
97
  }
73
- return inject(config, windows);
98
+ return inject(config, windows, undefined, (deps.readHubRoster ?? cachedHubRoster)(config));
74
99
  } catch (error) {
75
100
  warn(`⚠ Claude agent definitions could not be synced at proxy startup: ${error instanceof Error ? error.message : String(error)}`);
76
101
  return null;
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;