@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
@@ -65,6 +65,12 @@ import type { PersistedUsageAttempt } from "../../usage/log";
65
65
  import { AUTH_MATRIX, isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
66
66
  import { applySystemEnvToggle } from "../system-env";
67
67
  import { buildApiAccessEndpoints } from "./api-access";
68
+ import {
69
+ abortApiKeyRotation,
70
+ commitApiKeyRotation,
71
+ removeExpiredApiKeyRotations,
72
+ startApiKeyRotation,
73
+ } from "./api-key-rotation";
68
74
 
69
75
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
70
76
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
@@ -319,7 +325,16 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
319
325
  // Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown.
320
326
  if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") {
321
327
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
322
- if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400);
328
+ if (provider !== "anthropic") {
329
+ // Generic OAuth pool-settings contract (#695 slice 1): persisted per provider, inert until
330
+ // the selector consumes it. Codex keeps /api/codex-auth; api-key providers have no pool.
331
+ const { poolSettingsCapability, genericPoolSettingsDto } = await import("../../oauth/pool-settings-capability");
332
+ const prov = config.providers[provider];
333
+ if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
334
+ return jsonResponse({ error: "pool config is only supported for anthropic and generic OAuth providers" }, 400);
335
+ }
336
+ return jsonResponse(genericPoolSettingsDto(provider, prov));
337
+ }
323
338
  const pool = config.anthropicAccountPool ?? {};
324
339
  return jsonResponse({
325
340
  provider,
@@ -345,7 +360,43 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
345
360
  quotaWindow?: unknown;
346
361
  };
347
362
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
348
- if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400);
363
+ if (provider !== "anthropic") {
364
+ const {
365
+ poolSettingsCapability, genericPoolSettingsDto, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold,
366
+ } = await import("../../oauth/pool-settings-capability");
367
+ const prov = config.providers[provider];
368
+ if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
369
+ return jsonResponse({ error: "pool config is only supported for anthropic and generic OAuth providers" }, 400);
370
+ }
371
+ if (body.stickyLimit !== undefined || body.quotaWindow !== undefined) {
372
+ return jsonResponse({ error: "stickyLimit and quotaWindow are not part of the generic pool contract yet" }, 400);
373
+ }
374
+ const next = { ...(prov.oauthAccountFailover ?? {}) };
375
+ if (body.enabled !== undefined) {
376
+ if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
377
+ next.enabled = body.enabled;
378
+ }
379
+ if (body.strategy !== undefined) {
380
+ if (body.strategy === null) delete next.strategy;
381
+ else {
382
+ const parsed = parseGenericPoolStrategy(body.strategy);
383
+ if (parsed === null) return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400);
384
+ next.strategy = parsed;
385
+ }
386
+ }
387
+ if (body.autoSwitchThreshold !== undefined) {
388
+ if (body.autoSwitchThreshold === null) delete next.autoSwitchThreshold;
389
+ else {
390
+ const parsed = parseGenericAutoSwitchThreshold(body.autoSwitchThreshold);
391
+ if (parsed === null) return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400);
392
+ next.autoSwitchThreshold = parsed;
393
+ }
394
+ }
395
+ if (Object.keys(next).length > 0) prov.oauthAccountFailover = next;
396
+ else delete prov.oauthAccountFailover;
397
+ saveConfigPreservingClaudeCode(config);
398
+ return jsonResponse({ ok: true, ...genericPoolSettingsDto(provider, prov) });
399
+ }
349
400
  let enabled = config.anthropicAccountPool?.enabled === true;
350
401
  if (body.enabled !== undefined) {
351
402
  if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
@@ -530,6 +581,35 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
530
581
  clearKeyCooldowns(name); // manual key management resets 429 cooldown state
531
582
  return jsonResponse({ ok: true, id: result.id }, 201);
532
583
  }
584
+ // Opt-in OS keychain storage (#1221): move the active key and pool into the OS credential
585
+ // store (config keeps references), or restore plaintext. Store verifies the keychain before
586
+ // touching config so an unavailable store refuses instead of half-migrating.
587
+ if (url.pathname === "/api/providers/keychain" && req.method === "GET") {
588
+ const name = (url.searchParams.get("name") ?? "").trim();
589
+ if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
590
+ const { probeProviderKeychain, providerKeyStoreKind } = await import("../../providers/key-store");
591
+ const probe = probeProviderKeychain();
592
+ return jsonResponse({
593
+ name,
594
+ store: providerKeyStoreKind(config.providers[name]),
595
+ keychainAvailable: probe.available,
596
+ ...(probe.available ? {} : { keychainUnavailableReason: probe.reason }),
597
+ });
598
+ }
599
+ if (url.pathname === "/api/providers/keychain" && req.method === "POST") {
600
+ const body = await readManagementJsonBodyOr(req, {}) as { name?: unknown; action?: unknown };
601
+ const name = typeof body.name === "string" ? body.name.trim() : "";
602
+ if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
603
+ if (body.action !== "store" && body.action !== "restore") return jsonResponse({ error: "action must be store or restore" }, 400);
604
+ const { storeProviderKeyInKeychain, restoreProviderKeyFromKeychain, providerKeyStoreKind } = await import("../../providers/key-store");
605
+ const result = body.action === "store"
606
+ ? storeProviderKeyInKeychain(config, name)
607
+ : restoreProviderKeyFromKeychain(config, name);
608
+ if (!result.ok) return jsonResponse({ error: result.error }, result.status);
609
+ const { clearProviderQuotaCache } = await import("../../providers/quota");
610
+ clearProviderQuotaCache();
611
+ return jsonResponse({ ...result, name, store: providerKeyStoreKind(config.providers[name]) });
612
+ }
533
613
  if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
534
614
  const body = await readManagementJsonBodyOr(req, {}) as { name?: string; id?: string };
535
615
  const name = (body.name ?? "").trim();
@@ -579,6 +659,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
579
659
  // API Keys management
580
660
  // ---------------------------------------------------------------------------
581
661
  if (url.pathname === "/api/keys" && req.method === "GET") {
662
+ if (removeExpiredApiKeyRotations(config)) {
663
+ saveConfigPreservingClaudeCode(config);
664
+ reconcileLiveStateStores();
665
+ }
582
666
  const keys = config.apiKeys ?? [];
583
667
  const endpoints = buildApiAccessEndpoints(config, {
584
668
  requestUrl: req.url,
@@ -596,6 +680,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
596
680
  name: k.name,
597
681
  prefix: k.key.slice(0, 17) + "...",
598
682
  createdAt: k.createdAt,
683
+ ...(k.pendingRotation ? { pendingRotation: {
684
+ id: k.pendingRotation.id,
685
+ createdAt: k.pendingRotation.createdAt,
686
+ expiresAt: k.pendingRotation.expiresAt,
687
+ } } : {}),
599
688
  usage: rollup.get(k.id) ?? { requests7d: 0, totalRequests: 0 },
600
689
  })),
601
690
  // Dataset-level and singular: it describes the usage log, not any one key.
@@ -606,6 +695,50 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
606
695
  }, 200, req, config);
607
696
  }
608
697
 
698
+ if (url.pathname === "/api/keys/rotate" && req.method === "POST") {
699
+ const body = await readJsonBody(req);
700
+ if (!body || Object.keys(body).length !== 1 || typeof body.id !== "string" || !body.id) {
701
+ return jsonResponse({ error: "invalid body" }, 400, req, config);
702
+ }
703
+ const result = startApiKeyRotation(config, body.id);
704
+ if ("error" in result) {
705
+ return jsonResponse({ error: result.error === "not-found" ? "key not found" : "rotation already pending" }, result.error === "not-found" ? 404 : 409, req, config);
706
+ }
707
+ saveConfigPreservingClaudeCode(config);
708
+ reconcileLiveStateStores();
709
+ return jsonResponse(result, 201, req, config);
710
+ }
711
+
712
+ if (url.pathname === "/api/keys/rotate/commit" && req.method === "POST") {
713
+ const body = await readJsonBody(req);
714
+ if (!body || Object.keys(body).length !== 2 || typeof body.id !== "string" || !body.id
715
+ || typeof body.rotationId !== "string" || !body.rotationId) {
716
+ return jsonResponse({ error: "invalid body" }, 400, req, config);
717
+ }
718
+ const result = commitApiKeyRotation(config, body.id, body.rotationId);
719
+ if ("error" in result) {
720
+ if (result.error === "expired") saveConfigPreservingClaudeCode(config);
721
+ return jsonResponse({ error: result.error === "not-found" ? "key rotation not found" : `rotation ${result.error}` }, result.error === "not-found" ? 404 : 409, req, config);
722
+ }
723
+ saveConfigPreservingClaudeCode(config);
724
+ reconcileLiveStateStores();
725
+ return jsonResponse({ ok: true }, 200, req, config);
726
+ }
727
+
728
+ if (url.pathname === "/api/keys/rotate" && req.method === "DELETE") {
729
+ const body = await readJsonBody(req);
730
+ if (!body || Object.keys(body).length !== 2 || typeof body.id !== "string" || !body.id
731
+ || typeof body.rotationId !== "string" || !body.rotationId) {
732
+ return jsonResponse({ error: "invalid body" }, 400, req, config);
733
+ }
734
+ if (!abortApiKeyRotation(config, body.id, body.rotationId)) {
735
+ return jsonResponse({ error: "key rotation not found or mismatched" }, 409, req, config);
736
+ }
737
+ saveConfigPreservingClaudeCode(config);
738
+ reconcileLiveStateStores();
739
+ return jsonResponse({ ok: true }, 200, req, config);
740
+ }
741
+
609
742
  if (url.pathname === "/api/keys" && req.method === "POST") {
610
743
  const body = await readJsonBody(req);
611
744
  if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
@@ -9,6 +9,7 @@ import {
9
9
  codexAutoStartEnabled,
10
10
  hasOwnProvider,
11
11
  isValidProviderName,
12
+ modelDisplayNamesConfigError,
12
13
  multiAgentGuidanceEnabled,
13
14
  nonBlankStringArrayConfigError,
14
15
  normalizeNonBlankStringArray,
@@ -105,6 +106,63 @@ type ProviderPatchApplication =
105
106
  headersTouched: boolean;
106
107
  };
107
108
 
109
+ const PROVIDER_ALIAS_OVERLAY_FIELDS = ["alias", "modelAliases", "defaultAliases"] as const;
110
+ type ProviderAliasOverlayField = typeof PROVIDER_ALIAS_OVERLAY_FIELDS[number];
111
+
112
+ /**
113
+ * Alias overlays are owned by the dedicated alias management routes. A full provider POST
114
+ * may round-trip an already-persisted value, but it must not create, clear, or change one.
115
+ * PATCH is field-masked and rejects these keys outright below.
116
+ */
117
+ function providerAliasOverlayOwnershipError(
118
+ submitted: Record<string, unknown>,
119
+ existing: OcxProviderConfig | undefined,
120
+ ): string | null {
121
+ for (const field of PROVIDER_ALIAS_OVERLAY_FIELDS) {
122
+ if (!Object.hasOwn(submitted, field)) continue;
123
+ if (!existing || !Object.hasOwn(existing, field)) {
124
+ return `${field} is managed by the dedicated alias API`;
125
+ }
126
+ const incoming = submitted[field];
127
+ const persisted = existing[field];
128
+ if (field === "modelAliases") {
129
+ if (!isPlainRecord(incoming) || !isPlainRecord(persisted)) {
130
+ return "modelAliases is managed by the dedicated alias API";
131
+ }
132
+ const incomingEntries = Object.entries(incoming);
133
+ const persistedEntries = Object.entries(persisted);
134
+ if (
135
+ incomingEntries.length !== persistedEntries.length
136
+ || incomingEntries.some(([model, alias]) => typeof alias !== "string" || persisted[model] !== alias)
137
+ ) {
138
+ return "modelAliases is managed by the dedicated alias API";
139
+ }
140
+ continue;
141
+ }
142
+ if (incoming !== persisted) return `${field} is managed by the dedicated alias API`;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** Remove only alias overlays whose ownership has already been established by the caller. */
148
+ function providerTransportValidationCandidate(provider: Record<string, unknown>): Record<string, unknown> {
149
+ const candidate = { ...provider };
150
+ for (const field of PROVIDER_ALIAS_OVERLAY_FIELDS) delete candidate[field];
151
+ return candidate;
152
+ }
153
+
154
+ /** Preserve the authoritative alias values from the stored provider during a full edit. */
155
+ function restorePersistedAliasOverlays(target: OcxProviderConfig, existing: OcxProviderConfig | undefined): void {
156
+ for (const field of PROVIDER_ALIAS_OVERLAY_FIELDS) {
157
+ delete (target as Record<ProviderAliasOverlayField, unknown>)[field];
158
+ if (!existing || !Object.hasOwn(existing, field)) continue;
159
+ const value = existing[field];
160
+ (target as Record<ProviderAliasOverlayField, unknown>)[field] = field === "modelAliases"
161
+ ? structuredClone(value)
162
+ : value;
163
+ }
164
+ }
165
+
108
166
  /**
109
167
  * Apply the recognized PATCH field mask onto a provider copy. The caller runs this once
110
168
  * for validation and again inside the config mutation lock against the newest provider,
@@ -241,6 +299,11 @@ function applyProviderPatchFields(
241
299
  }
242
300
  touched = true;
243
301
  }
302
+ if (Object.hasOwn(rawBody, "upstreamWebsocket")) {
303
+ if (typeof rawBody.upstreamWebsocket !== "boolean") return { error: "upstreamWebsocket must be a boolean" };
304
+ next.upstreamWebsocket = rawBody.upstreamWebsocket;
305
+ touched = true;
306
+ }
244
307
  // The Models page edits the catalog hints in place; keep them on the existing
245
308
  // provider mutation path so validation, cache invalidation, and convergence stay unified (#1073).
246
309
  if (Object.hasOwn(rawBody, "contextWindow")) {
@@ -339,6 +402,32 @@ function applyProviderPatchFields(
339
402
  }
340
403
  touched = true;
341
404
  }
405
+ if (Object.hasOwn(rawBody, "retainModels")) {
406
+ const value = rawBody.retainModels;
407
+ if (value === null) {
408
+ delete next.retainModels;
409
+ } else {
410
+ const error = nonBlankStringArrayConfigError(value, "retainModels");
411
+ if (error) return { error };
412
+ const models = normalizeNonBlankStringArray(value as string[]);
413
+ if (models.length > 0) next.retainModels = models;
414
+ else delete next.retainModels;
415
+ }
416
+ touched = true;
417
+ }
418
+ if (Object.hasOwn(rawBody, "omitReasoningEffortWithToolsModels")) {
419
+ const value = rawBody.omitReasoningEffortWithToolsModels;
420
+ if (value === null) {
421
+ delete next.omitReasoningEffortWithToolsModels;
422
+ } else {
423
+ const error = nonBlankStringArrayConfigError(value, "omitReasoningEffortWithToolsModels");
424
+ if (error) return { error };
425
+ const models = normalizeNonBlankStringArray(value as string[]);
426
+ if (models.length > 0) next.omitReasoningEffortWithToolsModels = models;
427
+ else delete next.omitReasoningEffortWithToolsModels;
428
+ }
429
+ touched = true;
430
+ }
342
431
 
343
432
  // headers is the one object-valued field in the mask. PATCH semantics merge it
344
433
  // shallowly into the existing block so a single fingerprint header can be added
@@ -468,7 +557,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
468
557
  modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
469
558
  modelSupportsServiceTier: p.modelSupportsServiceTier,
470
559
  noStructuredOutputModels: p.noStructuredOutputModels,
560
+ retainModels: p.retainModels,
561
+ omitReasoningEffortWithToolsModels: p.omitReasoningEffortWithToolsModels,
471
562
  upstreamHttpVersion: p.upstreamHttpVersion,
563
+ upstreamWebsocket: p.upstreamWebsocket === true,
472
564
  authMode: p.authMode,
473
565
  apiKeyTransport: p.apiKeyTransport,
474
566
  disabled: p.disabled === true,
@@ -501,7 +593,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
501
593
  return jsonResponse({ error: "provider reload target unavailable" }, 404);
502
594
  }
503
595
  const provider = diskConfig.providers[name]!;
504
- const providerError = providerManagementConfigError(name, provider)
596
+ const providerError = providerManagementConfigError(
597
+ name,
598
+ providerTransportValidationCandidate(provider as unknown as Record<string, unknown>),
599
+ )
505
600
  ?? providerEmptyToolOutputConfigError(name, provider);
506
601
  if (providerError) return jsonResponse({ error: "provider reload target invalid" }, 409);
507
602
  const namespaceCollision = codexAccountNamespaceProviderCollisionError(
@@ -561,12 +656,21 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
561
656
  let body: { name?: unknown; provider?: unknown; setDefault?: boolean };
562
657
  try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
563
658
  const name = typeof body.name === "string" ? body.name.trim() : "";
564
- const providerError = providerManagementConfigError(name, body.provider)
565
- ?? providerEmptyToolOutputConfigError(name, body.provider);
659
+ if (!isPlainRecord(body.provider)) return jsonResponse({ error: "provider must be a plain object" }, 400);
660
+ const existing = config.providers[name];
661
+ const aliasOwnershipError = providerAliasOverlayOwnershipError(body.provider, existing);
662
+ if (aliasOwnershipError) return jsonResponse({ error: aliasOwnershipError }, 400);
663
+ const transportCandidate = providerTransportValidationCandidate(body.provider);
664
+ const providerError = providerManagementConfigError(name, transportCandidate)
665
+ ?? providerEmptyToolOutputConfigError(name, transportCandidate);
566
666
  if (providerError) return jsonResponse({ error: providerError }, 400);
567
- const serviceTierError = providerServiceTierConfigError(name, body.provider);
667
+ const rawProvider = body.provider as Record<string, unknown>;
668
+ if (rawProvider.upstreamWebsocket !== undefined && typeof rawProvider.upstreamWebsocket !== "boolean") {
669
+ return jsonResponse({ error: "upstreamWebsocket must be a boolean" }, 400);
670
+ }
671
+ const serviceTierError = providerServiceTierConfigError(name, transportCandidate);
568
672
  if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
569
- const prov = body.provider ? stripCodexRuntimeProviderFields(body.provider as OcxProviderConfig) : undefined;
673
+ const prov = stripCodexRuntimeProviderFields(transportCandidate as unknown as OcxProviderConfig);
570
674
  // PATCH already clears on null; POST persisted the body as submitted, so a `null` here
571
675
  // reached disk and the next loadConfig() refused it. Canonicalize to absent, which is what
572
676
  // "clear" means everywhere else.
@@ -574,6 +678,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
574
678
  if (!name || !prov?.adapter || !prov?.baseUrl) {
575
679
  return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
576
680
  }
681
+ const displayNamesError = modelDisplayNamesConfigError(prov.modelDisplayNames);
682
+ if (displayNamesError) return jsonResponse({ error: displayNamesError }, 400);
577
683
  if (!isValidProviderName(name)) {
578
684
  return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
579
685
  }
@@ -603,7 +709,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
603
709
  const submittedContextWindow = Object.hasOwn(prov, "contextWindow");
604
710
  const submittedModelContextWindows = Object.hasOwn(prov, "modelContextWindows");
605
711
  const submittedModelAutoCompactTokenLimits = Object.hasOwn(prov, "modelAutoCompactTokenLimits");
712
+ const submittedModelDisplayNames = Object.hasOwn(prov, "modelDisplayNames");
606
713
  const submittedRequestPacing = Object.hasOwn(prov, "requestPacing");
714
+ const submittedUpstreamWebsocket = Object.hasOwn(prov, "upstreamWebsocket");
607
715
  // Same trap, one more field: DeepSeek carries a registry default of `true` for
608
716
  // annotateEmptyToolOutputs, so enrichment cannot distinguish "the client omitted it"
609
717
  // from "the registry supplied it" either. Without this sample, an unrelated edit that
@@ -630,7 +738,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
630
738
  // has no member for either field, so the add/edit form structurally cannot send them:
631
739
  // absence in the request means "not carried", never "the user deleted it". Deletion goes
632
740
  // through PATCH with an explicit null (#1409).
633
- const existing = config.providers[name];
741
+ if (!submittedModelDisplayNames && existing?.modelDisplayNames) {
742
+ prov.modelDisplayNames = { ...existing.modelDisplayNames };
743
+ }
634
744
  if (!submittedRequestPacing && existing?.requestPacing) {
635
745
  prov.requestPacing = structuredClone(existing.requestPacing);
636
746
  }
@@ -642,6 +752,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
642
752
  if (!submittedAnnotateEmptyToolOutputs && existing?.annotateEmptyToolOutputs !== undefined) {
643
753
  prov.annotateEmptyToolOutputs = existing.annotateEmptyToolOutputs;
644
754
  }
755
+ // The provider add/edit form may omit this transport option. Preserve the stored value
756
+ // during a full overwrite; PATCH remains the explicit mutation path, and `!== undefined`
757
+ // keeps an operator's explicit false from being treated as absent.
758
+ if (!submittedUpstreamWebsocket && existing?.upstreamWebsocket !== undefined) {
759
+ prov.upstreamWebsocket = existing.upstreamWebsocket;
760
+ }
645
761
  if (existing?.modelContextWindows) {
646
762
  // When the client did send a map, its keys win and the user's other keys survive. When
647
763
  // it did not, the stored value is the user's map alone: merging the registry seed in
@@ -656,6 +772,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
656
772
  ? { ...existing.modelAutoCompactTokenLimits, ...(prov.modelAutoCompactTokenLimits ?? {}) }
657
773
  : { ...existing.modelAutoCompactTokenLimits };
658
774
  }
775
+ // DNS validation above awaits. Re-read the live row so a dedicated alias write that
776
+ // completed during that wait remains authoritative instead of being overwritten by the
777
+ // older ownership snapshot used to admit this POST.
778
+ restorePersistedAliasOverlays(prov, config.providers[name]);
659
779
  config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
660
780
  if (body.setDefault === true) config.defaultProvider = name;
661
781
  save(config);
@@ -677,6 +797,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
677
797
  try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
678
798
  if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider patch body must be a plain object" }, 400);
679
799
  const keys = Object.keys(rawBody);
800
+ const aliasField = PROVIDER_ALIAS_OVERLAY_FIELDS.find(field => Object.hasOwn(rawBody, field));
801
+ if (aliasField) return jsonResponse({ error: `${aliasField} is managed by the dedicated alias API` }, 400);
680
802
  const hasMode = Object.hasOwn(rawBody, "codexAccountMode");
681
803
  const hasSetDefault = Object.hasOwn(rawBody, "setDefault");
682
804
  const canonicalBudgetOnly = name === "openai"
@@ -746,13 +868,22 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
746
868
  if (applied.editorTouched && !pacingOnly) {
747
869
  const providerError = canonicalBudgetOnly
748
870
  ? canonicalOpenAiBudgetPatchError(next, rawBody, keys, config)
749
- : providerManagementConfigError(name, next)
871
+ : providerManagementConfigError(
872
+ name,
873
+ providerTransportValidationCandidate(next as unknown as Record<string, unknown>),
874
+ )
750
875
  ?? providerEmptyToolOutputConfigError(name, next);
751
876
  if (providerError) return jsonResponse({ error: providerError }, 400);
752
877
  if (!canonicalBudgetOnly) {
753
878
  const serviceTierError = providerServiceTierConfigError(name, next);
754
879
  if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
755
- const resolvedError = await providerDestinationResolvedError(name, next);
880
+ // Same DNS gate as POST and re-enable: the canonical built-in OpenAI forward
881
+ // provider may resolve through Clash/Mihomo fake-IP DNS (198.18.0.0/15), so the
882
+ // ordinary PATCH must not reject the very same destination the provider was
883
+ // created with. Loopback, RFC1918, metadata, and mixed dangerous answers still
884
+ // fail closed; nothing else gains the exception.
885
+ const allowBenchmarkAddresses = name === "openai" && isCanonicalOpenAiForwardProvider(next);
886
+ const resolvedError = await providerDestinationResolvedError(name, next, { allowBenchmarkAddresses });
756
887
  if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
757
888
  }
758
889
  } else if (applied.enablingOpenAi) {
@@ -780,7 +911,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
780
911
  if (replay.editorTouched && !pacingOnly) {
781
912
  const syncError = canonicalBudgetOnly
782
913
  ? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys, config)
783
- : providerManagementConfigError(name, replay.next)
914
+ : providerManagementConfigError(
915
+ name,
916
+ providerTransportValidationCandidate(replay.next as unknown as Record<string, unknown>),
917
+ )
784
918
  ?? providerEmptyToolOutputConfigError(name, replay.next);
785
919
  if (syncError) {
786
920
  replayError = syncError;
@@ -230,8 +230,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
230
230
  { method: "PUT", path: "/api/native-integrations/claude-desktop", module: "server/management/native-integration-routes", mutates: true },
231
231
  { method: "PUT", path: "/api/native-integrations/codex", module: "server/management/native-integration-routes", mutates: true },
232
232
  { method: "PUT", path: "/api/native-integrations/grok", module: "server/management/native-integration-routes", mutates: true },
233
+ // server/management/cursor-integration-routes
234
+ { method: "GET", path: "/api/native-integrations/cursor", module: "server/management/cursor-integration-routes", mutates: false },
233
235
  // server/management/oauth-account-routes
234
236
  { method: "DELETE", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: true },
237
+ { method: "DELETE", path: "/api/keys/rotate", module: "server/management/oauth-account-routes", mutates: true },
235
238
  { method: "DELETE", path: "/api/oauth/accounts", module: "server/management/oauth-account-routes", mutates: true },
236
239
  { method: "DELETE", path: "/api/providers/keys", module: "server/management/oauth-account-routes", mutates: true },
237
240
  { method: "GET", path: "/api/key-providers", module: "server/management/oauth-account-routes", mutates: false },
@@ -241,9 +244,13 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
241
244
  { method: "GET", path: "/api/oauth/providers", module: "server/management/oauth-account-routes", mutates: false },
242
245
  { method: "GET", path: "/api/oauth/status", module: "server/management/oauth-account-routes", mutates: false },
243
246
  { method: "GET", path: "/api/providers/keys", module: "server/management/oauth-account-routes", mutates: false },
247
+ { method: "GET", path: "/api/providers/keychain", module: "server/management/oauth-account-routes", mutates: false },
248
+ { method: "POST", path: "/api/providers/keychain", module: "server/management/oauth-account-routes", mutates: true },
244
249
  { method: "PATCH", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: true },
245
250
  { method: "PATCH", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true },
246
251
  { method: "POST", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: true },
252
+ { method: "POST", path: "/api/keys/rotate", module: "server/management/oauth-account-routes", mutates: true },
253
+ { method: "POST", path: "/api/keys/rotate/commit", module: "server/management/oauth-account-routes", mutates: true },
247
254
  { method: "POST", path: "/api/oauth/accounts/clear-cooldown", module: "server/management/oauth-account-routes", mutates: true },
248
255
  { method: "POST", path: "/api/oauth/accounts/import", module: "server/management/oauth-account-routes", mutates: true },
249
256
  { method: "POST", path: "/api/oauth/login", module: "server/management/oauth-account-routes", mutates: true },
@@ -275,6 +282,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
275
282
  { method: "GET", path: "/api/routing-profiles", module: "server/management/routing-profile-routes", mutates: false },
276
283
  { method: "POST", path: "/api/routing-profiles/dry-run", module: "server/management/routing-profile-routes", mutates: true },
277
284
  { method: "PUT", path: "/api/routing-profiles", module: "server/management/routing-profile-routes", mutates: true },
285
+ // server/management/session-routes
286
+ { method: "POST", path: "/api/session/logout", module: "server/management/session-routes", mutates: true, exempt: { reason: "session-only", why: "Logs out the CURRENT gui-session and requires its own Origin and CSRF. There is nothing for a CLI verb to log out of: the CLI holds an admin token, and the admin token is refused here precisely so it cannot end a consent session it never established." } },
278
287
  // server/management/sidebar-routes
279
288
  { method: "GET", path: "/api/github/star", module: "server/management/sidebar-routes", mutates: false },
280
289
  { method: "GET", path: "/api/update/badge", module: "server/management/sidebar-routes", mutates: false },
@@ -286,6 +295,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
286
295
  { method: "POST", path: "/api/storage/codex-logs/repair", module: "server/management/storage-log-guard-routes", mutates: true },
287
296
  { method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true },
288
297
  // server/management/system-routes
298
+ { method: "GET", path: "/api/system/health", module: "server/management/system-routes", mutates: false },
289
299
  { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false },
290
300
  { method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false },
291
301
  { method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true },
@@ -0,0 +1,13 @@
1
+ import { jsonResponse } from "../auth-cors";
2
+ import type { ManagementContext } from "./context";
3
+
4
+ export function handleSessionRoutes(ctx: ManagementContext): Response | null {
5
+ if (ctx.url.pathname !== "/api/session/logout" || ctx.req.method !== "POST") return null;
6
+ if (ctx.principal !== "gui-session") {
7
+ return jsonResponse({ error: "GUI session required" }, 403, ctx.req, ctx.config);
8
+ }
9
+ if (!ctx.sessionControl?.revokeCurrent(ctx.req)) {
10
+ return jsonResponse({ error: "GUI session not found" }, 401, ctx.req, ctx.config);
11
+ }
12
+ return jsonResponse({ ok: true }, 200, ctx.req, ctx.config);
13
+ }
@@ -48,7 +48,19 @@ import { acceptSystemRestart } from "./system-restart";
48
48
  const ENDPOINT_SAMPLE_LIMIT = 60;
49
49
 
50
50
  export async function handleSystemRoutes(ctx: ManagementContext): Promise<Response | null> {
51
- const { req, url, config } = ctx;
51
+ const { req, url, config, version } = ctx;
52
+ if (url.pathname === "/api/system/health" && req.method === "GET") {
53
+ // Authenticated management counterpart to /healthz. Remote Hub deliberately keeps the
54
+ // unauthenticated liveness route off its management ingress, while the connected dashboard
55
+ // still needs bounded process identity and PID replacement evidence (#3158).
56
+ return jsonResponse({
57
+ status: "ok",
58
+ service: "opencodex",
59
+ version,
60
+ uptime: process.uptime(),
61
+ pid: process.pid,
62
+ });
63
+ }
52
64
  if (url.pathname === "/api/system/memory" && req.method === "GET") {
53
65
  const usage = process.memoryUsage();
54
66
  let jscHeap: { heapSize: number; heapCapacity: number; objectCount: number } | null = null;
@@ -72,14 +72,16 @@ import { handleSidebarRoutes } from "./management/sidebar-routes";
72
72
  import { handleCodexPromptRoutes } from "./management/codex-prompt-routes";
73
73
  import { handleIntegrationRoutes } from "./management/integration-routes";
74
74
  import { handleNativeIntegrationRoutes } from "./management/native-integration-routes";
75
+ import { handleCursorIntegrationRoutes } from "./management/cursor-integration-routes";
75
76
  import type { ManagementContext } from "./management/context";
76
- import type { ManagementPrincipal } from "./management-auth";
77
+ import type { ManagementPrincipal, ManagementSessionControl } from "./management-auth";
77
78
  export type { ManagementApiDeps } from "./management/context";
78
79
  import { fetchAllModels } from "./management/shared";
79
80
  import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
80
81
  import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-types";
81
82
  import { normalizeCatalogDisposition } from "../codex/catalog-refresh-status";
82
83
  import { managementBodyTooLargeResponse } from "./management/body";
84
+ import { handleSessionRoutes } from "./management/session-routes";
83
85
 
84
86
  // installed npm version instead of a stale hardcode.
85
87
  export const VERSION = (() => {
@@ -136,6 +138,7 @@ export async function handleManagementAPI(
136
138
  config: OcxConfig,
137
139
  deps: ManagementApiDeps = {},
138
140
  principal?: ManagementPrincipal,
141
+ sessionControl?: ManagementSessionControl,
139
142
  ): Promise<Response | null> {
140
143
  if (!isAllowedManagementOrigin(req, config)) {
141
144
  return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
@@ -218,10 +221,11 @@ export async function handleManagementAPI(
218
221
  }
219
222
  } catch { /* best-effort */ }
220
223
  }
221
- const ctx: ManagementContext = { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort };
224
+ const ctx: ManagementContext = { req, url, config, deps, version: VERSION, principal, sessionControl, convergeCodexCatalog, syncClaudeAgentDefsBestEffort };
222
225
  let routed: Response | null;
223
226
  try {
224
- routed = (await handleConfigRoutes(ctx))
227
+ routed = handleSessionRoutes(ctx)
228
+ ?? (await handleConfigRoutes(ctx))
225
229
  ?? (await handleStorageLogGuardRoutes(ctx))
226
230
  ?? (await handleLogsUsageRoutes(ctx))
227
231
  ?? (await handleRequestHistoryRoutes(ctx))
@@ -231,6 +235,7 @@ export async function handleManagementAPI(
231
235
  ?? (await handleModelRoutes(ctx))
232
236
  ?? (await handleIntegrationRoutes(ctx))
233
237
  ?? (await handleNativeIntegrationRoutes(ctx))
238
+ ?? (await handleCursorIntegrationRoutes(ctx))
234
239
  ?? (await handleAgentSettingsRoutes(ctx))
235
240
  ?? (await handleCodexPromptRoutes(ctx))
236
241
  ?? (await handleOauthAccountRoutes(ctx))