diff --git a/docs/help/testing.md b/docs/help/testing.md index b2057e8a1da..d4b17bd55e8 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -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 -- ` or `pnpm test:macmini -- ` on lower-memory hosts When debugging real providers/models (requires real creds): diff --git a/docs/reference/test.md b/docs/reference/test.md index 378789f6d6e..8341e93a8d8 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -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 `. 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 -- ` so the repo test wrapper still applies the intended config/profile logic. On lower-memory hosts, prefer `pnpm test:macmini -- `. For memory-constrained full-suite runs, use: - `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` diff --git a/extensions/search-brave/src/provider.ts b/extensions/search-brave/src/provider.ts index cfd851d6585..888615240d3 100644 --- a/extensions/search-brave/src/provider.ts +++ b/extensions/search-brave/src/provider.ts @@ -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 { diff --git a/extensions/search-gemini/src/provider.ts b/extensions/search-gemini/src/provider.ts index e648e749360..3a7f50b6c59 100644 --- a/extensions/search-gemini/src/provider.ts +++ b/extensions/search-gemini/src/provider.ts @@ -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( + search as Record | 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 { diff --git a/extensions/search-grok/src/provider.ts b/extensions/search-grok/src/provider.ts index 028aee2e11a..e697b001a5d 100644 --- a/extensions/search-grok/src/provider.ts +++ b/extensions/search-grok/src/provider.ts @@ -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( + search as Record | 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 { diff --git a/extensions/search-kimi/src/provider.ts b/extensions/search-kimi/src/provider.ts index 43bbf2aae2d..0d62a6f78da 100644 --- a/extensions/search-kimi/src/provider.ts +++ b/extensions/search-kimi/src/provider.ts @@ -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( + search as Record | 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 { diff --git a/extensions/search-perplexity/src/provider.ts b/extensions/search-perplexity/src/provider.ts index 66de63f4f8b..771afceda73 100644 --- a/extensions/search-perplexity/src/provider.ts +++ b/extensions/search-perplexity/src/provider.ts @@ -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( + search as Record | 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(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(params.search)), + ).transport, + }), + }); export function createBundledPerplexitySearchProvider(): SearchProviderPlugin { return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6460473fe84..252ded6e4ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 5eba98205ff..64972b890d2 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -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); diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 2c690c45862..c1e42d7fcd4 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -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"; } diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index c9084a9c98a..146c469e9b9 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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", diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index a03f09f1216..2f56ab54605 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -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).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).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).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).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).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 | undefined), + "external-search": { + ...(cfg.plugins?.entries?.["external-search"] as + | Record + | 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 | undefined), + "external-search": { + ...(cfg.plugins?.entries?.["external-search"] as + | Record + | 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", }); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 9220c2d09b5..6c4839ce0a1 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -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( - 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 { - 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; + configUiHints?: Record; + 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 { - if (installableEntries.length === 0) { - return undefined; - } - if (installableEntries.length === 1) { - return installableEntries[0]; - } - const choice = await prompter.select({ - 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 { - 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 { - 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({ - 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", diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index 373ad9100e6..c3af440141c 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -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(); + }); +}); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index a67eb341f6a..e2ef9d100c9 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -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 { - 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 { + 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; diff --git a/src/plugin-sdk/web-search.ts b/src/plugin-sdk/web-search.ts index 5394eba4423..b6f4908bcad 100644 --- a/src/plugin-sdk/web-search.ts +++ b/src/plugin-sdk/web-search.ts @@ -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(search?: Record): T { return search as T; } +export function resolveSearchProviderSectionConfig( + search: Record | 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,