From fb6d3fe9ea345563c8b1afdfa2eacce1b632aaad Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 21 Aug 2026 10:54:20 +0900 Subject: [PATCH 1/4] feat(web-search): gemini executor on the Antigravity CCA transport (#2188 L8) runGeminiWebSearch sends the CCA envelope (registry-pinned endpoint, IDE fingerprint UA, discovered projectId, google_search tool, effort-mapped thinkingLevel) with the stored Antigravity OAuth and maps candidates[0] text + groundingMetadata.groundingChunks to the sidecar outcome. planWebSearch's gemini arm goes live fail-closed on OAuth or projectId absence; the loop arm fails closed without a resolved provider; the registry activates the backend on the same predicate. --- src/server/responses/core.ts | 1 + src/web-search/backends.ts | 13 ++++ src/web-search/gemini-executor.ts | 123 ++++++++++++++++++++++++++++++ src/web-search/index.ts | 45 ++++++++++- src/web-search/loop.ts | 8 ++ tests/gemini-web-search.test.ts | 78 +++++++++++++++++++ 6 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 src/web-search/gemini-executor.ts create mode 100644 tests/gemini-web-search.test.ts diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 7c92dd3f38..0f94e89b60 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3587,6 +3587,7 @@ async function handleResponsesInner( forwardProvider: wsPlan.forwardSidecar?.provider, anthropicSidecar: wsPlan.anthropicSidecar, xaiSidecar: wsPlan.xaiSidecar, + geminiSidecar: wsPlan.geminiSidecar, xaiSearchOptions: wsPlan.xaiSearchOptions, hostedTool: wsPlan.hostedTool, selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders, diff --git a/src/web-search/backends.ts b/src/web-search/backends.ts index e40720a234..2531d57096 100644 --- a/src/web-search/backends.ts +++ b/src/web-search/backends.ts @@ -66,6 +66,19 @@ export const WEB_SEARCH_BACKENDS: readonly WebSearchBackendDescriptor[] = [ }, eligibleModel: candidate => candidate.provider === "xai", }, + { + backend: "gemini", + // Probe = usable Antigravity OAuth + discovered projectId (findGeminiSidecarProvider's predicate). + isActive: (_auth, config) => { + const provider = config.providers["google-antigravity"]; + if (!provider || provider.disabled === true || provider.authMode !== "oauth") return false; + const set = getAccountSet("google-antigravity"); + const active = set?.accounts.find(account => account.id === set.activeAccountId); + if (!active || active.needsReauth === true) return false; + return !!(active.credential as { projectId?: string } | undefined)?.projectId; + }, + eligibleModel: candidate => candidate.provider === "google-antigravity", + }, ]; /** diff --git a/src/web-search/gemini-executor.ts b/src/web-search/gemini-executor.ts new file mode 100644 index 0000000000..a8af9a512c --- /dev/null +++ b/src/web-search/gemini-executor.ts @@ -0,0 +1,123 @@ +/** + * Execute ONE web search via Gemini google_search grounding on the Antigravity + * Cloud Code Assist transport (#2188 L8). Live-probed 2026-08-20/21 (devlog 002): + * the CCA envelope with tools [{google_search:{}}] returns a grounded answer + * plus groundingMetadata; a non-IDE User-Agent gets 404, so the request reuses + * the adapter's fingerprint constants. The OAuth bearer only ever travels to + * the REGISTRY-pinned endpoint — a config-level baseUrl override is never + * trusted for token transmission (same rule as src/server/images.ts). + * Never throws — returns {error} so the caller injects a graceful tool result. + */ +import type { OcxProviderConfig } from "../types"; +import { getValidAccessTokenSnapshot, publicOAuthAuthenticationErrorMessage } from "../oauth"; +import { getAccountSet } from "../oauth/store"; +import { fetchWithResetRetry } from "../lib/upstream-retry"; +import { signalWithTimeout } from "../lib/abort"; +import { sidecarEnter } from "../lib/sidecar-tracker"; +import { redactSecretString } from "../lib/redact"; +import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; +import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; +import { getProviderRegistryEntry } from "../providers/registry"; +import type { WebSearchSource } from "./parse"; +import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor"; + +const CCA_FALLBACK_BASE = "https://daily-cloudcode-pa.googleapis.com"; + +function isRec(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +export async function runGeminiWebSearch( + query: string, + providerName: string, + _provider: OcxProviderConfig, + settings: SidecarSettings, + abortSignal?: AbortSignal, +): Promise { + let token: string; + try { + token = (await getValidAccessTokenSnapshot(providerName)).accessToken; + } catch (e) { + return { text: "", sources: [], error: `gemini sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(e)}` }; + } + const set = getAccountSet(providerName); + const credential = set?.accounts.find(account => account.id === set.activeAccountId)?.credential as { projectId?: string } | undefined; + const project = credential?.projectId; + if (!project) { + return { text: "", sources: [], error: "gemini sidecar missing Cloud Code Assist project id — re-run ocx login google-antigravity" }; + } + // Destination pinned to the registry endpoint (see module doc). + const base = getProviderRegistryEntry("google-antigravity")?.baseUrl ?? CCA_FALLBACK_BASE; + const { wireModelId, thinkingLevel } = resolveAntigravityEffortWireModel(settings.model, settings.reasoning, base); + const instruction = settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION; + const envelope = { + model: wireModelId, + userAgent: "antigravity", + requestType: "agent", + project, + requestId: `agent-${crypto.randomUUID()}`, + request: { + systemInstruction: { role: "user", parts: [{ text: instruction }] }, + contents: [{ role: "user", parts: [{ text: query }] }], + tools: [{ google_search: {} }], + sessionId: crypto.randomUUID(), + ...(thinkingLevel ? { generationConfig: { thinkingConfig: { thinkingLevel } } } : {}), + }, + }; + const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); + const sidecarExit = sidecarEnter("web-search"); + const t0 = Date.now(); + try { + const res = await fetchWithResetRetry( + () => fetch(`${base}/v1internal:generateContent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + "User-Agent": ANTIGRAVITY_REQUEST_UA, + }, + body: JSON.stringify(envelope), + signal: linkedSignal.signal, + redirect: "manual", + }), + { abortSignal: linkedSignal.signal, label: "gemini-web-search-sidecar" }, + ); + if (!res.ok) { + const t = await res.text().catch(() => ""); + return { text: "", sources: [], error: `gemini sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` }; + } + const payload = await res.json().catch(() => null); + return mapCcaGroundedResponse(payload); + } catch (e) { + const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; + console.warn(`[web-search] gemini sidecar ${kind} (${Date.now() - t0}ms)`); + return { text: "", sources: [], error: e instanceof Error ? redactSecretString(e.message) : String(e) }; + } finally { + sidecarExit(); + linkedSignal.cleanup(); + } +} + +/** Map a CCA generateContent payload (possibly wrapped in {response}) to text + grounding sources. */ +export function mapCcaGroundedResponse(payload: unknown): SidecarOutcome { + const root = isRec(payload) && isRec(payload.response) ? payload.response : payload; + if (!isRec(root)) return { text: "", sources: [], error: "gemini sidecar returned a non-JSON or empty body" }; + const candidate = Array.isArray(root.candidates) && isRec(root.candidates[0]) ? root.candidates[0] : undefined; + if (!candidate) return { text: "", sources: [], error: "gemini sidecar returned no candidates" }; + const parts = isRec(candidate.content) && Array.isArray(candidate.content.parts) ? candidate.content.parts : []; + const text = parts.map(p => (isRec(p) && typeof p.text === "string" ? p.text : "")).join(""); + const sources: WebSearchSource[] = []; + const seen = new Set(); + const gm = isRec(candidate.groundingMetadata) ? candidate.groundingMetadata : undefined; + if (gm && Array.isArray(gm.groundingChunks)) { + for (const chunk of gm.groundingChunks) { + const web = isRec(chunk) && isRec(chunk.web) ? chunk.web : undefined; + const uri = web && typeof web.uri === "string" ? web.uri : undefined; + if (!uri || seen.has(uri)) continue; + seen.add(uri); + sources.push({ url: uri, ...(typeof web?.title === "string" && web.title.length > 0 ? { title: web.title } : {}) }); + } + } + if (text.length === 0) return { text: "", sources, error: "gemini sidecar returned no text" }; + return { text, sources }; +} diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 137852ab84..f02b2df7c4 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -14,12 +14,15 @@ export { runWithWebSearch } from "./loop"; export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME }; export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor"; export { runXaiWebSearch, parseXaiResponsesSSE, validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor"; +export { runGeminiWebSearch, mapCcaGroundedResponse } from "./gemini-executor"; const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna"; // Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset). const DEFAULT_ANTHROPIC_SIDECAR_MODEL = "claude-sonnet-5"; // Default Grok model for the xai-backed sidecar (probe-verified with hosted tools, devlog 003). const DEFAULT_XAI_SIDECAR_MODEL = "grok-4.6"; +// Default Gemini model for the gemini-backed sidecar (CCA grounding probe, devlog 002). +const DEFAULT_GEMINI_SIDECAR_MODEL = "gemini-3.7-flash"; // "low" is the lightest effort the ChatGPT backend allows with web_search ("minimal" is rejected: // "tools cannot be used with reasoning.effort 'minimal'") — keeps the sidecar fast/cheap. const DEFAULT_SIDECAR_REASONING = "low"; @@ -114,6 +117,23 @@ export function findXaiSidecarProvider(config: OcxConfig): { providerName: strin return undefined; } +/** + * First usable Antigravity credential holder: the "google-antigravity" provider + * (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose + * active stored account is healthy AND carries a discovered CCA projectId — the + * executor cannot form the envelope without it. + */ +export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { + const provider = config.providers["google-antigravity"]; + if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; + const set = getAccountSet("google-antigravity"); + const active = set?.accounts.find(account => account.id === set.activeAccountId); + if (!active || active.needsReauth === true) return undefined; + const projectId = (active.credential as { projectId?: string } | undefined)?.projectId; + if (!projectId) return undefined; + return { providerName: "google-antigravity", provider }; +} + /** Lift the persisted xSearch config block into executor options (absent block = web_search only). */ export function xaiSearchOptionsFromConfig(cfg: Pick): XaiSearchOptions { const x = cfg.xSearch; @@ -154,6 +174,8 @@ export interface SidecarPlan { anthropicSidecar?: AnthropicSidecarProvider; /** Present for the xai backend (stored Grok OAuth /v1/responses path). */ xaiSidecar?: { providerName: string; provider: OcxProviderConfig }; + /** Present for the gemini backend (Antigravity CCA grounding path). */ + geminiSidecar?: { providerName: string; provider: OcxProviderConfig }; /** Opt-in x_search options for the xai backend (validated at the management layer and again in the executor). */ xaiSearchOptions?: XaiSearchOptions; hostedTool: Record; @@ -206,9 +228,9 @@ export function planWebSearch( ? { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider } : undefined; const backend = resolveSidecarBackend(cfg.backend); - // Inert arms (roadmap 060): gemini/exa stay fail-closed until their executor - // layers land. The xai arm went live in L7 below. - if (backend === "gemini" || backend === "exa") return undefined; + // Inert arm (roadmap 060): exa stays fail-closed until its executor layer lands. + // xai went live in L7; gemini in L8 below. + if (backend === "exa") return undefined; const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES; const stallTimeoutSec = webSearchStallTimeoutSec( config.stallTimeoutSec, @@ -260,6 +282,23 @@ export function planWebSearch( }; } + // Gemini backend (L8): explicit-only, authenticated by the stored Antigravity CCA + // OAuth credential; requires the discovered projectId. Fail-closed like the others. + if (backend === "gemini") { + const geminiSidecar = findGeminiSidecarProvider(config); + if (!geminiSidecar) return undefined; + return { + backend: "gemini", + geminiSidecar, + hostedTool: parsed._webSearch, + settings: { model: cfg.model ?? DEFAULT_GEMINI_SIDECAR_MODEL, reasoning, timeoutMs, describeImages }, + maxSearches, + routedModelStallTimeoutMs, + stallTimeoutSec, + streamRoutedModelOutput, + }; + } + // OpenAI backend: needs a ChatGPT login (main) and a forward provider to reach server-side web_search. if (!openAiSidecar) return undefined; return { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 7df638b67a..d02dc63a5a 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -7,6 +7,7 @@ import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor"; +import { runGeminiWebSearch } from "./gemini-executor"; import type { WebSearchBackendId } from "./index"; import { clearableDeadline } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; @@ -264,6 +265,8 @@ export interface WebSearchLoopDeps { anthropicSidecar?: { providerName: string; provider: OcxProviderConfig }; /** Required for the xai backend: the stored Grok OAuth provider (L7). */ xaiSidecar?: { providerName: string; provider: OcxProviderConfig }; + /** Required for the gemini backend: the stored Antigravity CCA provider (L8). */ + geminiSidecar?: { providerName: string; provider: OcxProviderConfig }; /** Opt-in x_search options for the xai backend. */ xaiSearchOptions?: XaiSearchOptions; hostedTool: Record; @@ -677,6 +680,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise }>; activeAccountId?: string }> = {}; +mock.module("../src/oauth/store", () => ({ + ...storeModule, + getAccountSet: (provider: string) => accountSets[provider] ?? null, +})); + +import { mapCcaGroundedResponse } from "../src/web-search/gemini-executor"; +import { findGeminiSidecarProvider, planWebSearch } from "../src/web-search"; +import { parseRequest } from "../src/responses/parser"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const routed: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "k" }; +const cca: OcxProviderConfig = { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }; + +function config(overrides: Partial = {}): OcxConfig { + return { port: 10100, defaultProvider: "routed", providers: { routed, "google-antigravity": cca }, ...overrides }; +} +function parsedWithWebSearch() { + return parseRequest({ model: "routed/model", input: "search", stream: true, tools: [{ type: "web_search" }] }); +} +afterEach(() => { accountSets = {}; }); + +describe("mapCcaGroundedResponse (002 live capture shape)", () => { + test("wrapped response -> text + deduped grounding sources", () => { + const out = mapCcaGroundedResponse({ response: { candidates: [{ + content: { parts: [{ text: "Google announced " }, { text: "a device." }] }, + groundingMetadata: { webSearchQueries: ["q"], groundingChunks: [ + { web: { uri: "https://blog.google/a", title: "A" } }, + { web: { uri: "https://blog.google/a", title: "A dup" } }, + { web: { uri: "https://blog.google/b" } }, + ], groundingSupports: [{}] }, + }] } }); + expect(out.text).toBe("Google announced a device."); + expect(out.sources).toEqual([{ url: "https://blog.google/a", title: "A" }, { url: "https://blog.google/b" }]); + expect(out.error).toBeUndefined(); + }); + + test("absent groundingMetadata -> empty sources; empty text -> error outcome", () => { + const ok = mapCcaGroundedResponse({ candidates: [{ content: { parts: [{ text: "plain" }] } }] }); + expect(ok.sources).toEqual([]); + expect(ok.error).toBeUndefined(); + const bad = mapCcaGroundedResponse({ candidates: [{ content: { parts: [] } }] }); + expect(bad.error).toContain("no text"); + expect(mapCcaGroundedResponse(null).error).toBeDefined(); + expect(mapCcaGroundedResponse({}).error).toContain("no candidates"); + }); +}); + +describe("planWebSearch gemini arm (L8)", () => { + const healthy = { accounts: [{ id: "a1", credential: { projectId: "proj-1" } }], activeAccountId: "a1" }; + + test("explicit gemini + OAuth + projectId -> plan with geminiSidecar and 3.7-flash default", () => { + accountSets = { "google-antigravity": healthy }; + const plan = planWebSearch(config({ webSearchSidecar: { backend: "gemini" } }), parsedWithWebSearch(), false, routed, "model", undefined); + expect(plan?.backend).toBe("gemini"); + expect(plan?.geminiSidecar?.providerName).toBe("google-antigravity"); + expect(plan?.settings.model).toBe("gemini-3.7-flash"); + }); + + test.each([ + ["no account set", {}], + ["needsReauth", { "google-antigravity": { accounts: [{ id: "a1", needsReauth: true, credential: { projectId: "p" } }], activeAccountId: "a1" } }], + ["missing projectId", { "google-antigravity": { accounts: [{ id: "a1", credential: {} }], activeAccountId: "a1" } }], + ] as const)("%s -> fail closed (no plan)", (_name, sets) => { + accountSets = sets as typeof accountSets; + expect(planWebSearch(config({ webSearchSidecar: { backend: "gemini" } }), parsedWithWebSearch(), false, routed, "model", undefined)).toBeUndefined(); + }); + + test("findGeminiSidecarProvider: disabled and key-auth providers fail", () => { + accountSets = { "google-antigravity": healthy }; + expect(findGeminiSidecarProvider(config({ providers: { routed, "google-antigravity": { ...cca, disabled: true } } }))).toBeUndefined(); + expect(findGeminiSidecarProvider(config({ providers: { routed, "google-antigravity": { ...cca, authMode: "key", apiKey: "k" } } }))).toBeUndefined(); + expect(findGeminiSidecarProvider(config())?.providerName).toBe("google-antigravity"); + }); +}); From 38a50654e4451f014971933df73ee66952fe54a2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 21 Aug 2026 10:58:07 +0900 Subject: [PATCH 2/4] fix(web-search): redact all thrown branches and pin the gemini request shape Non-Error throws reached tool results unredacted in both new executors. Adds the reviewer-demanded request-shape test (registry destination despite a malicious baseUrl, manual redirect, bearer + IDE UA, full CCA envelope) and the gemini loop fail-closed regression mirroring xai. --- src/web-search/gemini-executor.ts | 2 +- src/web-search/xai-executor.ts | 4 +- tests/gemini-web-search.test.ts | 92 +++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/web-search/gemini-executor.ts b/src/web-search/gemini-executor.ts index a8af9a512c..d41b089cb9 100644 --- a/src/web-search/gemini-executor.ts +++ b/src/web-search/gemini-executor.ts @@ -91,7 +91,7 @@ export async function runGeminiWebSearch( } catch (e) { const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; console.warn(`[web-search] gemini sidecar ${kind} (${Date.now() - t0}ms)`); - return { text: "", sources: [], error: e instanceof Error ? redactSecretString(e.message) : String(e) }; + return { text: "", sources: [], error: redactSecretString(e instanceof Error ? e.message : String(e)) }; } finally { sidecarExit(); linkedSignal.cleanup(); diff --git a/src/web-search/xai-executor.ts b/src/web-search/xai-executor.ts index 11e68edeaf..5edc6686f0 100644 --- a/src/web-search/xai-executor.ts +++ b/src/web-search/xai-executor.ts @@ -127,7 +127,7 @@ export async function runXaiWebSearch( } catch (e) { const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; console.warn(`[web-search] xai sidecar ${kind} (${Date.now() - t0}ms)`); - return { text: "", sources: [], error: e instanceof Error ? redactSecretString(e.message) : String(e) }; + return { text: "", sources: [], error: redactSecretString(e instanceof Error ? e.message : String(e)) }; } finally { sidecarExit(); linkedSignal.cleanup(); @@ -208,7 +208,7 @@ export async function parseXaiResponsesSSE(response: Response): Promise { expect(findGeminiSidecarProvider(config())?.providerName).toBe("google-antigravity"); }); }); + +import { runGeminiWebSearch } from "../src/web-search/gemini-executor"; +import * as oauthModule from "../src/oauth"; +mock.module("../src/oauth", () => ({ + ...oauthModule, + getValidAccessTokenSnapshot: async () => ({ accessToken: "gem-token-abc", expiresAt: Date.now() + 3600_000 }), +})); +import { runWithWebSearch, type WebSearchLoopDeps } from "../src/web-search/loop"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import type { AdapterEvent, ProviderAdapter } from "../src/adapters/base"; + +describe("runGeminiWebSearch request shape (review P1)", () => { + test("malicious baseUrl ignored: registry destination, manual redirect, bearer + IDE UA, full envelope, thinkingConfig", async () => { + accountSets = { "google-antigravity": { accounts: [{ id: "a1", credential: { projectId: "proj-9" } }], activeAccountId: "a1" } }; + const captured: Array<{ url: string; init: RequestInit }> = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + captured.push({ url: String(input instanceof Request ? input.url : input), init: init ?? {} }); + return new Response(JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "ok" }] } }] } }), { status: 200 }); + }) as typeof fetch; + try { + const evil: OcxProviderConfig = { adapter: "google", baseUrl: "https://evil.example/v1", authMode: "oauth" }; + const out = await runGeminiWebSearch("q", "google-antigravity", evil, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000, describeImages: false }); + expect(out.text).toBe("ok"); + expect(captured).toHaveLength(1); + const req = captured[0]!; + expect(new URL(req.url).origin).toBe("https://daily-cloudcode-pa.googleapis.com"); + expect(req.url).toContain("/v1internal:generateContent"); + expect(req.init.redirect).toBe("manual"); + const headers = req.init.headers as Record; + expect(headers["Authorization"]).toBe("Bearer gem-token-abc"); + expect(headers["User-Agent"]).toContain("antigravity"); + const body = JSON.parse(String(req.init.body)); + expect(body.project).toBe("proj-9"); + expect(body.userAgent).toBe("antigravity"); + expect(body.requestType).toBe("agent"); + expect(body.request.tools).toEqual([{ google_search: {} }]); + expect(typeof body.request.sessionId).toBe("string"); + } finally { + globalThis.fetch = realFetch; + } + }); +}); + +describe("loop dispatch: gemini arm fails closed without a sidecar (review P1)", () => { + test("missing geminiSidecar yields the invariant error; forward executor and pool recorder untouched", async () => { + const firstPass: AdapterEvent[] = [ + { type: "tool_call_start", id: "ws1", name: "web_search" }, + { type: "tool_call_delta", arguments: "{\"query\":\"docs\"}" }, + { type: "tool_call_end" }, + { type: "done" }, + ]; + let pass = 0; + let sawToolResult = ""; + const adapter: ProviderAdapter = { + name: "two-pass", + buildRequest: (parsed) => { + if (pass > 0) sawToolResult = JSON.stringify(parsed.context.messages ?? parsed); + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + const events = pass++ === 0 ? firstPass : [{ type: "text_delta", text: "answer" } as AdapterEvent, { type: "done" } as AdapterEvent]; + for (const event of events) yield event; + }, + async parseResponse() { throw new Error("unreachable"); }, + }; + const fetches: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { fetches.push(String(input)); return new Response("{}", { status: 500 }); }) as typeof fetch; + let poolRecorded = 0; + try { + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }] }), + adapter, + backend: "gemini", + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer forward-secret" }), + settings: { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000, describeImages: false }, + maxSearches: 1, + recordSidecarOutcome: () => { poolRecorded += 1; }, + incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + } satisfies WebSearchLoopDeps); + await new Response(response.body).text(); + expect(fetches).toEqual([]); + expect(poolRecorded).toBe(0); + expect(sawToolResult).toContain("without a resolved Antigravity provider"); + } finally { + globalThis.fetch = realFetch; + } + }); +}); From 0780c63b4df2ade20a79e8daf56c2ae041283dec Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 21 Aug 2026 10:59:20 +0900 Subject: [PATCH 3/4] test(web-search): assert the effort-to-thinkingLevel mapping reaches the CCA envelope --- tests/gemini-web-search.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/gemini-web-search.test.ts b/tests/gemini-web-search.test.ts index 016b1f496a..aea601d748 100644 --- a/tests/gemini-web-search.test.ts +++ b/tests/gemini-web-search.test.ts @@ -114,6 +114,10 @@ describe("runGeminiWebSearch request shape (review P1)", () => { expect(body.requestType).toBe("agent"); expect(body.request.tools).toEqual([{ google_search: {} }]); expect(typeof body.request.sessionId).toBe("string"); + // Effort mapping (L8 requirement): "low" on gemini-3.7-flash resolves to the + // tiered wire model with thinkingLevel "low" — both must reach the envelope. + expect(body.model).toBe("gemini-3.7-flash-tiered"); + expect(body.request.generationConfig).toEqual({ thinkingConfig: { thinkingLevel: "low" } }); } finally { globalThis.fetch = realFetch; } From ef551e8dab56cb2923456c1e7df5c5c9f0b78bf2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 21 Aug 2026 12:08:18 +0900 Subject: [PATCH 4/4] fix(web-search): harden Gemini snapshot and body reads Bind the bearer token and Cloud Code Assist project to one OAuthAccessSnapshot so an active-account switch cannot cross-pair credentials. Guard the response body immediately after headers, then consume success and error payloads through the shared 64 KiB byte bound with linked cancellation and strict UTF-8 JSON parsing. Add regressions for account switching, post-header abort settlement, and oversized success/error cancellation. Evidence: bun test tests/gemini-web-search.test.ts tests/cancel-body-on-abort.test.ts tests/bounded-body.test.ts (50 pass); bun x tsc --noEmit; bun run privacy:scan. --- src/web-search/gemini-executor.ts | 42 +++++++---- tests/gemini-web-search.test.ts | 116 +++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 14 deletions(-) diff --git a/src/web-search/gemini-executor.ts b/src/web-search/gemini-executor.ts index d41b089cb9..f575526ef4 100644 --- a/src/web-search/gemini-executor.ts +++ b/src/web-search/gemini-executor.ts @@ -10,15 +10,15 @@ */ import type { OcxProviderConfig } from "../types"; import { getValidAccessTokenSnapshot, publicOAuthAuthenticationErrorMessage } from "../oauth"; -import { getAccountSet } from "../oauth/store"; import { fetchWithResetRetry } from "../lib/upstream-retry"; -import { signalWithTimeout } from "../lib/abort"; +import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; +import { readBoundedResponseBytes } from "../lib/bounded-body"; import { sidecarEnter } from "../lib/sidecar-tracker"; import { redactSecretString } from "../lib/redact"; import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; import { getProviderRegistryEntry } from "../providers/registry"; -import type { WebSearchSource } from "./parse"; +import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse"; import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor"; const CCA_FALLBACK_BASE = "https://daily-cloudcode-pa.googleapis.com"; @@ -35,14 +35,14 @@ export async function runGeminiWebSearch( abortSignal?: AbortSignal, ): Promise { let token: string; + let project: string | undefined; try { - token = (await getValidAccessTokenSnapshot(providerName)).accessToken; + const snapshot = await getValidAccessTokenSnapshot(providerName); + token = snapshot.accessToken; + project = snapshot.projectId; } catch (e) { return { text: "", sources: [], error: `gemini sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(e)}` }; } - const set = getAccountSet(providerName); - const credential = set?.accounts.find(account => account.id === set.activeAccountId)?.credential as { projectId?: string } | undefined; - const project = credential?.projectId; if (!project) { return { text: "", sources: [], error: "gemini sidecar missing Cloud Code Assist project id — re-run ocx login google-antigravity" }; } @@ -82,12 +82,30 @@ export async function runGeminiWebSearch( }), { abortSignal: linkedSignal.signal, label: "gemini-web-search-sidecar" }, ); - if (!res.ok) { - const t = await res.text().catch(() => ""); - return { text: "", sources: [], error: `gemini sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` }; + const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); + try { + const bounded = await readBoundedResponseBytes(res, { + maxBytes: MAX_SIDECAR_RESPONSE_BYTES, + signal: linkedSignal.signal, + }); + if (bounded.oversized) { + const prefix = res.ok ? "gemini sidecar response" : `gemini sidecar HTTP ${res.status} response`; + return { text: "", sources: [], error: `${prefix} exceeded byte bound` }; + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes); + if (!res.ok) { + return { text: "", sources: [], error: `gemini sidecar HTTP ${res.status}: ${redactSecretString(text.slice(0, 200))}` }; + } + let payload: unknown = null; + try { + payload = JSON.parse(text); + } catch { + // The mapper owns the stable malformed/empty JSON outcome. + } + return mapCcaGroundedResponse(payload); + } finally { + detachBodyGuard(); } - const payload = await res.json().catch(() => null); - return mapCcaGroundedResponse(payload); } catch (e) { const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; console.warn(`[web-search] gemini sidecar ${kind} (${Date.now() - t0}ms)`); diff --git a/tests/gemini-web-search.test.ts b/tests/gemini-web-search.test.ts index aea601d748..0cc71cca4c 100644 --- a/tests/gemini-web-search.test.ts +++ b/tests/gemini-web-search.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import * as storeModule from "../src/oauth/store"; +import { MAX_SIDECAR_RESPONSE_BYTES } from "../src/web-search/parse"; let accountSets: Record }>; activeAccountId?: string }> = {}; mock.module("../src/oauth/store", () => ({ @@ -21,7 +22,26 @@ function config(overrides: Partial = {}): OcxConfig { function parsedWithWebSearch() { return parseRequest({ model: "routed/model", input: "search", stream: true, tools: [{ type: "web_search" }] }); } -afterEach(() => { accountSets = {}; }); +let accessSnapshot = { + provider: "google-antigravity", + accountId: "a1", + generation: "generation-a", + accessToken: "gem-token-abc", + projectId: "proj-9", +}; +let afterAccessSnapshot: (() => void) | undefined; + +afterEach(() => { + accountSets = {}; + accessSnapshot = { + provider: "google-antigravity", + accountId: "a1", + generation: "generation-a", + accessToken: "gem-token-abc", + projectId: "proj-9", + }; + afterAccessSnapshot = undefined; +}); describe("mapCcaGroundedResponse (002 live capture shape)", () => { test("wrapped response -> text + deduped grounding sources", () => { @@ -81,7 +101,11 @@ import { runGeminiWebSearch } from "../src/web-search/gemini-executor"; import * as oauthModule from "../src/oauth"; mock.module("../src/oauth", () => ({ ...oauthModule, - getValidAccessTokenSnapshot: async () => ({ accessToken: "gem-token-abc", expiresAt: Date.now() + 3600_000 }), + getValidAccessTokenSnapshot: async () => { + const snapshot = accessSnapshot; + afterAccessSnapshot?.(); + return snapshot; + }, })); import { runWithWebSearch, type WebSearchLoopDeps } from "../src/web-search/loop"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; @@ -122,6 +146,94 @@ describe("runGeminiWebSearch request shape (review P1)", () => { globalThis.fetch = realFetch; } }); + + test("uses one atomic OAuth snapshot when the active account changes before dispatch", async () => { + accessSnapshot = { + provider: "google-antigravity", + accountId: "account-a", + generation: "generation-a", + accessToken: "token-a", + projectId: "project-a", + }; + accountSets = { + "google-antigravity": { + accounts: [ + { id: "account-a", credential: { projectId: "project-a" } }, + { id: "account-b", credential: { projectId: "project-b" } }, + ], + activeAccountId: "account-a", + }, + }; + afterAccessSnapshot = () => { + accountSets["google-antigravity"]!.activeAccountId = "account-b"; + }; + let request: RequestInit | undefined; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + request = init; + return new Response(JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "ok" }] } }] } }), { status: 200 }); + }) as typeof fetch; + try { + const out = await runGeminiWebSearch("q", "google-antigravity", cca, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000 }); + expect(out.text).toBe("ok"); + expect((request!.headers as Record)["Authorization"]).toBe("Bearer token-a"); + expect(JSON.parse(String(request!.body)).project).toBe("project-a"); + expect(accountSets["google-antigravity"]!.activeAccountId).toBe("account-b"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("an abort immediately after headers cancels and settles the response body", async () => { + const parent = new AbortController(); + let bodyCancelled = false; + let bodyCancelSettled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "late" }] } }] } }))); + }, + cancel() { + bodyCancelled = true; + return Promise.resolve().then(() => { bodyCancelSettled = true; }); + }, + }); + const realFetch = globalThis.fetch; + globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => new Promise(resolve => { + resolve(new Response(body, { status: 200 })); + parent.abort(new DOMException("client disconnected", "AbortError")); + })) as typeof fetch; + try { + const out = await runGeminiWebSearch("q", "google-antigravity", cca, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000 }, parent.signal); + await Promise.resolve(); + expect(out.error).toBeDefined(); + expect(bodyCancelled).toBe(true); + expect(bodyCancelSettled).toBe(true); + } finally { + globalThis.fetch = realFetch; + } + }); + + test.each([ + ["success", 200], + ["error", 500], + ] as const)("oversized %s body is rejected and cancelled", async (_branch, status) => { + let bodyCancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MAX_SIDECAR_RESPONSE_BYTES + 1).fill(0x61)); + }, + cancel() { bodyCancelled = true; }, + }); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(body, { status })) as typeof fetch; + try { + const out = await runGeminiWebSearch("q", "google-antigravity", cca, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000 }); + expect(out.error).toContain("byte bound"); + expect(bodyCancelled).toBe(true); + } finally { + globalThis.fetch = realFetch; + } + }); }); describe("loop dispatch: gemini arm fails closed without a sidecar (review P1)", () => {