@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
@@ -0,0 +1,192 @@
1
+ /**
2
+ * #4207: a connected client reported `connected` with a present, freshly synced catalog while
3
+ * its installed Codex CLI exited before making a request, because the hub's catalog contained a
4
+ * reasoning level that CLI does not know:
5
+ *
6
+ * failed to parse model_catalog_json ... unknown variant `max`,
7
+ * expected one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`
8
+ *
9
+ * The connection state answered a different question from the one the operator was asking. It
10
+ * proved the hub was reachable and the credential worked; it never proved the selected local
11
+ * runtime could consume what was downloaded. This module supplies the missing half, and the
12
+ * connect path fails closed on it: an incompatible catalog is refused before it is written, so
13
+ * the previous known-good file survives and no success is reported.
14
+ *
15
+ * What it deliberately does not do: rewrite the hub's catalog into a locally compatible
16
+ * projection (the client would then silently disagree with hub truth) and terminate running
17
+ * Codex processes. Both are ruled out by the issue.
18
+ */
19
+ import { catalogEffortCompatibility, codexSupportedReasoningEfforts } from "../codex/catalog/effort";
20
+ import type { RawEntry } from "../codex/catalog/parsing";
21
+
22
+ export type ClientCatalogCompatibility =
23
+ | { kind: "compatible" }
24
+ /** The runtime ladder could not be observed, so incompatibility cannot be established. */
25
+ | { kind: "unverified"; reason: string }
26
+ | {
27
+ kind: "incompatible";
28
+ unsupportedEfforts: readonly string[];
29
+ affectedModels: readonly string[];
30
+ };
31
+
32
+ export interface CatalogCompatibilityDeps {
33
+ /** Injected in tests; defaults to observing the selected local Codex runtime. */
34
+ supportedEfforts?: () => ReadonlySet<string> | null;
35
+ }
36
+
37
+ /** State of the materialized client catalog file, as `ocx connect status` already reports it. */
38
+ export type ClientCatalogFileState = "present" | "missing" | "unsafe";
39
+
40
+ /**
41
+ * Whether the selected local Codex runtime can consume the catalog that is *already on disk* —
42
+ * a different question from the write-time gate, and the one #4207 was actually asking.
43
+ *
44
+ * The gate runs once, on bytes about to be written. It cannot speak for a file that predates it,
45
+ * for a file written while the ladder was {@link ClientCatalogCompatibility} `unverified`, or for
46
+ * a runtime that was swapped after the write. Those are exactly the states that kept reporting
47
+ * `connected` while `codex exec` died on `unknown variant \`max\``.
48
+ *
49
+ * Only `ready` means ready. `unverified` is not `incompatible`: a client machine may legitimately
50
+ * have no observable Codex CLI, and calling that an incompatibility would condemn a working
51
+ * install on absent evidence — the same mistake the write-time gate refuses to make.
52
+ */
53
+ export type ClientCatalogReadiness =
54
+ | { kind: "ready" }
55
+ | { kind: "unverified"; reason: string }
56
+ | {
57
+ kind: "incompatible";
58
+ reason: string;
59
+ unsupportedEfforts: readonly string[];
60
+ affectedModels: readonly string[];
61
+ };
62
+
63
+ function parseModels(body: string): RawEntry[] | null {
64
+ try {
65
+ const parsed = JSON.parse(body) as { models?: unknown };
66
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
67
+ return Array.isArray(parsed.models) ? parsed.models as RawEntry[] : [];
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Assess a downloaded catalog against the reasoning efforts the selected local Codex runtime
75
+ * accepts. Unreadable bytes are reported as unverified rather than incompatible: the hub
76
+ * client already rejects a malformed body, and inventing a second cause for it here would
77
+ * repeat the mistake #4169 was filed for.
78
+ */
79
+ export function assessClientCatalogCompatibility(
80
+ body: string,
81
+ deps: CatalogCompatibilityDeps = {},
82
+ ): ClientCatalogCompatibility {
83
+ const models = parseModels(body);
84
+ if (!models) return { kind: "unverified", reason: "the downloaded catalog could not be read" };
85
+ const supported = (deps.supportedEfforts ?? (() => codexSupportedReasoningEfforts()))();
86
+ if (!supported) {
87
+ return {
88
+ kind: "unverified",
89
+ reason: "the selected local Codex runtime did not report the reasoning levels it supports",
90
+ };
91
+ }
92
+ const result = catalogEffortCompatibility(models, supported);
93
+ if (result.compatible) return { kind: "compatible" };
94
+ return {
95
+ kind: "incompatible",
96
+ unsupportedEfforts: result.unsupportedEfforts,
97
+ affectedModels: result.affectedModels,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Raised instead of writing an incompatible catalog. It names both remedies the issue asks
103
+ * for, because the operator cannot act on "incompatible" alone, and it never suggests editing
104
+ * the hub.
105
+ */
106
+ export class ClientCatalogIncompatibleError extends Error {
107
+ readonly unsupportedEfforts: readonly string[];
108
+ readonly affectedModels: readonly string[];
109
+
110
+ constructor(unsupportedEfforts: readonly string[], affectedModels: readonly string[]) {
111
+ const efforts = unsupportedEfforts.join(", ");
112
+ const models = affectedModels.length > 3
113
+ ? `${affectedModels.slice(0, 3).join(", ")} and ${affectedModels.length - 3} more`
114
+ : affectedModels.join(", ");
115
+ super(
116
+ `catalog_incompatible: the hub catalog uses reasoning ${unsupportedEfforts.length === 1 ? "level" : "levels"} `
117
+ + `${efforts}, which the selected local Codex CLI rejects${models ? ` (${models})` : ""}. `
118
+ + "The previous catalog was kept and nothing was changed. Upgrade the Codex CLI to a "
119
+ + "version that supports those levels, or point CODEX_CLI_PATH at one that does and run "
120
+ + "`ocx sync`, then retry. `ocx doctor` reports which runtime is selected.",
121
+ );
122
+ this.name = "ClientCatalogIncompatibleError";
123
+ this.unsupportedEfforts = unsupportedEfforts;
124
+ this.affectedModels = affectedModels;
125
+ }
126
+ }
127
+
128
+ /** Fail closed: refuse an incompatible catalog before anything is written. */
129
+ export function assertClientCatalogCompatible(body: string, deps: CatalogCompatibilityDeps = {}): void {
130
+ const assessment = assessClientCatalogCompatibility(body, deps);
131
+ if (assessment.kind !== "incompatible") return;
132
+ throw new ClientCatalogIncompatibleError(assessment.unsupportedEfforts, assessment.affectedModels);
133
+ }
134
+
135
+ /**
136
+ * Why an already-installed incompatible catalog does not reuse the refusal message above:
137
+ * nothing was kept back. The unusable bytes are the ones Codex will read on its next launch,
138
+ * so "the previous catalog was kept" would be false. The two remedies are the same, because
139
+ * the operator's options do not depend on when the file arrived.
140
+ */
141
+ function installedCatalogRejectionReason(
142
+ unsupportedEfforts: readonly string[],
143
+ affectedModels: readonly string[],
144
+ ): string {
145
+ const models = affectedModels.length > 3
146
+ ? `${affectedModels.slice(0, 3).join(", ")} and ${affectedModels.length - 3} more`
147
+ : affectedModels.join(", ");
148
+ return `the installed catalog uses reasoning ${unsupportedEfforts.length === 1 ? "level" : "levels"} `
149
+ + `${unsupportedEfforts.join(", ")}, which the selected local Codex CLI rejects`
150
+ + `${models ? ` (${models})` : ""}. Codex exits before its first request until the CLI is `
151
+ + "upgraded to a version that supports those levels, or CODEX_CLI_PATH points at one that "
152
+ + "does and `ocx sync` is run. `ocx doctor` reports which runtime is selected.";
153
+ }
154
+
155
+ /**
156
+ * Assess the catalog this machine has already installed, so a surface can stop calling a
157
+ * connection ready when the local runtime cannot launch against it.
158
+ *
159
+ * `body` is the file's bytes, or `null` when they could not be read; `file` is the state the
160
+ * caller already established by stat. Neither non-present file state is an incompatibility: an
161
+ * absent or non-regular catalog is a different fault, and this function only ever claims an
162
+ * incompatibility it has proven.
163
+ */
164
+ export function inspectClientCatalogReadiness(
165
+ file: ClientCatalogFileState,
166
+ body: string | null,
167
+ deps: CatalogCompatibilityDeps = {},
168
+ ): ClientCatalogReadiness {
169
+ if (file === "missing") {
170
+ return { kind: "unverified", reason: "no catalog is installed for the local Codex CLI to read" };
171
+ }
172
+ if (file === "unsafe") {
173
+ return { kind: "unverified", reason: "the catalog path is not a regular file, so its bytes were not read" };
174
+ }
175
+ if (body === null) return { kind: "unverified", reason: "the installed catalog could not be read" };
176
+ const assessment = assessClientCatalogCompatibility(body, deps);
177
+ if (assessment.kind === "compatible") return { kind: "ready" };
178
+ if (assessment.kind === "unverified") {
179
+ // assessClientCatalogCompatibility words its parse failure for bytes that have just been
180
+ // downloaded. These bytes are already installed, so blaming a download would send the
181
+ // operator to the wrong place; name the file that is actually unusable.
182
+ return parseModels(body) === null
183
+ ? { kind: "unverified", reason: "the installed catalog is not readable JSON, so the local Codex CLI cannot parse it either" }
184
+ : assessment;
185
+ }
186
+ return {
187
+ kind: "incompatible",
188
+ reason: installedCatalogRejectionReason(assessment.unsupportedEfforts, assessment.affectedModels),
189
+ unsupportedEfforts: assessment.unsupportedEfforts,
190
+ affectedModels: assessment.affectedModels,
191
+ };
192
+ }
@@ -67,6 +67,8 @@ import {
67
67
  readClientConnectionState,
68
68
  assertNoClientDisconnectPending, assertClientConnectionUnchanged, sameClientConnectionOwner,
69
69
  } from "./state";
70
+ import { assertClientCatalogCompatible, type CatalogCompatibilityDeps } from "./catalog-compatibility";
71
+ import { hubStateCachePath } from "./hub-state";
70
72
 
71
73
  class RotationRecoveryRequiredError extends Error {
72
74
  constructor(message: string, options?: ErrorOptions) {
@@ -89,6 +91,7 @@ export interface ClientConnectDeps {
89
91
  fetchImpl?: typeof fetch;
90
92
  now?: () => Date;
91
93
  lifecycleLockDeps?: ClientLifecycleLockDeps;
94
+ catalogCompatibility?: CatalogCompatibilityDeps;
92
95
  }
93
96
 
94
97
  export interface RotateClientOptions {
@@ -543,6 +546,12 @@ export async function connectClient(
543
546
  fetchImpl: deps.fetchImpl,
544
547
  timeoutMs: options.catalogTimeoutMs,
545
548
  });
549
+ // Fail closed BEFORE the write (#4207). The hub being reachable and the credential working
550
+ // does not mean the selected local Codex runtime can consume what arrived: an older CLI
551
+ // exits on an unknown reasoning level before making a single request, while connect
552
+ // reports success. Refusing here leaves the previous catalog in place untouched, rather
553
+ // than writing one and restoring it afterwards.
554
+ assertClientCatalogCompatible(catalog.body, deps.catalogCompatibility);
546
555
  writtenCatalogFingerprint = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
547
556
  assertConnectingState(persisted.fingerprint);
548
557
  atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body);
@@ -659,6 +668,10 @@ export async function syncConnectedClient(
659
668
  if (!transient) throw error;
660
669
  stale = true;
661
670
  }
671
+ // Same gate as connect (#4207): a sync must never replace a catalog the local CLI can parse
672
+ // with one it cannot. Refusing leaves the connection and the existing catalog exactly as
673
+ // they were, which is the known-good state.
674
+ if (downloaded) assertClientCatalogCompatible(downloaded.body, deps.catalogCompatibility);
662
675
  const next = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
663
676
  assertClientConnectionUnchanged(initial.connection);
664
677
  const token = readServiceApiTokenState();
@@ -879,6 +892,7 @@ export async function disconnectClient(
879
892
  if (!disconnectAtLeast(receipt, "clearing_connection")) advance("clearing_connection");
880
893
  if (clearClientConnection(receipt.owner) === "conflict") throw new Error("client_disconnect_owner_changed");
881
894
  if (!disconnectAtLeast(receipt, "connection_cleared")) advance("connection_cleared");
895
+ removeHubStateCache();
882
896
  requireDesktopResult(finishRemoteDesktopCleanup(held, receipt.owner));
883
897
  if (receipt.phase !== "complete") advance("complete");
884
898
  return {
@@ -890,6 +904,23 @@ export async function disconnectClient(
890
904
  }), deps.lifecycleLockDeps);
891
905
  }
892
906
 
907
+ /**
908
+ * Drop the cached hub-state document (#4236).
909
+ *
910
+ * It is derived data from a connection that no longer exists, and it is owner-stamped, so a
911
+ * reader would reject it anyway — but leaving it behind means `<OPENCODEX_HOME>/hub-state.json`
912
+ * keeps naming the previous hub's providers and logins on a machine that is no longer connected
913
+ * to anything, which is exactly the wrong artifact to leave where someone might read it.
914
+ *
915
+ * Best effort and unconditional on the phase: the disconnect has already succeeded by this point,
916
+ * and a cache file that cannot be removed must not fail it or block a retry.
917
+ */
918
+ function removeHubStateCache(): void {
919
+ try {
920
+ unlinkSync(hubStateCachePath());
921
+ } catch { /* absent, or not ours to remove */ }
922
+ }
923
+
893
924
  export async function revokeConnectedClientKey(
894
925
  credential: { kind: "admin"; value: Uint8Array },
895
926
  deps: ClientConnectDeps = {},
@@ -1,4 +1,5 @@
1
1
  import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
2
+ import { MAX_HUB_STATE_BYTES, parseHubStateBody, type HubStateDTO } from "../remote/hub-state";
2
3
  import { readBoundedResponseBytes } from "../lib/bounded-body";
3
4
  import { clearableDeadline } from "../lib/abort";
4
5
  import type { Desktop3pModelEntry } from "../claude/desktop-3p";
@@ -471,6 +472,57 @@ export async function downloadClientCatalog(
471
472
  return { kind: "fresh", body, ...(keyId ? { keyId } : {}) };
472
473
  }
473
474
 
475
+ /**
476
+ * Read the hub's provider/login/roster state with the per-client DATA key (#4236).
477
+ *
478
+ * Sits beside `downloadClientCatalog` because it is the same kind of call: one bounded,
479
+ * schema-validated, unconditional GET on the data plane with the credential the client already
480
+ * holds. It deliberately has no management variant — the client has no hub management
481
+ * credential, and handing it one to read a list of booleans is the trade #809 already refused.
482
+ *
483
+ * A hub too old to serve the route answers 404, which surfaces as `hub_state_unsupported`. The
484
+ * caller must report that as "state unavailable" and MUST NOT fall back to the client's own
485
+ * local provider/login state: that silent fallback is the defect this route exists to fix.
486
+ */
487
+ export async function fetchHubState(
488
+ serverUrl: string,
489
+ admissionToken: string,
490
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
491
+ ): Promise<HubStateDTO> {
492
+ const origin = normalizeHubOrigin(serverUrl);
493
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/hub-state`, {
494
+ method: "GET",
495
+ headers: new Headers({ Accept: "application/json", "x-opencodex-api-key": admissionToken }),
496
+ }, options.timeoutMs, "headers");
497
+ if (response.status === 404) {
498
+ try { await response.body?.cancel(); } catch { /* best effort */ }
499
+ throw new HubClientError("hub_state_unsupported", "Hub does not serve /v1/hub-state; upgrade the hub", 404);
500
+ }
501
+ if (!response.ok) {
502
+ const code = response.status === 401 ? "hub_state_unauthorized" : `hub_state_http_${response.status}`;
503
+ try { await response.body?.cancel(); } catch { /* best effort */ }
504
+ throw new HubClientError(code, `Hub state request failed (${response.status})`, response.status);
505
+ }
506
+ if (!jsonCompatibleContentType(response)) {
507
+ try { await response.body?.cancel(); } catch { /* best effort */ }
508
+ throw new HubClientError("hub_state_content_type_invalid", "Hub state response was not JSON", response.status);
509
+ }
510
+ let text: string;
511
+ try {
512
+ text = await boundedText(response, MAX_HUB_STATE_BYTES, {
513
+ inactivityTimeoutMs: safeTimeout(options.timeoutMs),
514
+ });
515
+ } catch (error) {
516
+ if (error instanceof DOMException && error.name === "TimeoutError") {
517
+ throw new HubClientError("unreachable", "Hub state read stalled", undefined, { cause: error });
518
+ }
519
+ throw error;
520
+ }
521
+ const parsed = parseHubStateBody(parseJson(text, "hub_state_invalid"));
522
+ if (!parsed) throw new HubClientError("hub_state_schema_invalid", "Hub state response was invalid", response.status);
523
+ return parsed;
524
+ }
525
+
474
526
  function desktopSnapshotModels(value: unknown): Desktop3pModelEntry[] {
475
527
  const invalid = () => new HubClientError("desktop_snapshot_invalid", "Hub Desktop model snapshot was invalid");
476
528
  if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid();
@@ -0,0 +1,214 @@
1
+ /**
2
+ * A connected client's view of its hub's provider, login and roster state (#4236).
3
+ *
4
+ * The rule this module exists to enforce: on a connected client, the hub is the authority, and
5
+ * when the hub cannot be read the answer is "unavailable" — never the client's own local
6
+ * credential store. That store is empty by design, and reporting it as the truth is what made an
7
+ * agent on a connected machine conclude the hub could not serve grok while the hub was serving
8
+ * grok. Every failure path here therefore lands on `stateSource: "unavailable"` with a reason a
9
+ * human can act on, and none of them reaches back into local config.
10
+ *
11
+ * The last good response is cached at `<OPENCODEX_HOME>/hub-state.json`, 0600, stamped with the
12
+ * connection that produced it. The owner stamp is not decoration: after `ocx disconnect` and a
13
+ * reconnect to a different hub (or a key rotation that changes `apiKeyId`), a stale file would
14
+ * otherwise be presented as this hub's state. `sameClientConnectionOwner` is the same triple
15
+ * (`serverUrl`, `apiKeyId`, `connectedAt`) the rest of the client lifecycle compares on.
16
+ */
17
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { getConfigDir } from "../config";
20
+ import { atomicWriteFile } from "../config/atomic-write";
21
+ import { parseHubStateBody, type HubStateDTO } from "../remote/hub-state";
22
+ import type { OcxClientConnectionConfig } from "../types";
23
+ import { fetchHubState, HubClientError } from "./hub-client";
24
+ import { sameClientConnectionOwner } from "./state";
25
+
26
+ /** Bound the status path: `ocx status` must answer even when the hub is gone. */
27
+ const DEFAULT_HUB_STATE_TIMEOUT_MS = 3_000;
28
+ /** The cache document plus its stamp; the DTO itself is already capped by its own contract. */
29
+ const MAX_CACHE_BYTES = 128 * 1024;
30
+
31
+ export type HubStateOwner = Pick<OcxClientConnectionConfig, "serverUrl" | "apiKeyId" | "connectedAt">;
32
+
33
+ /** Where the state came from. "unavailable" is a reportable outcome, not a fallback to local. */
34
+ export type HubStateSource = "hub" | "cache" | "unavailable";
35
+
36
+ export interface HubStateResolution {
37
+ stateSource: HubStateSource;
38
+ state: HubStateDTO | null;
39
+ /** Present whenever the live read did not succeed. Short, operator-facing. */
40
+ reason?: string;
41
+ /** ISO timestamp of the response this state came from. */
42
+ fetchedAt?: string;
43
+ ageSeconds?: number;
44
+ }
45
+
46
+ export function hubStateCachePath(): string {
47
+ return join(getConfigDir(), "hub-state.json");
48
+ }
49
+
50
+ interface CacheDocument {
51
+ version: 1;
52
+ owner: HubStateOwner;
53
+ fetchedAt: string;
54
+ state: HubStateDTO;
55
+ }
56
+
57
+ function readCacheDocument(): CacheDocument | null {
58
+ const path = hubStateCachePath();
59
+ if (!existsSync(path)) return null;
60
+ try {
61
+ const stat = lstatSync(path);
62
+ // A symlink or an oversized file is refused rather than followed: this file is written
63
+ // 0600 by us, and anything else about it is someone else's doing.
64
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_CACHE_BYTES) return null;
65
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
66
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
67
+ const doc = raw as Record<string, unknown>;
68
+ if (doc.version !== 1) return null;
69
+ const owner = doc.owner;
70
+ if (!owner || typeof owner !== "object" || Array.isArray(owner)) return null;
71
+ const ownerRow = owner as Record<string, unknown>;
72
+ if (typeof ownerRow.serverUrl !== "string" || typeof ownerRow.apiKeyId !== "string"
73
+ || typeof ownerRow.connectedAt !== "string") return null;
74
+ if (typeof doc.fetchedAt !== "string" || Number.isNaN(Date.parse(doc.fetchedAt))) return null;
75
+ const state = parseHubStateBody(doc.state);
76
+ if (!state) return null;
77
+ return {
78
+ version: 1,
79
+ owner: {
80
+ serverUrl: ownerRow.serverUrl,
81
+ apiKeyId: ownerRow.apiKeyId,
82
+ connectedAt: ownerRow.connectedAt,
83
+ },
84
+ fetchedAt: doc.fetchedAt,
85
+ state,
86
+ };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /** The cached state for THIS connection, or null when absent, malformed, or another hub's. */
93
+ export function readCachedHubState(owner: HubStateOwner): { state: HubStateDTO; fetchedAt: string } | null {
94
+ const doc = readCacheDocument();
95
+ if (!doc) return null;
96
+ if (!sameClientConnectionOwner(doc.owner, owner)) return null;
97
+ return { state: doc.state, fetchedAt: doc.fetchedAt };
98
+ }
99
+
100
+ /** Best-effort: a cache that cannot be written must never fail the command that asked. */
101
+ export function writeCachedHubState(owner: HubStateOwner, state: HubStateDTO, fetchedAt: string): boolean {
102
+ try {
103
+ const document: CacheDocument = {
104
+ version: 1,
105
+ owner: { serverUrl: owner.serverUrl, apiKeyId: owner.apiKeyId, connectedAt: owner.connectedAt },
106
+ fetchedAt,
107
+ state,
108
+ };
109
+ atomicWriteFile(hubStateCachePath(), `${JSON.stringify(document, null, 2)}\n`);
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Why the live read did not land, in words an operator can act on.
118
+ *
119
+ * `hub_state_unsupported` is the version-skew case and gets an explicit upgrade instruction:
120
+ * left as a bare code it reads like a bug in the client.
121
+ *
122
+ * Every code `fetchHubState` can throw has a sentence here, including the open-ended
123
+ * `hub_state_http_<status>` family. This reason is printed in the `ocx status` banner, and a
124
+ * banner reading `state unavailable (hub_state_http_507)` sends the reader looking for a client
125
+ * bug when the hub has in fact answered and said something.
126
+ */
127
+ export function hubStateFailureReason(error: unknown): string {
128
+ if (error instanceof HubClientError) {
129
+ switch (error.code) {
130
+ case "hub_state_unsupported":
131
+ return "this hub is too old to report its state; upgrade the hub";
132
+ case "hub_state_unauthorized":
133
+ return "the hub rejected this client's data key";
134
+ case "hub_state_schema_invalid":
135
+ case "hub_state_invalid":
136
+ return "the hub returned an unreadable hub-state document";
137
+ case "hub_state_content_type_invalid":
138
+ // Usually a captive portal, a TLS-terminating proxy or an error page in front of the
139
+ // hub: the request reached SOMETHING, and that something is not the hub's API.
140
+ return "the hub's state response was not JSON";
141
+ case "body_too_large":
142
+ return "the hub's state response exceeded the allowed size";
143
+ case "unreachable":
144
+ return "the hub is unreachable";
145
+ case "redirect_refused":
146
+ return "the hub redirected the state request";
147
+ default: {
148
+ const status = error.code.startsWith("hub_state_http_")
149
+ ? error.code.slice("hub_state_http_".length)
150
+ : null;
151
+ return status && /^\d+$/.test(status)
152
+ ? `the hub answered HTTP ${status} to the state request`
153
+ : error.code;
154
+ }
155
+ }
156
+ }
157
+ return "the hub state could not be read";
158
+ }
159
+
160
+ export interface ResolveHubStateOptions {
161
+ owner: HubStateOwner;
162
+ /** The per-client data key. Null when the token file is missing or unsafe. */
163
+ token: string | null;
164
+ timeoutMs?: number;
165
+ fetchImpl?: typeof fetch;
166
+ now?: number;
167
+ /** False reads only the cache — for paths that must not make a network call. */
168
+ allowNetwork?: boolean;
169
+ /** False skips the cache write, for read-only callers. */
170
+ persist?: boolean;
171
+ }
172
+
173
+ function withAge(
174
+ source: HubStateSource,
175
+ state: HubStateDTO | null,
176
+ fetchedAt: string | undefined,
177
+ now: number,
178
+ reason?: string,
179
+ ): HubStateResolution {
180
+ const ageSeconds = fetchedAt ? Math.max(0, Math.floor((now - Date.parse(fetchedAt)) / 1000)) : undefined;
181
+ return {
182
+ stateSource: source,
183
+ state,
184
+ ...(reason ? { reason } : {}),
185
+ ...(fetchedAt ? { fetchedAt } : {}),
186
+ ...(ageSeconds === undefined || Number.isNaN(ageSeconds) ? {} : { ageSeconds }),
187
+ };
188
+ }
189
+
190
+ export async function resolveHubState(options: ResolveHubStateOptions): Promise<HubStateResolution> {
191
+ const now = options.now ?? Date.now();
192
+ const fromCache = (reason: string): HubStateResolution => {
193
+ const cached = readCachedHubState(options.owner);
194
+ return cached
195
+ ? withAge("cache", cached.state, cached.fetchedAt, now, reason)
196
+ : withAge("unavailable", null, undefined, now, reason);
197
+ };
198
+ if (!options.token) return fromCache("this client has no usable data-plane token");
199
+ if (options.allowNetwork === false) return fromCache("a live hub read was not attempted");
200
+ let state: HubStateDTO;
201
+ try {
202
+ state = await fetchHubState(options.owner.serverUrl, options.token, {
203
+ timeoutMs: options.timeoutMs ?? DEFAULT_HUB_STATE_TIMEOUT_MS,
204
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
205
+ });
206
+ } catch (error) {
207
+ // Deliberately no local-state fallback here. A stale cache is still the HUB's state; the
208
+ // client's own providers and logins are not, at any age.
209
+ return fromCache(hubStateFailureReason(error));
210
+ }
211
+ const fetchedAt = new Date(now).toISOString();
212
+ if (options.persist !== false) writeCachedHubState(options.owner, state, fetchedAt);
213
+ return withAge("hub", state, fetchedAt, now);
214
+ }
@@ -1,6 +1,7 @@
1
1
  // ZCode config export.
2
2
  import type { ExportContext, ManagedContribution } from "./contracts";
3
3
  import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, singleFragment } from "./model-metadata";
4
+ import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
4
5
  import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants";
5
6
 
6
7
 
@@ -15,6 +16,16 @@ export interface ZcodeModelEntry {
15
16
  name?: string;
16
17
  limit?: { context: number; output?: number };
17
18
  modalities: { input: string[]; output: string[] };
19
+ /**
20
+ * On-disk Thought Level block. ZCode 3.x persists `variants`/`defaultVariant`
21
+ * and parses them into in-memory `levels`/`defaultLevel`. Omit the field when
22
+ * the catalog has no selectable ladder, so the picker stays hidden.
23
+ */
24
+ reasoning?: {
25
+ enabled: boolean;
26
+ variants: string[];
27
+ defaultVariant?: string;
28
+ };
18
29
  }
19
30
 
20
31
  export interface ZcodeProviderBlock {
@@ -61,6 +72,19 @@ export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig
61
72
  if (context !== undefined) {
62
73
  entry.limit = { context };
63
74
  }
75
+ // `none` is a Codex omit-sentinel, not a ZCode picker option. Keep catalog
76
+ // `ultra` when present: ZCode forwards the selected variant as
77
+ // `reasoning_effort`. Set `defaultVariant` only when it survives that filter.
78
+ const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts)
79
+ ?.filter(effort => effort !== "none");
80
+ if (efforts && efforts.length > 0) {
81
+ const defaultVariant = model.defaultReasoningEffort?.trim().toLowerCase();
82
+ entry.reasoning = {
83
+ enabled: true,
84
+ variants: efforts,
85
+ ...(defaultVariant && efforts.includes(defaultVariant) ? { defaultVariant } : {}),
86
+ };
87
+ }
64
88
  models[model.namespaced] = entry;
65
89
  }
66
90
  return {
@@ -60,6 +60,11 @@ export function isAccountNeedsReauth(id: string): boolean {
60
60
  return true;
61
61
  }
62
62
 
63
- export function clearAccountNeedsReauth(id: string): void {
63
+ export function clearAccountNeedsReauth(id: string, credentialGeneration?: number): void {
64
+ // A model response proves only the credential it used. Keep account-wide
65
+ // quarantine and evidence from another generation intact.
66
+ if (credentialGeneration !== undefined
67
+ && (reauthAccounts.get(id) !== credentialGeneration
68
+ || !isCodexAccountGenerationLive(id, credentialGeneration))) return;
64
69
  reauthAccounts.delete(id);
65
70
  }