@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.
- package/AGENTS_INSTALL.md +9 -1
- package/README.md +3 -0
- package/bin/ocx.mjs +222 -71
- package/gui/dist/assets/index-D7BdZpZm.js +115 -0
- 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/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +43 -1
- 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 +99 -31
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- 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/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +50 -13
- package/src/codex/auth-api.ts +156 -28
- package/src/codex/auth-context.ts +21 -0
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +71 -2
- package/src/codex/catalog/sync.ts +99 -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 +29 -12
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +54 -8
- 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/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +184 -12
- package/src/generated/compatibility-version.json +188 -116
- 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/errors.ts +8 -0
- 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/privacy.ts +25 -0
- 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/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/quota.ts +20 -1
- package/src/providers/registry.ts +35 -6
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +11 -0
- package/src/server/chat-completions.ts +10 -7
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +12 -6
- package/src/server/hub-state.ts +98 -0
- package/src/server/images.ts +2 -2
- package/src/server/index.ts +149 -8
- 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/management/logs-usage-routes.ts +4 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +9 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +1 -1
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/request-log.ts +10 -0
- 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 +76 -5
- package/src/server/responses/compact.ts +28 -11
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +201 -48
- package/src/server/responses/policy-fallback.ts +13 -3
- package/src/server/search.ts +2 -2
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +965 -68
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +93 -4
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- 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/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- 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
|
+
}
|
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
|
+
}
|
|
@@ -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
|
}
|