From 3f045d91295cf7110473ab94c7f703bc5e12c135 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 23:47:23 -0700 Subject: [PATCH] fix(web-search): scope explicit provider runtime loading --- CHANGELOG.md | 1 + src/web-search/runtime.test.ts | 145 +++++++++++++++++++++++++++++++-- src/web-search/runtime.ts | 82 +++++++++++++++++++ 3 files changed, 222 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ebc55dd5bc..4dc781e25f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Web search: scope explicit bundled `web_search` provider runtime loading through manifest ownership, so selecting DuckDuckGo/Gemini/etc. does not import unrelated bundled providers or log their optional dependency failures. Thanks @vincentkoc. - Release/beta smoke: resolve the dispatched Telegram beta E2E run from `gh run list` when `gh workflow run` returns no run URL, so the maintainer helper does not fail immediately after dispatch. Thanks @vincentkoc. - Media/images: keep HEIC/HEIF attachments fail-closed when optional Sharp conversion is unavailable instead of sending originals that still need conversion. Thanks @vincentkoc. - Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting. diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 00e75006077..f1a674a6289 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -12,12 +12,39 @@ type TestPluginWebSearchConfig = { }; }; -const { resolvePluginWebSearchProvidersMock, resolveRuntimeWebSearchProvidersMock } = vi.hoisted( - () => ({ - resolvePluginWebSearchProvidersMock: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []), - resolveRuntimeWebSearchProvidersMock: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []), - }), -); +type WebSearchProviderResolverParams = { + bundledAllowlistCompat?: boolean; + config?: OpenClawConfig; + onlyPluginIds?: readonly string[]; + origin?: string; +}; + +type ManifestContractOwnerParams = { + config?: OpenClawConfig; + contract?: string; + origin?: string; + value?: string; +}; + +const { + resolveManifestContractOwnerPluginIdMock, + resolvePluginWebSearchProvidersMock, + resolveRuntimeWebSearchProvidersMock, +} = vi.hoisted(() => ({ + resolveManifestContractOwnerPluginIdMock: vi.fn( + (_params: ManifestContractOwnerParams): string | undefined => undefined, + ), + resolvePluginWebSearchProvidersMock: vi.fn( + (_params?: WebSearchProviderResolverParams): PluginWebSearchProviderEntry[] => [], + ), + resolveRuntimeWebSearchProvidersMock: vi.fn( + (_params?: WebSearchProviderResolverParams): PluginWebSearchProviderEntry[] => [], + ), +})); + +vi.mock("../plugins/plugin-registry-contributions.js", () => ({ + resolveManifestContractOwnerPluginId: resolveManifestContractOwnerPluginIdMock, +})); vi.mock("../plugins/web-search-providers.runtime.js", () => ({ resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, @@ -108,8 +135,10 @@ describe("web search runtime", () => { }); beforeEach(() => { + resolveManifestContractOwnerPluginIdMock.mockReset(); resolvePluginWebSearchProvidersMock.mockReset(); resolveRuntimeWebSearchProvidersMock.mockReset(); + resolveManifestContractOwnerPluginIdMock.mockReturnValue(undefined); resolvePluginWebSearchProvidersMock.mockReturnValue([]); resolveRuntimeWebSearchProvidersMock.mockReturnValue([]); }); @@ -533,6 +562,110 @@ describe("web search runtime", () => { ).rejects.toThrow("google aborted"); }); + it("scopes runtime provider loading to the configured bundled web_search provider", async () => { + resolveManifestContractOwnerPluginIdMock.mockImplementation(({ value }) => + value === "duckduckgo" ? "duckduckgo" : undefined, + ); + resolveRuntimeWebSearchProvidersMock.mockReturnValue([createDuckDuckGoSearchProvider()]); + + await expect( + runWebSearch({ + config: { + tools: { + web: { + search: { + provider: "duckduckgo", + }, + }, + }, + }, + args: { query: "configured-duck" }, + }), + ).resolves.toMatchObject({ + provider: "duckduckgo", + }); + + expect(resolveManifestContractOwnerPluginIdMock).toHaveBeenCalledWith( + expect.objectContaining({ + contract: "webSearchProviders", + origin: "bundled", + value: "duckduckgo", + }), + ); + expect(resolveRuntimeWebSearchProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["duckduckgo"], + }), + ); + }); + + it("scopes runtime provider loading through manifest ownership when provider id differs from plugin id", async () => { + resolveManifestContractOwnerPluginIdMock.mockImplementation(({ value }) => + value === "gemini" ? "google" : undefined, + ); + resolveRuntimeWebSearchProvidersMock.mockReturnValue([ + createGoogleSearchProvider({ + id: "gemini", + pluginId: "google", + }), + ]); + + await expect( + runWebSearch({ + config: {}, + runtimeWebSearch: { + providerConfigured: "gemini", + selectedProvider: "gemini", + providerSource: "configured", + diagnostics: [], + }, + args: { query: "configured-gemini" }, + }), + ).resolves.toMatchObject({ + provider: "gemini", + }); + + expect(resolveRuntimeWebSearchProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["google"], + }), + ); + }); + + it("keeps runtime provider loading unscoped when configured provider ownership is unknown", async () => { + resolveManifestContractOwnerPluginIdMock.mockReturnValue(undefined); + resolveRuntimeWebSearchProvidersMock.mockReturnValue([ + createCustomSearchProvider({ + id: "external-search", + pluginId: "external-search", + requiresCredential: false, + }), + ]); + + await expect( + runWebSearch({ + config: { + tools: { + web: { + search: { + provider: "external-search", + }, + }, + }, + }, + args: { query: "external-provider" }, + }), + ).resolves.toMatchObject({ + provider: "external-search", + }); + + expect(resolveRuntimeWebSearchProvidersMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + onlyPluginIds: expect.anything(), + }), + ); + }); + it("does not fall back when the caller explicitly selects a provider", async () => { resolveRuntimeWebSearchProvidersMock.mockReturnValue([ createGoogleSearchProvider({ diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index f3db0738766..7cb86ec8706 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -5,6 +5,7 @@ import { } from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logVerbose } from "../globals.js"; +import { resolveManifestContractOwnerPluginId } from "../plugins/plugin-registry-contributions.js"; import type { PluginWebSearchProviderEntry, WebSearchProviderToolDefinition, @@ -185,22 +186,92 @@ export function resolveWebSearchProviderId(params: { return providers[0]?.id ?? ""; } +function resolveExplicitWebSearchProviderId(params: { + search?: WebSearchConfig; + runtimeWebSearch?: RuntimeWebSearchMetadata; + providerId?: string; + includeRuntimeSelection?: boolean; +}): string | undefined { + const callerProviderId = normalizeOptionalLowercaseString(params.providerId); + if (callerProviderId) { + return callerProviderId; + } + + if (params.includeRuntimeSelection && params.runtimeWebSearch?.providerSource === "configured") { + const runtimeProviderId = normalizeOptionalLowercaseString( + params.runtimeWebSearch.selectedProvider ?? params.runtimeWebSearch.providerConfigured, + ); + if (runtimeProviderId) { + return runtimeProviderId; + } + } + + const configuredProviderId = + params.search && "provider" in params.search + ? normalizeOptionalLowercaseString(params.search.provider) + : undefined; + if (configuredProviderId) { + return configuredProviderId; + } + return undefined; +} + +function resolveExplicitWebSearchProviderPluginIds(params: { + config?: OpenClawConfig; + search?: WebSearchConfig; + runtimeWebSearch?: RuntimeWebSearchMetadata; + providerId?: string; + includeRuntimeSelection?: boolean; +}): readonly string[] | undefined { + const providerId = resolveExplicitWebSearchProviderId(params); + if (!providerId) { + return undefined; + } + const ownerPluginId = resolveManifestContractOwnerPluginId({ + config: params.config, + contract: "webSearchProviders", + value: providerId, + origin: "bundled", + }); + return ownerPluginId ? [ownerPluginId] : undefined; +} + +function resolveWebSearchProviderLoadScope(params: { + config?: OpenClawConfig; + search?: WebSearchConfig; + runtimeWebSearch?: RuntimeWebSearchMetadata; + providerId?: string; + includeRuntimeSelection?: boolean; +}): { onlyPluginIds?: readonly string[] } { + const onlyPluginIds = resolveExplicitWebSearchProviderPluginIds(params); + return onlyPluginIds ? { onlyPluginIds } : {}; +} + export function resolveWebSearchDefinition( options?: ResolveWebSearchDefinitionParams, ): { provider: PluginWebSearchProviderEntry; definition: WebSearchProviderToolDefinition } | null { const config = resolveWebSearchRuntimeConfig(options?.config); const search = resolveSearchConfig(config); const runtimeWebSearch = options?.runtimeWebSearch ?? getActiveRuntimeWebToolsMetadata()?.search; + const loadScope = resolveWebSearchProviderLoadScope({ + config, + search, + runtimeWebSearch, + providerId: options?.providerId, + includeRuntimeSelection: Boolean(options?.preferRuntimeProviders), + }); const providers = sortWebSearchProvidersForAutoDetect( options?.preferRuntimeProviders ? resolveRuntimeWebSearchProviders({ config, bundledAllowlistCompat: true, + ...loadScope, }) : resolvePluginWebSearchProviders({ config, bundledAllowlistCompat: true, origin: "bundled", + ...loadScope, }), ); return resolveWebProviderDefinition({ @@ -245,17 +316,26 @@ function resolveWebSearchCandidates( if (!resolveWebSearchEnabled({ search, sandboxed: options?.sandboxed })) { return []; } + const loadScope = resolveWebSearchProviderLoadScope({ + config, + search, + runtimeWebSearch, + providerId: options?.providerId, + includeRuntimeSelection: Boolean(options?.preferRuntimeProviders), + }); const providers = sortWebSearchProvidersForAutoDetect( options?.preferRuntimeProviders ? resolveRuntimeWebSearchProviders({ config, bundledAllowlistCompat: true, + ...loadScope, }) : resolvePluginWebSearchProviders({ config, bundledAllowlistCompat: true, origin: "bundled", + ...loadScope, }), ).filter(Boolean); if (providers.length === 0) { @@ -389,5 +469,7 @@ export const __testing = { resolveSearchProvider: resolveWebSearchProviderId, resolveWebSearchProviderId, resolveWebSearchCandidates, + resolveExplicitWebSearchProviderId, + resolveExplicitWebSearchProviderPluginIds, hasExplicitWebSearchSelection, };