@bitkyc08/opencodex 2.50.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 (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-D7BdZpZm.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
@@ -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
+ }
@@ -20,34 +20,70 @@ export interface CodexAccountUsabilityOptions {
20
20
  modelEligibleAccountIds?: ReadonlySet<string>;
21
21
  }
22
22
 
23
- export function isCodexAccountUsable(
23
+ /**
24
+ * Why an account was refused, in the order the checks run. This is the attribution half of
25
+ * selection: an operator whose model quietly disappeared needs to know that one account fell out
26
+ * and why, not merely that the pool got smaller (#4212).
27
+ */
28
+ export type CodexAccountUnusableReason =
29
+ | "model_not_entitled"
30
+ | "main_hard_locked"
31
+ | "main_traffic_blocked"
32
+ | "legacy_pool_sentinel"
33
+ | "needs_reauth"
34
+ | "main_credential_unavailable"
35
+ | "not_in_pool"
36
+ | "missing_credential"
37
+ | "deleted"
38
+ | "validation_pending";
39
+
40
+ /**
41
+ * The single source of truth for both selection and its explanation. `isCodexAccountUsable()` is
42
+ * this function's boolean projection rather than a parallel copy of the same branches, so a reason
43
+ * can never claim an account is fine while routing drops it, or name a cause routing did not use.
44
+ */
45
+ export function codexAccountUnusableReason(
24
46
  config: OcxConfig,
25
47
  accountId: string,
26
48
  options: CodexAccountUsabilityOptions = {},
27
- ): boolean {
28
- if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false;
49
+ ): CodexAccountUnusableReason | undefined {
50
+ if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) {
51
+ return "model_not_entitled";
52
+ }
29
53
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
30
- if (isMainAccountHardLocked(config)) return false;
54
+ if (isMainAccountHardLocked(config)) return "main_hard_locked";
31
55
  // Startup recovery owns the physical auth/vault boundary. Never parse or select
32
56
  // native __main__ while an encrypted switch journal is pending or inconclusive.
33
- if (!options.nativeMainSelectionOnly && isNativeMainTrafficBlocked()) return false;
57
+ if (!options.nativeMainSelectionOnly && isNativeMainTrafficBlocked()) return "main_traffic_blocked";
34
58
  // A legacy pool row with the sentinel makes an active `__main__` ambiguous.
35
59
  // Fail closed until the authenticated compatibility-delete path removes it.
36
- if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false;
37
- if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return false;
60
+ if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return "legacy_pool_sentinel";
61
+ if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return "needs_reauth";
38
62
  // A selection-only caller owns the recovery/drain fence and will reject main
39
63
  // before reservation or token materialization. Treat cached main as a routing
40
64
  // candidate without touching the credential file so affinity is not rebound.
41
- if (options.nativeMainSelectionOnly) return true;
65
+ if (options.nativeMainSelectionOnly) return undefined;
42
66
  // Main account: a refresh grant is enough to route; materialization refreshes before I/O.
43
- return options.isMainAccountTokenLive
67
+ const mainLive = options.isMainAccountTokenLive
44
68
  ? options.isMainAccountTokenLive()
45
69
  : isMainAccountCredentialUsable();
70
+ return mainLive ? undefined : "main_credential_unavailable";
46
71
  }
47
72
  const exists = (config.codexAccounts ?? [])
48
73
  .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
49
- if (!exists) return false;
50
- if (isAccountNeedsReauth(accountId)) return false;
74
+ if (!exists) return "not_in_pool";
75
+ if (isAccountNeedsReauth(accountId)) return "needs_reauth";
51
76
  const record = readCodexAccountRecord(accountId);
52
- return !!record?.credential && record.deletedAt == null && !record.codexValidationPending;
77
+ if (!record?.credential) return "missing_credential";
78
+ if (record.deletedAt != null) return "deleted";
79
+ if (record.codexValidationPending) return "validation_pending";
80
+ return undefined;
81
+ }
82
+
83
+ export function isCodexAccountUsable(
84
+ config: OcxConfig,
85
+ accountId: string,
86
+ options: CodexAccountUsabilityOptions = {},
87
+ ): boolean {
88
+ return codexAccountUnusableReason(config, accountId, options) === undefined;
53
89
  }
@@ -116,7 +116,7 @@ import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome";
116
116
  import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock";
117
117
  import { observeMainReserveRevocation } from "./reserve-availability";
118
118
  import { emailMaskingEnabled, projectEmail } from "../lib/privacy";
119
- import { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
119
+ import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "./warmup";
120
120
  export { maskEmail } from "../lib/privacy";
121
121
  import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types";
122
122
  import type { CatalogDisposition } from "./convergence-types";
@@ -364,6 +364,20 @@ function mainQuotaWithCarriedResetCredits(
364
364
  };
365
365
  }
366
366
 
367
+ /**
368
+ * Why an account needs the operator. `missing_credential`, `refresh_failed`, and
369
+ * `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized`
370
+ * and `forbidden` exist because the shared health projection may return them; today
371
+ * `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union
372
+ * keeps this field correct if that projection widens rather than silently dropping a reason.
373
+ */
374
+ export type CodexAccountReauthReason =
375
+ | "missing_credential"
376
+ | "refresh_failed"
377
+ | "quota_unauthorized"
378
+ | "unauthorized"
379
+ | "forbidden";
380
+
367
381
  function poolAccountDto(
368
382
  account: CodexAccount,
369
383
  quotaResult: PoolQuotaResult,
@@ -374,8 +388,19 @@ function poolAccountDto(
374
388
  ): CodexAuthAccountDto {
375
389
  const plan = codexPlanValue(account.plan);
376
390
  const quota = quotaForPlan(quotaResult.quota, plan);
377
- const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
391
+ const runtimeReauth = isAccountNeedsReauth(account.id);
392
+ const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth;
378
393
  const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
394
+ // `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the
395
+ // health projection. Emitting only the boolean is what left #4212's reporter guessing which
396
+ // account took their model away and why, so name the cause they actually have to act on.
397
+ const reauthReason: CodexAccountReauthReason | undefined = !hasCredential
398
+ ? "missing_credential"
399
+ : runtimeReauth
400
+ ? "refresh_failed"
401
+ : quotaResult.needsReauth
402
+ ? "quota_unauthorized"
403
+ : health.status === "reauth_required" ? health.reason : undefined;
379
404
  return {
380
405
  id: account.id,
381
406
  email: projectEmail(account.email, maskEmails) ?? account.email,
@@ -387,6 +412,7 @@ function poolAccountDto(
387
412
  priority,
388
413
  quota: quota ? { ...quota } : null,
389
414
  needsReauth: needsReauth || health.status === "reauth_required",
415
+ ...(reauthReason !== undefined ? { reauthReason } : {}),
390
416
  hasCredential,
391
417
  ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
392
418
  ...oauthAccountHealthFields("codex", account.id, health),
@@ -588,7 +614,11 @@ async function verifyCodexAccountWarmup(
588
614
  return {
589
615
  ok: false,
590
616
  response: jsonResponse({
591
- error: "Codex account warmup failed. Reauthenticate the account and try again.",
617
+ // Every fallback model was refused for a provisioning reason, so telling the operator to
618
+ // reauthenticate sends them back through a login that already succeeded.
619
+ error: isCodexWarmupProvisioningFailure(err)
620
+ ? "Codex account warmup failed. Verify account model access or provisioning and try again."
621
+ : "Codex account warmup failed. Reauthenticate the account and try again.",
592
622
  code: "codex_warmup_failed",
593
623
  reason,
594
624
  accountId,
@@ -1157,6 +1187,11 @@ export interface CodexAuthAccountDto {
1157
1187
  priority: number;
1158
1188
  quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
1159
1189
  needsReauth?: boolean;
1190
+ /**
1191
+ * Which of the independent causes behind `needsReauth` fired. Present only when the account
1192
+ * needs the operator; `/api/oauth/accounts` already carries the same field name.
1193
+ */
1194
+ reauthReason?: CodexAccountReauthReason;
1160
1195
  hasCredential: boolean;
1161
1196
  health: OAuthAccountHealth;
1162
1197
  healthLabel: OAuthHealthLabel;
@@ -2005,12 +2040,20 @@ export async function listCodexAuthAccountsSnapshot(
2005
2040
  const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
2006
2041
  ? mainResult.hasCredential
2007
2042
  : getMainAccountCredentialPresence() ?? false;
2008
- const mainNeedsReauth = (mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential)
2009
- || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
2043
+ const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
2044
+ const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
2010
2045
  const mainHealth = projectCodexAccountHealth({
2011
2046
  accountId: MAIN_CODEX_ACCOUNT_ID,
2012
2047
  needsReauth: mainNeedsReauth,
2013
2048
  });
2049
+ // The main row carries the same attribution as a pool row. Reaching this point without
2050
+ // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the
2051
+ // cause is a refresh that did not complete.
2052
+ const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
2053
+ ? "missing_credential"
2054
+ : mainNeedsReauth
2055
+ ? "refresh_failed"
2056
+ : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
2014
2057
  const main: CodexAuthAccountDto = {
2015
2058
  id: MAIN_CODEX_ACCOUNT_ID,
2016
2059
  email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
@@ -2025,6 +2068,7 @@ export async function listCodexAuthAccountsSnapshot(
2025
2068
  priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
2026
2069
  hasCredential: hasMainCredential,
2027
2070
  needsReauth: mainNeedsReauth,
2071
+ ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
2028
2072
  quota: mainInfo.quota ? {
2029
2073
  ...quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan),
2030
2074
  } : null,
@@ -46,6 +46,7 @@ import {
46
46
  displayCodexRuntimePath,
47
47
  persistEffortClamp,
48
48
  resolveAndPersistCodexRuntime,
49
+ UNCLAMPABLE_REASONING_EFFORTS,
49
50
  type EffortClampDiagnostic,
50
51
  } from "../runtime";
51
52
 
@@ -354,7 +355,13 @@ export function clampEntryToCodexSupportedEfforts(
354
355
  ? entry.supported_reasoning_levels as Array<{ effort?: string }>
355
356
  : null;
356
357
  if (levels && levels.length > 0) {
357
- const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort));
358
+ // A rung survives when the observed runtime offers it OR when it is one of the rungs the
359
+ // clamp no longer removes (max/ultra, per the unconditional-emission ruling): CLI versions
360
+ // that genuinely lack them are out of support, and hiding them from current clients costs
361
+ // more than it buys. Hub admission is a different question and stays fail-closed in
362
+ // `catalogEffortCompatibility` below.
363
+ const kept = levels.filter(level => typeof level?.effort === "string"
364
+ && (supported.has(level.effort) || UNCLAMPABLE_REASONING_EFFORTS.has(level.effort)));
358
365
  if (requiresExactReserveEfforts(entry)) {
359
366
  entry.supported_reasoning_levels = kept;
360
367
  if (kept.length === 0) {
@@ -375,12 +382,19 @@ export function clampEntryToCodexSupportedEfforts(
375
382
  .map(level => ({ ...level }));
376
383
  }
377
384
  const currentDefault = entry.default_reasoning_level;
378
- if (typeof currentDefault === "string" && !supported.has(currentDefault)) {
379
- const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
380
- .flatMap(level => typeof (level as { effort?: string })?.effort === "string"
381
- ? [(level as { effort: string }).effort]
382
- : []);
383
- entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving);
385
+ const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
386
+ .flatMap(level => typeof (level as { effort?: string })?.effort === "string"
387
+ ? [(level as { effort: string }).effort]
388
+ : []);
389
+ // An exempt default survives only when the surviving ladder actually advertises it;
390
+ // otherwise the row would name a default the client cannot select (review: PR #4257).
391
+ if (typeof currentDefault === "string"
392
+ && !supported.has(currentDefault)) {
393
+ const exemptAndAdvertised = UNCLAMPABLE_REASONING_EFFORTS.has(currentDefault)
394
+ && surviving.includes(currentDefault);
395
+ if (!exemptAndAdvertised) {
396
+ entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving);
397
+ }
384
398
  }
385
399
  }
386
400
 
@@ -389,6 +403,47 @@ export interface ObservedCatalogEffortClamp {
389
403
  readonly affectedModels: readonly string[];
390
404
  }
391
405
 
406
+ export interface CatalogEffortCompatibility {
407
+ readonly compatible: boolean;
408
+ readonly unsupportedEfforts: readonly string[];
409
+ readonly affectedModels: readonly string[];
410
+ }
411
+
412
+ /**
413
+ * Report which reasoning efforts in a catalog the local Codex runtime would reject, without
414
+ * changing anything.
415
+ *
416
+ * The clamp above is mutate-and-continue, which is right when this process owns the file it
417
+ * is about to write. It is wrong for a catalog downloaded from a hub: rewriting it locally
418
+ * would make the client disagree with hub truth, and #4207 asks for the opposite — establish
419
+ * compatibility first, and refuse rather than materialise a catalog the local CLI cannot
420
+ * parse. `supported` of null means the runtime ladder could not be observed, which is not
421
+ * evidence of incompatibility, so nothing is reported.
422
+ */
423
+ export function catalogEffortCompatibility(
424
+ models: readonly RawEntry[],
425
+ supported: ReadonlySet<string> | null,
426
+ ): CatalogEffortCompatibility {
427
+ if (!supported) return { compatible: true, unsupportedEfforts: [], affectedModels: [] };
428
+ const unsupported = new Set<string>();
429
+ const affected: string[] = [];
430
+ for (const entry of models) {
431
+ const rejected = catalogEntryEfforts(entry).filter(effort => !supported.has(effort));
432
+ const fallback = typeof entry.default_reasoning_level === "string"
433
+ && !supported.has(entry.default_reasoning_level)
434
+ ? [entry.default_reasoning_level]
435
+ : [];
436
+ if (rejected.length === 0 && fallback.length === 0) continue;
437
+ for (const effort of [...rejected, ...fallback]) unsupported.add(effort);
438
+ if (typeof entry.slug === "string") affected.push(entry.slug);
439
+ }
440
+ return {
441
+ compatible: unsupported.size === 0,
442
+ unsupportedEfforts: [...unsupported].sort(),
443
+ affectedModels: affected,
444
+ };
445
+ }
446
+
392
447
  /** Apply an already-observed runtime ladder without probing, logging, or writing diagnostics. */
393
448
  export function clampCatalogModelsToObservedCodexSupport(
394
449
  models: RawEntry[],
@@ -425,7 +480,11 @@ export function clampCatalogModelsToObservedCodexSupport(
425
480
  const omitted = requiresExactReserveEfforts(entry) && hadLadder && after.size === 0;
426
481
  if (lost.length > 0 || defaultClamped || omitted) {
427
482
  for (const effort of lost) removed.add(effort);
428
- if (defaultClamped && beforeDefault) removed.add(beforeDefault);
483
+ // An orphaned exempt default (ultra with no ultra rung in the ladder) is repaired for
484
+ // coherence, but nothing was removed from the offering — do not name it in the diagnostic.
485
+ if (defaultClamped && beforeDefault && !UNCLAMPABLE_REASONING_EFFORTS.has(beforeDefault)) {
486
+ removed.add(beforeDefault);
487
+ }
429
488
  if (typeof entry.slug === "string") affected.push(entry.slug);
430
489
  }
431
490
  if (omitted) models.splice(index, 1);