From acb2f91ada1d27350362798aa6c61d105f238d04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 08:03:59 +0100 Subject: [PATCH] fix(config): validate web search providers --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 6 +- docs/tools/web.md | 8 ++ src/config/config.web-search-provider.test.ts | 112 +++++++++++++++--- src/config/validation.ts | 76 ++++++++++++ 5 files changed, 183 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb9ea653ce..442fd34b4a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Cron: keep implicit/default isolated cron announce deliveries out of the main session awareness queue, so isolated jobs do not accumulate in the main conversation. Fixes #61426. Thanks @Lihannon. - Subagents: avoid duplicate parent-visible replies when a parent uses `sessions_send` on its own persistent native subagent session, while preserving announce delivery for async sends. Fixes #73550. Thanks @sylviazhang2006-design. - Web search/Brave: add opt-in `brave.http` diagnostics for Brave request URLs/query params, response status/timing, and cache hit/miss/write events without logging API keys or response bodies. Fixes #55196. Thanks @mecampbellsoup. +- Web search/config: validate explicit `tools.web.search.provider` values against bundled and installed plugin manifests, while warning for stale third-party plugin config. Fixes #53092. Thanks @TinyTb. - Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan. - Agents/models: keep legacy CLI runtime model refs such as `claude-cli/*` in the configured allowlist after canonical runtime migration, so cron `payload.model` overrides keep working. Fixes #75753. Thanks @RyanSandoval. - Codex/app-server: restart the shared Codex app-server client once when it closes during startup thread resume, preserving the existing thread binding instead of retrying `thread/start` on a closed client. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 23c54309dc5..0bff9e7bc28 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -0020a49cdf07b79fddfed9584f10323ef77e3ca5c7f907d441c1f2e3f45932c9 config-baseline.json -8193fd771c9fa50e77c71ff69d41015e6dc02d140182ceea3baaa17713f04b18 config-baseline.core.json +3545cf963a093d50b69904f859544088212e6522905b72710eb7818caa154b89 config-baseline.json +2d132b4c2e3b0e0f2524fc1cc889d3be658ad0e40c970b2d367bf27348883658 config-baseline.core.json f42329d45c095881bd226bdb192c235980658fd250606d0c0badc2b12f12f5d3 config-baseline.channel.json -af71b84b2411d8ccabcc6e09de0ee41f8212ff9869a6677698b6e7e3afdfaa47 config-baseline.plugin.json +38b16427911ba4ff19240097e5002fb892178fb3cefdc9c50fd98ad2044c02bf config-baseline.plugin.json diff --git a/docs/tools/web.md b/docs/tools/web.md index c34e110ba9d..e1c508c89c5 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -218,6 +218,14 @@ Provider-specific config (API keys, base URLs, modes) lives under fallbacks after its dedicated web-search config and `GEMINI_API_KEY`. See the provider pages for examples. +`tools.web.search.provider` is validated against the web-search provider ids +declared by bundled and installed plugin manifests. A typo such as `"brvae"` +fails config validation instead of silently falling back to auto-detection. If a +configured provider only has stale plugin evidence, such as a leftover +`plugins.entries.` block after uninstalling a third-party plugin, +OpenClaw keeps startup resilient and reports a warning so you can reinstall the +plugin or run `openclaw doctor --fix` to clean up the stale config. + `web_fetch` fallback provider selection is separate: - choose it with `tools.web.fetch.provider` diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 87399a42007..630c3e4d052 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -185,32 +185,42 @@ vi.mock("../plugins/manifest-registry.js", () => { schemaCacheKey: "test:brave", configSchema: buildSchema(), }, - ...[ - "firecrawl", - "google", - "minimax", - "moonshot", - "perplexity", - "searxng", - "tavily", - "xai", - ].map((id) => ({ - id, - origin: "bundled", + ...mockWebSearchProviders + .filter((provider) => provider.pluginId !== "brave") + .map((provider) => ({ + id: provider.pluginId, + origin: "bundled", + channels: [], + providers: [], + contracts: { + webSearchProviders: [provider.id], + }, + cliBackends: [], + skills: [], + hooks: [], + rootDir: `/tmp/plugins/${provider.pluginId}`, + source: "test", + manifestPath: `/tmp/plugins/${provider.pluginId}/openclaw.plugin.json`, + schemaCacheKey: `test:${provider.pluginId}`, + configSchema: buildSchema(), + })), + { + id: "acme-search", + origin: "installed", channels: [], providers: [], contracts: { - webSearchProviders: [id], + webSearchProviders: ["acme-search"], }, cliBackends: [], skills: [], hooks: [], - rootDir: `/tmp/plugins/${id}`, + rootDir: "/tmp/plugins/acme-search", source: "test", - manifestPath: `/tmp/plugins/${id}/openclaw.plugin.json`, - schemaCacheKey: `test:${id}`, + manifestPath: "/tmp/plugins/acme-search/openclaw.plugin.json", + schemaCacheKey: "test:acme-search", configSchema: buildSchema(), - })), + }, ], diagnostics: [], }), @@ -413,6 +423,74 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + + it("accepts provider ids registered by installed plugin manifests", () => { + const res = validateConfigObjectWithPlugins( + buildWebSearchProviderConfig({ + provider: "acme-search", + }), + ); + + expect(res.ok).toBe(true); + }); + + it("rejects unknown provider ids without plugin evidence", () => { + const res = validateConfigObjectWithPlugins({ + tools: { + web: { + search: { + provider: "brvae", + }, + }, + }, + }); + + expect(res.ok).toBe(false); + if (res.ok) { + return; + } + expect(res.issues).toContainEqual( + expect.objectContaining({ + path: "tools.web.search.provider", + message: "unknown web_search provider: brvae", + allowedValues: expect.arrayContaining(["acme-search", "brave", "gemini"]), + }), + ); + }); + + it("warns for unknown provider ids when stale plugin config is present", () => { + const res = validateConfigObjectWithPlugins({ + tools: { + web: { + search: { + provider: "missing-third-party", + }, + }, + }, + plugins: { + entries: { + "missing-third-party": { + config: { + webSearch: {}, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "tools.web.search.provider", + message: expect.stringContaining("unknown web_search provider: missing-third-party"), + }), + ]), + ); + }); }); describe("web search provider auto-detection", () => { diff --git a/src/config/validation.ts b/src/config/validation.ts index c8ea9eb8af6..9072005909e 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -997,6 +997,80 @@ function validateConfigObjectWithPluginsBase( return ensureInstalledPluginRecordIds().has(normalizedChannelId); }; + const collectKnownWebSearchProviderIds = (): string[] => { + const { registry } = ensureRegistry(); + return [ + ...new Set( + registry.plugins.flatMap((record) => + (record.contracts?.webSearchProviders ?? []) + .map((providerId) => providerId.trim()) + .filter((providerId) => providerId.length > 0), + ), + ), + ].toSorted((left, right) => left.localeCompare(right)); + }; + + const hasStalePluginEvidenceForUnknownWebSearchProvider = (providerId: string): boolean => { + const normalizedProviderId = normalizePluginId(providerId); + if (!normalizedProviderId || ensureKnownIds().has(normalizedProviderId)) { + return false; + } + const pluginConfig = config.plugins; + if ( + Array.isArray(pluginConfig?.allow) && + pluginConfig.allow.some((pluginId) => normalizePluginId(pluginId) === normalizedProviderId) + ) { + return true; + } + if ( + isRecord(pluginConfig?.entries) && + Object.keys(pluginConfig.entries).some( + (pluginId) => normalizePluginId(pluginId) === normalizedProviderId, + ) + ) { + return true; + } + if ( + isRecord(pluginConfig?.installs) && + Object.keys(pluginConfig.installs).some( + (pluginId) => normalizePluginId(pluginId) === normalizedProviderId, + ) + ) { + return true; + } + return ensureInstalledPluginRecordIds().has(normalizedProviderId); + }; + + const validateWebSearchProvider = () => { + const provider = config.tools?.web?.search?.provider; + if (typeof provider !== "string") { + return; + } + const trimmed = provider.trim(); + const path = "tools.web.search.provider"; + if (!trimmed) { + issues.push({ path, message: "web_search provider must not be empty" }); + return; + } + const allowedValues = collectKnownWebSearchProviderIds(); + if (allowedValues.length === 0 || allowedValues.includes(trimmed)) { + return; + } + const issue = { + path, + message: `unknown web_search provider: ${trimmed}`, + allowedValues, + }; + if (hasStalePluginEvidenceForUnknownWebSearchProvider(trimmed)) { + warnings.push({ + ...issue, + message: `${issue.message} (stale web search plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)`, + }); + return; + } + issues.push(issue); + }; + const replaceChannelConfig = (channelId: string, nextValue: unknown) => { if (!channelsCloned) { mutatedConfig = { @@ -1143,6 +1217,8 @@ function validateConfigObjectWithPluginsBase( } } + validateWebSearchProvider(); + if (!hasExplicitPluginsConfig) { if (issues.length > 0) { return { ok: false, issues, warnings };