@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.
- package/README.md +9 -1
- package/gui/dist/assets/index-BNESwCzn.js +102 -0
- package/gui/dist/assets/index-CH7ncHCC.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/base.ts +3 -1
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/discovery.ts +25 -0
- package/src/adapters/cursor/live-transport.ts +19 -2
- package/src/adapters/cursor/native-exec.ts +47 -2
- package/src/adapters/cursor/protobuf-request.ts +80 -23
- package/src/adapters/cursor/request-builder.ts +119 -5
- package/src/adapters/cursor/transport.ts +5 -0
- package/src/adapters/cursor/types.ts +13 -0
- package/src/adapters/cursor.ts +109 -3
- package/src/adapters/google-antigravity-replay.ts +31 -4
- package/src/adapters/google.ts +323 -38
- package/src/adapters/openai-chat.ts +19 -0
- package/src/adapters/openai-responses.ts +218 -37
- package/src/bridge.ts +38 -20
- package/src/claude/desktop-3p.ts +15 -6
- package/src/cli/agent.ts +44 -1
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/dispatch.ts +24 -1
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +40 -38
- package/src/cli/integrations.ts +1 -1
- package/src/cli/registry.ts +2 -2
- package/src/clients/config-export.ts +119 -20
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/inject.ts +46 -14
- package/src/codex/journal.ts +22 -8
- package/src/config.ts +1 -0
- package/src/generated/compatibility-version.json +134 -66
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/registry.ts +25 -2
- package/src/integrations/writer.ts +32 -1
- package/src/lab/public/signature.ts +25 -1
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/redact.ts +2 -2
- package/src/oauth/log.ts +3 -1
- package/src/providers/derive.ts +9 -0
- package/src/providers/fastwire.ts +24 -18
- package/src/providers/openai-tiers.ts +60 -1
- package/src/providers/registry.ts +14 -7
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/responses/compaction.ts +18 -0
- package/src/responses/custom-tool-compat.ts +70 -5
- package/src/responses/namespace-tool-compat.ts +356 -0
- package/src/responses/parser.ts +2 -2
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/reasoning-replay-cache.ts +125 -7
- package/src/responses/spill-store.ts +6 -1
- package/src/responses/state.ts +11 -0
- package/src/router.ts +14 -0
- package/src/routing/compatibility/behavior.ts +1 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +57 -9
- package/src/server/management/config-routes.ts +134 -15
- package/src/server/management/integration-routes.ts +8 -55
- package/src/server/management/model-routes.ts +23 -1
- package/src/server/management/provider-routes.ts +22 -0
- package/src/server/management/vision-sidecar-options.ts +18 -11
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/responses/core.ts +588 -78
- package/src/server/responses/responses-field-backfill.ts +7 -11
- package/src/server/responses/terminal-guard.ts +22 -11
- package/src/server/responses-custom-tool-repair.ts +3 -1
- package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
- package/src/server/responses-tool-search-repair.ts +64 -14
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/types/config.ts +26 -5
- package/src/types/provider.ts +18 -0
- package/src/types/request.ts +26 -0
- package/src/types.ts +1 -0
- package/src/usage/log.ts +2 -0
- package/src/vision/index.ts +8 -9
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +88 -0
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +139 -12
- package/src/web-search/loop.ts +45 -5
- package/src/web-search/parse.ts +34 -23
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-D2sP-biU.js +0 -102
- 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
|
-
|
|
66
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/responses/state.ts
CHANGED
|
@@ -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",
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
&&
|
|
1070
|
-
return jsonResponse({ error: `${field}.backend must be
|
|
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
|
|
1077
|
-
//
|
|
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
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
&&
|
|
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
|
-
|
|
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
|
|
606
|
-
else
|
|
662
|
+
if (body.webSearch.model === "") delete webSearchCandidate.model;
|
|
663
|
+
else webSearchCandidate.model = body.webSearch.model;
|
|
607
664
|
}
|
|
608
|
-
if (body.webSearch.backend === null) delete
|
|
609
|
-
else if (
|
|
610
|
-
|
|
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)
|
|
616
|
-
else delete
|
|
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
|
|