@bitkyc08/opencodex 2.28.0 → 2.29.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 (90) hide show
  1. package/README.md +9 -1
  2. package/gui/dist/assets/index-BNESwCzn.js +102 -0
  3. package/gui/dist/assets/index-CH7ncHCC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +3 -1
  7. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  8. package/src/adapters/cursor/discovery.ts +25 -0
  9. package/src/adapters/cursor/live-transport.ts +19 -2
  10. package/src/adapters/cursor/native-exec.ts +47 -2
  11. package/src/adapters/cursor/protobuf-request.ts +80 -23
  12. package/src/adapters/cursor/request-builder.ts +119 -5
  13. package/src/adapters/cursor/transport.ts +5 -0
  14. package/src/adapters/cursor/types.ts +13 -0
  15. package/src/adapters/cursor.ts +109 -3
  16. package/src/adapters/google-antigravity-replay.ts +31 -4
  17. package/src/adapters/google.ts +323 -38
  18. package/src/adapters/openai-chat.ts +19 -0
  19. package/src/adapters/openai-responses.ts +218 -37
  20. package/src/bridge.ts +38 -20
  21. package/src/claude/desktop-3p.ts +15 -6
  22. package/src/cli/agent.ts +44 -1
  23. package/src/cli/claude-agent-startup-sync.ts +73 -0
  24. package/src/cli/dispatch.ts +24 -1
  25. package/src/cli/ensure-desired-integrations.ts +152 -0
  26. package/src/cli/help.ts +1 -1
  27. package/src/cli/index.ts +40 -38
  28. package/src/cli/integrations.ts +1 -1
  29. package/src/cli/registry.ts +2 -2
  30. package/src/clients/config-export.ts +119 -20
  31. package/src/codex/affinity-debug.ts +162 -0
  32. package/src/codex/inject.ts +46 -14
  33. package/src/codex/journal.ts +22 -8
  34. package/src/config.ts +1 -0
  35. package/src/generated/compatibility-version.json +134 -66
  36. package/src/integrations/mutation-flight.ts +71 -0
  37. package/src/integrations/owned-refresh.ts +74 -0
  38. package/src/integrations/registry.ts +25 -2
  39. package/src/integrations/writer.ts +32 -1
  40. package/src/lab/public/signature.ts +25 -1
  41. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  42. package/src/lib/redact.ts +2 -2
  43. package/src/oauth/log.ts +3 -1
  44. package/src/providers/derive.ts +9 -0
  45. package/src/providers/fastwire.ts +24 -18
  46. package/src/providers/openai-tiers.ts +60 -1
  47. package/src/providers/registry.ts +14 -7
  48. package/src/providers/xai-responses-opt-in.ts +15 -0
  49. package/src/responses/compaction.ts +18 -0
  50. package/src/responses/custom-tool-compat.ts +70 -5
  51. package/src/responses/namespace-tool-compat.ts +356 -0
  52. package/src/responses/parser.ts +2 -2
  53. package/src/responses/provider-continuation.ts +98 -0
  54. package/src/responses/reasoning-replay-cache.ts +125 -7
  55. package/src/responses/spill-store.ts +6 -1
  56. package/src/responses/state.ts +11 -0
  57. package/src/router.ts +14 -0
  58. package/src/routing/compatibility/behavior.ts +1 -0
  59. package/src/server/auth-cors.ts +4 -0
  60. package/src/server/management/agent-settings-routes.ts +57 -9
  61. package/src/server/management/config-routes.ts +134 -15
  62. package/src/server/management/integration-routes.ts +8 -55
  63. package/src/server/management/model-routes.ts +23 -1
  64. package/src/server/management/provider-routes.ts +22 -0
  65. package/src/server/management/vision-sidecar-options.ts +18 -11
  66. package/src/server/management/web-search-sidecar-options.ts +120 -0
  67. package/src/server/responses/core.ts +588 -78
  68. package/src/server/responses/responses-field-backfill.ts +7 -11
  69. package/src/server/responses/terminal-guard.ts +22 -11
  70. package/src/server/responses-custom-tool-repair.ts +3 -1
  71. package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
  72. package/src/server/responses-tool-search-repair.ts +64 -14
  73. package/src/sidecar/auth.ts +92 -0
  74. package/src/sidecar/candidates.ts +83 -0
  75. package/src/types/config.ts +26 -5
  76. package/src/types/provider.ts +18 -0
  77. package/src/types/request.ts +26 -0
  78. package/src/types.ts +1 -0
  79. package/src/usage/log.ts +2 -0
  80. package/src/vision/index.ts +8 -9
  81. package/src/web-search/backends.ts +108 -0
  82. package/src/web-search/exa-executor.ts +88 -0
  83. package/src/web-search/gemini-executor.ts +141 -0
  84. package/src/web-search/index.ts +139 -12
  85. package/src/web-search/loop.ts +45 -5
  86. package/src/web-search/parse.ts +34 -23
  87. package/src/web-search/sources.ts +60 -0
  88. package/src/web-search/xai-executor.ts +219 -0
  89. package/gui/dist/assets/index-D2sP-biU.js +0 -102
  90. package/gui/dist/assets/index-DQsMZzI5.css +0 -1
@@ -52,8 +52,16 @@ interface CacheEntry {
52
52
  at: number;
53
53
  }
54
54
 
55
+ interface ServingIdentityEntry {
56
+ identity: string;
57
+ bytes: number;
58
+ at: number;
59
+ }
60
+
55
61
  const entries = new Map<string, CacheEntry>();
62
+ const servingIdentities = new Map<string, ServingIdentityEntry>();
56
63
  let totalBytes = 0;
64
+ let servingIdentityTotalBytes = 0;
57
65
  let clockForTests: (() => number) | null = null;
58
66
 
59
67
  const now = (): number => clockForTests?.() ?? Date.now();
@@ -62,28 +70,136 @@ function nonEmpty(value: unknown): value is string {
62
70
  return typeof value === "string" && value.trim().length > 0;
63
71
  }
64
72
 
65
- function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined {
66
- const identity = scope?.current;
73
+ type ReasoningReplayIdentityTuple = readonly [string, string, string, string, string];
74
+
75
+ function tupleForIdentity(
76
+ identity: Readonly<OcxReasoningReplayIdentity> | undefined,
77
+ ): ReasoningReplayIdentityTuple | undefined {
67
78
  if (
68
- !nonEmpty(callId)
69
- || !nonEmpty(scope?.clientThreadId)
70
- || !nonEmpty(identity?.providerName)
79
+ !nonEmpty(identity?.providerName)
71
80
  || !nonEmpty(identity?.providerDestinationIdentity)
72
81
  || !nonEmpty(identity?.adapterName)
73
82
  || !nonEmpty(identity?.modelId)
74
83
  || !nonEmpty(identity?.credentialIdentity)
75
84
  ) return undefined;
76
- return JSON.stringify([
77
- scope.clientThreadId,
85
+ return [
78
86
  identity.providerName,
79
87
  identity.providerDestinationIdentity,
80
88
  identity.adapterName,
81
89
  identity.modelId,
82
90
  identity.credentialIdentity,
91
+ ];
92
+ }
93
+
94
+ function tupleForServingIdentity(
95
+ identity: Readonly<OcxReasoningReplayIdentity> | undefined,
96
+ ): ReasoningReplayIdentityTuple | undefined {
97
+ if (
98
+ !nonEmpty(identity?.providerName)
99
+ || !nonEmpty(identity?.providerDestinationDurableIdentity)
100
+ || !nonEmpty(identity?.adapterName)
101
+ || !nonEmpty(identity?.modelId)
102
+ || !nonEmpty(identity?.credentialDurableIdentity)
103
+ ) return undefined;
104
+ return [
105
+ identity.providerName,
106
+ identity.providerDestinationDurableIdentity,
107
+ identity.adapterName,
108
+ identity.modelId,
109
+ identity.credentialDurableIdentity,
110
+ ];
111
+ }
112
+
113
+ function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined {
114
+ const identity = tupleForIdentity(scope?.current);
115
+ if (!nonEmpty(callId) || !nonEmpty(scope?.clientThreadId) || !identity) return undefined;
116
+ return JSON.stringify([
117
+ scope.clientThreadId,
118
+ ...identity,
83
119
  callId,
84
120
  ]);
85
121
  }
86
122
 
123
+ function deleteServingIdentity(threadId: string): void {
124
+ const entry = servingIdentities.get(threadId);
125
+ if (!entry) return;
126
+ servingIdentities.delete(threadId);
127
+ servingIdentityTotalBytes -= entry.bytes;
128
+ }
129
+
130
+ function sweepExpiredServingIdentities(at: number): void {
131
+ for (const [threadId, entry] of servingIdentities) {
132
+ if (at - entry.at >= TTL_MS) deleteServingIdentity(threadId);
133
+ }
134
+ }
135
+
136
+ function servingIdentityFor(
137
+ scope: OcxReasoningReplayScopeRef | undefined,
138
+ ): { threadId: string; identity: string } | undefined {
139
+ const threadId = scope?.clientThreadId;
140
+ const identityTuple = tupleForServingIdentity(scope?.current);
141
+ if (!nonEmpty(threadId) || !identityTuple) return undefined;
142
+ return { threadId, identity: JSON.stringify(identityTuple) };
143
+ }
144
+
145
+ /**
146
+ * Compare this request's route with the last successfully serving route for its client thread.
147
+ * A live mismatch means replayed opaque reasoning was minted by another backend and must not be
148
+ * forwarded to this one. Comparison deliberately does not refresh or replace the recorded route:
149
+ * a failed candidate request did not serve the thread.
150
+ *
151
+ * Serving provenance uses restart-stable destination and credential dimensions so token
152
+ * generations and other volatile credential material cannot create false route changes. Missing
153
+ * durable identity, expired, or evicted state is deliberately unknown rather than a mismatch.
154
+ * This store is process-local, so a backend switch spanning a proxy restart is not detected.
155
+ */
156
+ export function reasoningReplayServingIdentityChanged(
157
+ scope: OcxReasoningReplayScopeRef | undefined,
158
+ ): boolean {
159
+ const current = servingIdentityFor(scope);
160
+ if (!current) return false;
161
+ const at = now();
162
+ sweepExpiredServingIdentities(at);
163
+ const previous = servingIdentities.get(current.threadId);
164
+ return previous !== undefined && previous.identity !== current.identity;
165
+ }
166
+
167
+ /** Record the route only after it has successfully served the client thread. */
168
+ export function commitReasoningReplayServingIdentity(
169
+ scope: OcxReasoningReplayScopeRef | undefined,
170
+ ): void {
171
+ const current = servingIdentityFor(scope);
172
+ if (!current) return;
173
+ const at = now();
174
+ sweepExpiredServingIdentities(at);
175
+ const previous = servingIdentities.get(current.threadId);
176
+ const { threadId, identity } = current;
177
+ const bytes = Buffer.byteLength(JSON.stringify([threadId, identity]), "utf8");
178
+ if (bytes > MAX_TOTAL_BYTES) {
179
+ deleteServingIdentity(threadId);
180
+ return;
181
+ }
182
+
183
+ if (previous) deleteServingIdentity(threadId);
184
+ servingIdentities.set(threadId, { identity, bytes, at });
185
+ servingIdentityTotalBytes += bytes;
186
+ while (
187
+ (servingIdentityTotalBytes > MAX_TOTAL_BYTES || servingIdentities.size > MAX_ENTRIES)
188
+ && servingIdentities.size > 1
189
+ ) {
190
+ let oldestThreadId: string | undefined;
191
+ let oldestAt = Infinity;
192
+ for (const [candidateThreadId, entry] of servingIdentities) {
193
+ if (entry.at < oldestAt) {
194
+ oldestAt = entry.at;
195
+ oldestThreadId = candidateThreadId;
196
+ }
197
+ }
198
+ if (oldestThreadId === undefined) break;
199
+ deleteServingIdentity(oldestThreadId);
200
+ }
201
+ }
202
+
87
203
  function processLocalIdentity(domain: string, material: string): string {
88
204
  return createHmac("sha256", replayIdentityKey)
89
205
  .update(domain)
@@ -303,6 +419,8 @@ export function peekReasoningForCall(
303
419
  /** Test-only: reset the cache and optionally pin the clock. */
304
420
  export function clearReasoningReplayCacheForTests(clock?: (() => number) | null): void {
305
421
  entries.clear();
422
+ servingIdentities.clear();
306
423
  totalBytes = 0;
424
+ servingIdentityTotalBytes = 0;
307
425
  clockForTests = clock ?? null;
308
426
  }
@@ -18,6 +18,7 @@ import { createHash, randomBytes } from "node:crypto";
18
18
  import { join } from "node:path";
19
19
  import { getConfigDir } from "../config";
20
20
  import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
21
+ import { isValidProviderContinuationOwner } from "./provider-continuation";
21
22
  import type { OcxProviderContinuationState } from "../types";
22
23
 
23
24
  export const RESPONSE_SPILL_VERSION = 1;
@@ -287,7 +288,11 @@ function validPayload(value: unknown, responseId: string): value is ResponseSpil
287
288
  }
288
289
  if (payload.providers !== undefined) {
289
290
  if (!payload.providers || typeof payload.providers !== "object" || Array.isArray(payload.providers)) return false;
290
- for (const providerState of Object.values(payload.providers)) {
291
+ const providers = payload.providers as Record<string, unknown>;
292
+ if (providers.__ocxOwner !== undefined
293
+ && !isValidProviderContinuationOwner(providers.__ocxOwner)) return false;
294
+ for (const [provider, providerState] of Object.entries(providers)) {
295
+ if (provider === "__ocxOwner") continue;
291
296
  if (!providerState || typeof providerState !== "object" || Array.isArray(providerState)) return false;
292
297
  }
293
298
  }
@@ -1216,6 +1216,17 @@ export function previousResponseReplayPrefixLength(body: unknown): number {
1216
1216
  return replayedInputPrefixLengths.get(body) ?? 0;
1217
1217
  }
1218
1218
 
1219
+ /** Copy proxy-private replay provenance to an internal clone with the same materialized input. */
1220
+ export function copyPreviousResponseReplayProvenance(source: unknown, target: unknown): void {
1221
+ if (!source || typeof source !== "object" || Array.isArray(source)) return;
1222
+ if (!target || typeof target !== "object" || Array.isArray(target)) return;
1223
+ const prefixLength = replayedInputPrefixLengths.get(source);
1224
+ if (!prefixLength) return;
1225
+ const input = (target as { input?: unknown }).input;
1226
+ if (!Array.isArray(input) || prefixLength > input.length) return;
1227
+ replayedInputPrefixLengths.set(target, prefixLength);
1228
+ }
1229
+
1219
1230
  /** True when a stale or foreign previous_response_id was removed from this exact request body. */
1220
1231
  export function previousResponseScopeMismatch(body: unknown): boolean {
1221
1232
  return !!body && typeof body === "object" && replayScopeMismatches.has(body as object);
package/src/router.ts CHANGED
@@ -355,6 +355,17 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
355
355
  ...(provider.supportsServiceTier === undefined && registryEntry.supportsServiceTier !== undefined
356
356
  ? { supportsServiceTier: registryEntry.supportsServiceTier }
357
357
  : {}),
358
+ // Registry-only web-search capability: without this backfill a saved provider row reaches
359
+ // the Responses adapter with the flag `undefined`, so the capability gate added in #2262
360
+ // reads "unclassified" and forwards Codex's OpenAI-only `web_search` config fields. xAI
361
+ // rejects the whole request before inference ("Argument not supported:
362
+ // external_web_access"), which killed every routed Grok turn on the Responses lane.
363
+ // enrichProviderFromRegistry() already fills this, but the request path resolves through
364
+ // routedProviderConfig() and never called it.
365
+ ...(provider.supportsOpenAiWebSearchToolFields === undefined
366
+ && registryEntry.supportsOpenAiWebSearchToolFields !== undefined
367
+ ? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields }
368
+ : {}),
358
369
  ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined
359
370
  ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent }
360
371
  : {}),
@@ -398,6 +409,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
398
409
  ...(provider.parallelToolCalls === undefined && registryEntry.parallelToolCalls !== undefined ? { parallelToolCalls: registryEntry.parallelToolCalls } : {}),
399
410
  ...(provider.promptCacheKey === undefined && registryEntry.promptCacheKey !== undefined ? { promptCacheKey: registryEntry.promptCacheKey } : {}),
400
411
  ...(provider.chatServiceTier === undefined && registryEntry.chatServiceTier !== undefined ? { chatServiceTier: registryEntry.chatServiceTier } : {}),
412
+ ...(provider.openaiChatEofTolerance === undefined && registryEntry.openaiChatEofTolerance !== undefined
413
+ ? { openaiChatEofTolerance: registryEntry.openaiChatEofTolerance }
414
+ : {}),
401
415
  ...(provider.reasoningWireFormat === undefined && registryEntry.reasoningWireFormat !== undefined
402
416
  ? { reasoningWireFormat: registryEntry.reasoningWireFormat }
403
417
  : {}),
@@ -228,6 +228,7 @@ export function resolveProductionBehaviorValues(
228
228
  "cache.forwarding": behaviorRow("provider_config", effective.promptCacheKey === true),
229
229
  "cache.retention": behaviorRow("global_config", config.cacheRetention ?? "short"),
230
230
  "anthropic.eofPolicy": behaviorRow("provider_config", effective.anthropicEofTolerance === true ? "tolerant" : "strict"),
231
+ "openai-chat.eofPolicy": behaviorRow("provider_config", effective.openaiChatEofTolerance === true ? "tolerant" : "strict"),
231
232
  "google.mode": behaviorRow("provider_config", effective.googleMode ?? null),
232
233
  "google.projectFingerprint": behaviorRow(
233
234
  "provider_config",
@@ -25,6 +25,7 @@ import { providerConfigSeed } from "../providers/derive";
25
25
  import type { OcxConfig, OcxProviderConfig } from "../types";
26
26
  import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
27
27
  import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
28
+ import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in";
28
29
 
29
30
  let _corsOrigin = "http://localhost:10100";
30
31
  export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
@@ -688,6 +689,9 @@ export function safeConfigDTO(config: OcxConfig): unknown {
688
689
  hasApiKey: !!provider.apiKey,
689
690
  hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0,
690
691
  };
692
+ if (name === "xai") {
693
+ dto.xaiResponsesOptInState = xaiResponsesOptInState(provider);
694
+ }
691
695
  for (const key of [
692
696
  "defaultModel",
693
697
  "disabled",
@@ -56,6 +56,11 @@ import {
56
56
  visionDescriberIsProvablyBlind,
57
57
  visionDescriberRejection,
58
58
  } from "./vision-sidecar-options";
59
+ import {
60
+ webSearchCandidateRows,
61
+ webSearchModelIsRejected,
62
+ webSearchModelRejection,
63
+ } from "./web-search-sidecar-options";
59
64
  import { drainAndShutdown } from "../lifecycle";
60
65
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
61
66
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
@@ -940,7 +945,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
940
945
  const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint });
941
946
  const desiredEnabled = claudeDesktopIntegrationEnabled(persisted);
942
947
  const applied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted";
943
- const stale = observed.kind === "gateway_drifted";
948
+ // "Needs update" is only meaningful while the integration is wanted. When the
949
+ // durable switch is OFF, a leftover drifted profile is residue to clear — not
950
+ // a stale apply the operator should refresh.
951
+ const stale = desiredEnabled && observed.kind === "gateway_drifted";
944
952
  const { getDesktopHealth } = await import("../../claude/desktop-health");
945
953
  const health = getDesktopHealth();
946
954
  return jsonResponse({
@@ -1065,16 +1073,23 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1065
1073
  const section = body[field];
1066
1074
  if (section === undefined || section === null) continue;
1067
1075
  if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400);
1076
+ // The widened union applies to the WEB-SEARCH override only (roadmap 060).
1077
+ // Vision keeps its two-backend contract — accepting a wider id there would
1078
+ // persist a backend the vision resolver reads as unset, silently activating
1079
+ // a backend the operator never chose (review F1).
1080
+ const allowedBackends = field === "webSearchSidecar"
1081
+ ? ["openai", "anthropic", "xai", "gemini", "exa"]
1082
+ : ["openai", "anthropic"];
1068
1083
  if (section.backend !== undefined && section.backend !== null
1069
- && section.backend !== "openai" && section.backend !== "anthropic") {
1070
- return jsonResponse({ error: `${field}.backend must be openai, anthropic, or null` }, 400);
1084
+ && !allowedBackends.includes(section.backend as string)) {
1085
+ return jsonResponse({ error: `${field}.backend must be ${allowedBackends.join(", ")}, or null` }, 400);
1071
1086
  }
1072
1087
  if (section.model !== undefined && typeof section.model !== "string") {
1073
1088
  return jsonResponse({ error: `${field}.model must be a string` }, 400);
1074
1089
  }
1075
1090
  // Vision override only: reject a model we can prove is blind. Unknown ids stay
1076
- // allowed; webSearchSidecar has no vision requirement and is left alone. Shares
1077
- // one policy module with /api/sidecar-settings so the two gates cannot drift.
1091
+ // allowed. Shares one policy module with /api/sidecar-settings so the two
1092
+ // gates cannot drift.
1078
1093
  if (field === "visionSidecar" && typeof section.model === "string" && section.model !== "") {
1079
1094
  const requested = section.model;
1080
1095
  const candidates = await visionCandidateRows(config);
@@ -1085,6 +1100,35 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1085
1100
  return jsonResponse(visionDescriberRejection("visionSidecar.model", requested, config, candidates), 400);
1086
1101
  }
1087
1102
  }
1103
+ // Web-search override: membership gate (#2188). The executor set is closed,
1104
+ // so an id outside (runnable candidates ∪ auth slots) can never run. Same
1105
+ // module as /api/sidecar-settings — a gate on one route and a stale copy on
1106
+ // the other is no gate at all.
1107
+ if (field === "webSearchSidecar"
1108
+ && (section.model !== undefined || section.backend !== undefined)) {
1109
+ const stored = config.claudeCode?.webSearchSidecar;
1110
+ const effectiveBackend = section.backend === "anthropic"
1111
+ ? "anthropic"
1112
+ : section.backend === "openai"
1113
+ ? "openai"
1114
+ : section.backend === null
1115
+ ? config.webSearchSidecar?.backend ?? "openai"
1116
+ : stored?.backend ?? config.webSearchSidecar?.backend ?? "openai";
1117
+ const effectiveModel = section.model === ""
1118
+ ? config.webSearchSidecar?.model
1119
+ : typeof section.model === "string"
1120
+ ? section.model
1121
+ : stored?.model ?? config.webSearchSidecar?.model;
1122
+ const candidates = await webSearchCandidateRows(config);
1123
+ if (effectiveModel && webSearchModelIsRejected(effectiveBackend, effectiveModel, candidates)) {
1124
+ return jsonResponse(webSearchModelRejection(
1125
+ "webSearchSidecar.model",
1126
+ effectiveBackend,
1127
+ effectiveModel,
1128
+ candidates,
1129
+ ), 400);
1130
+ }
1131
+ }
1088
1132
  }
1089
1133
  const next = { ...(config.claudeCode ?? {}) };
1090
1134
  for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
@@ -1094,13 +1138,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1094
1138
  delete next[field];
1095
1139
  continue;
1096
1140
  }
1097
- const requested = section as { backend?: "openai" | "anthropic" | null; model?: string };
1098
- const override: NonNullable<OcxClaudeCodeConfig[typeof field]> = { ...next[field] };
1141
+ // The per-field validation above guarantees vision only ever carries the two-member
1142
+ // union; the cast is the loop's shared-shape compromise, not a wider write path.
1143
+ const requested = section as { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string };
1144
+ const override = { ...next[field] } as NonNullable<OcxClaudeCodeConfig[typeof field]>;
1099
1145
  if (requested.backend === null) delete override.backend;
1100
- else if (requested.backend !== undefined) override.backend = requested.backend;
1146
+ else if (requested.backend !== undefined) override.backend = requested.backend as never;
1101
1147
  if (requested.model === "") delete override.model;
1102
1148
  else if (requested.model !== undefined) override.model = requested.model;
1103
- if (Object.keys(override).length > 0) next[field] = override;
1149
+ // Indexed write across the field union collapses to an intersection; runtime
1150
+ // validation above already guarantees the per-field shape.
1151
+ if (Object.keys(override).length > 0) next[field] = override as never;
1104
1152
  else delete next[field];
1105
1153
  }
1106
1154
  if (body.enabled !== undefined) {
@@ -70,6 +70,13 @@ import {
70
70
  visionDescriberRejection,
71
71
  visionModelOptionsFor,
72
72
  } from "./vision-sidecar-options";
73
+ import {
74
+ webSearchCandidateRows,
75
+ webSearchModelIsRejected,
76
+ webSearchModelOptionsFrom,
77
+ webSearchModelRejection,
78
+ } from "./web-search-sidecar-options";
79
+ import { validateXaiSearchOptions } from "../../web-search/xai-executor";
73
80
  import { getDebugLogEntries } from "../../lib/debug-log-buffer";
74
81
  import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
75
82
  import {
@@ -120,21 +127,22 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{
120
127
 
121
128
  /** One client's outcome from a fan-out sync. Absent from the list means "left alone". */
122
129
  interface ClientIntegrationSyncOutcome {
123
- readonly client: "grok" | "claude-desktop";
130
+ readonly client: "grok" | "claude-desktop" | "mcode";
124
131
  readonly ok: boolean;
125
132
  readonly changed?: boolean;
126
133
  readonly reason?: string;
127
134
  }
128
135
 
129
136
  /**
130
- * Re-inject every client integration the operator has switched ON.
137
+ * Re-inject native clients that are switched ON and file integrations whose
138
+ * OpenCodex ownership record is the operator's durable opt-in.
131
139
  *
132
140
  * Only Codex used to run here, so a catalog change reached Codex and nothing else: a Grok
133
141
  * fence or a written Desktop profile kept the context windows it was created with until the
134
142
  * next `ocx start`. The startup path already gates each client on its own toggle
135
143
  * (`src/cli/index.ts`), and this is that same fan-out for the on-demand command.
136
144
  *
137
- * A client that is OFF is omitted from the result rather than reported as skipped — the
145
+ * A client that is OFF or never connected is omitted from the result rather than reported as skipped — the
138
146
  * caller has to be able to tell "not touched" from "tried and failed". A client that fails
139
147
  * does not fail the sync: Codex is the one that matters for routing, and a broken Grok file
140
148
  * should surface as a warning, not as a 500 on a command that did its main job.
@@ -183,6 +191,31 @@ async function syncEnabledClientIntegrations(
183
191
  }
184
192
  }
185
193
 
194
+ try {
195
+ const { refreshOwnedIntegration } = await import("../../integrations/owned-refresh");
196
+ const result = await refreshOwnedIntegration({
197
+ clientId: "mcode",
198
+ models: async () => {
199
+ const { loadExportModels } = await import("./model-rows");
200
+ return loadExportModels(config);
201
+ },
202
+ config,
203
+ port,
204
+ });
205
+ if (result) {
206
+ out.push(result.ok
207
+ ? {
208
+ client: "mcode",
209
+ ok: true,
210
+ changed: result.changed === true,
211
+ ...(result.reason ? { reason: result.reason } : {}),
212
+ }
213
+ : { client: "mcode", ok: false, reason: result.reason });
214
+ }
215
+ } catch (error) {
216
+ out.push({ client: "mcode", ok: false, reason: error instanceof Error ? error.message : String(error) });
217
+ }
218
+
186
219
  return out;
187
220
  }
188
221
 
@@ -511,14 +544,19 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
511
544
  if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
512
545
  const ws = config.webSearchSidecar ?? {};
513
546
  const vision = await sidecarVisionResponseSettings(config);
547
+ const webSearchCandidates = await webSearchCandidateRows(config);
514
548
  return jsonResponse({
515
549
  webSearch: {
516
550
  model: ws.model ?? "gpt-5.6-luna",
517
551
  backend: ws.backend,
518
552
  streamRoutedModelOutput: ws.streamRoutedModelOutput === true,
553
+ ...(ws.xSearch ? { xSearch: ws.xSearch } : {}),
519
554
  },
520
555
  vision: publicVisionSidecarSettings(config, vision),
521
556
  visionModels: vision.models,
557
+ // ALWAYS present: the dashboard treats an omitted list as "no filter" and
558
+ // falls back to the full model union, so empty must be [] (review B3).
559
+ webSearchModels: webSearchModelOptionsFrom(config, webSearchCandidates),
522
560
  });
523
561
  }
524
562
 
@@ -531,7 +569,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
531
569
  if (raw.webSearch !== undefined && !isPlainRecord(raw.webSearch)) return jsonResponse({ error: "webSearch must be an object" }, 400);
532
570
  if (raw.vision !== undefined && !isPlainRecord(raw.vision)) return jsonResponse({ error: "vision must be an object" }, 400);
533
571
  const body = raw as {
534
- webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown; streamRoutedModelOutput?: unknown };
572
+ webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown; streamRoutedModelOutput?: unknown; exaApiKey?: unknown; xSearch?: unknown };
535
573
  vision?: {
536
574
  model?: unknown;
537
575
  backend?: unknown;
@@ -541,9 +579,13 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
541
579
  timeoutMs?: unknown;
542
580
  };
543
581
  };
582
+ const WEB_SEARCH_BACKENDS_UNION = ["openai", "anthropic", "xai", "gemini", "exa"] as const;
544
583
  if (body.webSearch && body.webSearch.backend !== undefined && body.webSearch.backend !== null
545
- && body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic") {
546
- return jsonResponse({ error: "webSearch.backend must be openai, anthropic, or null" }, 400);
584
+ && !WEB_SEARCH_BACKENDS_UNION.includes(body.webSearch.backend as never)) {
585
+ return jsonResponse({ error: "webSearch.backend must be openai, anthropic, xai, gemini, exa, or null" }, 400);
586
+ }
587
+ if (body.webSearch?.model !== undefined && typeof body.webSearch.model !== "string") {
588
+ return jsonResponse({ error: "webSearch.model must be a string" }, 400);
547
589
  }
548
590
  if (body.webSearch && body.webSearch.streamRoutedModelOutput !== undefined
549
591
  && typeof body.webSearch.streamRoutedModelOutput !== "boolean") {
@@ -600,21 +642,92 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
600
642
  }
601
643
 
602
644
  if (body.webSearch) {
603
- config.webSearchSidecar = { ...config.webSearchSidecar };
645
+ const pairTouched = body.webSearch.model !== undefined || body.webSearch.backend !== undefined;
646
+ const effectiveBackend = body.webSearch.backend === "anthropic"
647
+ ? "anthropic"
648
+ : body.webSearch.backend === "openai" || body.webSearch.backend === null
649
+ ? "openai"
650
+ : config.webSearchSidecar?.backend ?? "openai";
651
+ const effectiveModel = typeof body.webSearch.model === "string"
652
+ ? body.webSearch.model || undefined
653
+ : config.webSearchSidecar?.model;
654
+ if (pairTouched && effectiveModel) {
655
+ const candidates = await webSearchCandidateRows(config);
656
+ if (webSearchModelIsRejected(effectiveBackend, effectiveModel, candidates)) {
657
+ return jsonResponse(webSearchModelRejection("webSearch.model", effectiveBackend, effectiveModel, candidates), 400);
658
+ }
659
+ }
660
+ const webSearchCandidate = { ...config.webSearchSidecar };
604
661
  if (typeof body.webSearch.model === "string") {
605
- if (body.webSearch.model === "") delete config.webSearchSidecar.model;
606
- else config.webSearchSidecar.model = body.webSearch.model;
662
+ if (body.webSearch.model === "") delete webSearchCandidate.model;
663
+ else webSearchCandidate.model = body.webSearch.model;
607
664
  }
608
- if (body.webSearch.backend === null) delete config.webSearchSidecar.backend;
609
- else if (body.webSearch.backend === "openai" || body.webSearch.backend === "anthropic") {
610
- config.webSearchSidecar.backend = body.webSearch.backend;
665
+ if (body.webSearch.backend === null) delete webSearchCandidate.backend;
666
+ else if (WEB_SEARCH_BACKENDS_UNION.includes(body.webSearch.backend as never)) {
667
+ webSearchCandidate.backend = body.webSearch.backend as typeof WEB_SEARCH_BACKENDS_UNION[number];
668
+ }
669
+ if (typeof body.webSearch.reasoning === "string") webSearchCandidate.reasoning = body.webSearch.reasoning;
670
+ // Operator secret for the exa backend: string sets, empty string clears. The GET
671
+ // payload deliberately never carries it and redact.ts strips the key from logs.
672
+ if (typeof body.webSearch.exaApiKey === "string") {
673
+ if (body.webSearch.exaApiKey === "") delete webSearchCandidate.exaApiKey;
674
+ else webSearchCandidate.exaApiKey = body.webSearch.exaApiKey;
675
+ }
676
+ // Opt-in x_search block (L7): null clears; an object is doc-validated before persisting.
677
+ if (body.webSearch.xSearch === null) delete webSearchCandidate.xSearch;
678
+ else if (body.webSearch.xSearch !== undefined) {
679
+ if (!isPlainRecord(body.webSearch.xSearch)) {
680
+ return jsonResponse({ error: "webSearch.xSearch must be an object or null" }, 400);
681
+ }
682
+ const x = body.webSearch.xSearch as Record<string, unknown>;
683
+ const allowedXSearchKeys = new Set([
684
+ "enabled",
685
+ "allowedXHandles",
686
+ "excludedXHandles",
687
+ "fromDate",
688
+ "toDate",
689
+ ]);
690
+ const unknownKey = Object.keys(x).find(key => !allowedXSearchKeys.has(key));
691
+ if (unknownKey !== undefined) {
692
+ return jsonResponse({ error: `webSearch.xSearch.${unknownKey} is not a supported field` }, 400);
693
+ }
694
+ if (x.enabled !== undefined && typeof x.enabled !== "boolean") {
695
+ return jsonResponse({ error: "webSearch.xSearch.enabled must be a boolean" }, 400);
696
+ }
697
+ for (const field of ["allowedXHandles", "excludedXHandles"] as const) {
698
+ const value = x[field];
699
+ if (value !== undefined && (!Array.isArray(value) || !value.every(handle => typeof handle === "string"))) {
700
+ return jsonResponse({ error: `webSearch.xSearch.${field} must be an array of strings` }, 400);
701
+ }
702
+ }
703
+ for (const field of ["fromDate", "toDate"] as const) {
704
+ if (x[field] !== undefined && typeof x[field] !== "string") {
705
+ return jsonResponse({ error: `webSearch.xSearch.${field} must be an ISO-8601 date (YYYY-MM-DD)` }, 400);
706
+ }
707
+ }
708
+ const candidate = {
709
+ ...(x.enabled === true ? { enabled: true } : {}),
710
+ ...(x.allowedXHandles !== undefined ? { allowedXHandles: x.allowedXHandles as string[] } : {}),
711
+ ...(x.excludedXHandles !== undefined ? { excludedXHandles: x.excludedXHandles as string[] } : {}),
712
+ ...(x.fromDate !== undefined ? { fromDate: x.fromDate as string } : {}),
713
+ ...(x.toDate !== undefined ? { toDate: x.toDate as string } : {}),
714
+ };
715
+ const invalid = validateXaiSearchOptions({
716
+ xSearch: candidate.enabled,
717
+ allowedXHandles: candidate.allowedXHandles,
718
+ excludedXHandles: candidate.excludedXHandles,
719
+ fromDate: candidate.fromDate,
720
+ toDate: candidate.toDate,
721
+ });
722
+ if (invalid) return jsonResponse({ error: `webSearch.xSearch invalid: ${invalid}` }, 400);
723
+ webSearchCandidate.xSearch = candidate;
611
724
  }
612
- if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning;
613
725
  if (typeof body.webSearch.streamRoutedModelOutput === "boolean") {
614
726
  // `false` is the default — drop the key so config files stay minimal.
615
- if (body.webSearch.streamRoutedModelOutput) config.webSearchSidecar.streamRoutedModelOutput = true;
616
- else delete config.webSearchSidecar.streamRoutedModelOutput;
727
+ if (body.webSearch.streamRoutedModelOutput) webSearchCandidate.streamRoutedModelOutput = true;
728
+ else delete webSearchCandidate.streamRoutedModelOutput;
617
729
  }
730
+ config.webSearchSidecar = webSearchCandidate;
618
731
  }
619
732
  if (body.vision) {
620
733
  config.visionSidecar = { ...config.visionSidecar };
@@ -645,15 +758,21 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
645
758
  saveConfigPreservingClaudeCode(config);
646
759
  const ws = config.webSearchSidecar ?? {};
647
760
  const vision = await sidecarVisionResponseSettings(config);
761
+ const savedWebSearchCandidates = await webSearchCandidateRows(config);
648
762
  return jsonResponse({
649
763
  ok: true,
650
764
  webSearch: {
651
765
  model: ws.model ?? "gpt-5.6-luna",
652
766
  backend: ws.backend,
653
767
  streamRoutedModelOutput: ws.streamRoutedModelOutput === true,
768
+ ...(ws.xSearch ? { xSearch: ws.xSearch } : {}),
654
769
  },
655
770
  vision: publicVisionSidecarSettings(config, vision),
656
771
  visionModels: vision.models,
772
+ // Echoed for the same reason GET always carries it: the dashboard rebuilds
773
+ // its sidecar state from this body, and an omitted key reads as "old
774
+ // server" and falls back to the full union (review F1).
775
+ webSearchModels: webSearchModelOptionsFrom(config, savedWebSearchCandidates),
657
776
  });
658
777
  }
659
778