@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.
- package/bin/ocx.mjs +222 -71
- package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/cli/capabilities.ts +28 -0
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +94 -30
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/codex/account-usability.ts +48 -12
- package/src/codex/auth-api.ts +49 -5
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/sync.ts +85 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +26 -10
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +126 -12
- package/src/generated/compatibility-version.json +136 -76
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry.ts +34 -5
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +5 -0
- package/src/server/chat-completions.ts +6 -3
- package/src/server/claude-messages.ts +7 -1
- package/src/server/hub-state.ts +98 -0
- package/src/server/index.ts +124 -6
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +75 -4
- package/src/server/responses/compact.ts +20 -9
- package/src/server/responses/core.ts +57 -10
- package/src/server/responses/policy-fallback.ts +7 -1
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +906 -94
- package/src/types/config.ts +57 -4
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- package/src/vision/routed-describe.ts +51 -20
package/src/client/connect.ts
CHANGED
|
@@ -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 = {},
|
package/src/client/hub-client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
):
|
|
28
|
-
if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId))
|
|
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
|
|
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
|
|
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
|
|
37
|
-
if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return
|
|
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
|
|
65
|
+
if (options.nativeMainSelectionOnly) return undefined;
|
|
42
66
|
// Main account: a refresh grant is enough to route; materialization refreshes before I/O.
|
|
43
|
-
|
|
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
|
|
50
|
-
if (isAccountNeedsReauth(accountId)) return
|
|
74
|
+
if (!exists) return "not_in_pool";
|
|
75
|
+
if (isAccountNeedsReauth(accountId)) return "needs_reauth";
|
|
51
76
|
const record = readCodexAccountRecord(accountId);
|
|
52
|
-
|
|
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
|
}
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
2009
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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);
|