Web: derive search provider metadata from plugin contracts (#50935)
Merged via squash. Prepared head SHA: e1c7d72833afff6ef33e8d32cdd395190742dc08 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:
parent
acf32287b4
commit
3da66718f4
@ -184,6 +184,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/plugins: share plugin interactive callback routing and plugin bind approval state across duplicate module graphs so Telegram Codex picker buttons and plugin bind approvals no longer fall through to normal inbound message routing. (#50722) Thanks @huntharo.
|
||||
- Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp.
|
||||
- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys.
|
||||
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
|
||||
|
||||
### Breaking
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { optionalStringEnum } from "openclaw/plugin-sdk/core";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { runTavilyExtract } from "./tavily-client.js";
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { optionalStringEnum } from "openclaw/plugin-sdk/core";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { runTavilySearch } from "./tavily-client.js";
|
||||
|
||||
|
||||
@ -29,7 +29,6 @@ export function createWebSearchTool(options?: {
|
||||
|
||||
export const __testing = {
|
||||
SEARCH_CACHE,
|
||||
resolveSearchProvider: (
|
||||
search?: NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"],
|
||||
) => resolveWebSearchProviderId({ search }),
|
||||
resolveSearchProvider: (search?: Parameters<typeof resolveWebSearchProviderId>[0]["search"]) =>
|
||||
resolveWebSearchProviderId({ search }),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
210
src/commands/onboard-search.providers.test.ts
Normal file
210
src/commands/onboard-search.providers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
26
src/plugins/bundled-web-search-registry.ts
Normal file
26
src/plugins/bundled-web-search-registry.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import bravePlugin from "../../extensions/brave/index.js";
|
||||
import firecrawlPlugin from "../../extensions/firecrawl/index.js";
|
||||
import googlePlugin from "../../extensions/google/index.js";
|
||||
import moonshotPlugin from "../../extensions/moonshot/index.js";
|
||||
import perplexityPlugin from "../../extensions/perplexity/index.js";
|
||||
import tavilyPlugin from "../../extensions/tavily/index.js";
|
||||
import xaiPlugin from "../../extensions/xai/index.js";
|
||||
import type { OpenClawPluginApi } from "./types.js";
|
||||
|
||||
type RegistrablePlugin = {
|
||||
id: string;
|
||||
register: (api: OpenClawPluginApi) => void;
|
||||
};
|
||||
|
||||
export const bundledWebSearchPluginRegistrations: ReadonlyArray<{
|
||||
plugin: RegistrablePlugin;
|
||||
credentialValue: unknown;
|
||||
}> = [
|
||||
{ plugin: bravePlugin, credentialValue: "BSA-test" },
|
||||
{ plugin: firecrawlPlugin, credentialValue: "fc-test" },
|
||||
{ plugin: googlePlugin, credentialValue: "AIza-test" },
|
||||
{ plugin: moonshotPlugin, credentialValue: "sk-test" },
|
||||
{ plugin: perplexityPlugin, credentialValue: "pplx-test" },
|
||||
{ plugin: tavilyPlugin, credentialValue: "tvly-test" },
|
||||
{ plugin: xaiPlugin, credentialValue: "xai-test" },
|
||||
];
|
||||
@ -1,264 +1,29 @@
|
||||
import {
|
||||
getScopedCredentialValue,
|
||||
getTopLevelCredentialValue,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
setScopedCredentialValue,
|
||||
setTopLevelCredentialValue,
|
||||
} from "../agents/tools/web-search-provider-config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js";
|
||||
import { enablePluginInConfig } from "./enable.js";
|
||||
import { bundledWebSearchPluginRegistrations } from "./bundled-web-search-registry.js";
|
||||
import { capturePluginRegistration } from "./captured-registration.js";
|
||||
import type { PluginLoadOptions } from "./loader.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type { PluginWebSearchProviderEntry, WebSearchRuntimeMetadataContext } from "./types.js";
|
||||
import type { PluginWebSearchProviderEntry } from "./types.js";
|
||||
|
||||
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
|
||||
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||
|
||||
type BundledWebSearchProviderDescriptor = {
|
||||
pluginId: string;
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
envVars: string[];
|
||||
placeholder: string;
|
||||
signupUrl: string;
|
||||
docsUrl?: string;
|
||||
autoDetectOrder: number;
|
||||
credentialPath: string;
|
||||
inactiveSecretPaths: string[];
|
||||
credentialScope:
|
||||
| { kind: "top-level" }
|
||||
| {
|
||||
kind: "scoped";
|
||||
key: string;
|
||||
};
|
||||
supportsConfiguredCredentialValue?: boolean;
|
||||
applySelectionConfig?: (config: OpenClawConfig) => OpenClawConfig;
|
||||
resolveRuntimeMetadata?: (
|
||||
ctx: WebSearchRuntimeMetadataContext,
|
||||
) => Partial<RuntimeWebSearchMetadata>;
|
||||
};
|
||||
|
||||
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): "direct" | "openrouter" | undefined {
|
||||
if (!apiKey) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = apiKey.toLowerCase();
|
||||
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return "direct";
|
||||
}
|
||||
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return "openrouter";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
|
||||
try {
|
||||
return new URL(baseUrl.trim()).hostname.toLowerCase() === "api.perplexity.ai";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePerplexityRuntimeMetadata(
|
||||
ctx: WebSearchRuntimeMetadataContext,
|
||||
): Partial<RuntimeWebSearchMetadata> {
|
||||
const perplexity = ctx.searchConfig?.perplexity;
|
||||
const scoped =
|
||||
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
||||
? (perplexity as { baseUrl?: string; model?: string })
|
||||
: undefined;
|
||||
const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : "";
|
||||
const configuredModel = typeof scoped?.model === "string" ? scoped.model.trim() : "";
|
||||
const keySource = ctx.resolvedCredential?.source ?? "missing";
|
||||
const baseUrl = (() => {
|
||||
if (configuredBaseUrl) {
|
||||
return configuredBaseUrl;
|
||||
}
|
||||
if (keySource === "env") {
|
||||
if (ctx.resolvedCredential?.fallbackEnvVar === "PERPLEXITY_API_KEY") {
|
||||
return PERPLEXITY_DIRECT_BASE_URL;
|
||||
}
|
||||
if (ctx.resolvedCredential?.fallbackEnvVar === "OPENROUTER_API_KEY") {
|
||||
return DEFAULT_PERPLEXITY_BASE_URL;
|
||||
}
|
||||
}
|
||||
if ((keySource === "config" || keySource === "secretRef") && ctx.resolvedCredential?.value) {
|
||||
return inferPerplexityBaseUrlFromApiKey(ctx.resolvedCredential.value) === "openrouter"
|
||||
? DEFAULT_PERPLEXITY_BASE_URL
|
||||
: PERPLEXITY_DIRECT_BASE_URL;
|
||||
}
|
||||
return DEFAULT_PERPLEXITY_BASE_URL;
|
||||
})();
|
||||
return {
|
||||
perplexityTransport:
|
||||
configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl)
|
||||
? "chat_completions"
|
||||
: "search_api",
|
||||
};
|
||||
}
|
||||
|
||||
const BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS = [
|
||||
{
|
||||
pluginId: "brave",
|
||||
id: "brave",
|
||||
label: "Brave Search",
|
||||
hint: "Structured results · country/language/time filters",
|
||||
envVars: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
docsUrl: "https://docs.openclaw.ai/brave-search",
|
||||
autoDetectOrder: 10,
|
||||
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
|
||||
credentialScope: { kind: "top-level" },
|
||||
},
|
||||
{
|
||||
pluginId: "google",
|
||||
id: "gemini",
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Google Search grounding · AI-synthesized",
|
||||
envVars: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 20,
|
||||
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"],
|
||||
credentialScope: { kind: "scoped", key: "gemini" },
|
||||
},
|
||||
{
|
||||
pluginId: "xai",
|
||||
id: "grok",
|
||||
label: "Grok (xAI)",
|
||||
hint: "xAI web-grounded responses",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
placeholder: "xai-...",
|
||||
signupUrl: "https://console.x.ai/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 30,
|
||||
credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"],
|
||||
credentialScope: { kind: "scoped", key: "grok" },
|
||||
supportsConfiguredCredentialValue: false,
|
||||
},
|
||||
{
|
||||
pluginId: "moonshot",
|
||||
id: "kimi",
|
||||
label: "Kimi (Moonshot)",
|
||||
hint: "Moonshot web search",
|
||||
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
||||
placeholder: "sk-...",
|
||||
signupUrl: "https://platform.moonshot.cn/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 40,
|
||||
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"],
|
||||
credentialScope: { kind: "scoped", key: "kimi" },
|
||||
},
|
||||
{
|
||||
pluginId: "perplexity",
|
||||
id: "perplexity",
|
||||
label: "Perplexity Search",
|
||||
hint: "Structured results · domain/country/language/time filters",
|
||||
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
|
||||
placeholder: "pplx-...",
|
||||
signupUrl: "https://www.perplexity.ai/settings/api",
|
||||
docsUrl: "https://docs.openclaw.ai/perplexity",
|
||||
autoDetectOrder: 50,
|
||||
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"],
|
||||
credentialScope: { kind: "scoped", key: "perplexity" },
|
||||
resolveRuntimeMetadata: resolvePerplexityRuntimeMetadata,
|
||||
},
|
||||
{
|
||||
pluginId: "firecrawl",
|
||||
id: "firecrawl",
|
||||
label: "Firecrawl Search",
|
||||
hint: "Structured results with optional result scraping",
|
||||
envVars: ["FIRECRAWL_API_KEY"],
|
||||
placeholder: "fc-...",
|
||||
signupUrl: "https://www.firecrawl.dev/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
|
||||
autoDetectOrder: 60,
|
||||
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"],
|
||||
credentialScope: { kind: "scoped", key: "firecrawl" },
|
||||
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
|
||||
},
|
||||
{
|
||||
pluginId: "tavily",
|
||||
id: "tavily",
|
||||
label: "Tavily Search",
|
||||
hint: "Structured results with domain filters and AI answer summaries",
|
||||
envVars: ["TAVILY_API_KEY"],
|
||||
placeholder: "tvly-...",
|
||||
signupUrl: "https://tavily.com/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/tavily",
|
||||
autoDetectOrder: 70,
|
||||
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"],
|
||||
credentialScope: { kind: "scoped", key: "tavily" },
|
||||
applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<BundledWebSearchProviderDescriptor>;
|
||||
|
||||
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [
|
||||
...new Set(BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS.map((descriptor) => descriptor.pluginId)),
|
||||
] as ReadonlyArray<BundledWebSearchProviderDescriptor["pluginId"]>;
|
||||
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = bundledWebSearchPluginRegistrations
|
||||
.map((entry) => entry.plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
const bundledWebSearchPluginIdSet = new Set<string>(BUNDLED_WEB_SEARCH_PLUGIN_IDS);
|
||||
|
||||
function buildBundledWebSearchProviderEntry(
|
||||
descriptor: BundledWebSearchProviderDescriptor,
|
||||
): PluginWebSearchProviderEntry {
|
||||
const scopedKey =
|
||||
descriptor.credentialScope.kind === "scoped" ? descriptor.credentialScope.key : undefined;
|
||||
return {
|
||||
pluginId: descriptor.pluginId,
|
||||
id: descriptor.id,
|
||||
label: descriptor.label,
|
||||
hint: descriptor.hint,
|
||||
envVars: [...descriptor.envVars],
|
||||
placeholder: descriptor.placeholder,
|
||||
signupUrl: descriptor.signupUrl,
|
||||
docsUrl: descriptor.docsUrl,
|
||||
autoDetectOrder: descriptor.autoDetectOrder,
|
||||
credentialPath: descriptor.credentialPath,
|
||||
inactiveSecretPaths: [...descriptor.inactiveSecretPaths],
|
||||
getCredentialValue:
|
||||
descriptor.credentialScope.kind === "top-level"
|
||||
? getTopLevelCredentialValue
|
||||
: (searchConfig) => getScopedCredentialValue(searchConfig, scopedKey!),
|
||||
setCredentialValue:
|
||||
descriptor.credentialScope.kind === "top-level"
|
||||
? setTopLevelCredentialValue
|
||||
: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, scopedKey!, value),
|
||||
getConfiguredCredentialValue:
|
||||
descriptor.supportsConfiguredCredentialValue === false
|
||||
? undefined
|
||||
: (config) => resolveProviderWebSearchPluginConfig(config, descriptor.pluginId)?.apiKey,
|
||||
setConfiguredCredentialValue:
|
||||
descriptor.supportsConfiguredCredentialValue === false
|
||||
? undefined
|
||||
: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(
|
||||
configTarget,
|
||||
descriptor.pluginId,
|
||||
"apiKey",
|
||||
value,
|
||||
);
|
||||
},
|
||||
applySelectionConfig: descriptor.applySelectionConfig,
|
||||
resolveRuntimeMetadata: descriptor.resolveRuntimeMetadata,
|
||||
createTool: () => null,
|
||||
};
|
||||
type BundledWebSearchProviderEntry = PluginWebSearchProviderEntry & { pluginId: string };
|
||||
|
||||
let bundledWebSearchProvidersCache: BundledWebSearchProviderEntry[] | null = null;
|
||||
|
||||
function loadBundledWebSearchProviders(): BundledWebSearchProviderEntry[] {
|
||||
if (!bundledWebSearchProvidersCache) {
|
||||
bundledWebSearchProvidersCache = bundledWebSearchPluginRegistrations.flatMap(({ plugin }) =>
|
||||
capturePluginRegistration(plugin).webSearchProviders.map((provider) => ({
|
||||
...provider,
|
||||
pluginId: plugin.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return bundledWebSearchProvidersCache;
|
||||
}
|
||||
|
||||
export function resolveBundledWebSearchPluginIds(params: {
|
||||
@ -278,9 +43,7 @@ export function resolveBundledWebSearchPluginIds(params: {
|
||||
}
|
||||
|
||||
export function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] {
|
||||
return BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS.map((descriptor) =>
|
||||
buildBundledWebSearchProviderEntry(descriptor),
|
||||
);
|
||||
return loadBundledWebSearchProviders();
|
||||
}
|
||||
|
||||
export function resolveBundledWebSearchPluginId(
|
||||
@ -289,6 +52,5 @@ export function resolveBundledWebSearchPluginId(
|
||||
if (!providerId) {
|
||||
return undefined;
|
||||
}
|
||||
return BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS.find((descriptor) => descriptor.id === providerId)
|
||||
?.pluginId;
|
||||
return loadBundledWebSearchProviders().find((provider) => provider.id === providerId)?.pluginId;
|
||||
}
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js";
|
||||
import anthropicPlugin from "../../../extensions/anthropic/index.js";
|
||||
import bravePlugin from "../../../extensions/brave/index.js";
|
||||
import byteplusPlugin from "../../../extensions/byteplus/index.js";
|
||||
import chutesPlugin from "../../../extensions/chutes/index.js";
|
||||
import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js";
|
||||
import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js";
|
||||
import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js";
|
||||
import falPlugin from "../../../extensions/fal/index.js";
|
||||
import firecrawlPlugin from "../../../extensions/firecrawl/index.js";
|
||||
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
|
||||
import googlePlugin from "../../../extensions/google/index.js";
|
||||
import huggingFacePlugin from "../../../extensions/huggingface/index.js";
|
||||
@ -24,12 +22,10 @@ import openAIPlugin from "../../../extensions/openai/index.js";
|
||||
import opencodeGoPlugin from "../../../extensions/opencode-go/index.js";
|
||||
import opencodePlugin from "../../../extensions/opencode/index.js";
|
||||
import openrouterPlugin from "../../../extensions/openrouter/index.js";
|
||||
import perplexityPlugin from "../../../extensions/perplexity/index.js";
|
||||
import qianfanPlugin from "../../../extensions/qianfan/index.js";
|
||||
import qwenPortalAuthPlugin from "../../../extensions/qwen-portal-auth/index.js";
|
||||
import sglangPlugin from "../../../extensions/sglang/index.js";
|
||||
import syntheticPlugin from "../../../extensions/synthetic/index.js";
|
||||
import tavilyPlugin from "../../../extensions/tavily/index.js";
|
||||
import togetherPlugin from "../../../extensions/together/index.js";
|
||||
import venicePlugin from "../../../extensions/venice/index.js";
|
||||
import vercelAiGatewayPlugin from "../../../extensions/vercel-ai-gateway/index.js";
|
||||
@ -38,6 +34,7 @@ import volcenginePlugin from "../../../extensions/volcengine/index.js";
|
||||
import xaiPlugin from "../../../extensions/xai/index.js";
|
||||
import xiaomiPlugin from "../../../extensions/xiaomi/index.js";
|
||||
import zaiPlugin from "../../../extensions/zai/index.js";
|
||||
import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js";
|
||||
import { createCapturedPluginRegistration } from "../captured-registration.js";
|
||||
import { resolvePluginProviders } from "../providers.js";
|
||||
import type {
|
||||
@ -79,15 +76,11 @@ type PluginRegistrationContractEntry = {
|
||||
toolNames: string[];
|
||||
};
|
||||
|
||||
const bundledWebSearchPlugins: Array<RegistrablePlugin & { credentialValue: unknown }> = [
|
||||
{ ...bravePlugin, credentialValue: "BSA-test" },
|
||||
{ ...firecrawlPlugin, credentialValue: "fc-test" },
|
||||
{ ...googlePlugin, credentialValue: "AIza-test" },
|
||||
{ ...moonshotPlugin, credentialValue: "sk-test" },
|
||||
{ ...perplexityPlugin, credentialValue: "pplx-test" },
|
||||
{ ...tavilyPlugin, credentialValue: "tvly-test" },
|
||||
{ ...xaiPlugin, credentialValue: "xai-test" },
|
||||
];
|
||||
const bundledWebSearchPlugins: Array<RegistrablePlugin & { credentialValue: unknown }> =
|
||||
bundledWebSearchPluginRegistrations.map(({ plugin, credentialValue }) => ({
|
||||
...plugin,
|
||||
credentialValue,
|
||||
}));
|
||||
const bundledSpeechPlugins: RegistrablePlugin[] = [elevenLabsPlugin, microsoftPlugin, openAIPlugin];
|
||||
|
||||
const bundledMediaUnderstandingPlugins: RegistrablePlugin[] = [
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
WebSearchProviderToolDefinition,
|
||||
} from "../plugins/types.js";
|
||||
import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js";
|
||||
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
|
||||
import { resolveRuntimeWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
|
||||
import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js";
|
||||
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
|
||||
@ -88,6 +89,15 @@ export function listWebSearchProviders(params?: {
|
||||
});
|
||||
}
|
||||
|
||||
export function listConfiguredWebSearchProviders(params?: {
|
||||
config?: OpenClawConfig;
|
||||
}): PluginWebSearchProviderEntry[] {
|
||||
return resolvePluginWebSearchProviders({
|
||||
config: params?.config,
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveWebSearchProviderId(params: {
|
||||
search?: WebSearchConfig;
|
||||
config?: OpenClawConfig;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const runTui = vi.hoisted(() => vi.fn(async () => {}));
|
||||
@ -34,6 +36,18 @@ const readSystemdUserLingerStatus = vi.hoisted(() =>
|
||||
const resolveSetupSecretInputString = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<string | undefined>>(async () => undefined),
|
||||
);
|
||||
const resolveExistingKey = vi.hoisted(() =>
|
||||
vi.fn<(config: OpenClawConfig, provider: string) => string | undefined>(() => undefined),
|
||||
);
|
||||
const hasExistingKey = vi.hoisted(() =>
|
||||
vi.fn<(config: OpenClawConfig, provider: string) => boolean>(() => false),
|
||||
);
|
||||
const hasKeyInEnv = vi.hoisted(() =>
|
||||
vi.fn<(entry: Pick<PluginWebSearchProviderEntry, "envVars">) => boolean>(() => false),
|
||||
);
|
||||
const listConfiguredWebSearchProviders = vi.hoisted(() =>
|
||||
vi.fn<(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]>(() => []),
|
||||
);
|
||||
|
||||
vi.mock("../commands/onboard-helpers.js", () => ({
|
||||
detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })),
|
||||
@ -71,9 +85,14 @@ vi.mock("../commands/health.js", () => ({
|
||||
|
||||
vi.mock("../commands/onboard-search.js", () => ({
|
||||
SEARCH_PROVIDER_OPTIONS: [],
|
||||
hasExistingKey: vi.fn(() => false),
|
||||
hasKeyInEnv: vi.fn(() => false),
|
||||
resolveExistingKey: vi.fn(() => undefined),
|
||||
resolveSearchProviderOptions: () => [],
|
||||
hasExistingKey,
|
||||
hasKeyInEnv,
|
||||
resolveExistingKey,
|
||||
}));
|
||||
|
||||
vi.mock("../web-search/runtime.js", () => ({
|
||||
listConfiguredWebSearchProviders,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/service.js", () => ({
|
||||
@ -161,6 +180,14 @@ describe("finalizeSetupWizard", () => {
|
||||
readSystemdUserLingerStatus.mockResolvedValue({ user: "test-user", linger: "yes" });
|
||||
resolveSetupSecretInputString.mockReset();
|
||||
resolveSetupSecretInputString.mockResolvedValue(undefined);
|
||||
resolveExistingKey.mockReset();
|
||||
resolveExistingKey.mockReturnValue(undefined);
|
||||
hasExistingKey.mockReset();
|
||||
hasExistingKey.mockReturnValue(false);
|
||||
hasKeyInEnv.mockReset();
|
||||
hasKeyInEnv.mockReturnValue(false);
|
||||
listConfiguredWebSearchProviders.mockReset();
|
||||
listConfiguredWebSearchProviders.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it("resolves gateway password SecretRef for probe and TUI", async () => {
|
||||
@ -337,4 +364,160 @@ describe("finalizeSetupWizard", () => {
|
||||
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
|
||||
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
|
||||
});
|
||||
|
||||
it("reports selected providers blocked by plugin policy as unavailable", async () => {
|
||||
const prompter = buildWizardPrompter({
|
||||
select: vi.fn(async () => "later") as never,
|
||||
confirm: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
await finalizeSetupWizard({
|
||||
flow: "advanced",
|
||||
opts: {
|
||||
acceptRisk: true,
|
||||
authChoice: "skip",
|
||||
installDaemon: false,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
},
|
||||
baseConfig: {},
|
||||
nextConfig: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "firecrawl",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp",
|
||||
settings: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
authMode: "token",
|
||||
gatewayToken: undefined,
|
||||
tailscaleMode: "off",
|
||||
tailscaleResetOnExit: false,
|
||||
},
|
||||
prompter,
|
||||
runtime: createRuntime(),
|
||||
});
|
||||
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("selected but unavailable under the current plugin policy"),
|
||||
"Web search",
|
||||
);
|
||||
expect(resolveExistingKey).not.toHaveBeenCalled();
|
||||
expect(hasExistingKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only reports legacy auto-detect for runtime-visible providers", async () => {
|
||||
listConfiguredWebSearchProviders.mockReturnValue([
|
||||
{
|
||||
id: "perplexity",
|
||||
label: "Perplexity Search",
|
||||
hint: "Fast web answers",
|
||||
envVars: ["PERPLEXITY_API_KEY"],
|
||||
placeholder: "pplx-...",
|
||||
signupUrl: "https://www.perplexity.ai/",
|
||||
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
|
||||
},
|
||||
]);
|
||||
hasExistingKey.mockImplementation((_config, provider) => provider === "perplexity");
|
||||
|
||||
const prompter = buildWizardPrompter({
|
||||
select: vi.fn(async () => "later") as never,
|
||||
confirm: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
await finalizeSetupWizard({
|
||||
flow: "advanced",
|
||||
opts: {
|
||||
acceptRisk: true,
|
||||
authChoice: "skip",
|
||||
installDaemon: false,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
},
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
workspaceDir: "/tmp",
|
||||
settings: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
authMode: "token",
|
||||
gatewayToken: undefined,
|
||||
tailscaleMode: "off",
|
||||
tailscaleResetOnExit: false,
|
||||
},
|
||||
prompter,
|
||||
runtime: createRuntime(),
|
||||
});
|
||||
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Web search is available via Perplexity Search (auto-detected)."),
|
||||
"Web search",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured provider resolution instead of the active runtime registry", async () => {
|
||||
listConfiguredWebSearchProviders.mockReturnValue([
|
||||
{
|
||||
id: "firecrawl",
|
||||
label: "Firecrawl Search",
|
||||
hint: "Structured results",
|
||||
envVars: ["FIRECRAWL_API_KEY"],
|
||||
placeholder: "fc-...",
|
||||
signupUrl: "https://www.firecrawl.dev/",
|
||||
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
},
|
||||
]);
|
||||
hasExistingKey.mockImplementation((_config, provider) => provider === "firecrawl");
|
||||
|
||||
const prompter = buildWizardPrompter({
|
||||
select: vi.fn(async () => "later") as never,
|
||||
confirm: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
await finalizeSetupWizard({
|
||||
flow: "advanced",
|
||||
opts: {
|
||||
acceptRisk: true,
|
||||
authChoice: "skip",
|
||||
installDaemon: false,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
},
|
||||
baseConfig: {},
|
||||
nextConfig: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "firecrawl",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp",
|
||||
settings: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
authMode: "token",
|
||||
gatewayToken: undefined,
|
||||
tailscaleMode: "off",
|
||||
tailscaleResetOnExit: false,
|
||||
},
|
||||
prompter,
|
||||
runtime: createRuntime(),
|
||||
});
|
||||
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Web search is enabled, so your agent can look things up online when needed.",
|
||||
),
|
||||
"Web search",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -30,6 +30,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { restoreTerminalState } from "../terminal/restore.js";
|
||||
import { runTui } from "../tui/tui.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { listConfiguredWebSearchProviders } from "../web-search/runtime.js";
|
||||
import type { WizardPrompter } from "./prompts.js";
|
||||
import { setupWizardShellCompletion } from "./setup.completion.js";
|
||||
import { resolveSetupSecretInputString } from "./setup.secret-input.js";
|
||||
@ -483,13 +484,14 @@ export async function finalizeSetupWizard(
|
||||
|
||||
const webSearchProvider = nextConfig.tools?.web?.search?.provider;
|
||||
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
|
||||
const configuredSearchProviders = listConfiguredWebSearchProviders({ config: nextConfig });
|
||||
if (webSearchProvider) {
|
||||
const { SEARCH_PROVIDER_OPTIONS, resolveExistingKey, hasExistingKey, hasKeyInEnv } =
|
||||
const { resolveExistingKey, hasExistingKey, hasKeyInEnv } =
|
||||
await import("../commands/onboard-search.js");
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider);
|
||||
const entry = configuredSearchProviders.find((e) => e.id === webSearchProvider);
|
||||
const label = entry?.label ?? webSearchProvider;
|
||||
const storedKey = resolveExistingKey(nextConfig, webSearchProvider);
|
||||
const keyConfigured = hasExistingKey(nextConfig, webSearchProvider);
|
||||
const storedKey = entry ? resolveExistingKey(nextConfig, webSearchProvider) : undefined;
|
||||
const keyConfigured = entry ? hasExistingKey(nextConfig, webSearchProvider) : false;
|
||||
const envAvailable = entry ? hasKeyInEnv(entry) : false;
|
||||
const hasKey = keyConfigured || envAvailable;
|
||||
const keySource = storedKey
|
||||
@ -497,9 +499,20 @@ export async function finalizeSetupWizard(
|
||||
: keyConfigured
|
||||
? "API key: configured via secret reference."
|
||||
: envAvailable
|
||||
? `API key: provided via ${entry?.envKeys.join(" / ")} env var.`
|
||||
? `API key: provided via ${entry?.envVars.join(" / ")} env var.`
|
||||
: undefined;
|
||||
if (webSearchEnabled !== false && hasKey) {
|
||||
if (!entry) {
|
||||
await prompter.note(
|
||||
[
|
||||
`Web search provider ${label} is selected but unavailable under the current plugin policy.`,
|
||||
"web_search will not work until the provider is re-enabled or a different provider is selected.",
|
||||
` ${formatCliCommand("openclaw configure --section web")}`,
|
||||
"",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
} else if (webSearchEnabled !== false && hasKey) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Web search is enabled, so your agent can look things up online when needed.",
|
||||
@ -536,10 +549,9 @@ export async function finalizeSetupWizard(
|
||||
} else {
|
||||
// Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without
|
||||
// an explicit provider. Runtime auto-detects these, so avoid saying "skipped".
|
||||
const { SEARCH_PROVIDER_OPTIONS, hasExistingKey, hasKeyInEnv } =
|
||||
await import("../commands/onboard-search.js");
|
||||
const legacyDetected = SEARCH_PROVIDER_OPTIONS.find(
|
||||
(e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e),
|
||||
const { hasExistingKey, hasKeyInEnv } = await import("../commands/onboard-search.js");
|
||||
const legacyDetected = configuredSearchProviders.find(
|
||||
(e) => hasExistingKey(nextConfig, e.id) || hasKeyInEnv(e),
|
||||
);
|
||||
if (legacyDetected) {
|
||||
await prompter.note(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user