Web: derive search provider metadata from plugin contracts (#50935)

Merged via squash.

Prepared head SHA: e1c7d72833
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-03-20 12:41:04 -07:00
committed by GitHub
parent acf32287b4
commit 3da66718f4
15 changed files with 1101 additions and 420 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const mocks = vi.hoisted(() => ({
@@ -7,6 +7,12 @@ const mocks = vi.hoisted(() => ({
clackSelect: vi.fn(),
clackText: vi.fn(),
clackConfirm: vi.fn(),
applySearchKey: vi.fn(),
applySearchProviderSelection: vi.fn(),
hasExistingKey: vi.fn(),
hasKeyInEnv: vi.fn(),
resolveExistingKey: vi.fn(),
resolveSearchProviderOptions: vi.fn(),
readConfigFileSnapshot: vi.fn(),
writeConfigFile: vi.fn(),
resolveGatewayPort: vi.fn(),
@@ -95,10 +101,51 @@ vi.mock("./onboard-channels.js", () => ({
setupChannels: vi.fn(),
}));
vi.mock("./onboard-search.js", () => ({
resolveSearchProviderOptions: mocks.resolveSearchProviderOptions,
SEARCH_PROVIDER_OPTIONS: [
{
id: "firecrawl",
label: "Firecrawl Search",
hint: "Structured results with optional result scraping",
envVars: ["FIRECRAWL_API_KEY"],
placeholder: "fc-...",
signupUrl: "https://www.firecrawl.dev/",
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
},
],
resolveExistingKey: mocks.resolveExistingKey,
hasExistingKey: mocks.hasExistingKey,
applySearchKey: mocks.applySearchKey,
applySearchProviderSelection: mocks.applySearchProviderSelection,
hasKeyInEnv: mocks.hasKeyInEnv,
}));
import { WizardCancelledError } from "../wizard/prompts.js";
import { runConfigureWizard } from "./configure.wizard.js";
describe("runConfigureWizard", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.resolveExistingKey.mockReturnValue(undefined);
mocks.hasExistingKey.mockReturnValue(false);
mocks.hasKeyInEnv.mockReturnValue(false);
mocks.resolveSearchProviderOptions.mockReturnValue([
{
id: "firecrawl",
label: "Firecrawl Search",
hint: "Structured results with optional result scraping",
envVars: ["FIRECRAWL_API_KEY"],
placeholder: "fc-...",
signupUrl: "https://www.firecrawl.dev/",
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
},
]);
mocks.applySearchKey.mockReset();
mocks.applySearchProviderSelection.mockReset();
});
it("persists gateway.mode=local when only the run mode is selected", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
@@ -158,4 +205,214 @@ describe("runConfigureWizard", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("persists provider-owned web search config changes returned by applySearchKey", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
config: {},
issues: [],
});
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.createClackPrompter.mockReturnValue({});
mocks.resolveExistingKey.mockReturnValue(undefined);
mocks.hasExistingKey.mockReturnValue(false);
mocks.hasKeyInEnv.mockReturnValue(false);
mocks.applySearchKey.mockImplementation(
(cfg: OpenClawConfig, provider: string, key: string) => ({
...cfg,
tools: {
...cfg.tools,
web: {
...cfg.tools?.web,
search: {
provider,
enabled: true,
},
},
},
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
firecrawl: {
enabled: true,
config: { webSearch: { apiKey: key } },
},
},
},
}),
);
const selectQueue = ["local", "firecrawl"];
const confirmQueue = [true, false];
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift());
mocks.clackText.mockResolvedValue("fc-entered-key");
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
provider: "firecrawl",
enabled: true,
}),
}),
}),
plugins: expect.objectContaining({
entries: expect.objectContaining({
firecrawl: expect.objectContaining({
enabled: true,
config: expect.objectContaining({
webSearch: expect.objectContaining({ apiKey: "fc-entered-key" }),
}),
}),
}),
}),
}),
);
});
it("applies provider selection side effects when a key already exists via secret ref or env", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
config: {},
issues: [],
});
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.createClackPrompter.mockReturnValue({});
mocks.resolveExistingKey.mockReturnValue(undefined);
mocks.hasExistingKey.mockReturnValue(true);
mocks.hasKeyInEnv.mockReturnValue(false);
mocks.applySearchProviderSelection.mockImplementation(
(cfg: OpenClawConfig, provider: string) => ({
...cfg,
tools: {
...cfg.tools,
web: {
...cfg.tools?.web,
search: {
provider,
enabled: true,
},
},
},
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
firecrawl: {
enabled: true,
},
},
},
}),
);
const selectQueue = ["local", "firecrawl"];
const confirmQueue = [true, false];
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift());
mocks.clackText.mockResolvedValue("");
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(mocks.applySearchProviderSelection).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({ mode: "local" }),
}),
"firecrawl",
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
firecrawl: expect.objectContaining({
enabled: true,
}),
}),
}),
}),
);
});
it("does not crash when web search providers are unavailable under plugin policy", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
config: {},
issues: [],
});
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.createClackPrompter.mockReturnValue({});
mocks.resolveSearchProviderOptions.mockReturnValue([]);
const selectQueue = ["local"];
const confirmQueue = [true, false];
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift());
mocks.clackText.mockResolvedValue("");
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
await expect(
runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
),
).resolves.toBeUndefined();
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining(
"No web search providers are currently available under this plugin policy.",
),
"Web search",
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
enabled: false,
}),
}),
}),
}),
);
});
});

View File

@@ -167,34 +167,30 @@ async function promptWebToolsConfig(
const existingSearch = nextConfig.tools?.web?.search;
const existingFetch = nextConfig.tools?.web?.fetch;
const {
SEARCH_PROVIDER_OPTIONS,
resolveSearchProviderOptions,
resolveExistingKey,
hasExistingKey,
applySearchKey,
applySearchProviderSelection,
hasKeyInEnv,
} = await import("./onboard-search.js");
type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
const defaultProvider = SEARCH_PROVIDER_OPTIONS[0]?.value;
if (!defaultProvider) {
throw new Error("No web search providers are registered.");
}
const searchProviderOptions = resolveSearchProviderOptions(nextConfig);
const defaultProvider = searchProviderOptions[0]?.id;
const hasKeyForProvider = (provider: string): boolean => {
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider);
const entry = searchProviderOptions.find((e) => e.id === provider);
if (!entry) {
return false;
}
return hasExistingKey(nextConfig, provider) || hasKeyInEnv(entry);
};
const existingProvider: SP = (() => {
const existingProvider = (() => {
const stored = existingSearch?.provider;
if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) {
if (stored && searchProviderOptions.some((e) => e.id === stored)) {
return stored;
}
return (
SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? defaultProvider
);
return searchProviderOptions.find((e) => hasKeyForProvider(e.id))?.id ?? defaultProvider;
})();
note(
@@ -210,7 +206,7 @@ async function promptWebToolsConfig(
await confirm({
message: "Enable web_search?",
initialValue:
existingSearch?.enabled ?? SEARCH_PROVIDER_OPTIONS.some((e) => hasKeyForProvider(e.value)),
existingSearch?.enabled ?? searchProviderOptions.some((e) => hasKeyForProvider(e.id)),
}),
runtime,
);
@@ -219,64 +215,82 @@ async function promptWebToolsConfig(
...existingSearch,
enabled: enableSearch,
};
let workingConfig = nextConfig;
if (enableSearch) {
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
const configured = hasKeyForProvider(entry.value);
return {
value: entry.value,
label: entry.label,
hint: configured ? `${entry.hint} · configured` : entry.hint,
};
});
const providerChoice = guardCancel(
await select({
message: "Choose web search provider",
options: providerOptions,
initialValue: existingProvider,
}),
runtime,
);
nextSearch = { ...nextSearch, provider: providerChoice };
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
const existingKey = resolveExistingKey(nextConfig, providerChoice);
const keyConfigured = hasExistingKey(nextConfig, providerChoice);
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
const envVarNames = entry.envKeys.join(" / ");
const keyInput = guardCancel(
await text({
message: keyConfigured
? envAvailable
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
: `${entry.label} API key (leave blank to keep current)`
: envAvailable
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
: `${entry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
const applied = applySearchKey(nextConfig, providerChoice, (key || existingKey)!);
nextSearch = { ...applied.tools?.web?.search };
} else if (keyConfigured || envAvailable) {
nextSearch = { ...nextSearch };
} else {
if (searchProviderOptions.length === 0) {
note(
[
"No key stored yet — web_search won't work until a key is available.",
`Store a key here or set ${envVarNames} in the Gateway environment.`,
`Get your API key at: ${entry.signupUrl}`,
"No web search providers are currently available under this plugin policy.",
"Enable plugins or remove deny rules, then rerun configure.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
nextSearch = {
...existingSearch,
enabled: false,
};
} else {
const providerOptions = searchProviderOptions.map((entry) => {
const configured = hasKeyForProvider(entry.id);
return {
value: entry.id,
label: entry.label,
hint: configured ? `${entry.hint} · configured` : entry.hint,
};
});
const providerChoice = guardCancel(
await select({
message: "Choose web search provider",
options: providerOptions,
initialValue: existingProvider,
}),
runtime,
);
nextSearch = { ...nextSearch, provider: providerChoice };
const entry = searchProviderOptions.find((e) => e.id === providerChoice)!;
const existingKey = resolveExistingKey(nextConfig, providerChoice);
const keyConfigured = hasExistingKey(nextConfig, providerChoice);
const envAvailable = entry.envVars.some((k) => Boolean(process.env[k]?.trim()));
const envVarNames = entry.envVars.join(" / ");
const keyInput = guardCancel(
await text({
message: keyConfigured
? envAvailable
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
: `${entry.label} API key (leave blank to keep current)`
: envAvailable
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
: `${entry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
workingConfig = applySearchKey(workingConfig, providerChoice, (key || existingKey)!);
nextSearch = { ...workingConfig.tools?.web?.search };
} else if (keyConfigured || envAvailable) {
workingConfig = applySearchProviderSelection(workingConfig, providerChoice);
nextSearch = { ...workingConfig.tools?.web?.search };
} else {
nextSearch = { ...nextSearch, provider: providerChoice };
note(
[
"No key stored yet — web_search won't work until a key is available.",
`Store a key here or set ${envVarNames} in the Gateway environment.`,
`Get your API key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
}
}
}
@@ -294,11 +308,11 @@ async function promptWebToolsConfig(
};
return {
...nextConfig,
...workingConfig,
tools: {
...nextConfig.tools,
...workingConfig.tools,
web: {
...nextConfig.tools?.web,
...workingConfig.tools?.web,
search: nextSearch,
fetch: nextFetch,
},

View File

@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
const mocks = vi.hoisted(() => ({
resolvePluginWebSearchProviders: vi.fn<
(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]
>(() => []),
listBundledWebSearchProviders: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []),
resolveBundledWebSearchPluginId: vi.fn<(providerId?: string) => string | undefined>(
() => undefined,
),
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: mocks.resolvePluginWebSearchProviders,
}));
vi.mock("../plugins/bundled-web-search.js", () => ({
listBundledWebSearchProviders: mocks.listBundledWebSearchProviders,
resolveBundledWebSearchPluginId: mocks.resolveBundledWebSearchPluginId,
}));
function createCustomProviderEntry(): PluginWebSearchProviderEntry {
return {
id: "custom-search" as never,
pluginId: "custom-plugin",
label: "Custom Search",
hint: "Custom provider",
envVars: ["CUSTOM_SEARCH_API_KEY"],
placeholder: "custom-...",
signupUrl: "https://example.com/custom",
credentialPath: "plugins.entries.custom-plugin.config.webSearch.apiKey",
getCredentialValue: () => undefined,
setCredentialValue: () => {},
getConfiguredCredentialValue: (config) =>
(
config?.plugins?.entries?.["custom-plugin"]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
const entries = ((configTarget.plugins ??= {}).entries ??= {});
const pluginEntry = (entries["custom-plugin"] ??= {});
const pluginConfig = ((pluginEntry as Record<string, unknown>).config ??= {}) as Record<
string,
unknown
>;
const webSearch = (pluginConfig.webSearch ??= {}) as Record<string, unknown>;
webSearch.apiKey = value;
},
createTool: () => null,
};
}
function createBundledFirecrawlEntry(): PluginWebSearchProviderEntry {
return {
id: "firecrawl",
pluginId: "firecrawl",
label: "Firecrawl Search",
hint: "Structured results",
envVars: ["FIRECRAWL_API_KEY"],
placeholder: "fc-...",
signupUrl: "https://example.com/firecrawl",
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
getCredentialValue: () => undefined,
setCredentialValue: () => {},
getConfiguredCredentialValue: (config) =>
(
config?.plugins?.entries?.firecrawl?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
setConfiguredCredentialValue: () => {},
createTool: () => null,
};
}
describe("onboard-search provider resolution", () => {
afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it("uses config-aware non-bundled provider hooks when resolving existing keys", async () => {
const customEntry = createCustomProviderEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [customEntry] : [],
);
const mod = await import("./onboard-search.js");
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "custom-search" as never,
},
},
},
plugins: {
entries: {
"custom-plugin": {
config: {
webSearch: {
apiKey: "custom-key",
},
},
},
},
},
};
expect(mod.hasExistingKey(cfg, "custom-search" as never)).toBe(true);
expect(mod.resolveExistingKey(cfg, "custom-search" as never)).toBe("custom-key");
const updated = mod.applySearchKey(cfg, "custom-search" as never, "next-key");
expect(
(
updated.plugins?.entries?.["custom-plugin"]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
).toBe("next-key");
});
it("uses config-aware non-bundled providers when building secret refs", async () => {
const customEntry = createCustomProviderEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [customEntry] : [],
);
const mod = await import("./onboard-search.js");
const cfg: OpenClawConfig = {
plugins: {
installs: {
"custom-plugin": {
installPath: "/tmp/custom-plugin",
source: "path",
},
},
},
};
const notes: Array<{ title?: string; message: string }> = [];
const prompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async (message: string, title?: string) => {
notes.push({ title, message });
}),
select: vi.fn(async () => "custom-search"),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => ""),
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const result = await mod.setupSearch(cfg, {} as never, prompter as never, {
secretInputMode: "ref",
});
expect(result.tools?.web?.search?.provider).toBe("custom-search");
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(
(
result.plugins?.entries?.["custom-plugin"]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
).toEqual({
source: "env",
provider: "default",
id: "CUSTOM_SEARCH_API_KEY",
});
expect(notes.some((note) => note.message.includes("CUSTOM_SEARCH_API_KEY"))).toBe(true);
});
it("does not treat hard-disabled bundled providers as selectable credentials", async () => {
const firecrawlEntry = createBundledFirecrawlEntry();
mocks.resolvePluginWebSearchProviders.mockReturnValue([]);
mocks.listBundledWebSearchProviders.mockReturnValue([firecrawlEntry]);
mocks.resolveBundledWebSearchPluginId.mockReturnValue("firecrawl");
const mod = await import("./onboard-search.js");
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "firecrawl",
},
},
},
plugins: {
enabled: false,
entries: {
firecrawl: {
config: {
webSearch: {
apiKey: "fc-disabled-key",
},
},
},
},
},
};
expect(mod.hasExistingKey(cfg, "firecrawl")).toBe(false);
expect(mod.resolveExistingKey(cfg, "firecrawl")).toBeUndefined();
expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg);
});
});

View File

@@ -57,6 +57,45 @@ function pluginWebSearchApiKey(config: OpenClawConfig, pluginId: string): unknow
return entry?.config?.webSearch?.apiKey;
}
function createDisabledFirecrawlConfig(apiKey?: string): OpenClawConfig {
return {
tools: {
web: {
search: {
provider: "firecrawl",
},
},
},
plugins: {
entries: {
firecrawl: {
enabled: false,
...(apiKey
? {
config: {
webSearch: {
apiKey,
},
},
}
: {}),
},
},
},
};
}
function readFirecrawlPluginApiKey(config: OpenClawConfig): string | undefined {
const pluginConfig = config.plugins?.entries?.firecrawl?.config as
| {
webSearch?: {
apiKey?: string;
};
}
| undefined;
return pluginConfig?.webSearch?.apiKey;
}
async function runBlankPerplexityKeyEntry(
apiKey: string,
enabled?: boolean,
@@ -141,6 +180,20 @@ describe("setupSearch", () => {
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
});
it("re-enables firecrawl and persists its plugin config when selected from disabled state", async () => {
const cfg = createDisabledFirecrawlConfig();
const { prompter } = createPrompter({
selectValue: "firecrawl",
textValue: "fc-disabled-key",
});
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(result.tools?.web?.search?.firecrawl?.apiKey).toBeUndefined();
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
expect(readFirecrawlPluginApiKey(result)).toBe("fc-disabled-key");
});
it("sets provider and key for grok", async () => {
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({
@@ -314,6 +367,60 @@ describe("setupSearch", () => {
}
});
it("quickstart detects an existing firecrawl key even when the plugin is disabled", async () => {
const cfg = createDisabledFirecrawlConfig("fc-configured-key");
const { prompter } = createPrompter({ selectValue: "firecrawl" });
const result = await setupSearch(cfg, runtime, prompter, {
quickstartDefaults: true,
});
expect(prompter.text).not.toHaveBeenCalled();
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(result.tools?.web?.search?.firecrawl?.apiKey).toBeUndefined();
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
expect(readFirecrawlPluginApiKey(result)).toBe("fc-configured-key");
});
it("preserves disabled firecrawl plugin state and allowlist when web search stays disabled", async () => {
const original = process.env.FIRECRAWL_API_KEY;
process.env.FIRECRAWL_API_KEY = "env-firecrawl-key"; // pragma: allowlist secret
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "firecrawl",
enabled: false,
},
},
},
plugins: {
allow: ["google"],
entries: {
firecrawl: {
enabled: false,
},
},
},
};
try {
const { prompter } = createPrompter({ selectValue: "firecrawl" });
const result = await setupSearch(cfg, runtime, prompter, {
quickstartDefaults: true,
});
expect(prompter.text).not.toHaveBeenCalled();
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
expect(result.tools?.web?.search?.enabled).toBe(false);
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(false);
expect(result.plugins?.allow).toEqual(["google"]);
} finally {
if (original === undefined) {
delete process.env.FIRECRAWL_API_KEY;
} else {
process.env.FIRECRAWL_API_KEY = original;
}
}
});
it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => {
const originalPerplexity = process.env.PERPLEXITY_API_KEY;
const originalOpenRouter = process.env.OPENROUTER_API_KEY;
@@ -430,8 +537,8 @@ describe("setupSearch", () => {
});
it("exports all 7 providers in SEARCH_PROVIDER_OPTIONS", () => {
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.id);
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(7);
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value);
expect(values).toEqual([
"brave",
"gemini",

View File

@@ -6,6 +6,10 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../config/types.secrets.js";
import {
listBundledWebSearchProviders,
resolveBundledWebSearchPluginId,
} from "../plugins/bundled-web-search.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -18,41 +22,77 @@ export type SearchProvider = NonNullable<
type SearchConfig = NonNullable<NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"]>;
type MutableSearchConfig = SearchConfig & Record<string, unknown>;
type SearchProviderEntry = {
value: SearchProvider;
label: string;
hint: string;
envKeys: string[];
placeholder: string;
signupUrl: string;
credentialPath: string;
applySelectionConfig?: PluginWebSearchProviderEntry["applySelectionConfig"];
};
export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] =
export const SEARCH_PROVIDER_OPTIONS: readonly PluginWebSearchProviderEntry[] =
resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
}).map((provider) => ({
value: provider.id,
label: provider.label,
hint: provider.hint,
envKeys: provider.envVars,
placeholder: provider.placeholder,
signupUrl: provider.signupUrl,
credentialPath: provider.credentialPath,
applySelectionConfig: provider.applySelectionConfig,
}));
});
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
function sortSearchProviderOptions(
providers: PluginWebSearchProviderEntry[],
): PluginWebSearchProviderEntry[] {
return providers.toSorted((left, right) => {
const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.id.localeCompare(right.id);
});
}
function canRepairBundledProviderSelection(
config: OpenClawConfig,
provider: Pick<PluginWebSearchProviderEntry, "id" | "pluginId">,
): boolean {
const pluginId = provider.pluginId ?? resolveBundledWebSearchPluginId(provider.id);
if (!pluginId) {
return false;
}
if (config.plugins?.enabled === false) {
return false;
}
return !config.plugins?.deny?.includes(pluginId);
}
export function resolveSearchProviderOptions(
config?: OpenClawConfig,
): readonly PluginWebSearchProviderEntry[] {
if (!config) {
return SEARCH_PROVIDER_OPTIONS;
}
const merged = new Map<string, PluginWebSearchProviderEntry>(
resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
env: process.env,
}).map((entry) => [entry.id, entry]),
);
for (const entry of listBundledWebSearchProviders()) {
if (merged.has(entry.id) || !canRepairBundledProviderSelection(config, entry)) {
continue;
}
merged.set(entry.id, entry);
}
return sortSearchProviderOptions([...merged.values()]);
}
function resolveSearchProviderEntry(
config: OpenClawConfig,
provider: SearchProvider,
): PluginWebSearchProviderEntry | undefined {
return resolveSearchProviderOptions(config).find((entry) => entry.id === provider);
}
export function hasKeyInEnv(entry: Pick<PluginWebSearchProviderEntry, "envVars">): boolean {
return entry.envVars.some((k) => Boolean(process.env[k]?.trim()));
}
function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown {
const search = config.tools?.web?.search;
const entry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
const entry = resolveSearchProviderEntry(config, provider);
return (
entry?.getConfiguredCredentialValue?.(config) ??
entry?.getCredentialValue(search as Record<string, unknown> | undefined)
@@ -73,9 +113,12 @@ export function hasExistingKey(config: OpenClawConfig, provider: SearchProvider)
}
/** Build an env-backed SecretRef for a search provider. */
function buildSearchEnvRef(provider: SearchProvider): SecretRef {
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider);
const envVar = entry?.envKeys.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envKeys[0];
function buildSearchEnvRef(config: OpenClawConfig, provider: SearchProvider): SecretRef {
const entry =
resolveSearchProviderEntry(config, provider) ??
SEARCH_PROVIDER_OPTIONS.find((candidate) => candidate.id === provider) ??
listBundledWebSearchProviders().find((candidate) => candidate.id === provider);
const envVar = entry?.envVars.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envVars[0];
if (!envVar) {
throw new Error(
`No env var mapping for search provider "${provider}" at ${entry?.credentialPath ?? "unknown path"} in secret-input-mode=ref.`,
@@ -86,13 +129,14 @@ function buildSearchEnvRef(provider: SearchProvider): SecretRef {
/** Resolve a plaintext key into the appropriate SecretInput based on mode. */
function resolveSearchSecretInput(
config: OpenClawConfig,
provider: SearchProvider,
key: string,
secretInputMode?: SecretInputMode,
): SecretInput {
const useSecretRefMode = secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
return buildSearchEnvRef(provider);
return buildSearchEnvRef(config, provider);
}
return key;
}
@@ -102,12 +146,12 @@ export function applySearchKey(
provider: SearchProvider,
key: SecretInput,
): OpenClawConfig {
const providerEntry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
const providerEntry = resolveSearchProviderEntry(config, provider);
if (!providerEntry) {
return config;
}
const search: MutableSearchConfig = { ...config.tools?.web?.search, provider, enabled: true };
if (providerEntry && !providerEntry.setConfiguredCredentialValue) {
if (!providerEntry.setConfiguredCredentialValue) {
providerEntry.setCredentialValue(search, key);
}
const nextBase: OpenClawConfig = {
@@ -117,16 +161,19 @@ export function applySearchKey(
web: { ...config.tools?.web, search },
},
};
const next = providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
providerEntry?.setConfiguredCredentialValue?.(next, key);
const next = providerEntry.applySelectionConfig?.(nextBase) ?? nextBase;
providerEntry.setConfiguredCredentialValue?.(next, key);
return next;
}
function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig {
const providerEntry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
export function applySearchProviderSelection(
config: OpenClawConfig,
provider: SearchProvider,
): OpenClawConfig {
const providerEntry = resolveSearchProviderEntry(config, provider);
if (!providerEntry) {
return config;
}
const search: MutableSearchConfig = {
...config.tools?.web?.search,
provider,
@@ -142,20 +189,65 @@ function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): Op
},
},
};
return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
return providerEntry.applySelectionConfig?.(nextBase) ?? nextBase;
}
function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig {
if (original.tools?.web?.search?.enabled !== false) {
return result;
}
return {
const next: OpenClawConfig = {
...result,
tools: {
...result.tools,
web: { ...result.tools?.web, search: { ...result.tools?.web?.search, enabled: false } },
},
};
const provider = next.tools?.web?.search?.provider;
if (typeof provider !== "string") {
return next;
}
const providerEntry = resolveSearchProviderEntry(original, provider);
if (!providerEntry?.pluginId) {
return next;
}
const pluginId = providerEntry.pluginId;
const originalPluginEntry = (
original.plugins?.entries as Record<string, Record<string, unknown>> | undefined
)?.[pluginId];
const resultPluginEntry = (
next.plugins?.entries as Record<string, Record<string, unknown>> | undefined
)?.[pluginId];
const nextPlugins = { ...next.plugins } as Record<string, unknown>;
if (Array.isArray(original.plugins?.allow)) {
nextPlugins.allow = [...original.plugins.allow];
} else {
delete nextPlugins.allow;
}
if (resultPluginEntry || originalPluginEntry) {
const nextEntries = {
...(nextPlugins.entries as Record<string, Record<string, unknown>> | undefined),
};
const patchedEntry = { ...resultPluginEntry };
if (typeof originalPluginEntry?.enabled === "boolean") {
patchedEntry.enabled = originalPluginEntry.enabled;
} else {
delete patchedEntry.enabled;
}
nextEntries[pluginId] = patchedEntry;
nextPlugins.entries = nextEntries;
}
return {
...next,
plugins: nextPlugins as OpenClawConfig["plugins"],
};
}
export type SetupSearchOptions = {
@@ -169,6 +261,19 @@ export async function setupSearch(
prompter: WizardPrompter,
opts?: SetupSearchOptions,
): Promise<OpenClawConfig> {
const providerOptions = resolveSearchProviderOptions(config);
if (providerOptions.length === 0) {
await prompter.note(
[
"No web search providers are currently available under this plugin policy.",
"Enable plugins or remove deny rules, then run setup again.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
return config;
}
await prompter.note(
[
"Web search lets your agent look things up online.",
@@ -180,23 +285,21 @@ export async function setupSearch(
const existingProvider = config.tools?.web?.search?.provider;
const options = SEARCH_PROVIDER_OPTIONS.map((entry) => {
const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry);
const options = providerOptions.map((entry) => {
const configured = hasExistingKey(config, entry.id) || hasKeyInEnv(entry);
const hint = configured ? `${entry.hint} · configured` : entry.hint;
return { value: entry.value, label: entry.label, hint };
return { value: entry.id, label: entry.label, hint };
});
const defaultProvider: SearchProvider = (() => {
if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) {
if (existingProvider && providerOptions.some((entry) => entry.id === existingProvider)) {
return existingProvider;
}
const detected = SEARCH_PROVIDER_OPTIONS.find(
(e) => hasExistingKey(config, e.value) || hasKeyInEnv(e),
);
const detected = providerOptions.find((e) => hasExistingKey(config, e.id) || hasKeyInEnv(e));
if (detected) {
return detected.value;
return detected.id;
}
return SEARCH_PROVIDER_OPTIONS[0].value;
return providerOptions[0].id;
})();
const choice = await prompter.select({
@@ -216,7 +319,11 @@ export async function setupSearch(
return config;
}
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!;
const entry =
resolveSearchProviderEntry(config, choice) ?? providerOptions.find((e) => e.id === choice);
if (!entry) {
return config;
}
const existingKey = resolveExistingKey(config, choice);
const keyConfigured = hasExistingKey(config, choice);
const envAvailable = hasKeyInEnv(entry);
@@ -224,16 +331,16 @@ export async function setupSearch(
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
const result = existingKey
? applySearchKey(config, choice, existingKey)
: applyProviderOnly(config, choice);
: applySearchProviderSelection(config, choice);
return preserveDisabledState(config, result);
}
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
if (keyConfigured) {
return preserveDisabledState(config, applyProviderOnly(config, choice));
return preserveDisabledState(config, applySearchProviderSelection(config, choice));
}
const ref = buildSearchEnvRef(choice);
const ref = buildSearchEnvRef(config, choice);
await prompter.note(
[
"Secret references enabled — OpenClaw will store a reference instead of the API key.",
@@ -257,7 +364,7 @@ export async function setupSearch(
const key = keyInput?.trim() ?? "";
if (key) {
const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode);
const secretInput = resolveSearchSecretInput(config, choice, key, opts?.secretInputMode);
return applySearchKey(config, choice, secretInput);
}
@@ -266,7 +373,7 @@ export async function setupSearch(
}
if (keyConfigured || envAvailable) {
return preserveDisabledState(config, applyProviderOnly(config, choice));
return preserveDisabledState(config, applySearchProviderSelection(config, choice));
}
await prompter.note(