From d3f4530ce8904e61e97693663d3b3ca67127571c Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:41:09 +1000 Subject: [PATCH] feat(google): allow operator headers on Gemini web search (#115549) * feat(google): support Gemini web search headers * fix(google): validate Gemini search headers * docs(changelog): credit Gemini headers * test(secrets): route config contracts through collector * chore: remove release-owned changelog entry * test(google): cover resolved search headers --- docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- .../reference/secretref-credential-surface.md | 1 + ...tref-user-supplied-credentials-matrix.json | 7 + docs/tools/gemini-search.md | 33 ++++ extensions/google/manifest.test.ts | 51 +++++++ extensions/google/openclaw.plugin.json | 16 ++ .../src/gemini-web-search-provider.runtime.ts | 115 ++++++++++++-- .../src/gemini-web-search-provider.shared.ts | 1 + .../google/src/gemini-web-search-provider.ts | 2 + extensions/google/web-search-provider.test.ts | 144 +++++++++++++++++- ...-config-collectors-plugins.bundled.test.ts | 56 ++++++- src/secrets/runtime.coverage.test.ts | 28 ++-- 13 files changed, 430 insertions(+), 30 deletions(-) diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 68230125b9c5..2b1fd4092c86 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { "core": 2307, "channel": 3692, - "plugin": 4055 + "plugin": 4057 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 513d7d2bbd7c..ee5c56cea309 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -00b1e5142ead3ba8522cc4815dfb6f8d3fb5ea4e2937df93da280d44506308d7 config-baseline.json +6b02737084409d3a0c92611049077284826bafbe29e7227b3eda591ead80c2e3 config-baseline.json b89715475e4b18a0d32765fda42bcce38537f6d49f949bb4ff0c7d1630101882 config-baseline.core.json e9a81ee89ff032033012413e161316e4d07e8f6b206382a25fed2f8485151b5e config-baseline.channel.json -d0352edda9ee2e6ff3c52b163bf2364ffef6a1d0d2ffe3b0b38de28756fd6c1b config-baseline.plugin.json +c097c0bee74849e691bf295b070ce189d4e0e501bd770e448922d9f399c4901f config-baseline.plugin.json diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 23ef9b49e03f..62922568da59 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -50,6 +50,7 @@ The lists below are generated from the source target registry and checked agains - `plugins.entries.firecrawl.config.webFetch.apiKey` - `plugins.entries.google-meet.config.realtime.providers.*.apiKey` - `plugins.entries.google.config.webSearch.apiKey` +- `plugins.entries.google.config.webSearch.headers.*` - `plugins.entries.xai.config.webSearch.apiKey` - `plugins.entries.moonshot.config.webSearch.apiKey` - `plugins.entries.perplexity.config.webSearch.apiKey` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index 472096eb3221..c328bfed6df1 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -633,6 +633,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "plugins.entries.google.config.webSearch.headers.*", + "configFile": "openclaw.json", + "path": "plugins.entries.google.config.webSearch.headers.*", + "secretShape": "secret_input", + "optIn": true + }, { "id": "plugins.entries.minimax.config.webSearch.apiKey", "configFile": "openclaw.json", diff --git a/docs/tools/gemini-search.md b/docs/tools/gemini-search.md index 94bb8014b9d2..78981fe444f0 100644 --- a/docs/tools/gemini-search.md +++ b/docs/tools/gemini-search.md @@ -4,6 +4,7 @@ read_when: - You want to use Gemini for web_search - You need a GEMINI_API_KEY or models.providers.google.apiKey - You want Google Search grounding + - Your Gemini gateway requires request headers title: "Gemini search" --- @@ -41,6 +42,14 @@ citations. webSearch: { apiKey: "AIza...", // optional if GEMINI_API_KEY or models.providers.google.apiKey is set baseUrl: "https://generativelanguage.googleapis.com/v1beta", // optional; falls back to models.providers.google.baseUrl + headers: { + "X-Routing-Target": "staging", + "X-Gateway-Token": { + source: "env", + provider: "default", + id: "GEMINI_GATEWAY_TOKEN", + }, + }, model: "gemini-2.5-flash", // default }, }, @@ -65,6 +74,30 @@ then `models.providers.google.apiKey`. For base URLs, the dedicated For a gateway install, put env keys in `~/.openclaw/.env`. +### Request headers + +Set `plugins.entries.google.config.webSearch.headers` when an operator gateway +needs extra request metadata. Plain string values use normal config handling; +they are not automatically treated as secret merely because they are headers. +When a header contains a secret, use a [SecretRef](/gateway/secrets) value as +shown above. OpenClaw resolves that value at runtime and applies the existing +secret redaction path to it. + +The Gemini request keeps ownership of `Content-Type`, `x-goog-api-key`, and +`x-goog-api-client`; those values override same-named configured headers. +`models.providers.google.headers` are not inherited because they belong to the +model provider endpoint, which can differ from the web-search endpoint. + +Empty plain-string values are valid. Invalid fields and transport-owned or +framing names such as `Content-Length`, `Host`, and `Transfer-Encoding` fail the +current search before cache lookup or network I/O. + +Effective header names and values partition the in-memory search cache through a +digest, so two routing targets do not share results. Configured values for the +provider-owned names above are ignored and do not partition the cache. On a +cross-origin redirect, the guarded fetch path retains only its standard safe +redirect headers. + ## How it works Unlike traditional search providers that return a list of links and snippets, diff --git a/extensions/google/manifest.test.ts b/extensions/google/manifest.test.ts index 66aefda656bd..0398a5faaab6 100644 --- a/extensions/google/manifest.test.ts +++ b/extensions/google/manifest.test.ts @@ -1,5 +1,7 @@ // Google tests cover manifest plugin behavior. import { readFileSync } from "node:fs"; +import { buildJsonPluginConfigSchema } from "openclaw/plugin-sdk/core"; +import type { JsonSchemaObject } from "openclaw/plugin-sdk/json-schema-runtime"; import { describe, expect, it } from "vitest"; type GoogleManifest = { @@ -25,6 +27,13 @@ type GoogleManifest = { reason?: string; }>; }; + configSchema?: JsonSchemaObject; + configContracts?: { + secretInputs?: { + paths?: Array<{ path?: string; expected?: string }>; + }; + }; + uiHints?: Record; }; const RETIRED_GEMINI_CHAT_MODELS = [ @@ -128,3 +137,45 @@ describe("google manifest model catalog", () => { } }); }); + +describe("google manifest webSearch headers", () => { + function validateWebSearchConfig(webSearch: unknown): { success: boolean } { + const schema = loadManifest().configSchema; + if (!schema) { + throw new Error("expected google manifest configSchema"); + } + const safeParse = buildJsonPluginConfigSchema(schema).safeParse; + if (!safeParse) { + throw new Error("expected a safeParse validator for the google config schema"); + } + return safeParse({ webSearch }); + } + + it("accepts plain and SecretRef header values", () => { + expect(validateWebSearchConfig({ headers: { "X-Routing-Target": "staging" } }).success).toBe( + true, + ); + expect( + validateWebSearchConfig({ + headers: { + "X-Gateway-Token": { + source: "env", + provider: "default", + id: "GEMINI_GATEWAY_TOKEN", + }, + }, + }).success, + ).toBe(true); + expect(validateWebSearchConfig({ headers: { "X-Retry-Count": 3 } }).success).toBe(false); + }); + + it("uses the existing SecretRef contract without classifying plain headers as sensitive", () => { + const manifest = loadManifest(); + expect(manifest.configContracts?.secretInputs?.paths).toContainEqual({ + path: "webSearch.headers.*", + expected: "string", + }); + expect(manifest.uiHints?.["webSearch.headers"]?.sensitive).not.toBe(true); + expect(manifest.uiHints?.["webSearch.headers.*"]?.sensitive).not.toBe(true); + }); +}); diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 9f89351d32db..f5c2e1508673 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -718,6 +718,16 @@ "webSearch.baseUrl": { "label": "Gemini Search Base URL", "help": "Optional Gemini API base URL for web search grounding proxies." + }, + "webSearch.headers": { + "label": "Gemini Search Headers", + "help": "Optional headers merged into Gemini web search requests. Use a SecretRef value when a header contains a secret.", + "advanced": true + } + }, + "configContracts": { + "secretInputs": { + "paths": [{ "path": "webSearch.headers.*", "expected": "string" }] } }, "contracts": { @@ -763,6 +773,12 @@ }, "baseUrl": { "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": ["string", "object"] + } } } } diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts index 973c1fe97fae..f60c5438a6e7 100644 --- a/extensions/google/src/gemini-web-search-provider.runtime.ts +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -1,4 +1,5 @@ // Google provider module implements model/runtime integration. +import { createHash } from "node:crypto"; import { createProviderHttpError, formatProviderHttpErrorMessage, @@ -24,6 +25,7 @@ import { wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveGoogleApiClientHeaders } from "../google-api-client-header.js"; import { @@ -63,6 +65,28 @@ type GeminiGroundingResponse = { }; }; +const GEMINI_PROVIDER_OWNED_HEADER_NAMES = new Set([ + "content-type", + "x-goog-api-client", + "x-goog-api-key", +]); + +// Headers validates field syntax, but Undici does not implement Fetch's +// forbidden-request-header checks. These names can otherwise be consumed, +// ignored, or rejected only after the request reaches the transport. +const GEMINI_UNSAFE_REQUEST_HEADER_NAMES = new Set([ + "connection", + "content-length", + "expect", + "host", + "keep-alive", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + function throwMalformedGeminiResponse(): never { throw new Error("Gemini API error: malformed JSON response"); } @@ -178,6 +202,69 @@ function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined { ); } +function resolveGeminiWebSearchHeaders(gemini?: GeminiConfig): Record | undefined { + if (!isRecord(gemini?.headers)) { + return undefined; + } + const headers = new Headers(); + for (const [name, input] of Object.entries(gemini.headers)) { + const path = `plugins.entries.google.config.webSearch.headers[${JSON.stringify(name)}]`; + const value = + typeof input === "string" + ? input + : normalizeResolvedSecretInputString({ value: input, path }); + if (value === undefined) { + throw new Error(`${path} must be a string or resolved SecretRef.`); + } + let normalizedName: string; + let normalizedValue: string; + try { + const candidate = new Headers([[name, value]]); + const [entry] = candidate.entries(); + if (!entry) { + throw new Error("missing normalized header entry"); + } + [normalizedName, normalizedValue] = entry; + } catch { + throw new Error(`${path} is not a valid HTTP header.`); + } + if (GEMINI_UNSAFE_REQUEST_HEADER_NAMES.has(normalizedName)) { + throw new Error(`${path} uses a reserved or framing HTTP header.`); + } + if (GEMINI_PROVIDER_OWNED_HEADER_NAMES.has(normalizedName)) { + continue; + } + headers.set(normalizedName, normalizedValue); + } + const entries = [...headers.entries()]; + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function buildGeminiRequestHeaders(params: { + apiKey: string; + baseUrl: string; + operatorHeaders?: Record; +}): HeadersInit { + const providerHeaders = { + "Content-Type": "application/json", + "x-goog-api-key": params.apiKey, + ...resolveGoogleApiClientHeaders({ + baseUrl: params.baseUrl, + api: "google-generative-ai", + capability: "other", + transport: "http", + }), + }; + if (!params.operatorHeaders) { + return providerHeaders; + } + const headers = new Headers(params.operatorHeaders); + for (const [name, value] of Object.entries(providerHeaders)) { + headers.set(name, value); + } + return headers; +} + async function runGeminiSearch(params: { query: string; apiKey: string; @@ -186,6 +273,7 @@ async function runGeminiSearch(params: { timeoutSeconds: number; signal?: AbortSignal; timeRangeFilter?: GeminiTimeRangeFilter; + headers?: Record; }): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { const endpoint = `${params.baseUrl}/models/${params.model}:generateContent`; const googleSearch = @@ -198,16 +286,11 @@ async function runGeminiSearch(params: { signal: params.signal, init: { method: "POST", - headers: { - "Content-Type": "application/json", - "x-goog-api-key": params.apiKey, - ...resolveGoogleApiClientHeaders({ - baseUrl: params.baseUrl, - api: "google-generative-ai", - capability: "other", - transport: "http", - }), - }, + headers: buildGeminiRequestHeaders({ + apiKey: params.apiKey, + baseUrl: params.baseUrl, + operatorHeaders: params.headers, + }), body: JSON.stringify({ contents: [{ parts: [{ text: params.query }] }], tools: [{ google_search: googleSearch }], @@ -338,6 +421,16 @@ export async function executeGeminiSearch( undefined; const model = resolveGeminiModel(geminiConfig); const baseUrl = resolveGeminiBaseUrl(geminiConfig); + const headers = resolveGeminiWebSearchHeaders(geminiConfig); + const headersCacheKey = headers + ? createHash("sha256") + .update( + JSON.stringify( + Object.entries(headers).toSorted(([left], [right]) => left.localeCompare(right)), + ), + ) + .digest("hex") + : undefined; const cacheKey = buildSearchCacheKey([ "gemini", query, @@ -347,6 +440,7 @@ export async function executeGeminiSearch( timeRange.freshness, timeRange.timeRangeFilter?.startTime, timeRange.timeRangeFilter?.endTime, + headersCacheKey, ]); const cached = readCachedSearchPayload(cacheKey); if (cached) { @@ -362,6 +456,7 @@ export async function executeGeminiSearch( timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), signal: context?.signal, timeRangeFilter: timeRange.timeRangeFilter, + headers, }); const payload = { query, diff --git a/extensions/google/src/gemini-web-search-provider.shared.ts b/extensions/google/src/gemini-web-search-provider.shared.ts index faad24c13047..cea1bbd8ef1c 100644 --- a/extensions/google/src/gemini-web-search-provider.shared.ts +++ b/extensions/google/src/gemini-web-search-provider.shared.ts @@ -10,6 +10,7 @@ const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash"; export type GeminiConfig = { apiKey?: unknown; baseUrl?: unknown; + headers?: unknown; model?: unknown; providerApiKey?: unknown; providerBaseUrl?: unknown; diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index 191802dd5556..5472d62e4247 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -97,6 +97,8 @@ function withGoogleModelProviderFallbacks( if (provider.baseUrl !== undefined) { gemini.providerBaseUrl = provider.baseUrl; } + // Provider headers stay scoped to the provider base URL. Web-search headers + // are configured explicitly under the Google plugin for its own endpoint. Object.defineProperty(mergedSearchConfig, "gemini", { value: gemini, enumerable: geminiDescriptor?.enumerable ?? false, diff --git a/extensions/google/web-search-provider.test.ts b/extensions/google/web-search-provider.test.ts index fa5dd86b02a5..0f015d547906 100644 --- a/extensions/google/web-search-provider.test.ts +++ b/extensions/google/web-search-provider.test.ts @@ -51,7 +51,27 @@ function requireFirstGeminiFetchCall( function getFetchHeaders(mockFetch: ReturnType): Record { const [, init] = requireFirstGeminiFetchCall(mockFetch); - return (init?.headers as Record | undefined) ?? {}; + return Object.fromEntries(new Headers(init?.headers).entries()); +} + +function createGeminiToolWithHeaders(headers: Record) { + return createGeminiWebSearchProvider().createTool({ + config: { + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: "AIza-plugin-test", + headers, + }, + }, + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); } function getGeminiFetchUrl(mockFetch: ReturnType): string | undefined { @@ -142,6 +162,128 @@ describe("google web search provider", () => { ); }); + it("sends operator headers while keeping provider-owned headers authoritative", async () => { + const mockFetch = installGeminiFetch(); + const tool = createGeminiToolWithHeaders({ + "X-Routing-Target": "staging", + "X-Gateway-Token": "resolved-gateway-token", + "X-Goog-Api-Key": "operator-value", + }); + + await tool?.execute({ query: "OpenClaw operator headers" }); + + expect(getFetchHeaders(mockFetch)).toMatchObject({ + "content-type": "application/json", + "x-gateway-token": "resolved-gateway-token", + "x-goog-api-key": "AIza-plugin-test", + "x-routing-target": "staging", + }); + }); + + it("partitions cached Gemini results by operator headers", async () => { + const mockFetch = installGeminiFetch(); + + await createGeminiToolWithHeaders({ "X-Routing-Target": "staging" })?.execute({ + query: "OpenClaw header cache partition", + }); + await createGeminiToolWithHeaders({ "X-Routing-Target": "production" })?.execute({ + query: "OpenClaw header cache partition", + }); + + const postCalls = mockFetch.mock.calls.filter(([, init]) => typeof init?.body === "string"); + expect(postCalls).toHaveLength(2); + }); + + it("does not partition cached results by overwritten provider-owned headers", async () => { + const mockFetch = installGeminiFetch(); + + await createGeminiToolWithHeaders({ "X-Goog-Api-Key": "operator-one" })?.execute({ + query: "OpenClaw provider-owned header cache", + }); + await createGeminiToolWithHeaders({ "x-goog-api-key": "operator-two" })?.execute({ + query: "OpenClaw provider-owned header cache", + }); + + const postCalls = mockFetch.mock.calls.filter(([, init]) => typeof init?.body === "string"); + expect(postCalls).toHaveLength(1); + expect(getFetchHeaders(mockFetch)["x-goog-api-key"]).toBe("AIza-plugin-test"); + }); + + it("normalizes case collisions before sending and partitioning the cache", async () => { + const mockFetch = installGeminiFetch(); + + await createGeminiToolWithHeaders({ + "X-Routing-Target": "stale", + "x-routing-target": "production", + })?.execute({ query: "OpenClaw case-colliding header cache" }); + await createGeminiToolWithHeaders({ "X-Routing-Target": "production" })?.execute({ + query: "OpenClaw case-colliding header cache", + }); + + const postCalls = mockFetch.mock.calls.filter(([, init]) => typeof init?.body === "string"); + expect(postCalls).toHaveLength(1); + expect(getFetchHeaders(mockFetch)["x-routing-target"]).toBe("production"); + }); + + it("preserves legal empty literal header values", async () => { + const mockFetch = installGeminiFetch(); + const tool = createGeminiToolWithHeaders({ "X-Optional-Metadata": " \t " }); + + await tool?.execute({ query: "OpenClaw empty operator header" }); + + expect(getFetchHeaders(mockFetch)["x-optional-metadata"]).toBe(""); + }); + + it("rejects malformed operator headers before sending a request", async () => { + const mockFetch = installGeminiFetch(); + const tool = createGeminiToolWithHeaders({ "Bad Header": "value" }); + + await expect(tool?.execute({ query: "OpenClaw malformed header" })).rejects.toThrow( + 'plugins.entries.google.config.webSearch.headers["Bad Header"] is not a valid HTTP header', + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it.each([ + "Connection", + "Content-Length", + "Expect", + "Host", + "Keep-Alive", + "Proxy-Connection", + "TE", + "Trailer", + "Transfer-Encoding", + "Upgrade", + ])("rejects reserved or framing operator header %s before fetch", async (name) => { + const mockFetch = installGeminiFetch(); + const tool = createGeminiToolWithHeaders({ [name]: "configured-value" }); + + await expect(tool?.execute({ query: `OpenClaw rejects ${name}` })).rejects.toThrow( + `plugins.entries.google.config.webSearch.headers["${name}"] uses a reserved or framing HTTP header`, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("keeps unresolved explicit header SecretRefs strict", async () => { + const mockFetch = installGeminiFetch(); + const tool = createGeminiToolWithHeaders({ + "X-Gateway-Token": { + source: "env", + provider: "default", + id: "GEMINI_GATEWAY_TOKEN", + }, + }); + + await expect( + tool?.execute({ query: "OpenClaw unresolved header SecretRef" }), + ).rejects.toMatchObject({ + name: "UnresolvedSecretInputError", + path: 'plugins.entries.google.config.webSearch.headers["X-Gateway-Token"]', + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("accepts Gemini success JSON with empty grounding metadata", async () => { vi.stubGlobal( "fetch", diff --git a/src/secrets/runtime-config-collectors-plugins.bundled.test.ts b/src/secrets/runtime-config-collectors-plugins.bundled.test.ts index 62d7c57748cd..2ca7ef181665 100644 --- a/src/secrets/runtime-config-collectors-plugins.bundled.test.ts +++ b/src/secrets/runtime-config-collectors-plugins.bundled.test.ts @@ -5,8 +5,9 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import type { OpenClawConfig } from "../config/config.js"; import { findBundledPluginMetadataById } from "../plugins/bundled-plugin-metadata.js"; import { resolvePluginConfigContractsById } from "../plugins/config-contracts.js"; +import { resolveSecretRefValues } from "./resolve.js"; import { collectPluginConfigAssignments } from "./runtime-config-collectors-plugins.js"; -import { createResolverContext } from "./runtime-shared.js"; +import { applyResolvedAssignments, createResolverContext } from "./runtime-shared.js"; function envRef(id: string) { return { source: "env" as const, provider: "default", id }; @@ -143,6 +144,59 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => { }); }); + it("resolves only explicitly referenced Google web-search headers", async () => { + expect( + findBundledPluginMetadataById("google", { + includeChannelConfigs: false, + includeSyntheticChannelConfigs: false, + })?.manifest.configContracts?.secretInputs?.paths, + ).toEqual([{ path: "webSearch.headers.*", expected: "string" }]); + const config = { + agents: explicitMainRoster, + plugins: { + entries: { + google: { + enabled: true, + config: { + webSearch: { + headers: { + "X-Routing-Target": "staging", + "X-Gateway-Token": envRef("GEMINI_GATEWAY_TOKEN"), + }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + const env = { GEMINI_GATEWAY_TOKEN: "resolved-gateway-token" }; + const context = createResolverContext({ sourceConfig: config, env }); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: new Map([["google", "bundled"]]), + }); + + expect(context.assignments.map((assignment) => assignment.path)).toEqual([ + "plugins.entries.google.config.webSearch.headers.X-Gateway-Token", + ]); + const resolved = await resolveSecretRefValues( + context.assignments.map((assignment) => assignment.ref), + { config, env, cache: context.cache }, + ); + applyResolvedAssignments({ assignments: context.assignments, resolved }); + expect(config.plugins?.entries?.google?.config).toMatchObject({ + webSearch: { + headers: { + "X-Routing-Target": "staging", + "X-Gateway-Token": "resolved-gateway-token", + }, + }, + }); + }); + it("collects voice-call SecretRef assignments from bundled manifest contracts", () => { expect( findBundledPluginMetadataById("voice-call", { diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index 94ebc7bae689..40e7917453bc 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -520,25 +520,23 @@ function logCoverageBatch(label: string, batch: readonly SecretRegistryEntry[]): ); } -function batchNeedsRuntimeWebTools(batch: readonly SecretRegistryEntry[]): boolean { - return batch.some( - (entry) => - entry.id.startsWith("tools.web.") || - (entry.id.startsWith("plugins.entries.") && - (entry.id.includes(".config.webSearch.") || entry.id.includes(".config.webFetch."))), +function isRuntimeWebCredentialTarget(entry: SecretRegistryEntry): boolean { + if (!entry.id.endsWith(".apiKey")) { + return false; + } + return ( + entry.id.startsWith("tools.web.") || + (entry.id.startsWith("plugins.entries.") && + (entry.id.includes(".config.webSearch.") || entry.id.includes(".config.webFetch."))) ); } +function batchNeedsRuntimeWebTools(batch: readonly SecretRegistryEntry[]): boolean { + return batch.some(isRuntimeWebCredentialTarget); +} + function batchUsesRuntimeWebToolsOnly(batch: readonly SecretRegistryEntry[]): boolean { - return ( - batch.length > 0 && - batch.every( - (entry) => - entry.id.startsWith("tools.web.") || - (entry.id.startsWith("plugins.entries.") && - (entry.id.includes(".config.webSearch.") || entry.id.includes(".config.webFetch."))), - ) - ); + return batch.length > 0 && batch.every(isRuntimeWebCredentialTarget); } function collectOpenClawCoverageEntries(options: {