@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -0,0 +1,457 @@
1
+ import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
2
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
3
+
4
+ /**
5
+ * A pairing grant may cross loopback or authenticated HTTPS, and nothing else.
6
+ *
7
+ * Mirrors the hub-side rule in src/server/gui-session.ts. Checking here too is not
8
+ * redundant: it keeps the client from spending a single-use code on a request the hub is
9
+ * certain to refuse.
10
+ */
11
+ function isPairingTransportPermitted(origin: string): boolean {
12
+ let url: URL;
13
+ try {
14
+ url = new URL(origin);
15
+ } catch {
16
+ return false;
17
+ }
18
+ if (url.protocol === "https:") return true;
19
+ if (url.protocol !== "http:") return false;
20
+ const host = url.hostname.toLowerCase();
21
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
22
+ }
23
+ import {
24
+ checkRemoteProtocolCompatibility,
25
+ parseRemoteReadyMetadata,
26
+ type RemoteReadyMetadata,
27
+ } from "../remote/protocol";
28
+
29
+ const READY_BODY_LIMIT = 64 * 1024;
30
+ const MANAGEMENT_BODY_LIMIT = 128 * 1024;
31
+ const DEFAULT_TIMEOUT_MS = 5_000;
32
+
33
+ export type OneTimeConnectCredential =
34
+ | { kind: "admin"; value: Uint8Array }
35
+ | { kind: "pairing-grant"; value: Uint8Array };
36
+
37
+ export interface ConnectGuiSession {
38
+ token: string;
39
+ csrfToken: string;
40
+ browserOrigin: string;
41
+ serverOrigin: string;
42
+ }
43
+
44
+ export interface IssuedClientKey {
45
+ id: string;
46
+ key: string;
47
+ createdAt: string;
48
+ name: string;
49
+ }
50
+
51
+ export interface StartedClientKeyRotation extends IssuedClientKey {
52
+ rotationId: string;
53
+ expiresAt: string;
54
+ }
55
+
56
+ export class HubClientError extends Error {
57
+ constructor(
58
+ readonly code: string,
59
+ message: string,
60
+ readonly status?: number,
61
+ options?: ErrorOptions,
62
+ ) {
63
+ super(message, options);
64
+ this.name = "HubClientError";
65
+ }
66
+ }
67
+
68
+ function credentialString(value: Uint8Array): string {
69
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(value).trim();
70
+ if (!decoded || /[\r\n\0]/.test(decoded) || value.byteLength > 4096) {
71
+ throw new HubClientError("credential_invalid", "Connect credential is invalid");
72
+ }
73
+ return decoded;
74
+ }
75
+
76
+ function safeTimeout(timeoutMs: number | undefined): number {
77
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
78
+ ? Math.min(Math.floor(timeoutMs), 120_000)
79
+ : DEFAULT_TIMEOUT_MS;
80
+ }
81
+
82
+ async function fetchBounded(
83
+ fetchImpl: typeof fetch,
84
+ url: string,
85
+ init: RequestInit,
86
+ timeoutMs: number | undefined,
87
+ ): Promise<Response> {
88
+ try {
89
+ const response = await fetchImpl(url, {
90
+ ...init,
91
+ redirect: "manual",
92
+ signal: AbortSignal.timeout(safeTimeout(timeoutMs)),
93
+ });
94
+ if (response.status >= 300 && response.status < 400 && response.status !== 304) {
95
+ throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status);
96
+ }
97
+ return response;
98
+ } catch (error) {
99
+ if (error instanceof HubClientError) throw error;
100
+ throw new HubClientError("unreachable", "Hub request did not complete", undefined, { cause: error });
101
+ }
102
+ }
103
+
104
+ async function boundedText(response: Response, maxBytes: number): Promise<string> {
105
+ const declared = Number(response.headers.get("content-length") ?? "0");
106
+ if (Number.isFinite(declared) && declared > maxBytes) {
107
+ throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status);
108
+ }
109
+ const result = await readBoundedResponseBytes(response, { maxBytes });
110
+ if (result.oversized) {
111
+ throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status);
112
+ }
113
+ try {
114
+ return new TextDecoder("utf-8", { fatal: true }).decode(result.bytes);
115
+ } catch (error) {
116
+ throw new HubClientError("body_invalid", "Hub response was not valid UTF-8", response.status, { cause: error });
117
+ }
118
+ }
119
+
120
+ function jsonCompatibleContentType(response: Response): boolean {
121
+ const value = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
122
+ return value === "application/json" || value?.endsWith("+json") === true;
123
+ }
124
+
125
+ function validateRemoteCatalog(value: unknown): void {
126
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
127
+ throw new HubClientError("catalog_schema_invalid", "Hub catalog response was invalid");
128
+ }
129
+ const models = (value as Record<string, unknown>).models;
130
+ if (!Array.isArray(models) || models.length > 2_000) {
131
+ throw new HubClientError("catalog_schema_invalid", "Hub catalog model list was invalid");
132
+ }
133
+ const slugs = new Set<string>();
134
+ for (const row of models) {
135
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
136
+ throw new HubClientError("catalog_schema_invalid", "Hub catalog model row was invalid");
137
+ }
138
+ const slug = (row as Record<string, unknown>).slug;
139
+ if (typeof slug !== "string" || !slug.trim() || /[\x00-\x1f\x7f]/.test(slug) || slugs.has(slug)) {
140
+ throw new HubClientError("catalog_schema_invalid", "Hub catalog model slug was invalid");
141
+ }
142
+ slugs.add(slug);
143
+ }
144
+ }
145
+
146
+ function parseJson(text: string, code: string): unknown {
147
+ try {
148
+ return JSON.parse(text) as unknown;
149
+ } catch (error) {
150
+ throw new HubClientError(code, "Hub returned malformed JSON", undefined, { cause: error });
151
+ }
152
+ }
153
+
154
+ export function normalizeHubOrigin(input: string): string {
155
+ let parsed: URL;
156
+ try {
157
+ parsed = new URL(input);
158
+ } catch {
159
+ throw new HubClientError("url_invalid", "Hub URL must be an absolute HTTP(S) URL");
160
+ }
161
+ if (
162
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:")
163
+ || parsed.username
164
+ || parsed.password
165
+ || parsed.search
166
+ || parsed.hash
167
+ || (parsed.pathname !== "/" && parsed.pathname !== "/v1" && parsed.pathname !== "/v1/")
168
+ ) {
169
+ throw new HubClientError(
170
+ "url_invalid",
171
+ "Hub URL must be an HTTP(S) origin without credentials, query, fragment, or non-/v1 path",
172
+ );
173
+ }
174
+ return parsed.origin;
175
+ }
176
+
177
+ export async function fetchHubReady(
178
+ serverUrl: string,
179
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
180
+ ): Promise<{ status: "ready" | "pending" | "failed"; metadata: RemoteReadyMetadata }> {
181
+ const origin = normalizeHubOrigin(serverUrl);
182
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/readyz`, {
183
+ method: "GET",
184
+ headers: { Accept: "application/json" },
185
+ }, options.timeoutMs);
186
+ const body = parseJson(await boundedText(response, READY_BODY_LIMIT), "ready_invalid");
187
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
188
+ throw new HubClientError("ready_invalid", "Hub readiness response was invalid", response.status);
189
+ }
190
+ const raw = body as Record<string, unknown>;
191
+ const status = raw.status;
192
+ if (status !== "ready" && status !== "pending" && status !== "failed") {
193
+ throw new HubClientError("ready_invalid", "Hub readiness status was invalid", response.status);
194
+ }
195
+ const metadata = parseRemoteReadyMetadata(raw);
196
+ const compatibility = checkRemoteProtocolCompatibility(raw);
197
+ if (!metadata || !compatibility.ok) {
198
+ throw new HubClientError(
199
+ compatibility.ok ? "ready_invalid" : compatibility.reason,
200
+ compatibility.ok ? "Hub readiness metadata was invalid" : compatibility.message,
201
+ response.status,
202
+ );
203
+ }
204
+ if ((status === "ready" && response.status !== 200) || (status !== "ready" && response.status !== 503)) {
205
+ throw new HubClientError("ready_invalid", "Hub readiness HTTP status did not match its state", response.status);
206
+ }
207
+ return { status, metadata };
208
+ }
209
+
210
+ function htmlMeta(html: string, name: string): string | null {
211
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
212
+ const match = new RegExp(`<meta\\s+name=["']${escaped}["']\\s+content=["']([^"']*)["']`, "i").exec(html);
213
+ return match?.[1]
214
+ ?.replaceAll("&quot;", '"')
215
+ .replaceAll("&lt;", "<")
216
+ .replaceAll("&gt;", ">")
217
+ .replaceAll("&amp;", "&") ?? null;
218
+ }
219
+
220
+ export async function exchangeConnectPairingGrant(
221
+ managementUrl: string,
222
+ browserOrigin: string,
223
+ grant: Uint8Array,
224
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
225
+ ): Promise<ConnectGuiSession> {
226
+ const origin = normalizeHubOrigin(managementUrl);
227
+ const browser = normalizeHubOrigin(browserOrigin);
228
+ // No opt-in. An earlier revision let `--allow-insecure-http` carry a grant over plaintext
229
+ // when the hub also opted in, on the theory that requiring both sides made it deliberate.
230
+ // Deliberateness is not the control that matters: the grant is readable by anything on the
231
+ // path and the session it mints is reusable. The hub refuses this exchange outright now, so
232
+ // sending it would only burn a single-use code against a certain rejection.
233
+ if (!isPairingTransportPermitted(origin)) {
234
+ throw new HubClientError("insecure_http_refused", "Pairing requires loopback or HTTPS; plaintext HTTP cannot carry a grant");
235
+ }
236
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/opencodex-session`, {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/json", Origin: browser, Accept: "text/html" },
239
+ body: JSON.stringify({ grant: credentialString(grant) }),
240
+ }, options.timeoutMs);
241
+ if (!response.ok) throw new HubClientError("pairing_refused", "Hub pairing grant was refused", response.status);
242
+ const html = await boundedText(response, MANAGEMENT_BODY_LIMIT);
243
+ const session: ConnectGuiSession = {
244
+ token: htmlMeta(html, "opencodex-session-token") ?? "",
245
+ csrfToken: htmlMeta(html, "opencodex-session-csrf") ?? "",
246
+ browserOrigin: htmlMeta(html, "opencodex-session-origin") ?? "",
247
+ serverOrigin: htmlMeta(html, "opencodex-session-server-origin") ?? "",
248
+ };
249
+ if (!session.token || !session.csrfToken || session.browserOrigin !== browser || session.serverOrigin !== origin) {
250
+ throw new HubClientError("pairing_invalid", "Hub pairing session response was invalid", response.status);
251
+ }
252
+ return session;
253
+ }
254
+
255
+ function parseIssuedClientKey(value: unknown): IssuedClientKey | null {
256
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
257
+ const raw = value as Record<string, unknown>;
258
+ if (
259
+ typeof raw.id !== "string" || !raw.id || raw.id.length > 256
260
+ || typeof raw.name !== "string" || !raw.name || raw.name.length > 80
261
+ || typeof raw.key !== "string" || !/^ocx_data_[0-9a-f]{40}$/.test(raw.key)
262
+ || typeof raw.createdAt !== "string" || Number.isNaN(Date.parse(raw.createdAt))
263
+ ) return null;
264
+ return { id: raw.id, name: raw.name, key: raw.key, createdAt: raw.createdAt };
265
+ }
266
+
267
+ export async function issueClientKey(
268
+ managementUrl: string,
269
+ credential:
270
+ | { kind: "admin"; value: Uint8Array }
271
+ | { kind: "gui-session"; value: ConnectGuiSession },
272
+ name: string,
273
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
274
+ ): Promise<IssuedClientKey> {
275
+ const origin = normalizeHubOrigin(managementUrl);
276
+ if (!name.trim() || name.length > 80 || /[\x00-\x1f\x7f]/.test(name)) {
277
+ throw new HubClientError("key_name_invalid", "Client key name is invalid");
278
+ }
279
+ if (credential.kind === "admin" && new URL(origin).protocol !== "https:") {
280
+ throw new HubClientError("admin_http_refused", "Admin credentials may be sent only over HTTPS");
281
+ }
282
+ const headers = new Headers({ "Content-Type": "application/json", Accept: "application/json" });
283
+ if (credential.kind === "admin") {
284
+ headers.set("x-opencodex-api-key", credentialString(credential.value));
285
+ } else {
286
+ headers.set("x-opencodex-api-key", credential.value.token);
287
+ headers.set("Origin", credential.value.browserOrigin);
288
+ headers.set("X-OpenCodex-GUI-Origin", credential.value.browserOrigin);
289
+ headers.set("X-OpenCodex-CSRF-Token", credential.value.csrfToken);
290
+ }
291
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/api/keys`, {
292
+ method: "POST",
293
+ headers,
294
+ body: JSON.stringify({ name: name.trim() }),
295
+ }, options.timeoutMs);
296
+ if (!response.ok) {
297
+ throw new HubClientError(`key_issue_http_${response.status}`, `Hub refused client key issuance (${response.status})`, response.status);
298
+ }
299
+ const issued = parseIssuedClientKey(parseJson(
300
+ await boundedText(response, MANAGEMENT_BODY_LIMIT),
301
+ "key_issue_invalid",
302
+ ));
303
+ if (!issued) throw new HubClientError("key_issue_invalid", "Hub returned an invalid client key response", response.status);
304
+ return issued;
305
+ }
306
+
307
+ export async function revokeClientKey(
308
+ managementUrl: string,
309
+ credential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession },
310
+ id: string,
311
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
312
+ ): Promise<void> {
313
+ const origin = normalizeHubOrigin(managementUrl);
314
+ if (!id || id.length > 256) throw new HubClientError("key_id_invalid", "Client key id is invalid");
315
+ if (credential.kind === "admin" && new URL(origin).protocol !== "https:") {
316
+ throw new HubClientError("admin_http_refused", "Admin credentials may be sent only over HTTPS");
317
+ }
318
+ const headers = new Headers({ "Content-Type": "application/json", Accept: "application/json" });
319
+ if (credential.kind === "admin") headers.set("x-opencodex-api-key", credentialString(credential.value));
320
+ else {
321
+ headers.set("x-opencodex-api-key", credential.value.token);
322
+ headers.set("Origin", credential.value.browserOrigin);
323
+ headers.set("X-OpenCodex-GUI-Origin", credential.value.browserOrigin);
324
+ headers.set("X-OpenCodex-CSRF-Token", credential.value.csrfToken);
325
+ }
326
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/api/keys`, {
327
+ method: "DELETE",
328
+ headers,
329
+ body: JSON.stringify({ id }),
330
+ }, options.timeoutMs);
331
+ if (!response.ok) throw new HubClientError("key_revoke_failed", `Hub refused key revocation (${response.status})`, response.status);
332
+ }
333
+
334
+ function rotationManagementHeaders(
335
+ credential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession },
336
+ ): Headers {
337
+ const headers = new Headers({ "Content-Type": "application/json", Accept: "application/json" });
338
+ if (credential.kind === "admin") headers.set("x-opencodex-api-key", credentialString(credential.value));
339
+ else {
340
+ headers.set("x-opencodex-api-key", credential.value.token);
341
+ headers.set("Origin", credential.value.browserOrigin);
342
+ headers.set("X-OpenCodex-GUI-Origin", credential.value.browserOrigin);
343
+ headers.set("X-OpenCodex-CSRF-Token", credential.value.csrfToken);
344
+ }
345
+ return headers;
346
+ }
347
+
348
+ function assertRotationAuthorityOrigin(origin: string, credential: { kind: "admin" } | { kind: "gui-session" }): void {
349
+ if (credential.kind === "admin" && new URL(origin).protocol !== "https:") {
350
+ throw new HubClientError("admin_http_refused", "Admin credentials may be sent only over HTTPS");
351
+ }
352
+ }
353
+
354
+ export async function startClientKeyRotation(
355
+ managementUrl: string,
356
+ credential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession },
357
+ id: string,
358
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
359
+ ): Promise<StartedClientKeyRotation> {
360
+ const origin = normalizeHubOrigin(managementUrl);
361
+ assertRotationAuthorityOrigin(origin, credential);
362
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/api/keys/rotate`, {
363
+ method: "POST",
364
+ headers: rotationManagementHeaders(credential),
365
+ body: JSON.stringify({ id }),
366
+ }, options.timeoutMs);
367
+ if (!response.ok) throw new HubClientError("key_rotation_start_failed", `Hub refused key rotation (${response.status})`, response.status);
368
+ const value = parseJson(await boundedText(response, MANAGEMENT_BODY_LIMIT), "key_rotation_invalid");
369
+ const raw = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
370
+ const issued = parseIssuedClientKey(value);
371
+ if (!raw || !issued || typeof raw.rotationId !== "string" || !raw.rotationId
372
+ || typeof raw.expiresAt !== "string" || Number.isNaN(Date.parse(raw.expiresAt))) {
373
+ throw new HubClientError("key_rotation_invalid", "Hub returned an invalid key rotation response", response.status);
374
+ }
375
+ return { ...issued, rotationId: raw.rotationId, expiresAt: raw.expiresAt };
376
+ }
377
+
378
+ export async function commitClientKeyRotation(
379
+ managementUrl: string,
380
+ credential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession },
381
+ id: string,
382
+ rotationId: string,
383
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
384
+ ): Promise<void> {
385
+ const origin = normalizeHubOrigin(managementUrl);
386
+ assertRotationAuthorityOrigin(origin, credential);
387
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/api/keys/rotate/commit`, {
388
+ method: "POST",
389
+ headers: rotationManagementHeaders(credential),
390
+ body: JSON.stringify({ id, rotationId }),
391
+ }, options.timeoutMs);
392
+ if (!response.ok) throw new HubClientError("key_rotation_commit_failed", `Hub refused rotation commit (${response.status})`, response.status);
393
+ }
394
+
395
+ export async function abortClientKeyRotation(
396
+ managementUrl: string,
397
+ credential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession },
398
+ id: string,
399
+ rotationId: string,
400
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
401
+ ): Promise<void> {
402
+ const origin = normalizeHubOrigin(managementUrl);
403
+ assertRotationAuthorityOrigin(origin, credential);
404
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/api/keys/rotate`, {
405
+ method: "DELETE",
406
+ headers: rotationManagementHeaders(credential),
407
+ body: JSON.stringify({ id, rotationId }),
408
+ }, options.timeoutMs);
409
+ if (!response.ok) throw new HubClientError("key_rotation_abort_failed", `Hub refused rotation abort (${response.status})`, response.status);
410
+ }
411
+
412
+ export async function downloadClientCatalog(
413
+ serverUrl: string,
414
+ admissionToken: string,
415
+ options: { timeoutMs?: number; maxBytes?: number; fetchImpl?: typeof fetch } = {},
416
+ ): Promise<{ kind: "fresh"; body: string; keyId?: string }> {
417
+ const origin = normalizeHubOrigin(serverUrl);
418
+ const headers = new Headers({ Accept: "application/json", "x-opencodex-api-key": admissionToken });
419
+ // Unconditional by contract: /v1/catalog emits no validator (Phase 1, D2) because its
420
+ // body varies by key identity, so there is nothing to revalidate against and a 304 could
421
+ // only come from a hub that is misconfigured or being impersonated.
422
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/catalog`, {
423
+ method: "GET",
424
+ headers,
425
+ }, options.timeoutMs);
426
+ if (response.status === 304) {
427
+ throw new HubClientError("catalog_unexpected_304", "Hub answered 304 to an unconditional catalog request", 304);
428
+ }
429
+ if (!response.ok) {
430
+ const code = response.status === 401 ? "catalog_unauthorized" : `catalog_http_${response.status}`;
431
+ throw new HubClientError(code, `Hub catalog request failed (${response.status})`, response.status);
432
+ }
433
+ if (!jsonCompatibleContentType(response)) {
434
+ try { await response.body?.cancel(); } catch { /* best effort */ }
435
+ throw new HubClientError("catalog_content_type_invalid", "Hub catalog response was not JSON", response.status);
436
+ }
437
+ const body = await boundedText(response, options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES);
438
+ const parsed = parseJson(body, "catalog_invalid");
439
+ validateRemoteCatalog(parsed);
440
+ const keyId = response.headers.get("x-opencodex-key-id")?.trim() || undefined;
441
+ return { kind: "fresh", body, ...(keyId ? { keyId } : {}) };
442
+ }
443
+
444
+ export async function probeClientKeyId(
445
+ serverUrl: string,
446
+ admissionToken: string,
447
+ expectedKeyId: string,
448
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
449
+ ): Promise<boolean> {
450
+ try {
451
+ const catalog = await downloadClientCatalog(serverUrl, admissionToken, options);
452
+ return catalog.kind === "fresh" && catalog.keyId === expectedKeyId;
453
+ } catch (error) {
454
+ if (error instanceof HubClientError && error.status === 401) return false;
455
+ throw error;
456
+ }
457
+ }