From 034798b10172ad94b22e74b8ceff039d4c5d9acf Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:52:26 -0500 Subject: [PATCH] feat: refine pluggable web search configure flow --- src/commands/configure.wizard.test.ts | 23 +- src/commands/configure.wizard.ts | 49 +--- src/commands/doctor-config-flow.test.ts | 133 +++++++++ src/commands/doctor-config-flow.ts | 199 ++++++++++++- src/commands/onboard-search.test.ts | 189 +++++++++++- src/commands/onboard-search.ts | 337 ++++++++++++++++++---- src/commands/onboarding/plugin-install.ts | 2 + src/wizard/onboarding.finalize.test.ts | 2 +- src/wizard/onboarding.finalize.ts | 6 +- 9 files changed, 825 insertions(+), 115 deletions(-) diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index d4d83a1f259..9996768c50f 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -121,6 +121,11 @@ describe("runConfigureWizard", () => { }); beforeEach(() => { + vi.stubEnv("BRAVE_API_KEY", ""); + vi.stubEnv("GEMINI_API_KEY", ""); + vi.stubEnv("XAI_API_KEY", ""); + vi.stubEnv("MOONSHOT_API_KEY", ""); + vi.stubEnv("PERPLEXITY_API_KEY", ""); mocks.clackIntro.mockReset(); mocks.clackOutro.mockReset(); mocks.clackSelect.mockReset(); @@ -212,7 +217,7 @@ describe("runConfigureWizard", () => { mocks.clackOutro.mockResolvedValue(undefined); mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mocks.clackSelect.mockImplementation(async (params: { message: string }) => { - if (params.message === "Choose web search provider") { + if (params.message === "Choose active web search provider") { return "tavily"; } if (params.message.startsWith("Search depth")) { @@ -325,7 +330,7 @@ describe("runConfigureWizard", () => { mocks.clackOutro.mockResolvedValue(undefined); mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mocks.clackSelect.mockImplementation(async (params: { message: string }) => { - if (params.message === "Choose web search provider") { + if (params.message === "Choose active web search provider") { return "tavily"; } if (params.message.startsWith("Search depth")) { @@ -344,10 +349,14 @@ describe("runConfigureWizard", () => { }, ); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining("Api Key"), - "Invalid plugin config", - ); + expect( + mocks.note.mock.calls.some( + ([message, title]) => + title === "Invalid plugin config" && + typeof message === "string" && + message.includes("Api Key"), + ), + ).toBe(true); expect(mocks.writeConfigFile).toHaveBeenCalledWith( expect.objectContaining({ plugins: expect.objectContaining({ @@ -450,7 +459,7 @@ describe("runConfigureWizard", () => { mocks.clackOutro.mockResolvedValue(undefined); mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mocks.clackSelect.mockImplementation(async (params: { message: string }) => { - if (params.message === "Choose web search provider") { + if (params.message === "Choose active web search provider") { return "__install_plugin__"; } if (params.message.startsWith("Search depth")) { diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index df5ebde0af2..095afc78909 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -189,12 +189,8 @@ async function promptWebToolsConfig( confirm: async (params) => guardCancel(await confirm(params), runtime), progress: () => ({ update: () => {}, stop: () => {} }), }; - const { - applySearchProviderChoice, - SEARCH_PROVIDER_OPTIONS, - buildSearchProviderPickerModel, - resolveSearchProviderPickerEntries, - } = await import("./onboard-search.js"); + const { resolveSearchProviderPickerEntries, promptSearchProviderFlow } = + await import("./onboard-search.js"); const providerEntries = await resolveSearchProviderPickerEntries(nextConfig, workspaceDir); note( @@ -220,39 +216,18 @@ async function promptWebToolsConfig( }; if (enableSearch) { - type ProviderChoice = string; - const pickerModel = buildSearchProviderPickerModel({ + const applied = await promptSearchProviderFlow({ config: nextConfig, - providerEntries, - includeSkipOption: false, - }); - const providerChoice = guardCancel( - await select({ - message: "Choose web search provider", - options: pickerModel.options.filter((option) => option.value !== "__skip__"), - initialValue: - pickerModel.initialValue === "__skip__" - ? SEARCH_PROVIDER_OPTIONS[0].value - : pickerModel.initialValue, - }), runtime, - ); - - if (providerChoice === "__keep_current__") { - nextSearch = { ...nextSearch, provider: existingSearch?.provider }; - } else { - const applied = await applySearchProviderChoice({ - config: nextConfig, - choice: providerChoice, - runtime, - prompter, - opts: { - workspaceDir, - }, - }); - nextConfig = applied; - nextSearch = { ...applied.tools?.web?.search }; - } + prompter, + opts: { + workspaceDir, + }, + includeSkipOption: true, + skipHint: "Leave the current web search setup unchanged", + }); + nextConfig = applied; + nextSearch = { ...applied.tools?.web?.search }; } const enableFetch = guardCancel( diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 265c90197e2..a4d361df22e 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "../../test/helpers/temp-home.js"; +import { validateConfigObjectWithPlugins } from "../config/config.js"; import * as noteModule from "../terminal/note.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; @@ -179,6 +180,138 @@ describe("doctor config flow", () => { }); }); + it("removes invalid plugin config leaves and disables the affected plugin on repair", async () => { + const tavilyPath = path.join(process.cwd(), "extensions", "tavily-search"); + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + plugins: { + load: { paths: [tavilyPath] }, + allow: ["tavily-search"], + entries: { + "tavily-search": { + enabled: true, + config: { + apiKey: "◇ Enable web_search?", + searchDepth: "basic", + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: "brave", + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const cfg = result.cfg as { + plugins?: { + entries?: Record }>; + }; + tools?: { web?: { search?: { provider?: string } } }; + }; + expect(cfg.plugins?.entries?.["tavily-search"]?.enabled).toBe(false); + expect(cfg.plugins?.entries?.["tavily-search"]?.config).toBeUndefined(); + expect(cfg.tools?.web?.search?.provider).toBe("brave"); + + const validated = validateConfigObjectWithPlugins(cfg); + expect(validated.ok).toBe(true); + }); + + it("does not delete missing plugin entries during repair", async () => { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + plugins: { + allow: ["webchat"], + entries: { + webchat: { + enabled: true, + config: { + port: 3000, + }, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const cfg = result.cfg as { + plugins?: { + allow?: string[]; + entries?: Record; + }; + }; + expect(cfg.plugins?.entries?.webchat).toBeDefined(); + expect(cfg.plugins?.allow).toContain("webchat"); + + const validated = validateConfigObjectWithPlugins(cfg); + expect(validated.ok).toBe(false); + expect( + validated.warnings.some( + (warning) => + warning.path === "plugins.entries.webchat" && + warning.message.includes("plugin not found"), + ), + ).toBe(true); + expect( + validated.issues.some( + (issue) => issue.path === "plugins.allow" && issue.message.includes("plugin not found"), + ), + ).toBe(true); + }); + + it("clears active web search provider when it points at a repaired plugin", async () => { + const tavilyPath = path.join(process.cwd(), "extensions", "tavily-search"); + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + plugins: { + load: { paths: [tavilyPath] }, + allow: ["tavily-search"], + entries: { + "tavily-search": { + enabled: true, + config: { + apiKey: "not-a-real-key", + searchDepth: "basic", + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: "tavily", + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const cfg = result.cfg as { + plugins?: { + entries?: Record }>; + }; + tools?: { web?: { search?: { provider?: string } } }; + }; + expect(cfg.plugins?.entries?.["tavily-search"]?.enabled).toBe(false); + expect(cfg.plugins?.entries?.["tavily-search"]?.config).toBeUndefined(); + expect(cfg.tools?.web?.search?.provider).toBeUndefined(); + + const validated = validateConfigObjectWithPlugins(cfg); + expect(validated.ok).toBe(true); + }); + it("preserves discord streaming intent while stripping unsupported keys on repair", async () => { const result = await runDoctorConfigWithInput({ repair: true, diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index f616bfaba55..d614a61fdfb 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -5,6 +5,7 @@ import { listTelegramAccountIds, resolveTelegramAccount, } from "../../extensions/telegram/src/accounts.js"; +import { isBuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js"; import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, @@ -16,7 +17,12 @@ import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gatewa import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { listRouteBindings } from "../config/bindings.js"; import type { OpenClawConfig } from "../config/config.js"; -import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js"; +import { + CONFIG_PATH, + migrateLegacyConfig, + readConfigFileSnapshot, + validateConfigObjectWithPlugins, +} from "../config/config.js"; import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; @@ -79,6 +85,181 @@ function asObjectRecord(value: unknown): Record | null { return value as Record; } +function parseConfigPath(pathLabel: string): Array | null { + if (!pathLabel || pathLabel === "") { + return []; + } + const parts: Array = []; + let token = ""; + for (let i = 0; i < pathLabel.length; i += 1) { + const ch = pathLabel[i]; + if (ch === ".") { + if (token) { + parts.push(token); + token = ""; + } + continue; + } + if (ch === "[") { + if (token) { + parts.push(token); + token = ""; + } + const end = pathLabel.indexOf("]", i); + if (end === -1) { + return null; + } + const indexText = pathLabel.slice(i + 1, end); + const index = Number.parseInt(indexText, 10); + if (!Number.isInteger(index)) { + return null; + } + parts.push(index); + i = end; + continue; + } + token += ch; + } + if (token) { + parts.push(token); + } + return parts; +} + +function deleteConfigPath(root: unknown, path: Array): boolean { + if (path.length === 0) { + return false; + } + let current: unknown = root; + for (let i = 0; i < path.length - 1; i += 1) { + const part = path[i]; + if (typeof part === "number") { + if (!Array.isArray(current) || part < 0 || part >= current.length) { + return false; + } + current = current[part]; + continue; + } + if (!current || typeof current !== "object" || Array.isArray(current)) { + return false; + } + const record = current as Record; + if (!(part in record)) { + return false; + } + current = record[part]; + } + + const leaf = path[path.length - 1]; + if (typeof leaf === "number") { + if (!Array.isArray(current) || leaf < 0 || leaf >= current.length) { + return false; + } + current.splice(leaf, 1); + return true; + } + if (!current || typeof current !== "object" || Array.isArray(current)) { + return false; + } + const record = current as Record; + if (!(leaf in record)) { + return false; + } + delete record[leaf]; + return true; +} + +function maybeRepairInvalidPluginConfig(candidate: OpenClawConfig): { + config: OpenClawConfig; + changes: string[]; +} { + const validation = validateConfigObjectWithPlugins(candidate); + if (validation.ok) { + return { config: candidate, changes: [] }; + } + + const next = structuredClone(candidate); + const changes: string[] = []; + const affectedPluginIds = new Set(); + for (const issue of validation.issues) { + if (!issue.path.startsWith("plugins.entries.")) { + continue; + } + if (!issue.message.startsWith("invalid config:")) { + continue; + } + const parts = parseConfigPath(issue.path); + if (!parts || parts.length <= 4) { + continue; + } + if ( + parts[0] !== "plugins" || + parts[1] !== "entries" || + typeof parts[2] !== "string" || + parts[3] !== "config" + ) { + continue; + } + const pluginId = parts[2]; + if (deleteConfigPath(next, parts)) { + affectedPluginIds.add(pluginId); + changes.push( + `- Removed invalid plugin config value at ${issue.path}; re-run configure to re-enter a valid value if you still want this plugin enabled.`, + ); + } + } + + if (changes.length === 0) { + return { config: candidate, changes: [] }; + } + + const revalidated = validateConfigObjectWithPlugins(next); + if (!revalidated.ok) { + for (const pluginId of affectedPluginIds) { + const configRoot = `plugins.entries.${pluginId}.config`; + const stillInvalid = revalidated.issues.some((issue) => issue.path.startsWith(configRoot)); + if (!stillInvalid) { + continue; + } + const pluginEntry = next.plugins?.entries?.[pluginId]; + if (pluginEntry) { + delete pluginEntry.config; + pluginEntry.enabled = false; + } + changes.push( + `- Disabled plugin ${pluginId} and cleared its config because required plugin settings were still incomplete after removing invalid values.`, + ); + } + } + + const finalValidation = validateConfigObjectWithPlugins(next); + if (!finalValidation.ok) { + const activeProvider = next.tools?.web?.search?.provider?.trim().toLowerCase(); + const hasProviderIssue = finalValidation.issues.some( + (issue) => + issue.path === "tools.web.search.provider" && + issue.message.startsWith("unknown web search provider:"), + ); + if (hasProviderIssue && activeProvider && !isBuiltinWebSearchProviderId(activeProvider)) { + if (next.tools?.web?.search) { + delete next.tools.web.search.provider; + changes.push( + `- Cleared tools.web.search.provider because it referenced repaired plugin provider "${activeProvider}", which is no longer available after the config cleanup.`, + ); + } + } + } + + return { config: next, changes }; +} + +function maybeRepairMissingPluginEntries(candidate: OpenClawConfig): { + config: OpenClawConfig; + changes: string[]; +} { + return { config: candidate, changes: [] }; +} + function normalizeBindingChannelKey(raw?: string | null): string { const normalized = normalizeChatChannelId(raw); if (normalized) { @@ -1824,6 +2005,22 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { if (safeBinProfileRepair.warnings.length > 0) { note(safeBinProfileRepair.warnings.join("\n"), "Doctor warnings"); } + + const missingPluginEntryRepair = maybeRepairMissingPluginEntries(candidate); + if (missingPluginEntryRepair.changes.length > 0) { + note(missingPluginEntryRepair.changes.join("\n"), "Doctor changes"); + candidate = missingPluginEntryRepair.config; + pendingChanges = true; + cfg = missingPluginEntryRepair.config; + } + + const invalidPluginConfigRepair = maybeRepairInvalidPluginConfig(candidate); + if (invalidPluginConfigRepair.changes.length > 0) { + note(invalidPluginConfigRepair.changes.join("\n"), "Doctor changes"); + candidate = invalidPluginConfigRepair.config; + pendingChanges = true; + cfg = invalidPluginConfigRepair.config; + } } else { const hits = scanTelegramAllowFromUsernameEntries(candidate); if (hits.length > 0) { diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index bd2ca42a1e6..f52839551c4 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -6,6 +6,9 @@ import type { WizardPrompter } from "../wizard/prompts.js"; const loadOpenClawPlugins = vi.hoisted(() => vi.fn(() => ({ searchProviders: [] as unknown[], plugins: [] as unknown[] })), ); +const loadPluginManifestRegistry = vi.hoisted(() => + vi.fn(() => ({ plugins: [] as unknown[], diagnostics: [] as unknown[] })), +); const ensureOnboardingPluginInstalled = vi.hoisted(() => vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })), ); @@ -15,6 +18,10 @@ vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins, })); +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry, +})); + vi.mock("./onboarding/plugin-install.js", () => ({ ensureOnboardingPluginInstalled, reloadOnboardingPluginRegistry, @@ -30,7 +37,11 @@ const runtime: RuntimeEnv = { }) as RuntimeEnv["exit"], }; -function createPrompter(params: { selectValue?: string; textValue?: string }): { +function createPrompter(params: { + selectValue?: string; + actionValue?: string; + textValue?: string; +}): { prompter: WizardPrompter; notes: Array<{ title?: string; message: string }>; } { @@ -41,9 +52,12 @@ function createPrompter(params: { selectValue?: string; textValue?: string }): { note: vi.fn(async (message: string, title?: string) => { notes.push({ title, message }); }), - select: vi.fn( - async () => params.selectValue ?? "perplexity", - ) as unknown as WizardPrompter["select"], + 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"], multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"], text: vi.fn(async () => params.textValue ?? ""), confirm: vi.fn(async () => true), @@ -96,8 +110,15 @@ describe("setupSearch", () => { }); beforeEach(() => { + vi.stubEnv("BRAVE_API_KEY", ""); + vi.stubEnv("GEMINI_API_KEY", ""); + vi.stubEnv("XAI_API_KEY", ""); + vi.stubEnv("MOONSHOT_API_KEY", ""); + vi.stubEnv("PERPLEXITY_API_KEY", ""); loadOpenClawPlugins.mockReset(); loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [] }); + loadPluginManifestRegistry.mockReset(); + loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] }); ensureOnboardingPluginInstalled.mockReset(); ensureOnboardingPluginInstalled.mockImplementation( async ({ cfg }: { cfg: OpenClawConfig }) => ({ @@ -139,7 +160,10 @@ describe("setupSearch", () => { const { prompter } = createPrompter({ selectValue: "__skip__" }); await setupSearch(cfg, runtime, prompter); - expect(prompter.select).toHaveBeenCalledWith( + const providerSelectCall = (prompter.select as ReturnType).mock.calls.find( + (call) => call[0]?.message === "Choose active web search provider", + ); + expect(providerSelectCall?.[0]).toEqual( expect.objectContaining({ options: expect.arrayContaining([ expect.objectContaining({ @@ -247,14 +271,16 @@ describe("setupSearch", () => { await setupSearch(cfg, runtime, prompter); - const options = (prompter.select as ReturnType).mock.calls[0]?.[0]?.options; + const options = (prompter.select as ReturnType).mock.calls.find( + (call) => call[0]?.message === "Choose active web search provider", + )?.[0]?.options; expect(options[0]).toMatchObject({ value: "tavily", - hint: "Plugin search · Third-party plugin · Configured · current", + hint: "Plugin search · Third-party plugin · Active now", }); expect(options[1]).toMatchObject({ value: "brave", - hint: "Structured results · country/language/time filters · Configured", + hint: "Structured results · country/language/time filters · Built-in · Configured", }); }); @@ -633,6 +659,153 @@ 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); + return hasApiKey + ? { + searchProviders: [ + { + pluginId: "tavily-search", + provider: { + id: "tavily", + name: "Tavily Search", + description: "Plugin search", + configFieldOrder: ["apiKey", "searchDepth"], + search: async () => ({ content: "ok" }), + }, + }, + ], + plugins: [ + { + id: "tavily-search", + name: "Tavily Search", + description: "External Tavily plugin", + origin: "workspace", + source: "/tmp/tavily-search", + configJsonSchema: { + type: "object", + required: ["apiKey"], + properties: { + apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" }, + searchDepth: { type: "string", enum: ["basic", "advanced"] }, + }, + }, + configUiHints: { + apiKey: { + label: "Tavily API key", + placeholder: "tvly-...", + sensitive: true, + }, + searchDepth: { + label: "Search depth", + }, + }, + }, + ], + } + : { + searchProviders: [], + plugins: [ + { + id: "tavily-search", + name: "Tavily Search", + description: "External Tavily plugin", + origin: "workspace", + source: "/tmp/tavily-search", + configJsonSchema: { + type: "object", + required: ["apiKey"], + properties: { + apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" }, + searchDepth: { type: "string", enum: ["basic", "advanced"] }, + }, + }, + configUiHints: { + apiKey: { + label: "Tavily API key", + placeholder: "tvly-...", + sensitive: true, + }, + searchDepth: { + label: "Search depth", + }, + }, + }, + ], + }; + }); + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "tavily-search", + name: "Tavily Search", + description: "External Tavily plugin", + origin: "workspace", + source: "/tmp/tavily-search", + configSchema: { + type: "object", + required: ["apiKey"], + properties: { + apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" }, + searchDepth: { type: "string", enum: ["basic", "advanced"] }, + }, + }, + configUiHints: { + apiKey: { + label: "Tavily API key", + placeholder: "tvly-...", + sensitive: true, + }, + searchDepth: { + label: "Search depth", + }, + }, + }, + ], + diagnostics: [], + }); + ensureOnboardingPluginInstalled.mockImplementation( + async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg: { + ...cfg, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + "tavily-search": { + ...(cfg.plugins?.entries?.["tavily-search"] as Record | undefined), + enabled: true, + }, + }, + }, + }, + installed: true, + }), + ); + + const { prompter, notes } = createPrompter({ + selectValue: "__install_plugin__", + textValue: "tvly-installed-key", + }); + (prompter.select as ReturnType) + .mockResolvedValueOnce("__install_plugin__") + .mockResolvedValueOnce("advanced"); + + const result = await setupSearch({}, runtime, prompter, { + workspaceDir: "/tmp/workspace-search", + }); + + 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({ + apiKey: "tvly-installed-key", + searchDepth: "advanced", + }); + }); + it("shows missing-key note when no key is provided and no env var", async () => { const original = process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY; diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index d7abfac7fa8..415cb940738 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -14,8 +14,9 @@ import { } from "../config/types.secrets.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; -import type { PluginConfigUiHint, PluginOrigin, SearchProviderPlugin } from "../plugins/types.js"; +import type { PluginConfigUiHint, PluginOrigin } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { SecretInputMode } from "./onboard-types.js"; @@ -35,6 +36,8 @@ export const SEARCH_PROVIDER_OPTIONS = BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS; 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; type PluginSearchProviderEntry = { kind: "plugin"; @@ -49,7 +52,6 @@ type PluginSearchProviderEntry = { configFieldOrder?: string[]; configJsonSchema?: Record; configUiHints?: Record; - provider: SearchProviderPlugin; }; export type SearchProviderPickerEntry = @@ -57,6 +59,7 @@ export type SearchProviderPickerEntry = | PluginSearchProviderEntry; type SearchProviderPickerChoice = string; +type SearchProviderFlowIntent = "switch-active" | "configure-provider"; type PluginPromptableField = | { @@ -409,7 +412,6 @@ export async function resolveSearchProviderPickerEntries( configFieldOrder: registration.provider.configFieldOrder, configJsonSchema: pluginRecord.configJsonSchema, configUiHints: pluginRecord.configUiHints, - provider: registration.provider, }; }) .filter(Boolean) as PluginSearchProviderEntry[]; @@ -432,6 +434,40 @@ export async function resolveSearchProviderPickerEntry( return entries.find((entry) => entry.value === providerId); } +function buildPluginSearchProviderEntryFromManifest(params: { + config: OpenClawConfig; + installEntry: InstallableSearchProviderPluginCatalogEntry; + workspaceDir?: string; +}): PluginSearchProviderEntry | undefined { + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + cache: false, + }); + const pluginRecord = registry.plugins.find((plugin) => plugin.id === params.installEntry.id); + if (!pluginRecord) { + return undefined; + } + + return { + kind: "plugin", + value: params.installEntry.providerId, + label: params.installEntry.meta.label, + hint: [ + params.installEntry.description || pluginRecord.description || "Plugin-provided web search", + formatPluginSourceHint(pluginRecord.origin), + ].join(" · "), + configured: false, + pluginId: pluginRecord.id, + origin: pluginRecord.origin, + description: params.installEntry.description || pluginRecord.description, + docsUrl: undefined, + configFieldOrder: undefined, + configJsonSchema: pluginRecord.configSchema, + configUiHints: pluginRecord.configUiHints, + }; +} + async function promptSearchProviderPluginInstallChoice( installableEntries: InstallableSearchProviderPluginCatalogEntry[], prompter: WizardPrompter, @@ -485,17 +521,40 @@ async function installSearchProviderPlugin(params: { cfg: result.cfg, runtime: params.runtime, workspaceDir: params.workspaceDir, + suppressOpenAllowlistWarning: true, }); return result.cfg; } +async function resolveInstalledSearchProviderEntry(params: { + config: OpenClawConfig; + installEntry: InstallableSearchProviderPluginCatalogEntry; + workspaceDir?: string; +}): Promise { + const installedProvider = await resolveSearchProviderPickerEntry( + params.config, + params.installEntry.providerId, + params.workspaceDir, + ); + if (installedProvider?.kind === "plugin") { + return installedProvider; + } + return buildPluginSearchProviderEntryFromManifest({ + config: params.config, + installEntry: params.installEntry, + workspaceDir: params.workspaceDir, + }); +} + export async function applySearchProviderChoice(params: { config: OpenClawConfig; choice: SearchProviderPickerChoice; + intent?: SearchProviderFlowIntent; runtime: RuntimeEnv; prompter: WizardPrompter; opts?: SetupSearchOptions; }): Promise { + const intent = params.intent ?? "switch-active"; if ( params.choice === SEARCH_PROVIDER_SKIP_SENTINEL || params.choice === SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL @@ -526,11 +585,11 @@ export async function applySearchProviderChoice(params: { if (installedConfig === params.config) { return params.config; } - const installedProvider = await resolveSearchProviderPickerEntry( - installedConfig, - selectedInstallEntry.providerId, - params.opts?.workspaceDir, - ); + const installedProvider = await resolveInstalledSearchProviderEntry({ + config: installedConfig, + installEntry: selectedInstallEntry, + workspaceDir: params.opts?.workspaceDir, + }); if (!installedProvider) { await params.prompter.note( [ @@ -541,18 +600,20 @@ export async function applySearchProviderChoice(params: { ); return installedConfig; } - return configureSearchProviderSelection( - installedConfig, - selectedInstallEntry.providerId, - params.prompter, - params.opts, - ); + const enabled = enablePluginInConfig(installedConfig, installedProvider.pluginId); + let next = + intent === "switch-active" + ? setWebSearchProvider(enabled.config, installedProvider.value) + : enabled.config; + next = await promptPluginSearchProviderConfig(next, installedProvider, params.prompter); + return preserveSearchProviderIntent(installedConfig, next, intent, installedProvider.value); } return configureSearchProviderSelection( params.config, params.choice, params.prompter, + intent, params.opts, ); } @@ -570,6 +631,7 @@ type SearchProviderPickerModel = { options: Array<{ value: SearchProviderPickerChoice; label: string; hint?: string }>; initialValue: SearchProviderPickerChoice; configuredCount: number; + activeProvider?: string; }; function formatPickerEntryHint(params: { @@ -578,23 +640,21 @@ function formatPickerEntryHint(params: { configuredCount: number; }): string { const { entry, isActive, configuredCount } = params; - const baseHint = + const baseParts = entry.kind === "plugin" ? [ entry.description?.trim() || "Plugin-provided web search", formatPluginSourceHint(entry.origin), - ].join(" · ") - : entry.hint; + ] + : [entry.hint, "Built-in"]; - if (configuredCount <= 1) { - return isActive && !entry.configured ? `${baseHint} · current` : baseHint; + if (configuredCount > 1) { + if (entry.configured) { + baseParts.push(isActive ? "Active now" : "Configured"); + } } - if (entry.configured) { - return isActive ? `${baseHint} · Configured · current` : `${baseHint} · Configured`; - } - - return isActive ? `${baseHint} · current` : baseHint; + return baseParts.join(" · "); } export function buildSearchProviderPickerModel( @@ -691,6 +751,7 @@ export function buildSearchProviderPickerModel( ? SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL : defaultProvider, configuredCount, + activeProvider: activeLoadedProvider, }; } @@ -698,31 +759,48 @@ export async function configureSearchProviderSelection( config: OpenClawConfig, choice: string, prompter: WizardPrompter, + intent: SearchProviderFlowIntent = "switch-active", opts?: SetupSearchOptions, ): Promise { const providerEntries = await resolveSearchProviderPickerEntries(config, opts?.workspaceDir); const selectedEntry = providerEntries.find((entry) => entry.value === choice); if (selectedEntry?.kind === "plugin") { const enabled = enablePluginInConfig(config, selectedEntry.pluginId); - let next = setWebSearchProvider(enabled.config, selectedEntry.value); + let next = + intent === "switch-active" + ? setWebSearchProvider(enabled.config, selectedEntry.value) + : enabled.config; + if (selectedEntry.configured) { + return preserveSearchProviderIntent(config, next, intent, selectedEntry.value); + } if (opts?.quickstartDefaults && selectedEntry.configured) { - return preserveDisabledState(config, next); + return preserveSearchProviderIntent(config, next, intent, selectedEntry.value); } next = await promptPluginSearchProviderConfig(next, selectedEntry, prompter); - return preserveDisabledState(config, next); + return preserveSearchProviderIntent(config, next, intent, selectedEntry.value); } const builtinChoice = choice as SearchProvider; - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === builtinChoice)!; + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === builtinChoice); + if (!entry) { + return config; + } const existingKey = resolveExistingKey(config, builtinChoice); const keyConfigured = hasExistingKey(config, builtinChoice); const envAvailable = hasKeyInEnv(entry); + if (intent === "switch-active" && (keyConfigured || envAvailable)) { + const result = existingKey + ? applySearchKey(config, builtinChoice, existingKey) + : applyProviderOnly(config, builtinChoice); + return preserveSearchProviderIntent(config, result, intent, builtinChoice); + } + if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) { const result = existingKey ? applySearchKey(config, builtinChoice, existingKey) : applyProviderOnly(config, builtinChoice); - return preserveDisabledState(config, result); + return preserveSearchProviderIntent(config, result, intent, builtinChoice); } const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret @@ -740,7 +818,12 @@ export async function configureSearchProviderSelection( ].join("\n"), "Web search", ); - return applySearchKey(config, builtinChoice, ref); + return preserveSearchProviderIntent( + config, + applySearchKey(config, builtinChoice, ref), + intent, + builtinChoice, + ); } const keyInput = await prompter.text({ @@ -755,15 +838,30 @@ export async function configureSearchProviderSelection( const key = keyInput?.trim() ?? ""; if (key) { const secretInput = resolveSearchSecretInput(builtinChoice, key, opts?.secretInputMode); - return applySearchKey(config, builtinChoice, secretInput); + return preserveSearchProviderIntent( + config, + applySearchKey(config, builtinChoice, secretInput), + intent, + builtinChoice, + ); } if (existingKey) { - return preserveDisabledState(config, applySearchKey(config, builtinChoice, existingKey)); + return preserveSearchProviderIntent( + config, + applySearchKey(config, builtinChoice, existingKey), + intent, + builtinChoice, + ); } if (keyConfigured || envAvailable) { - return preserveDisabledState(config, applyProviderOnly(config, builtinChoice)); + return preserveSearchProviderIntent( + config, + applyProviderOnly(config, builtinChoice), + intent, + builtinChoice, + ); } await prompter.note( @@ -775,19 +873,154 @@ export async function configureSearchProviderSelection( "Web search", ); - return { - ...config, - tools: { - ...config.tools, - web: { - ...config.tools?.web, - search: { - ...config.tools?.web?.search, - provider: builtinChoice, + return preserveSearchProviderIntent( + config, + { + ...config, + tools: { + ...config.tools, + web: { + ...config.tools?.web, + search: { + ...config.tools?.web?.search, + provider: builtinChoice, + }, }, }, }, - }; + intent, + builtinChoice, + ); +} + +function preserveSearchProviderIntent( + original: OpenClawConfig, + result: OpenClawConfig, + intent: SearchProviderFlowIntent, + selectedProvider: string, +): OpenClawConfig { + if (intent !== "configure-provider") { + return preserveDisabledState(original, result); + } + + const currentProvider = original.tools?.web?.search?.provider; + let next = result; + if (currentProvider && currentProvider !== selectedProvider) { + next = { + ...next, + tools: { + ...next.tools, + web: { + ...next.tools?.web, + search: { + ...next.tools?.web?.search, + provider: currentProvider, + }, + }, + }, + }; + } + return preserveDisabledState(original, next); +} + +async function promptSearchProviderIntent(params: { + prompter: WizardPrompter; + includeSkipOption: boolean; + configuredCount: number; +}): Promise { + if (params.configuredCount <= 1) { + return "switch-active"; + } + return await params.prompter.select< + SearchProviderFlowIntent | typeof SEARCH_PROVIDER_SKIP_SENTINEL + >({ + message: "Web search setup", + options: [ + { + value: SEARCH_PROVIDER_CONFIGURE_SENTINEL, + label: "Configure a provider", + hint: "Update keys or plugin settings without changing the active provider", + }, + { + value: SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL, + label: "Switch active provider", + hint: "Change which provider web_search uses right now", + }, + ...(params.includeSkipOption + ? [ + { + value: SEARCH_PROVIDER_SKIP_SENTINEL, + label: "Skip for now", + hint: "Configure later with openclaw configure --section web", + }, + ] + : []), + ], + initialValue: SEARCH_PROVIDER_CONFIGURE_SENTINEL, + }); +} + +export async function promptSearchProviderFlow(params: { + config: OpenClawConfig; + runtime: RuntimeEnv; + prompter: WizardPrompter; + opts?: SetupSearchOptions; + includeSkipOption: boolean; + skipHint?: string; +}): Promise { + const providerEntries = await resolveSearchProviderPickerEntries( + params.config, + params.opts?.workspaceDir, + ); + const pickerModel = buildSearchProviderPickerModel({ + config: params.config, + providerEntries, + includeSkipOption: params.includeSkipOption, + skipHint: params.skipHint, + }); + const action = await promptSearchProviderIntent({ + prompter: params.prompter, + includeSkipOption: params.includeSkipOption, + configuredCount: pickerModel.configuredCount, + }); + 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", + options: pickerModel.options + .filter((option) => { + if (intent === "configure-provider") { + return option.value !== SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL; + } + return true; + }) + .map((option) => + intent === "switch-active" && option.value === pickerModel.activeProvider + ? { ...option, label: `[Active] ${option.label}` } + : option, + ), + initialValue: + intent === "switch-active" + ? pickerModel.initialValue + : (pickerModel.options.find( + (option) => option.value !== SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL, + )?.value ?? pickerModel.initialValue), + }); + + return applySearchProviderChoice({ + config: params.config, + choice, + intent, + runtime: params.runtime, + prompter: params.prompter, + opts: params.opts, + }); } export function hasKeyInEnv(entry: SearchProviderEntry): boolean { @@ -931,24 +1164,12 @@ export async function setupSearch( "Web search", ); - const providerEntries = await resolveSearchProviderPickerEntries(config, opts?.workspaceDir); - const pickerModel = buildSearchProviderPickerModel({ + return promptSearchProviderFlow({ config, - providerEntries, - includeSkipOption: true, - skipHint: "Configure later with openclaw configure --section web", - }); - const choice = await prompter.select({ - message: "Search provider", - options: pickerModel.options, - initialValue: pickerModel.initialValue, - }); - - return applySearchProviderChoice({ - config, - choice, runtime, prompter, opts, + includeSkipOption: true, + skipHint: "Configure later with openclaw configure --section web", }); } diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index edecd2b06d2..37785026ec5 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -235,6 +235,7 @@ export function reloadOnboardingPluginRegistry(params: { cfg: OpenClawConfig; runtime: RuntimeEnv; workspaceDir?: string; + suppressOpenAllowlistWarning?: boolean; }): void { clearPluginDiscoveryCache(); const workspaceDir = @@ -245,5 +246,6 @@ export function reloadOnboardingPluginRegistry(params: { workspaceDir, cache: false, logger: createPluginLoaderLogger(log), + suppressOpenAllowlistWarning: params.suppressOpenAllowlistWarning, }); } diff --git a/src/wizard/onboarding.finalize.test.ts b/src/wizard/onboarding.finalize.test.ts index eb0a025da54..30ad89b559b 100644 --- a/src/wizard/onboarding.finalize.test.ts +++ b/src/wizard/onboarding.finalize.test.ts @@ -467,7 +467,7 @@ describe("finalizeOnboardingWizard", () => { const webSearchNote = noteCalls.find((call) => call?.[1] === "Web search"); expect(webSearchNote?.[0]).toContain("Active provider: Tavily Search"); expect(webSearchNote?.[0]).toContain( - "Multiple web search providers are configured; this is the active provider for web_search.", + "Multiple web search providers are configured; the others remain available to switch to later via configure.", ); }); }); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index b91b49c272e..d30470b0e93 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -530,7 +530,7 @@ export async function finalizeOnboardingWizard( ...(sourceLine ? [sourceLine] : []), ...(configuredProviderCount > 1 ? [ - "Multiple web search providers are configured; this is the active provider for web_search.", + "Multiple web search providers are configured; the others remain available to switch to later via configure.", ] : []), "Plugin-managed providers may use plugin config or plugin-specific credentials instead of the built-in API key fields.", @@ -548,7 +548,7 @@ export async function finalizeOnboardingWizard( `Active provider: ${label}`, ...(configuredProviderCount > 1 ? [ - "Multiple web search providers are configured; this is the active provider for web_search.", + "Multiple web search providers are configured; the others remain available to switch to later via configure.", ] : []), "Plugin-managed providers may use plugin config or plugin-specific credentials instead of the built-in API key fields.", @@ -565,7 +565,7 @@ export async function finalizeOnboardingWizard( ...(sourceLine ? [sourceLine] : []), ...(configuredProviderCount > 1 ? [ - "Multiple web search providers are configured; this is the active provider for web_search.", + "Multiple web search providers are configured; the others remain available to switch to later via configure.", ] : []), ...(keySource ? [keySource] : []),