@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
@@ -7,7 +7,7 @@ import { describeImage, type DescribeOutcome, type VisionSettings } from "./desc
7
7
  import { describeImageAnthropic } from "./anthropic-describe";
8
8
  import { normalizeVisionReasoningForModel } from "./reasoning";
9
9
  import type { CodexAuthContext } from "../codex/auth-context";
10
- import { getAccountSet } from "../oauth/store";
10
+ import { resolveSidecarAuth } from "../sidecar/auth";
11
11
  import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
12
12
  import type { SidecarOutcomeRecorder } from "../web-search/executor";
13
13
  import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
@@ -215,15 +215,14 @@ export interface AnthropicVisionProvider {
215
215
  provider: OcxProviderConfig;
216
216
  }
217
217
 
218
- /** First enabled Anthropic OAuth provider whose active stored account is not marked for reauth. */
218
+ /**
219
+ * First enabled Anthropic OAuth provider whose active stored account is not marked for reauth.
220
+ * Delegates to the shared sidecar auth module (#2188) — same predicate as web-search.
221
+ */
219
222
  export function findAnthropicVisionProvider(config: OcxConfig): AnthropicVisionProvider | undefined {
220
- for (const [providerName, provider] of Object.entries(config.providers)) {
221
- if (provider.disabled === true || provider.adapter !== "anthropic" || provider.authMode !== "oauth") continue;
222
- const accountSet = getAccountSet(providerName);
223
- const active = accountSet?.accounts.find(account => account.id === accountSet.activeAccountId);
224
- if (active && active.needsReauth !== true) return { providerName, provider };
225
- }
226
- return undefined;
223
+ const auth = resolveSidecarAuth(config);
224
+ if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined;
225
+ return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider };
227
226
  }
228
227
 
229
228
  export function resolveVisionBackend(
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Which backends may RUN a hosted web_search, and which candidate rows each
3
+ * can run (#2188 rule 2 for web-search: ∩ probed backend with an executor).
4
+ *
5
+ * A backend registers here only when BOTH hold:
6
+ * - an executor exists in this repository (src/web-search/executor.ts or
7
+ * anthropic-executor.ts today), and
8
+ * - its liveness probe passes. For the two shipped backends the probe IS
9
+ * auth presence — the ChatGPT forward path and the stored Anthropic OAuth
10
+ * path fail closed without a credential, so a live credential is the
11
+ * strongest pre-flight signal short of spending a search.
12
+ *
13
+ * Future backends (Gemini google_search, Grok web_search, Zen hosted search,
14
+ * Exa-class vendors) stay OUT of this table until a live probe and an
15
+ * executor land — the research and probe contracts are recorded in
16
+ * devlog/_plan/260820_sidecar_selection_unification/002 and 031. Documenting
17
+ * a tool is not the same as being able to run it.
18
+ */
19
+ import type { OcxConfig } from "../types";
20
+ import { AUTH_SLOT_MODELS, type SidecarAuthState } from "../sidecar/auth";
21
+ import type { SidecarCandidate } from "../sidecar/candidates";
22
+ import { getAccountSet } from "../oauth/store";
23
+ import type { WebSearchBackendId } from "./index";
24
+
25
+ export interface WebSearchBackendDescriptor {
26
+ backend: WebSearchBackendId;
27
+ /** Liveness signal for this backend (auth presence for the shipped two). */
28
+ isActive(auth: SidecarAuthState, config: OcxConfig): boolean;
29
+ /** Which candidate rows this backend's executor can actually run. */
30
+ eligibleModel(candidate: SidecarCandidate, auth: SidecarAuthState): boolean;
31
+ }
32
+
33
+ export const WEB_SEARCH_BACKENDS: readonly WebSearchBackendDescriptor[] = [
34
+ {
35
+ backend: "openai",
36
+ isActive: auth => auth.isCodexAuth,
37
+ // The ChatGPT forward executor runs BARE native slugs and the Codex auth
38
+ // slot — settings.model is POSTed verbatim to the forward /responses, so
39
+ // an account-bound "selector/slug" row (model-rows emits those as
40
+ // provider "openai", native true) or a custom openai-keyed row would
41
+ // persist an id the executor cannot run (review F1). The sidecar never
42
+ // calls routeModel; there is no prefix-stripping on this path.
43
+ eligibleModel: candidate => candidate.provider === "openai"
44
+ && (candidate.native === true || candidate.authSlot === true)
45
+ && !candidate.id.includes("/"),
46
+ },
47
+ {
48
+ backend: "anthropic",
49
+ isActive: auth => auth.isAnthropicAuth,
50
+ // The stored-OAuth Messages executor dispatches through exactly ONE
51
+ // provider — the one the shared auth module resolved. Same-adapter keyed
52
+ // rows are unreachable, mirroring visionBackendForCandidate's stance.
53
+ eligibleModel: (candidate, auth) => candidate.provider === auth.anthropicProviderName,
54
+ },
55
+ {
56
+ backend: "xai",
57
+ // Probe = stored Grok OAuth usable: enabled oauth-mode "xai" provider whose
58
+ // active account is not marked for reauth — the same predicate
59
+ // findXaiSidecarProvider applies at plan time (L7).
60
+ isActive: (_auth, config) => {
61
+ const provider = config.providers["xai"];
62
+ if (!provider || provider.disabled === true || provider.authMode !== "oauth") return false;
63
+ const set = getAccountSet("xai");
64
+ const active = set?.accounts.find(account => account.id === set.activeAccountId);
65
+ return !!active && active.needsReauth !== true;
66
+ },
67
+ eligibleModel: candidate => candidate.provider === "xai",
68
+ },
69
+ {
70
+ backend: "gemini",
71
+ // Probe = usable Antigravity OAuth + discovered projectId (findGeminiSidecarProvider's predicate).
72
+ isActive: (_auth, config) => {
73
+ const provider = config.providers["google-antigravity"];
74
+ if (!provider || provider.disabled === true || provider.authMode !== "oauth") return false;
75
+ const set = getAccountSet("google-antigravity");
76
+ const active = set?.accounts.find(account => account.id === set.activeAccountId);
77
+ if (!active || active.needsReauth === true) return false;
78
+ return !!(active.credential as { projectId?: string } | undefined)?.projectId;
79
+ },
80
+ eligibleModel: candidate => candidate.provider === "google-antigravity",
81
+ },
82
+ {
83
+ backend: "exa",
84
+ // Probe = operator key present. Exa is not an LLM: no candidate models ever
85
+ // match, so the GUI's model list stays untouched by this backend.
86
+ isActive: (_auth, config) => !!config.webSearchSidecar?.exaApiKey,
87
+ eligibleModel: () => false,
88
+ },
89
+ ];
90
+
91
+ /**
92
+ * (picker-visible ∪ auth slots) ∩ (active backend able to run the row).
93
+ * The auth slots always survive their own side's activation: a logged-in
94
+ * side keeps Luna/Haiku even when the picker hides them.
95
+ */
96
+ export function webSearchSidecarCandidates(
97
+ config: OcxConfig,
98
+ auth: SidecarAuthState,
99
+ all: readonly SidecarCandidate[],
100
+ ): SidecarCandidate[] {
101
+ const active = WEB_SEARCH_BACKENDS.filter(descriptor => descriptor.isActive(auth, config));
102
+ return all.filter(candidate => active.some(descriptor => descriptor.eligibleModel(candidate, auth)));
103
+ }
104
+
105
+ /** True when the id is one of the fixed auth-slot models (#2188 write-gate exception). */
106
+ export function isWebSearchAuthSlotModel(id: string): boolean {
107
+ return id === AUTH_SLOT_MODELS.codex || id === AUTH_SLOT_MODELS.anthropic;
108
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Execute ONE web search via the Exa Search API (#2188 L9) — the non-LLM lane.
3
+ *
4
+ * Exa returns ranked result JSON, not a prose answer: the outcome's text is a
5
+ * per-result digest the routed model synthesizes from. Probe-verified 2026-08-21
6
+ * (devlog 002): POST https://api.exa.ai/search with x-api-key returns
7
+ * {requestId, results[{title,url,publishedDate,text}], costDollars}.
8
+ * redirect: "manual" because Bun forwards custom headers across redirects.
9
+ * Never throws; every error string passes redactSecretString.
10
+ */
11
+ import { fetchWithResetRetry } from "../lib/upstream-retry";
12
+ import { signalWithTimeout } from "../lib/abort";
13
+ import { sidecarEnter } from "../lib/sidecar-tracker";
14
+ import { redactSecretString } from "../lib/redact";
15
+ import type { WebSearchSource } from "./parse";
16
+ import type { SidecarOutcome, SidecarSettings } from "./executor";
17
+
18
+ const EXA_SEARCH_URL = "https://api.exa.ai/search";
19
+ const EXA_NUM_RESULTS = 5;
20
+ const EXA_SNIPPET_CHARS = 1000;
21
+
22
+ function isRec(v: unknown): v is Record<string, unknown> {
23
+ return !!v && typeof v === "object" && !Array.isArray(v);
24
+ }
25
+
26
+ export async function runExaWebSearch(
27
+ query: string,
28
+ apiKey: string,
29
+ settings: SidecarSettings,
30
+ abortSignal?: AbortSignal,
31
+ ): Promise<SidecarOutcome> {
32
+ if (!apiKey) return { text: "", sources: [], error: "exa backend selected without an exaApiKey" };
33
+ // The executor KNOWS the secret — pattern-based redaction cannot be trusted to
34
+ // recognize an arbitrary operator key, so scrub the literal value explicitly.
35
+ const scrub = (s: string) => redactSecretString(s.split(apiKey).join("[redacted-exa-key]"));
36
+ const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
37
+ const sidecarExit = sidecarEnter("web-search");
38
+ const t0 = Date.now();
39
+ try {
40
+ const res = await fetchWithResetRetry(
41
+ () => fetch(EXA_SEARCH_URL, {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json", "x-api-key": apiKey },
44
+ body: JSON.stringify({ query, numResults: EXA_NUM_RESULTS, contents: { text: { maxCharacters: EXA_SNIPPET_CHARS } } }),
45
+ signal: linkedSignal.signal,
46
+ redirect: "manual",
47
+ }),
48
+ { abortSignal: linkedSignal.signal, label: "exa-web-search-sidecar" },
49
+ );
50
+ if (!res.ok) {
51
+ const t = await res.text().catch(() => "");
52
+ // Scrub BEFORE truncating: slicing first can cut the literal key at the
53
+ // boundary, leaving an unscrubbable key prefix in the surviving text.
54
+ return { text: "", sources: [], error: `exa sidecar HTTP ${res.status}: ${scrub(t).slice(0, 200)}` };
55
+ }
56
+ const payload = await res.json().catch(() => null);
57
+ return mapExaSearchResponse(payload);
58
+ } catch (e) {
59
+ const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
60
+ console.warn(`[web-search] exa sidecar ${kind} (${Date.now() - t0}ms)`);
61
+ return { text: "", sources: [], error: scrub(e instanceof Error ? e.message : String(e)) };
62
+ } finally {
63
+ sidecarExit();
64
+ linkedSignal.cleanup();
65
+ }
66
+ }
67
+
68
+ /** Map an Exa /search payload to a digest the routed model can synthesize from. */
69
+ export function mapExaSearchResponse(payload: unknown): SidecarOutcome {
70
+ if (!isRec(payload) || !Array.isArray(payload.results)) {
71
+ return { text: "", sources: [], error: "exa sidecar returned a non-JSON or shapeless body" };
72
+ }
73
+ const sources: WebSearchSource[] = [];
74
+ const lines: string[] = [];
75
+ const seen = new Set<string>();
76
+ for (const result of payload.results) {
77
+ if (!isRec(result) || typeof result.url !== "string" || result.url.length === 0) continue;
78
+ if (seen.has(result.url)) continue;
79
+ seen.add(result.url);
80
+ const title = typeof result.title === "string" && result.title.length > 0 ? result.title : result.url;
81
+ sources.push({ url: result.url, ...(title !== result.url ? { title } : {}) });
82
+ const snippet = typeof result.text === "string" ? result.text.trim().slice(0, EXA_SNIPPET_CHARS) : "";
83
+ const dated = typeof result.publishedDate === "string" ? ` (${result.publishedDate.slice(0, 10)})` : "";
84
+ lines.push(`- ${title}${dated}: ${snippet || "(no excerpt)"} [${result.url}]`);
85
+ }
86
+ if (lines.length === 0) return { text: "", sources: [], error: "exa sidecar returned no results" };
87
+ return { text: `Search results:\n${lines.join("\n")}`, sources };
88
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Execute ONE web search via Gemini google_search grounding on the Antigravity
3
+ * Cloud Code Assist transport (#2188 L8). Live-probed 2026-08-20/21 (devlog 002):
4
+ * the CCA envelope with tools [{google_search:{}}] returns a grounded answer
5
+ * plus groundingMetadata; a non-IDE User-Agent gets 404, so the request reuses
6
+ * the adapter's fingerprint constants. The OAuth bearer only ever travels to
7
+ * the REGISTRY-pinned endpoint — a config-level baseUrl override is never
8
+ * trusted for token transmission (same rule as src/server/images.ts).
9
+ * Never throws — returns {error} so the caller injects a graceful tool result.
10
+ */
11
+ import type { OcxProviderConfig } from "../types";
12
+ import { getValidAccessTokenSnapshot, publicOAuthAuthenticationErrorMessage } from "../oauth";
13
+ import { fetchWithResetRetry } from "../lib/upstream-retry";
14
+ import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
15
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
16
+ import { sidecarEnter } from "../lib/sidecar-tracker";
17
+ import { redactSecretString } from "../lib/redact";
18
+ import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
19
+ import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
20
+ import { getProviderRegistryEntry } from "../providers/registry";
21
+ import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse";
22
+ import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor";
23
+
24
+ const CCA_FALLBACK_BASE = "https://daily-cloudcode-pa.googleapis.com";
25
+
26
+ function isRec(v: unknown): v is Record<string, unknown> {
27
+ return !!v && typeof v === "object" && !Array.isArray(v);
28
+ }
29
+
30
+ export async function runGeminiWebSearch(
31
+ query: string,
32
+ providerName: string,
33
+ _provider: OcxProviderConfig,
34
+ settings: SidecarSettings,
35
+ abortSignal?: AbortSignal,
36
+ ): Promise<SidecarOutcome> {
37
+ let token: string;
38
+ let project: string | undefined;
39
+ try {
40
+ const snapshot = await getValidAccessTokenSnapshot(providerName);
41
+ token = snapshot.accessToken;
42
+ project = snapshot.projectId;
43
+ } catch (e) {
44
+ return { text: "", sources: [], error: `gemini sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(e)}` };
45
+ }
46
+ if (!project) {
47
+ return { text: "", sources: [], error: "gemini sidecar missing Cloud Code Assist project id — re-run ocx login google-antigravity" };
48
+ }
49
+ // Destination pinned to the registry endpoint (see module doc).
50
+ const base = getProviderRegistryEntry("google-antigravity")?.baseUrl ?? CCA_FALLBACK_BASE;
51
+ const { wireModelId, thinkingLevel } = resolveAntigravityEffortWireModel(settings.model, settings.reasoning, base);
52
+ const instruction = settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION;
53
+ const envelope = {
54
+ model: wireModelId,
55
+ userAgent: "antigravity",
56
+ requestType: "agent",
57
+ project,
58
+ requestId: `agent-${crypto.randomUUID()}`,
59
+ request: {
60
+ systemInstruction: { role: "user", parts: [{ text: instruction }] },
61
+ contents: [{ role: "user", parts: [{ text: query }] }],
62
+ tools: [{ google_search: {} }],
63
+ sessionId: crypto.randomUUID(),
64
+ ...(thinkingLevel ? { generationConfig: { thinkingConfig: { thinkingLevel } } } : {}),
65
+ },
66
+ };
67
+ const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
68
+ const sidecarExit = sidecarEnter("web-search");
69
+ const t0 = Date.now();
70
+ try {
71
+ const res = await fetchWithResetRetry(
72
+ () => fetch(`${base}/v1internal:generateContent`, {
73
+ method: "POST",
74
+ headers: {
75
+ "Content-Type": "application/json",
76
+ "Authorization": `Bearer ${token}`,
77
+ "User-Agent": ANTIGRAVITY_REQUEST_UA,
78
+ },
79
+ body: JSON.stringify(envelope),
80
+ signal: linkedSignal.signal,
81
+ redirect: "manual",
82
+ }),
83
+ { abortSignal: linkedSignal.signal, label: "gemini-web-search-sidecar" },
84
+ );
85
+ const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
86
+ try {
87
+ const bounded = await readBoundedResponseBytes(res, {
88
+ maxBytes: MAX_SIDECAR_RESPONSE_BYTES,
89
+ signal: linkedSignal.signal,
90
+ });
91
+ if (bounded.oversized) {
92
+ const prefix = res.ok ? "gemini sidecar response" : `gemini sidecar HTTP ${res.status} response`;
93
+ return { text: "", sources: [], error: `${prefix} exceeded byte bound` };
94
+ }
95
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes);
96
+ if (!res.ok) {
97
+ return { text: "", sources: [], error: `gemini sidecar HTTP ${res.status}: ${redactSecretString(text.slice(0, 200))}` };
98
+ }
99
+ let payload: unknown = null;
100
+ try {
101
+ payload = JSON.parse(text);
102
+ } catch {
103
+ // The mapper owns the stable malformed/empty JSON outcome.
104
+ }
105
+ return mapCcaGroundedResponse(payload);
106
+ } finally {
107
+ detachBodyGuard();
108
+ }
109
+ } catch (e) {
110
+ const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
111
+ console.warn(`[web-search] gemini sidecar ${kind} (${Date.now() - t0}ms)`);
112
+ return { text: "", sources: [], error: redactSecretString(e instanceof Error ? e.message : String(e)) };
113
+ } finally {
114
+ sidecarExit();
115
+ linkedSignal.cleanup();
116
+ }
117
+ }
118
+
119
+ /** Map a CCA generateContent payload (possibly wrapped in {response}) to text + grounding sources. */
120
+ export function mapCcaGroundedResponse(payload: unknown): SidecarOutcome {
121
+ const root = isRec(payload) && isRec(payload.response) ? payload.response : payload;
122
+ if (!isRec(root)) return { text: "", sources: [], error: "gemini sidecar returned a non-JSON or empty body" };
123
+ const candidate = Array.isArray(root.candidates) && isRec(root.candidates[0]) ? root.candidates[0] : undefined;
124
+ if (!candidate) return { text: "", sources: [], error: "gemini sidecar returned no candidates" };
125
+ const parts = isRec(candidate.content) && Array.isArray(candidate.content.parts) ? candidate.content.parts : [];
126
+ const text = parts.map(p => (isRec(p) && typeof p.text === "string" ? p.text : "")).join("");
127
+ const sources: WebSearchSource[] = [];
128
+ const seen = new Set<string>();
129
+ const gm = isRec(candidate.groundingMetadata) ? candidate.groundingMetadata : undefined;
130
+ if (gm && Array.isArray(gm.groundingChunks)) {
131
+ for (const chunk of gm.groundingChunks) {
132
+ const web = isRec(chunk) && isRec(chunk.web) ? chunk.web : undefined;
133
+ const uri = web && typeof web.uri === "string" ? web.uri : undefined;
134
+ if (!uri || seen.has(uri)) continue;
135
+ seen.add(uri);
136
+ sources.push({ url: uri, ...(typeof web?.title === "string" && web.title.length > 0 ? { title: web.title } : {}) });
137
+ }
138
+ }
139
+ if (text.length === 0) return { text: "", sources, error: "gemini sidecar returned no text" };
140
+ return { text, sources };
141
+ }
@@ -3,17 +3,27 @@ import { modelInList, toolChoiceToolPredicate } from "../types";
3
3
  import { isModelTextOnly } from "../vision";
4
4
  import type { SidecarSettings } from "./executor";
5
5
  import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
6
+ import { resolveSidecarAuth } from "../sidecar/auth";
6
7
  import { getAccountSet } from "../oauth/store";
8
+ import { validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor";
9
+ import type { OcxWebSearchSidecarConfig } from "../types";
7
10
  import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout";
8
11
  import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
9
12
 
10
13
  export { runWithWebSearch } from "./loop";
11
14
  export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME };
12
15
  export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor";
16
+ export { runXaiWebSearch, parseXaiResponsesSSE, validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor";
17
+ export { runGeminiWebSearch, mapCcaGroundedResponse } from "./gemini-executor";
18
+ export { runExaWebSearch, mapExaSearchResponse } from "./exa-executor";
13
19
 
14
20
  const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
15
21
  // Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset).
16
22
  const DEFAULT_ANTHROPIC_SIDECAR_MODEL = "claude-sonnet-5";
23
+ // Default Grok model for the xai-backed sidecar (probe-verified with hosted tools, devlog 003).
24
+ const DEFAULT_XAI_SIDECAR_MODEL = "grok-4.6";
25
+ // Default Gemini model for the gemini-backed sidecar (CCA grounding probe, devlog 002).
26
+ const DEFAULT_GEMINI_SIDECAR_MODEL = "gemini-3.7-flash";
17
27
  // "low" is the lightest effort the ChatGPT backend allows with web_search ("minimal" is rejected:
18
28
  // "tools cannot be used with reasoning.effort 'minimal'") — keeps the sidecar fast/cheap.
19
29
  const DEFAULT_SIDECAR_REASONING = "low";
@@ -83,37 +93,94 @@ export interface AnthropicSidecarProvider {
83
93
  * only path that can run web_search_20250305 without a ChatGPT forward provider. Presence is decided by
84
94
  * getAccountSet + the active account's `needsReauth` marker (audit F1: getCredential alone can pick a
85
95
  * terminally-invalid account); token refresh happens later at executor time.
96
+ * Delegates to the shared sidecar auth module (#2188) so web-search and vision
97
+ * cannot drift on what "Anthropic auth present" means.
86
98
  */
87
99
  export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSidecarProvider | undefined {
88
- for (const [name, prov] of Object.entries(config.providers)) {
89
- if (prov.disabled === true) continue;
90
- if (prov.adapter !== "anthropic" || prov.authMode !== "oauth") continue;
91
- const set = getAccountSet(name);
92
- const active = set?.accounts.find(a => a.id === set.activeAccountId);
93
- if (active && active.needsReauth !== true) return { providerName: name, provider: prov };
94
- }
100
+ const auth = resolveSidecarAuth(config);
101
+ if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined;
102
+ return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider };
103
+ }
104
+
105
+ /**
106
+ * First enabled provider whose stored Grok OAuth account is active and not marked for
107
+ * reauth — the only credential the xai web-search executor may spend. Same account-set
108
+ * predicate the shared sidecar auth module applies to Anthropic.
109
+ */
110
+ export function findXaiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined {
111
+ // The stored Grok credential lives under the provider named "xai" (registry id);
112
+ // OAuth account sets are keyed by provider name, so the name IS the credential key.
113
+ const provider = config.providers["xai"];
114
+ if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined;
115
+ const set = getAccountSet("xai");
116
+ const active = set?.accounts.find(account => account.id === set.activeAccountId);
117
+ if (active && active.needsReauth !== true) return { providerName: "xai", provider };
95
118
  return undefined;
96
119
  }
97
120
 
121
+ /**
122
+ * First usable Antigravity credential holder: the "google-antigravity" provider
123
+ * (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose
124
+ * active stored account is healthy AND carries a discovered CCA projectId — the
125
+ * executor cannot form the envelope without it.
126
+ */
127
+ export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined {
128
+ const provider = config.providers["google-antigravity"];
129
+ if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined;
130
+ const set = getAccountSet("google-antigravity");
131
+ const active = set?.accounts.find(account => account.id === set.activeAccountId);
132
+ if (!active || active.needsReauth === true) return undefined;
133
+ const projectId = (active.credential as { projectId?: string } | undefined)?.projectId;
134
+ if (!projectId) return undefined;
135
+ return { providerName: "google-antigravity", provider };
136
+ }
137
+
138
+ /** Lift the persisted xSearch config block into executor options (absent block = web_search only). */
139
+ export function xaiSearchOptionsFromConfig(cfg: Pick<OcxWebSearchSidecarConfig, "xSearch">): XaiSearchOptions {
140
+ const x = cfg.xSearch;
141
+ if (!x || x.enabled !== true) return {};
142
+ return {
143
+ xSearch: true,
144
+ ...(x.allowedXHandles ? { allowedXHandles: x.allowedXHandles } : {}),
145
+ ...(x.excludedXHandles ? { excludedXHandles: x.excludedXHandles } : {}),
146
+ ...(x.fromDate ? { fromDate: x.fromDate } : {}),
147
+ ...(x.toDate ? { toDate: x.toDate } : {}),
148
+ };
149
+ }
150
+
151
+ /** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */
152
+ export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa";
153
+
98
154
  /**
99
155
  * Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The
100
156
  * anthropic backend (web_search_20250305) is only used when explicitly configured — auto-selecting
101
157
  * it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna)
102
158
  * to the Anthropic API.
159
+ * The 2188 follow-up ids (xai/gemini/exa) resolve to themselves the same explicit-only way; their
160
+ * planWebSearch arms stay fail-closed until each executor layer lands.
103
161
  */
104
162
  export function resolveSidecarBackend(
105
- explicit: "openai" | "anthropic" | undefined,
106
- ): "openai" | "anthropic" {
107
- return explicit === "anthropic" ? "anthropic" : "openai";
163
+ explicit: WebSearchBackendId | undefined,
164
+ ): WebSearchBackendId {
165
+ if (explicit === "anthropic" || explicit === "xai" || explicit === "gemini" || explicit === "exa") return explicit;
166
+ return "openai";
108
167
  }
109
168
 
110
169
  export interface SidecarPlan {
111
170
  /** Which executor runs the search. Anthropic does not require a forward provider. */
112
- backend: "openai" | "anthropic";
171
+ backend: WebSearchBackendId;
113
172
  /** Present for the openai backend (ChatGPT forward path); undefined for anthropic. */
114
173
  forwardSidecar?: ResolvedOpenAiForwardSidecar;
115
174
  /** Present for the anthropic backend (stored-OAuth /v1/messages path); undefined for openai. */
116
175
  anthropicSidecar?: AnthropicSidecarProvider;
176
+ /** Present for the xai backend (stored Grok OAuth /v1/responses path). */
177
+ xaiSidecar?: { providerName: string; provider: OcxProviderConfig };
178
+ /** Present for the gemini backend (Antigravity CCA grounding path). */
179
+ geminiSidecar?: { providerName: string; provider: OcxProviderConfig };
180
+ /** Opt-in x_search options for the xai backend (validated at the management layer and again in the executor). */
181
+ xaiSearchOptions?: XaiSearchOptions;
182
+ /** Presence marker for the exa backend — the API key itself never rides the plan. */
183
+ exaConfigured?: true;
117
184
  hostedTool: Record<string, unknown>;
118
185
  settings: SidecarSettings;
119
186
  maxSearches: number;
@@ -157,7 +224,12 @@ export function planWebSearch(
157
224
  const routedModelStallTimeoutMs = resolveRoutedModelStallTimeoutMs(cfg.routedModelStallTimeoutMs);
158
225
  // Same `?? 200_000` default the server applies when threading connectTimeoutMs into the loop.
159
226
  const connectTimeoutMs = config.connectTimeoutMs ?? 200_000;
160
- const anthropicSidecar = findAnthropicSidecarProvider(config);
227
+ // Shared auth state (#2188): presence only — backend PREFERENCE stays with
228
+ // resolveSidecarBackend's explicit-or-openai contract.
229
+ const auth = resolveSidecarAuth(config);
230
+ const anthropicSidecar = auth.isAnthropicAuth && auth.anthropicProviderName && auth.anthropicProvider
231
+ ? { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }
232
+ : undefined;
161
233
  const backend = resolveSidecarBackend(cfg.backend);
162
234
  const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES;
163
235
  const stallTimeoutSec = webSearchStallTimeoutSec(
@@ -189,6 +261,61 @@ export function planWebSearch(
189
261
  };
190
262
  }
191
263
 
264
+ // xAI backend (L7): explicit-only, authenticated by the STORED Grok OAuth credential.
265
+ // Same fail-closed stance as anthropic — an explicit choice with no usable credential
266
+ // produces no plan instead of borrowing another login.
267
+ if (backend === "xai") {
268
+ const xaiSidecar = findXaiSidecarProvider(config);
269
+ if (!xaiSidecar) return undefined;
270
+ const xaiOptions = xaiSearchOptionsFromConfig(cfg);
271
+ if (validateXaiSearchOptions(xaiOptions)) return undefined;
272
+ return {
273
+ backend: "xai",
274
+ xaiSidecar,
275
+ xaiSearchOptions: xaiOptions,
276
+ hostedTool: parsed._webSearch,
277
+ settings: { model: cfg.model ?? DEFAULT_XAI_SIDECAR_MODEL, reasoning, timeoutMs, describeImages },
278
+ maxSearches,
279
+ routedModelStallTimeoutMs,
280
+ stallTimeoutSec,
281
+ streamRoutedModelOutput,
282
+ };
283
+ }
284
+
285
+ // Gemini backend (L8): explicit-only, authenticated by the stored Antigravity CCA
286
+ // OAuth credential; requires the discovered projectId. Fail-closed like the others.
287
+ if (backend === "gemini") {
288
+ const geminiSidecar = findGeminiSidecarProvider(config);
289
+ if (!geminiSidecar) return undefined;
290
+ return {
291
+ backend: "gemini",
292
+ geminiSidecar,
293
+ hostedTool: parsed._webSearch,
294
+ settings: { model: cfg.model ?? DEFAULT_GEMINI_SIDECAR_MODEL, reasoning, timeoutMs, describeImages },
295
+ maxSearches,
296
+ routedModelStallTimeoutMs,
297
+ stallTimeoutSec,
298
+ streamRoutedModelOutput,
299
+ };
300
+ }
301
+
302
+ // exa (L9): explicit-only, keyed by the operator-supplied exaApiKey. The KEY never
303
+ // rides the plan object — core.ts reads it from config at unpack time; the plan
304
+ // carries only a presence marker. Fail-closed without a key.
305
+ if (backend === "exa") {
306
+ if (!cfg.exaApiKey) return undefined;
307
+ return {
308
+ backend: "exa",
309
+ exaConfigured: true,
310
+ hostedTool: parsed._webSearch,
311
+ settings: { model: cfg.model ?? DEFAULT_SIDECAR_MODEL, reasoning, timeoutMs, describeImages },
312
+ maxSearches,
313
+ routedModelStallTimeoutMs,
314
+ stallTimeoutSec,
315
+ streamRoutedModelOutput,
316
+ };
317
+ }
318
+
192
319
  // OpenAI backend: needs a ChatGPT login (main) and a forward provider to reach server-side web_search.
193
320
  if (!openAiSidecar) return undefined;
194
321
  return {