feat: improve pluggable web search onboarding

This commit is contained in:
Tak Hoffman
2026-03-14 12:00:12 -05:00
parent 8e5b535d48
commit f4ea5221df
16 changed files with 520 additions and 571 deletions

View File

@@ -28,6 +28,7 @@ When you touch tests or want extra confidence:
- Coverage gate: `pnpm test:coverage`
- E2E suite: `pnpm test:e2e`
- Targeted local repro: `pnpm test -- <path/to/test>` or `pnpm test:macmini -- <path/to/test>` on lower-memory hosts
When debugging real providers/models (requires real creds):

View File

@@ -28,7 +28,7 @@ For local PR land/gate checks, run:
- `pnpm test`
- `pnpm check:docs`
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run <path/to/test>`. For memory-constrained hosts, use:
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm test -- <path/to/test>` so the repo test wrapper still applies the intended config/profile logic. On lower-memory hosts, prefer `pnpm test:macmini -- <path/to/test>`. For memory-constrained full-suite runs, use:
- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`

View File

@@ -1,5 +1,6 @@
import {
CacheEntry,
createLegacySearchProviderMetadata,
createMissingSearchKeyPayload,
formatCliCommand,
normalizeCacheKey,
@@ -125,10 +126,7 @@ function resolveBraveConfig(search?: WebSearchConfig): BraveConfig {
return {};
}
const brave = "brave" in search ? search.brave : undefined;
if (!brave || typeof brave !== "object") {
return {};
}
return brave as BraveConfig;
return brave && typeof brave === "object" ? (brave as BraveConfig) : {};
}
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
@@ -397,16 +395,16 @@ async function runBraveWebSearch(params: {
);
}
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
label: "Brave Search",
hint: "Structured results · country/language/time filters",
envKeys: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
apiKeyConfigPath: "tools.web.search.apiKey",
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "brave"),
writeApiKeyValue: (search, value) => void ((search.apiKey = value) as unknown),
};
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
provider: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
envKeys: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
apiKeyConfigPath: "tools.web.search.apiKey",
});
export function createBundledBraveSearchProvider(): SearchProviderPlugin {
return {

View File

@@ -1,14 +1,15 @@
import {
buildSearchRequestCacheIdentity,
createLegacySearchProviderMetadata,
createMissingSearchKeyPayload,
normalizeCacheKey,
normalizeSecretInput,
readCache,
readResponseText,
readSearchProviderApiKeyValue,
rejectUnsupportedSearchFilters,
resolveCitationRedirectUrl,
resolveSearchConfig,
resolveSearchProviderSectionConfig,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderLegacyUiMetadata,
@@ -16,7 +17,6 @@ import {
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
writeSearchProviderApiKeyValue,
} from "openclaw/plugin-sdk/web-search";
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
@@ -62,10 +62,10 @@ type GeminiGroundingResponse = {
};
function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig {
if (!search || typeof search !== "object") return {};
const gemini = "gemini" in search ? search.gemini : undefined;
if (!gemini || typeof gemini !== "object") return {};
return gemini as GeminiConfig;
return resolveSearchProviderSectionConfig<GeminiConfig>(
search as Record<string, unknown> | undefined,
"gemini",
);
}
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
@@ -156,17 +156,16 @@ async function runGeminiSearch(params: {
);
}
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
label: "Gemini (Google Search)",
hint: "Google Search grounding · AI-synthesized",
envKeys: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
apiKeyConfigPath: "tools.web.search.gemini.apiKey",
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "gemini"),
writeApiKeyValue: (search, value) =>
writeSearchProviderApiKeyValue({ search, provider: "gemini", value }),
};
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
provider: "gemini",
label: "Gemini (Google Search)",
hint: "Google Search grounding · AI-synthesized",
envKeys: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
apiKeyConfigPath: "tools.web.search.gemini.apiKey",
});
export function createBundledGeminiSearchProvider(): SearchProviderPlugin {
return {

View File

@@ -1,12 +1,13 @@
import {
buildSearchRequestCacheIdentity,
createLegacySearchProviderMetadata,
createMissingSearchKeyPayload,
normalizeCacheKey,
normalizeSecretInput,
readCache,
readSearchProviderApiKeyValue,
rejectUnsupportedSearchFilters,
resolveSearchConfig,
resolveSearchProviderSectionConfig,
throwWebSearchApiError,
type OpenClawConfig,
type SearchProviderExecutionResult,
@@ -15,7 +16,6 @@ import {
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
writeSearchProviderApiKeyValue,
} from "openclaw/plugin-sdk/web-search";
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
@@ -67,10 +67,10 @@ type GrokSearchResponse = {
};
function resolveGrokConfig(search?: WebSearchConfig): GrokConfig {
if (!search || typeof search !== "object") return {};
const grok = "grok" in search ? search.grok : undefined;
if (!grok || typeof grok !== "object") return {};
return grok as GrokConfig;
return resolveSearchProviderSectionConfig<GrokConfig>(
search as Record<string, unknown> | undefined,
"grok",
);
}
function resolveGrokApiKey(grok?: GrokConfig): string | undefined {
@@ -164,17 +164,16 @@ async function runGrokSearch(params: {
);
}
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
label: "Grok (xAI)",
hint: "xAI web-grounded responses",
envKeys: ["XAI_API_KEY"],
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
apiKeyConfigPath: "tools.web.search.grok.apiKey",
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "grok"),
writeApiKeyValue: (search, value) =>
writeSearchProviderApiKeyValue({ search, provider: "grok", value }),
};
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
provider: "grok",
label: "Grok (xAI)",
hint: "xAI web-grounded responses",
envKeys: ["XAI_API_KEY"],
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
apiKeyConfigPath: "tools.web.search.grok.apiKey",
});
export function createBundledGrokSearchProvider(): SearchProviderPlugin {
return {

View File

@@ -1,12 +1,13 @@
import {
buildSearchRequestCacheIdentity,
createLegacySearchProviderMetadata,
createMissingSearchKeyPayload,
normalizeCacheKey,
normalizeSecretInput,
readCache,
readSearchProviderApiKeyValue,
rejectUnsupportedSearchFilters,
resolveSearchConfig,
resolveSearchProviderSectionConfig,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderLegacyUiMetadata,
@@ -14,7 +15,6 @@ import {
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
writeSearchProviderApiKeyValue,
} from "openclaw/plugin-sdk/web-search";
const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1";
@@ -67,10 +67,10 @@ type KimiSearchResponse = {
};
function resolveKimiConfig(search?: WebSearchConfig): KimiConfig {
if (!search || typeof search !== "object") return {};
const kimi = "kimi" in search ? search.kimi : undefined;
if (!kimi || typeof kimi !== "object") return {};
return kimi as KimiConfig;
return resolveSearchProviderSectionConfig<KimiConfig>(
search as Record<string, unknown> | undefined,
"kimi",
);
}
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
@@ -216,17 +216,16 @@ async function runKimiSearch(params: {
};
}
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
label: "Kimi (Moonshot)",
hint: "Moonshot web search",
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
placeholder: "sk-...",
signupUrl: "https://platform.moonshot.cn/",
apiKeyConfigPath: "tools.web.search.kimi.apiKey",
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "kimi"),
writeApiKeyValue: (search, value) =>
writeSearchProviderApiKeyValue({ search, provider: "kimi", value }),
};
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
provider: "kimi",
label: "Kimi (Moonshot)",
hint: "Moonshot web search",
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
placeholder: "sk-...",
signupUrl: "https://platform.moonshot.cn/",
apiKeyConfigPath: "tools.web.search.kimi.apiKey",
});
export function createBundledKimiSearchProvider(): SearchProviderPlugin {
return {

View File

@@ -1,5 +1,6 @@
import {
buildSearchRequestCacheIdentity,
createLegacySearchProviderMetadata,
createMissingSearchKeyPayload,
createSearchProviderErrorResult,
normalizeCacheKey,
@@ -7,8 +8,8 @@ import {
normalizeResolvedSecretInputString,
normalizeSecretInput,
readCache,
readSearchProviderApiKeyValue,
resolveSearchConfig,
resolveSearchProviderSectionConfig,
resolveSiteName,
throwWebSearchApiError,
type OpenClawConfig,
@@ -18,7 +19,6 @@ import {
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
writeSearchProviderApiKeyValue,
} from "openclaw/plugin-sdk/web-search";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
@@ -112,14 +112,10 @@ function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
}
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
if (!search || typeof search !== "object") {
return {};
}
const perplexity = "perplexity" in search ? search.perplexity : undefined;
if (!perplexity || typeof perplexity !== "object") {
return {};
}
return perplexity as PerplexityConfig;
return resolveSearchProviderSectionConfig<PerplexityConfig>(
search as Record<string, unknown> | undefined,
"perplexity",
);
}
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
@@ -380,22 +376,21 @@ function createPerplexityPayload(params: {
return payload;
}
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
label: "Perplexity Search",
hint: "Structured results · domain/country/language/time filters",
envKeys: ["PERPLEXITY_API_KEY"],
placeholder: "pplx-...",
signupUrl: "https://www.perplexity.ai/settings/api",
apiKeyConfigPath: "tools.web.search.perplexity.apiKey",
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "perplexity"),
writeApiKeyValue: (search, value) =>
writeSearchProviderApiKeyValue({ search, provider: "perplexity", value }),
resolveRuntimeMetadata: (params) => ({
perplexityTransport: resolvePerplexityTransport(
resolvePerplexityConfig(resolveSearchConfig<WebSearchConfig>(params.search)),
).transport,
}),
};
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
provider: "perplexity",
label: "Perplexity Search",
hint: "Structured results · domain/country/language/time filters",
envKeys: ["PERPLEXITY_API_KEY"],
placeholder: "pplx-...",
signupUrl: "https://www.perplexity.ai/settings/api",
apiKeyConfigPath: "tools.web.search.perplexity.apiKey",
resolveRuntimeMetadata: (params) => ({
perplexityTransport: resolvePerplexityTransport(
resolvePerplexityConfig(resolveSearchConfig<WebSearchConfig>(params.search)),
).transport,
}),
});
export function createBundledPerplexitySearchProvider(): SearchProviderPlugin {
return {

12
pnpm-lock.yaml generated
View File

@@ -456,6 +456,16 @@ importers:
extensions/sglang: {}
extensions/search-brave: {}
extensions/search-gemini: {}
extensions/search-grok: {}
extensions/search-kimi: {}
extensions/search-perplexity: {}
extensions/signal: {}
extensions/slack: {}
@@ -466,6 +476,8 @@ importers:
specifier: ^4.3.6
version: 4.3.6
extensions/tavily-search: {}
extensions/telegram: {}
extensions/tlon:

View File

@@ -1513,7 +1513,7 @@ async function _runBraveLlmContextSearch(params: {
}>;
sources?: BraveLlmContextResponse["sources"];
}> {
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
const url = new URL(_BRAVE_LLM_CONTEXT_ENDPOINT);
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);

View File

@@ -29,6 +29,9 @@ const loadPluginManifestRegistry = vi.hoisted(() =>
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
);
const ensureGenericOnboardingPluginInstalled = vi.hoisted(() =>
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
);
const reloadOnboardingPluginRegistry = vi.hoisted(() => vi.fn());
vi.mock("@clack/prompts", () => ({
@@ -63,6 +66,7 @@ vi.mock("../plugins/manifest-registry.js", () => ({
}));
vi.mock("./onboarding/plugin-install.js", () => ({
ensureGenericOnboardingPluginInstalled,
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
}));
@@ -160,6 +164,13 @@ describe("runConfigureWizard", () => {
installed: false,
}),
);
ensureGenericOnboardingPluginInstalled.mockReset();
ensureGenericOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg,
installed: false,
}),
);
reloadOnboardingPluginRegistry.mockReset();
});
@@ -226,7 +237,7 @@ describe("runConfigureWizard", () => {
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
if (params.message === "Choose active web search provider") {
if (params.message === "Choose web search provider") {
return "tavily";
}
if (params.message.startsWith("Search depth")) {
@@ -251,6 +262,7 @@ describe("runConfigureWizard", () => {
web: expect.objectContaining({
search: expect.objectContaining({
enabled: true,
provider: "tavily",
}),
}),
}),
@@ -305,10 +317,7 @@ describe("runConfigureWizard", () => {
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
if (params.message === "Web search setup") {
return "__configure_provider__";
}
if (params.message === "Choose provider to configure") {
if (params.message === "Choose web search provider") {
return "brave";
}
return "__continue";
@@ -402,7 +411,7 @@ describe("runConfigureWizard", () => {
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
if (params.message === "Choose active web search provider") {
if (params.message === "Choose web search provider") {
return "tavily";
}
if (params.message.startsWith("Search depth")) {
@@ -497,6 +506,7 @@ describe("runConfigureWizard", () => {
id: "tavily-search",
name: "Tavily Search",
description: "Search the web using Tavily.",
provides: ["providers.search.tavily"],
origin: "bundled",
source: "/tmp/bundled/tavily-search",
configSchema: {
@@ -561,7 +571,7 @@ describe("runConfigureWizard", () => {
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
if (params.message === "Choose active web search provider") {
if (params.message === "Choose web search provider") {
return "tavily";
}
if (params.message.startsWith("Search depth")) {
@@ -777,11 +787,11 @@ describe("runConfigureWizard", () => {
if (params.message === "Choose web search provider") {
expect(params.options?.[0]).toMatchObject({
value: "tavily",
hint: "Plugin search · External plugin · Configured · current",
hint: "Plugin search · External plugin",
});
expect(params.options?.[1]).toMatchObject({
value: "brave",
hint: "Structured results · country/language/time filters · Configured",
value: "__install_plugin__",
hint: "Add an external web search plugin",
});
return "tavily";
}

View File

@@ -196,7 +196,7 @@ async function promptWebToolsConfig(
note(
[
"Web search lets your agent look things up online using the `web_search` tool.",
"Choose a provider and enter the required built-in or plugin settings.",
"Choose a provider and enter the required settings.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",

View File

@@ -12,6 +12,9 @@ const loadPluginManifestRegistry = vi.hoisted(() =>
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
);
const ensureGenericOnboardingPluginInstalled = vi.hoisted(() =>
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
);
const reloadOnboardingPluginRegistry = vi.hoisted(() => vi.fn());
vi.mock("../plugins/loader.js", () => ({
@@ -23,6 +26,7 @@ vi.mock("../plugins/manifest-registry.js", () => ({
}));
vi.mock("./onboarding/plugin-install.js", () => ({
ensureGenericOnboardingPluginInstalled,
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
}));
@@ -37,11 +41,7 @@ const runtime: RuntimeEnv = {
}) as RuntimeEnv["exit"],
};
function createPrompter(params: {
selectValue?: string;
actionValue?: string;
textValue?: string;
}): {
function createPrompter(params: { selectValue?: string; textValue?: string }): {
prompter: WizardPrompter;
notes: Array<{ title?: string; message: string }>;
} {
@@ -52,12 +52,9 @@ function createPrompter(params: {
note: vi.fn(async (message: string, title?: string) => {
notes.push({ title, message });
}),
select: vi.fn(async (promptParams: { message?: string }) => {
if (promptParams?.message === "Web search setup") {
return params.actionValue ?? "__switch_active__";
}
return params.selectValue ?? "perplexity";
}) as unknown as WizardPrompter["select"],
select: vi.fn(
async () => params.selectValue ?? "perplexity",
) as unknown as WizardPrompter["select"],
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
text: vi.fn(async () => params.textValue ?? ""),
confirm: vi.fn(async () => true),
@@ -126,6 +123,13 @@ describe("setupSearch", () => {
installed: false,
}),
);
ensureGenericOnboardingPluginInstalled.mockReset();
ensureGenericOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg,
installed: false,
}),
);
reloadOnboardingPluginRegistry.mockReset();
});
@@ -162,7 +166,7 @@ describe("setupSearch", () => {
await setupSearch(cfg, runtime, prompter);
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0]?.message === "Choose active web search provider",
(call) => call[0]?.message === "Choose web search provider",
);
expect(providerSelectCall?.[0]).toEqual(
expect.objectContaining({
@@ -182,7 +186,7 @@ describe("setupSearch", () => {
);
});
it("shows bundled plugin providers directly in the picker instead of the external install path", async () => {
it("shows bundled plugin providers directly in the picker while keeping the external install path available", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [],
plugins: [],
@@ -194,6 +198,7 @@ describe("setupSearch", () => {
id: "tavily-search",
name: "Tavily Search",
description: "Search the web using Tavily.",
provides: ["providers.search.tavily"],
origin: "bundled",
source: "/tmp/bundled/tavily-search",
configSchema: {
@@ -220,7 +225,7 @@ describe("setupSearch", () => {
await setupSearch(cfg, runtime, prompter);
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0]?.message === "Choose active web search provider",
(call) => call[0]?.message === "Choose web search provider",
);
expect(providerSelectCall?.[0]).toEqual(
expect.objectContaining({
@@ -233,10 +238,11 @@ describe("setupSearch", () => {
]),
}),
);
expect(providerSelectCall?.[0]?.options).not.toEqual(
expect(providerSelectCall?.[0]?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: "__install_plugin__",
label: "Install external provider plugin",
}),
]),
);
@@ -288,7 +294,7 @@ describe("setupSearch", () => {
await setupSearch(cfg, runtime, prompter);
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0]?.message === "Choose active web search provider",
(call) => call[0]?.message === "Choose web search provider",
);
const matchingOptions =
providerSelectCall?.[0]?.options?.filter(
@@ -346,7 +352,6 @@ describe("setupSearch", () => {
},
};
const { prompter } = createPrompter({
actionValue: "__skip__",
selectValue: "__skip__",
});
@@ -354,11 +359,11 @@ describe("setupSearch", () => {
expect(prompter.select).toHaveBeenCalledWith(
expect.objectContaining({
message: "Web search setup",
message: "Choose web search provider",
options: expect.arrayContaining([
expect.objectContaining({
value: "__configure_provider__",
label: "Configure or install a provider",
value: "__install_plugin__",
label: "Install external provider plugin",
}),
]),
}),
@@ -457,7 +462,7 @@ describe("setupSearch", () => {
await setupSearch(cfg, runtime, prompter);
const options = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0]?.message === "Choose active web search provider",
(call) => call[0]?.message === "Choose web search provider",
)?.[0]?.options;
expect(options[0]).toMatchObject({
value: "tavily",
@@ -517,14 +522,13 @@ describe("setupSearch", () => {
},
};
const { prompter } = createPrompter({
actionValue: "__configure_provider__",
selectValue: "__skip__",
});
await setupSearch(cfg, runtime, prompter);
const configurePickerCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0]?.message === "Choose provider to configure",
(call) => call[0]?.message === "Choose web search provider",
);
expect(configurePickerCall?.[0]).toEqual(
expect.objectContaining({
@@ -726,7 +730,6 @@ describe("setupSearch", () => {
});
const { prompter, notes } = createPrompter({
actionValue: "__configure_provider__",
selectValue: "tavily",
textValue: "tvly-test-key",
});
@@ -811,7 +814,6 @@ describe("setupSearch", () => {
};
const { prompter } = createPrompter({
actionValue: "__switch_active__",
selectValue: "tavily",
});
@@ -894,7 +896,6 @@ describe("setupSearch", () => {
};
const { prompter } = createPrompter({
actionValue: "__switch_active__",
selectValue: "tavily",
});
@@ -1046,7 +1047,6 @@ describe("setupSearch", () => {
};
const { prompter, notes } = createPrompter({
actionValue: "__switch_active__",
selectValue: "tavily",
textValue: "tvly-valid-key",
});
@@ -1146,17 +1146,17 @@ describe("setupSearch", () => {
});
});
it("installs a search plugin from the shared catalog and continues provider setup", async () => {
it("installs an external search plugin and continues provider setup for the discovered provider", async () => {
loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => {
const enabled = config.plugins?.entries?.["tavily-search"]?.enabled === true;
const enabled = config.plugins?.entries?.["external-search"]?.enabled === true;
return enabled
? {
searchProviders: [
{
pluginId: "tavily-search",
pluginId: "external-search",
provider: {
id: "tavily",
name: "Tavily Search",
id: "external-search",
name: "External Search",
description: "Plugin search",
configFieldOrder: ["apiKey", "searchDepth"],
search: async () => ({ content: "ok" }),
@@ -1165,11 +1165,11 @@ describe("setupSearch", () => {
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
id: "external-search",
name: "External Search",
description: "External search plugin",
origin: "workspace",
source: "/tmp/tavily-search",
source: "/tmp/external-search",
configJsonSchema: {
type: "object",
properties: {
@@ -1193,7 +1193,7 @@ describe("setupSearch", () => {
}
: { searchProviders: [], plugins: [], typedHooks: [] };
});
ensureOnboardingPluginInstalled.mockImplementation(
ensureGenericOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
@@ -1201,14 +1201,17 @@ describe("setupSearch", () => {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
"tavily-search": {
...(cfg.plugins?.entries?.["tavily-search"] as Record<string, unknown> | undefined),
"external-search": {
...(cfg.plugins?.entries?.["external-search"] as
| Record<string, unknown>
| undefined),
enabled: true,
},
},
},
},
installed: true,
pluginId: "external-search",
}),
);
@@ -1224,15 +1227,8 @@ describe("setupSearch", () => {
workspaceDir: "/tmp/workspace-search",
});
expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith(
expect(ensureGenericOnboardingPluginInstalled).toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({
id: "tavily-search",
install: expect.objectContaining({
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
}),
}),
workspaceDir: "/tmp/workspace-search",
}),
);
@@ -1241,9 +1237,9 @@ describe("setupSearch", () => {
workspaceDir: "/tmp/workspace-search",
}),
);
expect(result.tools?.web?.search?.provider).toBe("tavily");
expect(result.plugins?.entries?.["tavily-search"]?.enabled).toBe(true);
expect(result.plugins?.entries?.["tavily-search"]?.config).toEqual({
expect(result.tools?.web?.search?.provider).toBe("external-search");
expect(result.plugins?.entries?.["external-search"]?.enabled).toBe(true);
expect(result.plugins?.entries?.["external-search"]?.config).toEqual({
apiKey: "tvly-installed-key",
searchDepth: "advanced",
});
@@ -1251,15 +1247,15 @@ describe("setupSearch", () => {
it("continues into plugin config prompts even when the newly installed provider cannot register yet", async () => {
loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => {
const hasApiKey = Boolean(config.plugins?.entries?.["tavily-search"]?.config?.apiKey);
const hasApiKey = Boolean(config.plugins?.entries?.["external-search"]?.config?.apiKey);
return hasApiKey
? {
searchProviders: [
{
pluginId: "tavily-search",
pluginId: "external-search",
provider: {
id: "tavily",
name: "Tavily Search",
id: "external-search",
name: "External Search",
description: "Plugin search",
configFieldOrder: ["apiKey", "searchDepth"],
search: async () => ({ content: "ok" }),
@@ -1268,11 +1264,11 @@ describe("setupSearch", () => {
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
id: "external-search",
name: "External Search",
description: "External search plugin",
origin: "workspace",
source: "/tmp/tavily-search",
source: "/tmp/external-search",
configJsonSchema: {
type: "object",
required: ["apiKey"],
@@ -1299,11 +1295,11 @@ describe("setupSearch", () => {
searchProviders: [],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
id: "external-search",
name: "External Search",
description: "External search plugin",
origin: "workspace",
source: "/tmp/tavily-search",
source: "/tmp/external-search",
configJsonSchema: {
type: "object",
required: ["apiKey"],
@@ -1330,11 +1326,12 @@ describe("setupSearch", () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
id: "external-search",
name: "External Search",
description: "External search plugin",
origin: "workspace",
source: "/tmp/tavily-search",
source: "/tmp/external-search",
provides: ["providers.search.external-search"],
configSchema: {
type: "object",
required: ["apiKey"],
@@ -1357,7 +1354,7 @@ describe("setupSearch", () => {
],
diagnostics: [],
});
ensureOnboardingPluginInstalled.mockImplementation(
ensureGenericOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
@@ -1365,14 +1362,17 @@ describe("setupSearch", () => {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
"tavily-search": {
...(cfg.plugins?.entries?.["tavily-search"] as Record<string, unknown> | undefined),
"external-search": {
...(cfg.plugins?.entries?.["external-search"] as
| Record<string, unknown>
| undefined),
enabled: true,
},
},
},
},
installed: true,
pluginId: "external-search",
}),
);
@@ -1391,8 +1391,8 @@ describe("setupSearch", () => {
expect(
notes.some((note) => note.message.includes("could not load its web search provider yet")),
).toBe(false);
expect(result.tools?.web?.search?.provider).toBe("tavily");
expect(result.plugins?.entries?.["tavily-search"]?.config).toEqual({
expect(result.tools?.web?.search?.provider).toBe("external-search");
expect(result.plugins?.entries?.["external-search"]?.config).toEqual({
apiKey: "tvly-installed-key",
searchDepth: "advanced",
});

View File

@@ -1,10 +1,4 @@
import {
BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS,
MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS,
type BuiltinWebSearchProviderEntry,
type BuiltinWebSearchProviderId,
isBuiltinWebSearchProviderId,
} from "../agents/tools/web-search-provider-catalog.js";
import { isBuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js";
import type { OpenClawConfig } from "../config/config.js";
import {
DEFAULT_SECRET_PROVIDER_ALIAS,
@@ -13,10 +7,6 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../config/types.secrets.js";
import {
readSearchProviderApiKeyValue,
writeSearchProviderApiKeyValue,
} from "../plugin-sdk/web-search.js";
import {
applyCapabilitySlotSelection,
resolveCapabilitySlotSelection,
@@ -35,12 +25,11 @@ import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { SecretInputMode } from "./onboard-types.js";
import {
ensureOnboardingPluginInstalled,
ensureGenericOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
} from "./onboarding/plugin-install.js";
import {
buildProviderSelectionOptions,
promptProviderManagementIntent,
type ProviderManagementIntent,
} from "./provider-management.js";
import {
@@ -48,19 +37,11 @@ import {
type InstallableSearchProviderPluginCatalogEntry,
} from "./search-provider-plugin-catalog.js";
export type SearchProvider = BuiltinWebSearchProviderId;
type SearchProviderEntry = BuiltinWebSearchProviderEntry;
export const SEARCH_PROVIDER_OPTIONS = BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS;
export type SearchProvider = string;
const SEARCH_PROVIDER_INSTALL_SENTINEL = "__install_plugin__" as const;
const SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL = "__keep_current__" as const;
const SEARCH_PROVIDER_SKIP_SENTINEL = "__skip__" as const;
const SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL = "__switch_active__" as const;
const SEARCH_PROVIDER_CONFIGURE_SENTINEL = "__configure_provider__" as const;
const MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_ID_SET = new Set<string>(
MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS,
);
type PluginSearchProviderEntry = {
kind: "plugin";
@@ -78,9 +59,7 @@ type PluginSearchProviderEntry = {
legacyConfig?: SearchProviderLegacyConfigMetadata;
};
export type SearchProviderPickerEntry =
| (SearchProviderEntry & { kind: "builtin"; configured: boolean })
| PluginSearchProviderEntry;
export type SearchProviderPickerEntry = PluginSearchProviderEntry;
type SearchProviderPickerChoice = string;
type SearchProviderFlowIntent = ProviderManagementIntent;
@@ -119,21 +98,6 @@ type SearchProviderHookDetails = {
configured: boolean;
};
function legacyConfigFromBuiltinEntry(
entry: SearchProviderEntry,
): SearchProviderLegacyConfigMetadata {
return {
hint: entry.hint,
envKeys: entry.envKeys,
placeholder: entry.placeholder,
signupUrl: entry.signupUrl,
apiKeyConfigPath: entry.apiKeyConfigPath,
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, entry.value),
writeApiKeyValue: (search, value) =>
writeSearchProviderApiKeyValue({ search, provider: entry.value, value }),
};
}
const HOOK_RUNNER_LOGGER = {
warn: () => {},
error: () => {},
@@ -546,14 +510,6 @@ export async function resolveSearchProviderPickerEntries(
config: OpenClawConfig,
workspaceDir?: string,
): Promise<SearchProviderPickerEntry[]> {
const builtins: SearchProviderPickerEntry[] = SEARCH_PROVIDER_OPTIONS.filter(
(entry) => !MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_ID_SET.has(entry.value),
).map((entry) => ({
...entry,
kind: "builtin",
configured: hasExistingKey(config, legacyConfigFromBuiltinEntry(entry)) || hasKeyInEnv(entry),
}));
let pluginEntries: PluginSearchProviderEntry[] = [];
try {
const registry = loadOpenClawPlugins({
@@ -601,15 +557,7 @@ export async function resolveSearchProviderPickerEntries(
legacyConfig: registration.provider.legacyConfig,
};
})
.filter((entry) => {
if (!entry) {
return false;
}
return !(
entry.origin === "bundled" &&
!MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_ID_SET.has(entry.value)
);
})
.filter(Boolean)
.filter(Boolean) as PluginSearchProviderEntry[];
pluginEntries = resolvedPluginEntries.toSorted((left, right) =>
left.label.localeCompare(right.label),
@@ -651,7 +599,7 @@ export async function resolveSearchProviderPickerEntries(
// Ignore manifest lookup failures and fall back to loaded entries only.
}
return [...builtins, ...pluginEntries];
return pluginEntries;
}
export async function resolveSearchProviderPickerEntry(
@@ -663,6 +611,48 @@ export async function resolveSearchProviderPickerEntry(
return entries.find((entry) => entry.value === providerId);
}
function searchProviderIdFromProvides(provides: string[]): string | undefined {
return provides
.find((capability) => capability.startsWith("providers.search."))
?.slice("providers.search.".length);
}
function buildPluginSearchProviderEntryFromManifestRecord(pluginRecord: {
id: string;
name?: string;
description?: string;
origin: PluginOrigin;
configSchema?: Record<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
provides: string[];
}): PluginSearchProviderEntry | undefined {
const providerId = searchProviderIdFromProvides(pluginRecord.provides);
if (!providerId) {
return undefined;
}
const hintParts = [
pluginRecord.description || "Plugin-provided web search",
formatPluginSourceHint(pluginRecord.origin),
];
return {
kind: "plugin",
value: providerId,
label: pluginRecord.name || providerId,
hint: hintParts.join(" · "),
configured: false,
pluginId: pluginRecord.id,
origin: pluginRecord.origin,
description: pluginRecord.description,
docsUrl: undefined,
configFieldOrder: undefined,
configJsonSchema: pluginRecord.configSchema,
configUiHints: pluginRecord.configUiHints,
legacyConfig: undefined,
};
}
function buildPluginSearchProviderEntryFromManifest(params: {
config: OpenClawConfig;
installEntry: InstallableSearchProviderPluginCatalogEntry;
@@ -677,74 +667,23 @@ function buildPluginSearchProviderEntryFromManifest(params: {
if (!pluginRecord) {
return undefined;
}
return {
kind: "plugin",
value: params.installEntry.providerId,
label: params.installEntry.meta.label,
hint: [
pluginRecord.description || "Plugin-provided web search",
formatPluginSourceHint(pluginRecord.origin),
].join(" · "),
configured: false,
pluginId: pluginRecord.id,
origin: pluginRecord.origin,
description: pluginRecord.description,
docsUrl: undefined,
configFieldOrder: undefined,
configJsonSchema: pluginRecord.configSchema,
configUiHints: pluginRecord.configUiHints,
};
}
async function promptSearchProviderPluginInstallChoice(
installableEntries: InstallableSearchProviderPluginCatalogEntry[],
prompter: WizardPrompter,
): Promise<InstallableSearchProviderPluginCatalogEntry | undefined> {
if (installableEntries.length === 0) {
return undefined;
}
if (installableEntries.length === 1) {
return installableEntries[0];
}
const choice = await prompter.select<string>({
message: "Choose provider plugin to install",
options: [
...installableEntries.map((entry) => ({
value: entry.providerId,
label: entry.meta.label,
hint: entry.description,
})),
{
value: SEARCH_PROVIDER_SKIP_SENTINEL,
label: "Skip for now",
hint: "Keep the current search setup unchanged",
},
],
initialValue: installableEntries[0]?.providerId ?? SEARCH_PROVIDER_SKIP_SENTINEL,
});
if (choice === SEARCH_PROVIDER_SKIP_SENTINEL) {
return undefined;
}
return installableEntries.find((entry) => entry.providerId === choice);
return buildPluginSearchProviderEntryFromManifestRecord(pluginRecord);
}
async function installSearchProviderPlugin(params: {
config: OpenClawConfig;
entry: InstallableSearchProviderPluginCatalogEntry;
runtime: RuntimeEnv;
prompter: WizardPrompter;
workspaceDir?: string;
}): Promise<OpenClawConfig> {
const result = await ensureOnboardingPluginInstalled({
}): Promise<{ config: OpenClawConfig; installed: boolean; pluginId?: string }> {
const result = await ensureGenericOnboardingPluginInstalled({
cfg: params.config,
entry: params.entry,
prompter: params.prompter,
runtime: params.runtime,
workspaceDir: params.workspaceDir,
});
if (!result.installed) {
return params.config;
return { config: params.config, installed: false };
}
reloadOnboardingPluginRegistry({
cfg: result.cfg,
@@ -752,27 +691,39 @@ async function installSearchProviderPlugin(params: {
workspaceDir: params.workspaceDir,
suppressOpenAllowlistWarning: true,
});
return result.cfg;
return { config: result.cfg, installed: true, pluginId: result.pluginId };
}
async function resolveInstalledSearchProviderEntry(params: {
config: OpenClawConfig;
installEntry: InstallableSearchProviderPluginCatalogEntry;
pluginId?: string;
workspaceDir?: string;
}): Promise<PluginSearchProviderEntry | undefined> {
const installedProvider = await resolveSearchProviderPickerEntry(
const providerEntries = await resolveSearchProviderPickerEntries(
params.config,
params.installEntry.providerId,
params.workspaceDir,
);
if (installedProvider?.kind === "plugin") {
return installedProvider;
if (params.pluginId) {
const loadedProvider = providerEntries.find(
(entry) => entry.kind === "plugin" && entry.pluginId === params.pluginId,
);
if (loadedProvider?.kind === "plugin") {
return loadedProvider;
}
}
return buildPluginSearchProviderEntryFromManifest({
if (!params.pluginId) {
return undefined;
}
const manifestRegistry = loadPluginManifestRegistry({
config: params.config,
installEntry: params.installEntry,
workspaceDir: params.workspaceDir,
cache: false,
});
const manifestRecord = manifestRegistry.plugins.find((plugin) => plugin.id === params.pluginId);
if (!manifestRecord) {
return undefined;
}
return buildPluginSearchProviderEntryFromManifestRecord(manifestRecord);
}
export async function applySearchProviderChoice(params: {
@@ -792,44 +743,31 @@ export async function applySearchProviderChoice(params: {
}
if (params.choice === SEARCH_PROVIDER_INSTALL_SENTINEL) {
const providerEntries = await resolveSearchProviderPickerEntries(
params.config,
params.opts?.workspaceDir,
);
const installableEntries = resolveInstallableSearchProviderPlugins(providerEntries);
const selectedInstallEntry = await promptSearchProviderPluginInstallChoice(
installableEntries,
params.prompter,
);
if (!selectedInstallEntry) {
return params.config;
}
const installedConfig = await installSearchProviderPlugin({
config: params.config,
entry: selectedInstallEntry,
runtime: params.runtime,
prompter: params.prompter,
workspaceDir: params.opts?.workspaceDir,
});
if (installedConfig === params.config) {
if (!installedConfig.installed) {
return params.config;
}
const installedProvider = await resolveInstalledSearchProviderEntry({
config: installedConfig,
installEntry: selectedInstallEntry,
config: installedConfig.config,
pluginId: installedConfig.pluginId,
workspaceDir: params.opts?.workspaceDir,
});
if (!installedProvider) {
await params.prompter.note(
[
`Installed ${selectedInstallEntry.meta.label}, but OpenClaw could not load its web search provider yet.`,
"Restart the gateway and try configure again.",
"Installed plugin, but it did not register a web search provider yet.",
"Restart the gateway and try configure again if this plugin should provide web search.",
].join("\n"),
"Plugin install",
);
return installedConfig;
return installedConfig.config;
}
const enabled = enablePluginInConfig(installedConfig, installedProvider.pluginId);
const enabled = enablePluginInConfig(installedConfig.config, installedProvider.pluginId);
const hookRunner = createSearchProviderHookRunner(enabled.config, params.opts?.workspaceDir);
const providerDetails: SearchProviderHookDetails = {
providerId: installedProvider.value,
@@ -857,20 +795,20 @@ export async function applySearchProviderChoice(params: {
);
const result = pluginConfigResult.valid
? preserveSearchProviderIntent(
installedConfig,
installedConfig.config,
pluginConfigResult.config,
intent,
installedProvider.value,
)
: preserveSearchProviderIntent(
installedConfig,
installedConfig.config,
enabled.config,
"configure-provider",
installedProvider.value,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: installedConfig,
originalConfig: installedConfig.config,
resultConfig: result,
provider: providerDetails,
intent,
@@ -971,7 +909,7 @@ export function buildSearchProviderPickerModel(
(configuredCount === 1 ? configuredEntries[0]?.value : undefined) ??
configuredEntries[0]?.value ??
sortedEntries[0]?.value ??
SEARCH_PROVIDER_OPTIONS[0].value;
SEARCH_PROVIDER_SKIP_SENTINEL;
const installableEntries = resolveInstallableSearchProviderPlugins(providerEntries);
const options: Array<{ value: SearchProviderPickerChoice; label: string; hint?: string }> = [
@@ -993,15 +931,14 @@ export function buildSearchProviderPickerModel(
configuredCount,
}),
})),
...(installableEntries.length > 0
? [
{
value: SEARCH_PROVIDER_INSTALL_SENTINEL as const,
label: "Install external provider plugin",
hint: "Add an external web search plugin",
},
]
: []),
{
value: SEARCH_PROVIDER_INSTALL_SENTINEL as const,
label: "Install external provider plugin",
hint:
installableEntries.length > 0
? "Add an external web search plugin"
: "Install an external web search plugin from npm or a local path",
},
...(includeSkipOption
? [
{
@@ -1056,8 +993,8 @@ export async function configureSearchProviderSelection(
if (legacyConfig && intent === "switch-active" && (keyConfigured || envAvailable)) {
const result = existingKey
? applySearchKey(config, selectedEntry.value as SearchProvider, legacyConfig, existingKey)
: applyProviderOnly(config, selectedEntry.value as SearchProvider);
? applySearchKey(config, selectedEntry.value, legacyConfig, existingKey)
: applyProviderOnly(config, selectedEntry.value);
const nextConfig = preserveSearchProviderIntent(config, result, intent, selectedEntry.value);
await runAfterSearchProviderHooks({
hookRunner,
@@ -1107,7 +1044,7 @@ export async function configureSearchProviderSelection(
if (keyConfigured) {
return preserveSearchProviderIntent(
config,
applyProviderOnly(config, selectedEntry.value as SearchProvider),
applyProviderOnly(config, selectedEntry.value),
intent,
selectedEntry.value,
);
@@ -1124,7 +1061,7 @@ export async function configureSearchProviderSelection(
);
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, selectedEntry.value as SearchProvider, legacyConfig, ref),
applySearchKey(config, selectedEntry.value, legacyConfig, ref),
intent,
selectedEntry.value,
);
@@ -1151,14 +1088,14 @@ export async function configureSearchProviderSelection(
const key = keyInput?.trim() ?? "";
if (key) {
const secretInput = resolveSearchSecretInput(
selectedEntry.value as SearchProvider,
selectedEntry.value,
legacyConfig,
key,
opts?.secretInputMode,
);
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, selectedEntry.value as SearchProvider, legacyConfig, secretInput),
applySearchKey(config, selectedEntry.value, legacyConfig, secretInput),
intent,
selectedEntry.value,
);
@@ -1176,7 +1113,7 @@ export async function configureSearchProviderSelection(
if (existingKey) {
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, selectedEntry.value as SearchProvider, legacyConfig, existingKey),
applySearchKey(config, selectedEntry.value, legacyConfig, existingKey),
intent,
selectedEntry.value,
);
@@ -1194,7 +1131,7 @@ export async function configureSearchProviderSelection(
if (keyConfigured || envAvailable) {
const result = preserveSearchProviderIntent(
config,
applyProviderOnly(config, selectedEntry.value as SearchProvider),
applyProviderOnly(config, selectedEntry.value),
intent,
selectedEntry.value,
);
@@ -1245,195 +1182,7 @@ export async function configureSearchProviderSelection(
});
return result;
}
const builtinChoice = choice as SearchProvider;
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === builtinChoice);
if (!entry) {
return config;
}
const hookRunner = createSearchProviderHookRunner(config, opts?.workspaceDir);
const builtinLegacyConfig = legacyConfigFromBuiltinEntry(entry);
const providerDetails: SearchProviderHookDetails = {
providerId: builtinChoice,
providerLabel: entry.label,
providerSource: "builtin",
configured: hasExistingKey(config, builtinLegacyConfig) || hasKeyInEnv(entry),
};
const existingKey = resolveExistingKey(config, builtinLegacyConfig);
const keyConfigured = hasExistingKey(config, builtinLegacyConfig);
const envAvailable = hasKeyInEnv(entry);
if (intent === "switch-active" && (keyConfigured || envAvailable)) {
const result = existingKey
? applySearchKey(config, builtinChoice, builtinLegacyConfig, existingKey)
: applyProviderOnly(config, builtinChoice);
const next = preserveSearchProviderIntent(config, result, intent, builtinChoice);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: next,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return next;
}
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
const result = existingKey
? applySearchKey(config, builtinChoice, builtinLegacyConfig, existingKey)
: applyProviderOnly(config, builtinChoice);
const next = preserveSearchProviderIntent(config, result, intent, builtinChoice);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: next,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return next;
}
await maybeNoteBeforeSearchProviderConfigure({
hookRunner,
config,
provider: providerDetails,
intent,
prompter,
workspaceDir: opts?.workspaceDir,
});
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
if (keyConfigured) {
return preserveDisabledState(config, applyProviderOnly(config, builtinChoice));
}
const ref = buildSearchEnvRef(builtinLegacyConfig);
await prompter.note(
[
"Secret references enabled — OpenClaw will store a reference instead of the API key.",
`Env var: ${ref.id}${envAvailable ? " (detected)" : ""}.`,
...(envAvailable ? [] : [`Set ${ref.id} in the Gateway environment.`]),
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, builtinChoice, builtinLegacyConfig, ref),
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
const keyInput = await prompter.text({
message: keyConfigured
? `${entry.label} API key (leave blank to keep current)`
: envAvailable
? `${entry.label} API key (leave blank to use env var)`
: `${entry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
});
const key = keyInput?.trim() ?? "";
if (key) {
const secretInput = resolveSearchSecretInput(
builtinChoice,
builtinLegacyConfig,
key,
opts?.secretInputMode,
);
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, builtinChoice, builtinLegacyConfig, secretInput),
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
if (existingKey) {
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, builtinChoice, builtinLegacyConfig, existingKey),
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
if (keyConfigured || envAvailable) {
const result = preserveSearchProviderIntent(
config,
applyProviderOnly(config, builtinChoice),
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
await prompter.note(
[
"No API key stored — web_search won't work until a key is available.",
`Get your key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
const result = preserveSearchProviderIntent(
config,
applyCapabilitySlotSelection({
config,
slot: "providers.search",
selectedId: builtinChoice,
}),
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
return config;
}
function preserveSearchProviderIntent(
@@ -1448,7 +1197,13 @@ function preserveSearchProviderIntent(
const currentProvider = resolveCapabilitySlotSelection(original, "providers.search");
let next = result;
if (currentProvider && currentProvider !== selectedProvider) {
if (!currentProvider) {
next = applyCapabilitySlotSelection({
config: next,
slot: "providers.search",
selectedId: selectedProvider,
});
} else if (currentProvider !== selectedProvider) {
next = applyCapabilitySlotSelection({
config: next,
slot: "providers.search",
@@ -1476,45 +1231,33 @@ export async function promptSearchProviderFlow(params: {
includeSkipOption: params.includeSkipOption,
skipHint: params.skipHint,
});
const action = await promptProviderManagementIntent({
prompter: params.prompter,
message: "Web search setup",
includeSkipOption: params.includeSkipOption,
configuredCount: pickerModel.configuredCount,
configureValue: SEARCH_PROVIDER_CONFIGURE_SENTINEL,
switchValue: SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL,
skipValue: SEARCH_PROVIDER_SKIP_SENTINEL,
configureLabel: "Configure or install a provider",
configureHint:
"Update keys, plugin settings, or install a provider without changing the active provider",
switchLabel: "Switch active provider",
switchHint: "Change which provider web_search uses right now",
skipHint: "Configure later with openclaw configure --section web",
});
if (action === SEARCH_PROVIDER_SKIP_SENTINEL) {
return params.config;
}
const intent: SearchProviderFlowIntent =
action === SEARCH_PROVIDER_CONFIGURE_SENTINEL ? "configure-provider" : "switch-active";
const choice = await params.prompter.select<SearchProviderPickerChoice>({
message:
intent === "switch-active"
? "Choose active web search provider"
: "Choose provider to configure",
message: "Choose web search provider",
options: buildProviderSelectionOptions({
intent,
intent: "configure-provider",
options: pickerModel.options,
activeValue: pickerModel.activeProvider,
hiddenValues: intent === "configure-provider" ? [SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL] : [],
}),
initialValue:
intent === "switch-active"
? pickerModel.initialValue
: (pickerModel.options.find(
(option) => option.value !== SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL,
)?.value ?? pickerModel.initialValue),
initialValue: pickerModel.initialValue,
});
if (
choice === SEARCH_PROVIDER_SKIP_SENTINEL ||
choice === SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL
) {
return params.config;
}
const selectedEntry = providerEntries.find((entry) => entry.value === choice);
const intent: SearchProviderFlowIntent =
choice === SEARCH_PROVIDER_INSTALL_SENTINEL
? "configure-provider"
: choice === pickerModel.activeProvider
? "configure-provider"
: selectedEntry?.configured
? "switch-active"
: "configure-provider";
return applySearchProviderChoice({
config: params.config,
choice,
@@ -1525,8 +1268,8 @@ export async function promptSearchProviderFlow(params: {
});
}
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
export function hasKeyInEnv(metadata: SearchProviderLegacyConfigMetadata): boolean {
return metadata.envKeys.some((key) => Boolean(process.env[key]?.trim()));
}
function rawKeyValue(
@@ -1645,7 +1388,7 @@ export async function setupSearch(
await prompter.note(
[
"Web search lets your agent look things up online.",
"Choose a provider and enter the required built-in or plugin settings.",
"Choose a provider and enter the required settings.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",

View File

@@ -18,6 +18,10 @@ const installPluginFromNpmSpec = vi.fn();
vi.mock("../../plugins/install.js", () => ({
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args),
}));
const loadPluginManifest = vi.fn();
vi.mock("../../plugins/manifest.js", () => ({
loadPluginManifest: (...args: unknown[]) => loadPluginManifest(...args),
}));
const resolveBundledPluginSources = vi.fn();
vi.mock("../../plugins/bundled-sources.js", () => ({
@@ -61,6 +65,7 @@ import { loadOpenClawPlugins } from "../../plugins/loader.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import { makePrompter, makeRuntime } from "./__tests__/test-utils.js";
import {
ensureGenericOnboardingPluginInstalled,
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
} from "./plugin-install.js";
@@ -84,6 +89,7 @@ const baseEntry: ChannelPluginCatalogEntry = {
beforeEach(() => {
vi.clearAllMocks();
resolveBundledPluginSources.mockReturnValue(new Map());
loadPluginManifest.mockReset();
});
function mockRepoLocalPathExists() {
@@ -461,3 +467,87 @@ describe("ensureOnboardingPluginInstalled", () => {
);
});
});
describe("ensureGenericOnboardingPluginInstalled", () => {
it("installs an arbitrary scoped npm package", async () => {
const runtime = makeRuntime();
const prompter = makePrompter({
text: vi.fn(async () => "@other/provider") as WizardPrompter["text"],
});
const cfg: OpenClawConfig = {};
vi.mocked(fs.existsSync).mockReturnValue(false);
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "external-search",
targetDir: "/tmp/external-search",
extensions: [],
});
const result = await ensureGenericOnboardingPluginInstalled({
cfg,
prompter,
runtime,
});
expect(result.installed).toBe(true);
expect(result.pluginId).toBe("external-search");
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({ spec: "@other/provider" }),
);
});
it("links an arbitrary existing local plugin path and derives its plugin id from the manifest", async () => {
const runtime = makeRuntime();
const note = vi.fn(async () => {});
const prompter = makePrompter({
text: vi.fn(async () => "extensions/external-search") as WizardPrompter["text"],
note,
});
const cfg: OpenClawConfig = {};
vi.mocked(fs.existsSync).mockImplementation((value) => {
const raw = String(value);
return (
raw.endsWith(`${path.sep}.git`) ||
raw.endsWith(`${path.sep}extensions${path.sep}external-search`)
);
});
loadPluginManifest.mockReturnValue({
ok: true,
manifest: { id: "external-search" },
});
const result = await ensureGenericOnboardingPluginInstalled({
cfg,
prompter,
runtime,
});
expect(result.installed).toBe(true);
expect(result.pluginId).toBe("external-search");
expect(result.cfg.plugins?.load?.paths).toContain(
path.resolve(process.cwd(), "extensions/external-search"),
);
expect(note).toHaveBeenCalledWith(
`Using existing local plugin at ${path.resolve(process.cwd(), "extensions/external-search")}.\nNo download needed.`,
"Plugin install",
);
});
it("skips cleanly when the generic install input is blank", async () => {
const runtime = makeRuntime();
const prompter = makePrompter({
text: vi.fn(async () => "") as WizardPrompter["text"],
});
const cfg: OpenClawConfig = {};
const result = await ensureGenericOnboardingPluginInstalled({
cfg,
prompter,
runtime,
});
expect(result.installed).toBe(false);
expect(result.cfg).toBe(cfg);
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
});

View File

@@ -14,6 +14,7 @@ import { installPluginFromNpmSpec } from "../../plugins/install.js";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
import { createPluginLoaderLogger } from "../../plugins/logger.js";
import { loadPluginManifest } from "../../plugins/manifest.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
@@ -32,6 +33,7 @@ export type InstallablePluginCatalogEntry = {
type InstallResult = {
cfg: OpenClawConfig;
installed: boolean;
pluginId?: string;
};
function hasGitWorkspace(workspaceDir?: string): boolean {
@@ -114,12 +116,12 @@ function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawCon
}
async function promptInstallChoice(params: {
entry: InstallablePluginCatalogEntry;
prompter: WizardPrompter;
workspaceDir?: string;
allowLocal: boolean;
expectedNpmSpec?: string;
}): Promise<string | null> {
const { entry, prompter, workspaceDir, allowLocal } = params;
const { prompter, workspaceDir, allowLocal, expectedNpmSpec } = params;
const message = allowLocal ? "npm package or local path" : "npm package";
const placeholder = allowLocal
? "@scope/plugin-name or extensions/plugin-name (leave blank to skip)"
@@ -153,11 +155,11 @@ async function promptInstallChoice(params: {
continue;
}
if (!matchesCatalogNpmSpec(source, entry.install.npmSpec)) {
if (expectedNpmSpec && !matchesCatalogNpmSpec(source, expectedNpmSpec)) {
await prompter.note(
allowLocal
? `This flow installs ${entry.install.npmSpec}. Enter that npm package or a local plugin path.`
: `This flow installs ${entry.install.npmSpec}. Enter that npm package.`,
? `This flow installs ${expectedNpmSpec}. Enter that npm package or a local plugin path.`
: `This flow installs ${expectedNpmSpec}. Enter that npm package.`,
"Plugin install",
);
continue;
@@ -225,10 +227,10 @@ export async function ensureOnboardingPluginInstalled(params: {
})?.bundledSource.localPath ?? null;
const localPath = bundledLocalPath ?? resolveLocalPath(entry, workspaceDir, allowLocal);
const source = await promptInstallChoice({
entry,
prompter,
workspaceDir,
allowLocal,
expectedNpmSpec: entry.install.npmSpec,
});
if (!source) {
@@ -242,7 +244,7 @@ export async function ensureOnboardingPluginInstalled(params: {
);
next = addPluginLoadPath(next, source);
next = enablePluginInConfig(next, entry.id).config;
return { cfg: next, installed: true };
return { cfg: next, installed: true, pluginId: entry.id };
}
const result = await installPluginFromNpmSpec({
@@ -263,7 +265,7 @@ export async function ensureOnboardingPluginInstalled(params: {
version: result.version,
...buildNpmResolutionInstallFields(result.npmResolution),
});
return { cfg: next, installed: true };
return { cfg: next, installed: true, pluginId: result.pluginId };
}
await prompter.note(`Failed to install ${source}: ${result.error}`, "Plugin install");
@@ -280,7 +282,7 @@ export async function ensureOnboardingPluginInstalled(params: {
);
next = addPluginLoadPath(next, localPath);
next = enablePluginInConfig(next, entry.id).config;
return { cfg: next, installed: true };
return { cfg: next, installed: true, pluginId: entry.id };
}
}
@@ -288,6 +290,69 @@ export async function ensureOnboardingPluginInstalled(params: {
return { cfg: next, installed: false };
}
export async function ensureGenericOnboardingPluginInstalled(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir?: string;
}): Promise<InstallResult> {
const { prompter, runtime, workspaceDir } = params;
let next = params.cfg;
const allowLocal = hasGitWorkspace(workspaceDir);
const source = await promptInstallChoice({
prompter,
workspaceDir,
allowLocal,
});
if (!source) {
return { cfg: next, installed: false };
}
if (isLikelyLocalPath(source)) {
const manifestRes = loadPluginManifest(source, false);
if (!manifestRes.ok) {
await prompter.note(
`Failed to load plugin from ${source}: ${manifestRes.error}`,
"Plugin install",
);
return { cfg: next, installed: false };
}
await prompter.note(
[`Using existing local plugin at ${source}.`, "No download needed."].join("\n"),
"Plugin install",
);
next = addPluginLoadPath(next, source);
next = enablePluginInConfig(next, manifestRes.manifest.id).config;
return { cfg: next, installed: true, pluginId: manifestRes.manifest.id };
}
const result = await installPluginFromNpmSpec({
spec: source,
logger: {
info: (msg) => runtime.log?.(msg),
warn: (msg) => runtime.log?.(msg),
},
});
if (result.ok) {
next = enablePluginInConfig(next, result.pluginId).config;
next = recordPluginInstall(next, {
pluginId: result.pluginId,
source: "npm",
spec: source,
installPath: result.targetDir,
version: result.version,
...buildNpmResolutionInstallFields(result.npmResolution),
});
return { cfg: next, installed: true, pluginId: result.pluginId };
}
await prompter.note(`Failed to install ${source}: ${result.error}`, "Plugin install");
runtime.error?.(`Plugin install failed: ${result.error}`);
return { cfg: next, installed: false };
}
export function reloadOnboardingPluginRegistry(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;

View File

@@ -40,12 +40,50 @@ export type SearchProviderFilterSupport = {
domainFilter?: boolean;
};
export type SearchProviderLegacyUiMetadataParams = Omit<
SearchProviderLegacyUiMetadata,
"readApiKeyValue" | "writeApiKeyValue"
> & {
provider: string;
};
const WEB_SEARCH_DOCS_URL = "https://docs.openclaw.ai/tools/web";
export function resolveSearchConfig<T>(search?: Record<string, unknown>): T {
return search as T;
}
export function resolveSearchProviderSectionConfig<T>(
search: Record<string, unknown> | undefined,
provider: string,
): T {
if (!search || typeof search !== "object") {
return {} as T;
}
const scoped = search[provider];
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
return {} as T;
}
return scoped as T;
}
export function createLegacySearchProviderMetadata(
params: SearchProviderLegacyUiMetadataParams,
): SearchProviderLegacyUiMetadata {
return {
label: params.label,
hint: params.hint,
envKeys: params.envKeys,
placeholder: params.placeholder,
signupUrl: params.signupUrl,
apiKeyConfigPath: params.apiKeyConfigPath,
resolveRuntimeMetadata: params.resolveRuntimeMetadata,
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, params.provider),
writeApiKeyValue: (search, value) =>
writeSearchProviderApiKeyValue({ search, provider: params.provider, value }),
};
}
export function createSearchProviderErrorResult(
error: string,
message: string,