@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
@@ -2640,6 +2640,23 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2640
2640
  // Narrowed carry of #3641: the official Codex example declares a local static catalog,
2641
2641
  // not an HTTP /models contract. Keep Responses separate from the Chat endpoint above.
2642
2642
  // Source: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md (checked 2026-09-07).
2643
+ //
2644
+ // #4201 completes the roster. The `models.json` example on that Codex page is a *starter
2645
+ // catalog*, not the set of models the endpoint serves, and reading it as the latter is what
2646
+ // left Flash off a subscription that sells it. Three upstream pages say so directly, all
2647
+ // checked 2026-09-11:
2648
+ // - coding-plan/latest-model.md pins Codex to THIS baseUrl
2649
+ // (`Codex:https://open.bigmodel.cn/api/v1`) and opens with GLM Coding Plan supporting
2650
+ // GLM-5.3 and GLM-5.3-Flash for every tier (Max & Pro & Lite), then treats
2651
+ // `glm-5.3-flash` as an already-callable id in that same tool.
2652
+ // - coding-plan/overview.md: every plan supports GLM-5.3 and GLM-5.3-Flash, and calls to
2653
+ // GLM-5-Turbo are auto-switched to GLM-5.3-Flash. Turbo below is therefore an alias of
2654
+ // the very model this row omitted, which is the clearest statement that the endpoint
2655
+ // serves Flash: it was already serving it under another name.
2656
+ // - guide/models/vlm/glm-5.3-flash.md: native multimodal input, 1M context, and text
2657
+ // parameters explicitly "consistent with GLM-5.3".
2658
+ // No authenticated /models probe is implied by any of this, so `liveModels` and
2659
+ // `apiKeyValidation` below are deliberately unchanged.
2643
2660
  {
2644
2661
  id: "zhipu-bigmodel-responses",
2645
2662
  label: "Zhipu AI — BigModel Coding Plan (Responses)",
@@ -2648,22 +2665,34 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2648
2665
  authKind: "key",
2649
2666
  dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys",
2650
2667
  defaultModel: "glm-5.3",
2651
- models: ["glm-5.3", "glm-5-turbo"],
2668
+ models: ["glm-5.3", "glm-5.3-flash", "glm-5-turbo"],
2652
2669
  liveModels: false,
2653
2670
  // The local Codex catalog does not establish an authenticated HTTP /models contract.
2654
2671
  apiKeyValidation: "unknown",
2655
2672
  jawcodeBundle: "zai",
2656
2673
  // A pre-existing same-named custom provider must retain its destination and key boundary.
2657
2674
  preserveCustomDestination: true,
2658
- modelContextWindows: { "glm-5.3": 1_048_576, "glm-5-turbo": 204_800 },
2659
- modelInputModalities: { "glm-5.3": ["text"], "glm-5-turbo": ["text"] },
2675
+ // Flash tracks its 5.3 sibling on this row rather than the Chat row's 1_000_000. Both
2676
+ // models are documented as "1M", and this preset expresses that family's 1M the way
2677
+ // BigModel's own Codex declaration does. Splitting the two would leave one preset
2678
+ // claiming two different sizes for one documented window.
2679
+ modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576, "glm-5-turbo": 204_800 },
2680
+ // Flash is the only row here that can actually see an image. Its siblings are declared
2681
+ // text-only and get `image` back from the vision sidecar at catalog-build time; declaring
2682
+ // Flash text-only would route a native VLM's pictures through a describe-it-first detour
2683
+ // and hand the model prose about an image it could have read (same defect
2684
+ // ZAI_GLM_5X_SIDECAR_VISION_MODELS exists to prevent on the Chat rows).
2685
+ modelInputModalities: { "glm-5.3": ["text"], "glm-5.3-flash": ["text", "image"], "glm-5-turbo": ["text"] },
2660
2686
  modelReasoningEfforts: {
2661
2687
  "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS,
2688
+ // Same three effective tiers: upstream documents Flash's text parameters as identical
2689
+ // to GLM-5.3, and the Codex effort table folds every inbound value into low/high/max.
2690
+ "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS,
2662
2691
  // Explicitly empty: Turbo must not inherit the generic selectable effort ladder.
2663
2692
  "glm-5-turbo": [],
2664
2693
  },
2665
- modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5-turbo": "max" },
2666
- modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5-turbo": true },
2694
+ modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5.3-flash": "max", "glm-5-turbo": "max" },
2695
+ modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5.3-flash": true, "glm-5-turbo": true },
2667
2696
  // Responses replay uses this provider-level flag, not the Chat-path model list.
2668
2697
  preserveResponsesReasoningContent: true,
2669
2698
  note: "Domestic BigModel Coding Plan Responses endpoint; static model roster",
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The hub-state contract shared by `GET|HEAD /v1/hub-state` and every connected client.
3
+ *
4
+ * Why it exists (#4236): an agent working on a connected client machine read that machine's
5
+ * own `~/.opencodex/config.json` and `ocx status`, saw `xai ✗ not logged in`, no grok provider
6
+ * and five delegable models, and concluded the hub could not serve grok — while the hub had
7
+ * xAI logged in and was serving grok all along. A client's local credential store is empty BY
8
+ * DESIGN, so reporting it as the truth is not a cosmetic defect: it makes the client lie about
9
+ * the only machine that has the facts.
10
+ *
11
+ * What may cross this boundary is deliberately narrow. The client holds a per-client DATA key
12
+ * and no management credential, so this is a least-privilege data-plane read in the `/v1/catalog`
13
+ * (#809) tradition rather than a widened `/api/*` boundary. The payload is BOOLEANS and model
14
+ * ids: `hasCredential` is the same `!!p.apiKey` projection `GET /api/providers` already ships,
15
+ * and `loggedIn` is `oauthLoginSummary`'s boolean with the email and account id dropped
16
+ * entirely. No keys, no tokens, no quotas, no usage, no account identity — and nothing of that
17
+ * shape may be added later, because this surface is reachable with a data key.
18
+ *
19
+ * The delta over `/v1/catalog`, stated exactly, because "only two booleans" was wrong and a
20
+ * wrong boundary claim is worse than none. Provider names already leak through `/v1/catalog`
21
+ * slugs and `/v1/models` ids, but only for providers those routes list. What this route adds is:
22
+ * `hasCredential`, `loggedIn`, `authMode`, the featured roster — and the NAME and adapter of an
23
+ * ENABLED provider that the catalog omits for want of a usable credential. That last one is the
24
+ * point of the route (a client has to be able to say "the hub has xai configured and has no key
25
+ * for it" rather than "the hub cannot serve grok"), and it is the whole widening.
26
+ *
27
+ * A provider the operator marked `disabled` is NOT exported — see `src/server/hub-state.ts`.
28
+ * Naming it would tell a data-key holder about a provider no other data-plane route mentions,
29
+ * and a client has no use for it: it is not routable, so "absent" is the truthful report.
30
+ */
31
+
32
+ export const HUB_STATE_SCHEMA_VERSION = 1;
33
+
34
+ /** Hard caps, so the serialized body is bounded by construction rather than by hope. */
35
+ export const MAX_HUB_STATE_PROVIDERS = 200;
36
+ export const MAX_HUB_STATE_SUBAGENT_MODELS = 32;
37
+ export const MAX_HUB_STATE_OAUTH_PROVIDERS = 200;
38
+ export const MAX_HUB_STATE_STRING_CHARS = 200;
39
+ /** Response/transfer ceiling. The caps above keep a realistic body two orders below this. */
40
+ export const MAX_HUB_STATE_BYTES = 64 * 1024;
41
+
42
+ /**
43
+ * Mirrors `OcxProviderConfig.authMode` (src/types/provider.ts); default `"key"`.
44
+ *
45
+ * It is shape, not secret: it says HOW a provider authenticates, which is what lets a client
46
+ * explain `hasCredential: false` on an `oauth` provider without claiming nothing is configured.
47
+ */
48
+ export const HUB_STATE_AUTH_MODES = ["key", "forward", "oauth", "local"] as const;
49
+ export type HubStateAuthMode = (typeof HUB_STATE_AUTH_MODES)[number] | null;
50
+
51
+ export interface HubStateProvider {
52
+ name: string;
53
+ adapter: string;
54
+ authMode: HubStateAuthMode;
55
+ /** Presence only — the same `!!p.apiKey` projection `GET /api/providers` ships. */
56
+ hasCredential: boolean;
57
+ /**
58
+ * Always `false` from a hub of this version, which does not export a disabled provider at all.
59
+ * The field stays in the contract because an OLDER hub does send `true`, and a client reading
60
+ * one must still be able to label the row rather than present it as routable.
61
+ */
62
+ disabled: boolean;
63
+ }
64
+
65
+ export interface HubStateOAuthEntry {
66
+ provider: string;
67
+ /** `oauthLoginSummary().loggedIn`. The email and account id are dropped, not masked. */
68
+ loggedIn: boolean;
69
+ }
70
+
71
+ export interface HubStateDTO {
72
+ schemaVersion: typeof HUB_STATE_SCHEMA_VERSION;
73
+ /** Always "hub": the route 404s on any other role, so a client can trust what it reads. */
74
+ runtimeRole: "hub";
75
+ hubVersion: string;
76
+ /** `hub.dataPublicOrigin` when the operator set one; null rather than a guess. */
77
+ origin: string | null;
78
+ providers: HubStateProvider[];
79
+ oauth: HubStateOAuthEntry[];
80
+ /** The hub's effective featured subagent roster — what a client should delegate to. */
81
+ subagentModels: string[];
82
+ /**
83
+ * At least one row was dropped to fit a cap above, so the arrays are a prefix rather than the
84
+ * whole truth. Silent truncation is what this flag exists to prevent: a client that lists 200
85
+ * of 240 providers and says nothing has told the reader the other 40 do not exist, which is
86
+ * the same class of confident-and-wrong report #4236 is about.
87
+ */
88
+ truncated: boolean;
89
+ claudeCode: { enabled: boolean };
90
+ }
91
+
92
+ function boundedString(value: unknown, max = MAX_HUB_STATE_STRING_CHARS): string | null {
93
+ if (typeof value !== "string") return null;
94
+ const trimmed = value.trim();
95
+ if (!trimmed || trimmed.length > max || /[\x00-\x1f\x7f]/.test(trimmed)) return null;
96
+ return trimmed;
97
+ }
98
+
99
+ /**
100
+ * Validate a hub-state body received over the wire.
101
+ *
102
+ * Returns null instead of throwing so both the live fetch and the on-disk cache can reject a
103
+ * malformed document the same way, and so a client NEVER degrades to its own local login state
104
+ * on a shape it does not recognize — degrading quietly is the defect being fixed.
105
+ *
106
+ * Unknown keys are dropped rather than refused: a newer hub must be readable by an older
107
+ * client, and the fields this projection reads are all required.
108
+ */
109
+ export function parseHubStateBody(value: unknown): HubStateDTO | null {
110
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
111
+ const raw = value as Record<string, unknown>;
112
+ if (raw.schemaVersion !== HUB_STATE_SCHEMA_VERSION) return null;
113
+ if (raw.runtimeRole !== "hub") return null;
114
+ const hubVersion = boundedString(raw.hubVersion, 64);
115
+ if (!hubVersion) return null;
116
+ const origin = raw.origin === null || raw.origin === undefined ? null : boundedString(raw.origin, 512);
117
+ if (raw.origin !== null && raw.origin !== undefined && origin === null) return null;
118
+ if (!Array.isArray(raw.providers) || raw.providers.length > MAX_HUB_STATE_PROVIDERS) return null;
119
+ if (!Array.isArray(raw.oauth) || raw.oauth.length > MAX_HUB_STATE_OAUTH_PROVIDERS) return null;
120
+ if (!Array.isArray(raw.subagentModels) || raw.subagentModels.length > MAX_HUB_STATE_SUBAGENT_MODELS) return null;
121
+ const claudeCode = raw.claudeCode;
122
+ if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) return null;
123
+ const enabled = (claudeCode as Record<string, unknown>).enabled;
124
+ if (typeof enabled !== "boolean") return null;
125
+ // Absent means false: a hub that predates the flag truncated nothing this client can detect,
126
+ // and refusing its document would turn a new honesty field into a compatibility break. A
127
+ // present non-boolean is still refused, like every other field here.
128
+ if (raw.truncated !== undefined && typeof raw.truncated !== "boolean") return null;
129
+ const truncated = raw.truncated === true;
130
+
131
+ const providers: HubStateProvider[] = [];
132
+ for (const row of raw.providers) {
133
+ if (!row || typeof row !== "object" || Array.isArray(row)) return null;
134
+ const entry = row as Record<string, unknown>;
135
+ const name = boundedString(entry.name);
136
+ const adapter = boundedString(entry.adapter);
137
+ if (!name || !adapter) return null;
138
+ if (typeof entry.hasCredential !== "boolean" || typeof entry.disabled !== "boolean") return null;
139
+ const authMode = entry.authMode;
140
+ const normalizedAuthMode = typeof authMode === "string" && (HUB_STATE_AUTH_MODES as readonly string[]).includes(authMode)
141
+ ? authMode as NonNullable<HubStateAuthMode>
142
+ : null;
143
+ // An unrecognized authMode is refused rather than nulled: nulling it would let a newer hub's
144
+ // new mode read as "unset", which is a different claim about the provider.
145
+ if (authMode !== null && authMode !== undefined && normalizedAuthMode === null) return null;
146
+ providers.push({
147
+ name,
148
+ adapter,
149
+ authMode: normalizedAuthMode,
150
+ hasCredential: entry.hasCredential,
151
+ disabled: entry.disabled,
152
+ });
153
+ }
154
+
155
+ const oauth: HubStateOAuthEntry[] = [];
156
+ for (const row of raw.oauth) {
157
+ if (!row || typeof row !== "object" || Array.isArray(row)) return null;
158
+ const entry = row as Record<string, unknown>;
159
+ const provider = boundedString(entry.provider);
160
+ if (!provider || typeof entry.loggedIn !== "boolean") return null;
161
+ oauth.push({ provider, loggedIn: entry.loggedIn });
162
+ }
163
+
164
+ const subagentModels: string[] = [];
165
+ for (const row of raw.subagentModels) {
166
+ const model = boundedString(row);
167
+ if (!model) return null;
168
+ subagentModels.push(model);
169
+ }
170
+
171
+ return {
172
+ schemaVersion: HUB_STATE_SCHEMA_VERSION,
173
+ runtimeRole: "hub",
174
+ hubVersion,
175
+ origin,
176
+ providers,
177
+ oauth,
178
+ subagentModels,
179
+ truncated,
180
+ claudeCode: { enabled },
181
+ };
182
+ }
@@ -439,6 +439,11 @@ export const AUTH_MATRIX: readonly ApiAuthMatrixRow[] = [
439
439
  // /v1/models and for the same reason — it forwards no caller credential upstream — so a
440
440
  // remote client no longer needs an admin token just to read the model catalog.
441
441
  { endpoint: "/v1/catalog", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
442
+ // #4236: the hub-state read a connected client uses instead of reporting its own empty
443
+ // credential store. Same admission set and the same justification as the two rows above —
444
+ // it forwards no caller credential upstream and its body is booleans plus model ids — and it
445
+ // 404s on any host whose runtimeRole is not "hub", so no standalone install gains a surface.
446
+ { endpoint: "/v1/hub-state", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
442
447
  ];
443
448
 
444
449
  /** Whether `token` is the environment-provided management secret. */
@@ -26,7 +26,10 @@ import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel }
26
26
  import { evidenceFromBody } from "../routing/request-evidence";
27
27
  import { resolveWireProtocolOverride } from "./adapter-resolve";
28
28
  import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport";
29
- import { normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation";
29
+ import {
30
+ getOrAllocateRequestSessionLane,
31
+ linkRequestSessionLane,
32
+ } from "./request-log-conversation";
30
33
  import type { OcxConfig } from "../types";
31
34
  import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
32
35
  import {
@@ -142,8 +145,7 @@ async function handleChatCompletionsWithBudget(
142
145
  let chatNativeRoute: ReturnType<typeof routeModel> | null = null;
143
146
  try {
144
147
  const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody));
145
- route.provider = resolveOpenCodeGoTransport(route.provider,
146
- sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session")));
148
+ route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req));
147
149
  // Settle the wire once so every branch below reads the adapter this model will
148
150
  // actually use, not the provider-wide default (#404).
149
151
  route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat");
@@ -305,6 +307,7 @@ async function handleChatCompletionsWithBudget(
305
307
  headers,
306
308
  body: internalBodyJson,
307
309
  });
310
+ linkRequestSessionLane(req, internalReq);
308
311
 
309
312
  let nativeLogged = false;
310
313
  const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" | "non_stream" }) => {
@@ -36,7 +36,12 @@ import { resolveWireProtocolOverride } from "./adapter-resolve";
36
36
  import type { OcxConfig } from "../types";
37
37
  import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
38
38
  import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
39
- import { conversationIdFromClaudeMetadata, normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation";
39
+ import {
40
+ conversationIdFromClaudeMetadata,
41
+ linkRequestSessionLane,
42
+ normalizeLogConversationId,
43
+ sessionLaneIdFromRequest,
44
+ } from "./request-log-conversation";
40
45
  import { responseWithDeferredRequestLog } from "./relay";
41
46
  import { handleResponses } from "./responses";
42
47
  import {
@@ -897,6 +902,7 @@ async function handleClaudeMessagesWithBudget(
897
902
  headers,
898
903
  body: JSON.stringify(internalBody),
899
904
  });
905
+ linkRequestSessionLane(req, internalReq);
900
906
  } finally {
901
907
  reservation.release();
902
908
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The hub's own projection for `GET|HEAD /v1/hub-state` (#4236).
3
+ *
4
+ * Pure: it takes the config and an already-computed login summary and returns the DTO. The
5
+ * route owns admission, the role gate and the size ceiling; this module owns what a client is
6
+ * allowed to learn. Keeping the projection here — and building each row field by field rather
7
+ * than spreading a provider or a login summary — is what makes "no keys, no emails, no account
8
+ * ids" checkable by reading one function. A spread would silently start exporting whatever the
9
+ * next field added to those records happens to be.
10
+ *
11
+ * Contract and caps live in `src/remote/hub-state.ts` so the client validates the same shape.
12
+ */
13
+ import {
14
+ HUB_STATE_SCHEMA_VERSION,
15
+ MAX_HUB_STATE_OAUTH_PROVIDERS,
16
+ MAX_HUB_STATE_PROVIDERS,
17
+ MAX_HUB_STATE_SUBAGENT_MODELS,
18
+ HUB_STATE_AUTH_MODES,
19
+ type HubStateDTO,
20
+ type HubStateOAuthEntry,
21
+ type HubStateProvider,
22
+ } from "../remote/hub-state";
23
+ import { DEFAULT_SUBAGENT_MODELS } from "../config/subagent-models";
24
+ import type { OcxConfig } from "../types";
25
+
26
+ export type HubStateConfigView = Pick<OcxConfig, "providers" | "subagentModels" | "claudeCode" | "hub">;
27
+
28
+ /** A login summary row as `oauthLoginSummary()` returns it; extra fields are never read. */
29
+ export interface HubStateLoginRow {
30
+ provider: string;
31
+ loggedIn: boolean;
32
+ }
33
+
34
+ /**
35
+ * The hub's effective featured roster: the same "unset means the defaults, an explicit `[]`
36
+ * means none" rule `buildClaudeAgentDefs` applies, so a client that delegates from this list
37
+ * sees exactly what the hub itself would offer.
38
+ */
39
+ export function hubSubagentRoster(config: Pick<OcxConfig, "subagentModels">): string[] {
40
+ return uncappedSubagentRoster(config).slice(0, MAX_HUB_STATE_SUBAGENT_MODELS);
41
+ }
42
+
43
+ /** The same roster before the cap, so `truncated` can be computed instead of guessed. */
44
+ function uncappedSubagentRoster(config: Pick<OcxConfig, "subagentModels">): string[] {
45
+ const roster = config.subagentModels === undefined ? DEFAULT_SUBAGENT_MODELS : config.subagentModels;
46
+ return roster
47
+ .filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
48
+ .map(entry => entry.trim());
49
+ }
50
+
51
+ export function buildHubState(
52
+ config: HubStateConfigView,
53
+ logins: readonly HubStateLoginRow[],
54
+ hubVersion: string,
55
+ ): HubStateDTO {
56
+ // A disabled provider is dropped, not exported with a flag. `/v1/catalog` and `/v1/models`
57
+ // both filter it out (`src/router.ts`, `src/codex/catalog/*`), so exporting it here was the
58
+ // one thing this route told a data-key holder that no other data-plane route does — and a
59
+ // client has no use for it either: it cannot be routed to, so absence IS the truthful report,
60
+ // and `authMode` already explains a present-but-credential-less row without it.
61
+ const enabledProviders = Object.entries(config.providers ?? {}).filter(([, provider]) => provider.disabled !== true);
62
+ const providers: HubStateProvider[] = enabledProviders
63
+ .slice(0, MAX_HUB_STATE_PROVIDERS)
64
+ .map(([name, provider]) => ({
65
+ name,
66
+ adapter: provider.adapter,
67
+ authMode: provider.authMode !== undefined && HUB_STATE_AUTH_MODES.includes(provider.authMode)
68
+ ? provider.authMode
69
+ : null,
70
+ // Presence only. Identical to the projection GET /api/providers already ships.
71
+ hasCredential: Boolean(provider.apiKey),
72
+ // Always false here. The field stays in the contract for an older hub's documents; see
73
+ // `HubStateProvider` in src/remote/hub-state.ts.
74
+ disabled: false,
75
+ }));
76
+ // Field-by-field, never a spread: oauthLoginSummary also carries the operator's email.
77
+ const oauth: HubStateOAuthEntry[] = logins
78
+ .slice(0, MAX_HUB_STATE_OAUTH_PROVIDERS)
79
+ .map(entry => ({ provider: entry.provider, loggedIn: entry.loggedIn === true }));
80
+ const rosterBeforeCap = uncappedSubagentRoster(config).length;
81
+ // Said out loud rather than silently: the caps are a prefix, and a client presenting a prefix
82
+ // as the whole list is the same confident-and-wrong report this route exists to prevent.
83
+ const truncated = enabledProviders.length > MAX_HUB_STATE_PROVIDERS
84
+ || logins.length > MAX_HUB_STATE_OAUTH_PROVIDERS
85
+ || rosterBeforeCap > MAX_HUB_STATE_SUBAGENT_MODELS;
86
+ return {
87
+ schemaVersion: HUB_STATE_SCHEMA_VERSION,
88
+ runtimeRole: "hub",
89
+ hubVersion,
90
+ origin: config.hub?.dataPublicOrigin ?? null,
91
+ providers,
92
+ oauth,
93
+ subagentModels: hubSubagentRoster(config),
94
+ truncated,
95
+ // Same predicate the launch path uses: absence means enabled.
96
+ claudeCode: { enabled: config.claudeCode?.enabled !== false },
97
+ };
98
+ }
@@ -17,6 +17,7 @@ import {
17
17
  loadConfig,
18
18
  saveConfig,
19
19
  getConfigDir,
20
+ loopbackCompanionBindError,
20
21
  websocketsEnabled,
21
22
  } from "../config";
22
23
  import { grokDefaultReasoningEffort } from "../grok/effort";
@@ -28,6 +29,7 @@ import { withCatalogWriteSerialization } from "../codex/catalog-write-serializat
28
29
  import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync";
29
30
  import { currentServiceHomes, serviceStatePathsForOpenCodexHome } from "../service";
30
31
  import { shouldSyncCodexOnStart } from "../codex/desired-state";
32
+ import { effectiveLoopbackListenerPort } from "../codex/loopback-target";
31
33
  import {
32
34
  createWindowsTaskListingCache,
33
35
  inspectNativeCodexOwnership,
@@ -773,8 +775,16 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
773
775
  const bindHost = !configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
774
776
 
775
777
  // Unauthenticated loopback listener (#1102). Off unless explicitly enabled.
778
+ // A port-less enabled entry is the companion form: same port as the public listener, on
779
+ // 127.0.0.1 (#4236). Refuse an impossible pair here, before any bind, so a hand edit that
780
+ // bypassed validateConfigCandidate reports the collision rather than EADDRINUSE from a
781
+ // rollback that looks like a foreign process holding the port.
776
782
  const loopbackListener = config.unauthenticatedLoopbackListener;
777
- const loopbackListenerPort = loopbackListener?.enabled ? loopbackListener.port : null;
783
+ if (loopbackListener?.enabled === true && loopbackListener.port === undefined) {
784
+ const companionError = loopbackCompanionBindError(config.hostname, listenPort);
785
+ if (companionError) throw new Error(companionError);
786
+ }
787
+ const loopbackListenerPort = effectiveLoopbackListenerPort(config, listenPort);
778
788
  // Hub management ingress is a third, management-only listener. Its address is intentionally
779
789
  // fixed: the kernel loopback bind is the trust boundary that permits Tailscale identity headers.
780
790
  const managementIngress = config.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
@@ -812,6 +822,25 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
812
822
  * keeps the paid upstream behind its own admission and forward-credential checks, so admit only
813
823
  * the exact methods and paths it serves (#3428).
814
824
  *
825
+ * `POST /v1/messages` (Anthropic wire) and `POST /v1/chat/completions` (OpenAI chat wire)
826
+ * are the inference endpoints the hub's OWN local clients speak: `ocx claude` and the
827
+ * `system-env` injection and Claude Desktop 3P dial the first, Cursor Private Inference, the
828
+ * vision `routed-describe` helper and aside/opencode the second (#4236). On a hub whose
829
+ * public listener binds a tailnet address there is no other local socket for them, so
830
+ * leaving them off this list left every non-Codex local client pointed at a closed port.
831
+ * Both handlers resolve their own admission from the RECEIVING listener's policy view — the
832
+ * same resolver and the same loopback short-circuit `/v1/responses` already uses — so this
833
+ * adds a wire, not a trust level. `/api/*` is deliberately still absent: local management
834
+ * discovery goes to the authenticated management surface, never to this listener.
835
+ *
836
+ * `POST /v1/messages/count_tokens` completes that Anthropic wire. It is admitted on a
837
+ * narrower argument than the other two rather than on symmetry: it spends no provider quota,
838
+ * reaches no stored credential, and returns a token count computed from the request body the
839
+ * caller already holds. Withholding it bought no confinement — the same caller may POST the
840
+ * whole conversation to `/v1/messages` on this socket — and cost Claude Code its server-side
841
+ * count, which it then silently replaces with a local estimate. `/api/*`, `/healthz`,
842
+ * `/readyz` and the GUI remain 404 here, which is the boundary that actually matters.
843
+ *
815
844
  * `GET /v1/models` is on the list for a reason that is easy to miss. When catalog
816
845
  * materialization fails or finds no source, `syncCodex` warns and injects with
817
846
  * `catalogPath: null`; Codex then builds an ONLINE model manager and `model/list` refreshes
@@ -824,6 +853,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
824
853
  return req.method === "POST" || req.headers.get("upgrade")?.toLowerCase() === "websocket";
825
854
  }
826
855
  if (path === "/v1/responses/compact") return req.method === "POST";
856
+ if (path === "/v1/messages" || path === "/v1/chat/completions") return req.method === "POST";
857
+ if (path === "/v1/messages/count_tokens") return req.method === "POST";
827
858
  if (path === "/v1/alpha/search") return req.method === "POST";
828
859
  if (path === "/v1/images/generations" || path === "/v1/images/edits") {
829
860
  return req.method === "POST";
@@ -1370,6 +1401,83 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1370
1401
  );
1371
1402
  }
1372
1403
 
1404
+ if (url.pathname === "/v1/hub-state" && (req.method === "GET" || req.method === "HEAD")) {
1405
+ // #4236: a connected client had no way to learn which providers this hub can actually
1406
+ // serve, so `ocx status` on the client reported the CLIENT's empty credential store as
1407
+ // if it were the truth — "xai ✗ not logged in" on a machine whose hub has xAI logged
1408
+ // in. The fix is one least-privilege data-plane read, in the /v1/catalog (#809)
1409
+ // tradition: same admission resolver, same origin check, no parameters, no caller
1410
+ // credential forwarded upstream, and a body of booleans plus model ids. Widening
1411
+ // `/api/*` or handing the client an admin token to read `GET /api/providers` would
1412
+ // have traded a reporting defect for a credential one.
1413
+ //
1414
+ // What it discloses beyond /v1/catalog and /v1/models, exactly: `hasCredential`,
1415
+ // `loggedIn`, `authMode`, the featured roster, and the NAME and adapter of an ENABLED
1416
+ // provider those routes omit for want of a usable credential — which is the point of
1417
+ // the route. A `disabled` provider is NOT exported (`buildHubState` drops it), because
1418
+ // the catalog filters it out too and naming it here would be the only place a data key
1419
+ // learns of it.
1420
+ //
1421
+ // Placed between /v1/catalog and /v1/models so all three least-privilege client reads
1422
+ // stay in sight of each other.
1423
+ const admission = resolveApiAuth(req, policy);
1424
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1425
+ if (!isAllowedRequestOrigin(req, policy)) {
1426
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
1427
+ }
1428
+ // Role gate AFTER admission, deliberately: answering an unauthenticated caller would
1429
+ // turn this into a free "is that machine a hub?" probe. A standalone or client install
1430
+ // gains no surface at all — the route simply does not exist there.
1431
+ //
1432
+ // Built, not formatErrorResponse'd, for the same reason /v1/catalog builds its 404: the
1433
+ // code has to distinguish "this route exists and this host is not a hub" from "this
1434
+ // build has no such route", which is the difference between admission proof and a
1435
+ // vacuous pass in tests/server/api-key-attribution.test.ts.
1436
+ if (config.runtimeRole !== "hub") {
1437
+ return withCors(
1438
+ new Response(JSON.stringify({
1439
+ error: {
1440
+ type: "invalid_request_error",
1441
+ code: "hub_state_not_a_hub",
1442
+ message: "hub state is served only by a host whose runtimeRole is hub",
1443
+ },
1444
+ }), { status: 404, headers: { "content-type": "application/json" } }),
1445
+ req,
1446
+ policy,
1447
+ );
1448
+ }
1449
+ const { buildHubState } = await import("./hub-state");
1450
+ const { MAX_HUB_STATE_BYTES } = await import("../remote/hub-state");
1451
+ const { oauthLoginSummary } = await import("../oauth");
1452
+ // `true` masks emails, but the projection drops the field entirely; passing the mask
1453
+ // anyway means a future refactor that starts copying fields cannot leak a raw address.
1454
+ const body = JSON.stringify(buildHubState(config, oauthLoginSummary(true), VERSION));
1455
+ const bytes = Buffer.byteLength(body);
1456
+ if (bytes > MAX_HUB_STATE_BYTES) {
1457
+ return withCors(
1458
+ new Response(JSON.stringify({
1459
+ error: { type: "server_error", code: "hub_state_too_large", message: "hub state exceeds the maximum served size" },
1460
+ }), { status: 507, headers: { "content-type": "application/json" } }),
1461
+ req,
1462
+ policy,
1463
+ );
1464
+ }
1465
+ return withCors(
1466
+ new Response(req.method === "HEAD" ? null : body, {
1467
+ status: 200,
1468
+ headers: {
1469
+ "content-type": "application/json",
1470
+ // Varies by credential-bearing identity and by live login state: never cached,
1471
+ // and no validator to revalidate with (same rule as /v1/catalog).
1472
+ "cache-control": "no-store",
1473
+ "content-length": String(bytes),
1474
+ },
1475
+ }),
1476
+ req,
1477
+ policy,
1478
+ );
1479
+ }
1480
+
1373
1481
  if (url.pathname === "/v1/models" && req.method === "GET") {
1374
1482
  // #809: the catalog read sits immediately before model discovery because it shares
1375
1483
  // that route's admission rationale exactly. Keep them adjacent so a future change to
@@ -2004,10 +2112,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
2004
2112
  ...admissionFields(admission),
2005
2113
  inboundProtocol: "chat",
2006
2114
  };
2115
+ // `policy`, not `config`: this route is now served on the unauthenticated loopback
2116
+ // listener too (#4236), and only the receiving listener's view produces CORS headers
2117
+ // that match the admission decision made above.
2007
2118
  return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
2008
2119
  await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
2009
2120
  req,
2010
- config,
2121
+ policy,
2011
2122
  ));
2012
2123
  }
2013
2124
 
@@ -2511,10 +2622,17 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
2511
2622
  // who forgot, has to be able to see that an unauthenticated surface is live without
2512
2623
  // reading the file.
2513
2624
  const loopbackPort = loopbackServer.port ?? loopbackListenerPort;
2514
- console.warn(`⚠️ Unauthenticated loopback listener active on http://127.0.0.1:${loopbackPort}`);
2515
- console.warn(` Any local process can use it without a credential — it spends account`);
2516
- console.warn(` quota and paid provider credentials, and can starve authenticated`);
2517
- console.warn(` remote clients. Not for shared or multi-tenant hosts.`);
2625
+ if (loopbackListener?.enabled === true && loopbackListener.port === undefined) {
2626
+ // The companion form is the intended one-port hub topology, not a surprise surface: the
2627
+ // public listener is already on a non-loopback address, so this line states where local
2628
+ // processes go rather than warning about a second port nobody asked for.
2629
+ console.log(`🔁 Loopback companion active on http://127.0.0.1:${loopbackPort} — same port as the public listener; local processes need no credential`);
2630
+ } else {
2631
+ console.warn(`⚠️ Unauthenticated loopback listener active on http://127.0.0.1:${loopbackPort}`);
2632
+ console.warn(` Any local process can use it without a credential — it spends account`);
2633
+ console.warn(` quota and paid provider credentials, and can starve authenticated`);
2634
+ console.warn(` remote clients. Not for shared or multi-tenant hosts.`);
2635
+ }
2518
2636
  }
2519
2637
 
2520
2638
  if (managementIngressServer) {
@@ -1,4 +1,6 @@
1
1
  import type { OcxConfig } from "../../types";
2
+ import { isWildcardHostname } from "../../codex/loopback-target";
3
+ import { localInferenceDestination } from "../../lib/local-destinations";
2
4
  import { probeHostname } from "../proxy-liveness";
3
5
 
4
6
  export interface ApiAccessEndpoints {
@@ -21,9 +23,14 @@ export type BuildApiAccessEndpointsOptions = {
21
23
  requestOrigin?: string | null;
22
24
  };
23
25
 
26
+ /**
27
+ * Wildcard bind scope, shared with `probeHostname` and the loopback-companion gate rather than
28
+ * re-spelled here: a third list of three spellings is how `0.0.0.0.` and `::0` ended up treated
29
+ * as specific bind addresses on one side and wildcards on the other.
30
+ */
24
31
  function isWildcardBindHost(hostname: string | undefined): boolean {
25
32
  const trimmed = (hostname ?? "").trim();
26
- return !trimmed || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]";
33
+ return !trimmed || isWildcardHostname(trimmed);
27
34
  }
28
35
 
29
36
  /** Bracket bare IPv6 literals for URL authority composition. */
@@ -66,7 +73,7 @@ function originBaseUrl(raw: string): string | null {
66
73
  * Falls back to loopback only when no usable request context is available.
67
74
  */
68
75
  export function resolveApiAccessBaseUrl(
69
- config: Pick<OcxConfig, "hostname" | "port">,
76
+ config: Pick<OcxConfig, "hostname" | "port" | "unauthenticatedLoopbackListener">,
70
77
  opts: BuildApiAccessEndpointsOptions = {},
71
78
  ): string {
72
79
  const port = config.port ?? 10100;
@@ -104,7 +111,11 @@ export function resolveApiAccessBaseUrl(
104
111
  }
105
112
  }
106
113
 
107
- return `http://127.0.0.1:${port}/v1`;
114
+ // Last resort: a wildcard bind with no usable request context, so the only address we can
115
+ // name is loopback — and on that address the unauthenticated loopback listener, when one is
116
+ // enabled, is the port a local caller should use (#4236). The branches above are unchanged:
117
+ // a specific bind or a real request host still describes the address the CLIENT reached.
118
+ return `${localInferenceDestination(config, port).origin}/v1`;
108
119
  }
109
120
 
110
121
  /** @deprecated Prefer resolveApiAccessBaseUrl; retained for focused host-format tests. */