fix(config): validate web search providers

This commit is contained in:
Peter Steinberger
2026-05-02 08:03:59 +01:00
parent b5e7857c4b
commit acb2f91ada
5 changed files with 183 additions and 20 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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.<plugin>` 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`

View File

@@ -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", () => {

View File

@@ -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 };