feat: refine pluggable web search configure flow

This commit is contained in:
Tak Hoffman 2026-03-11 21:52:26 -05:00
parent 8542194901
commit 034798b101
9 changed files with 825 additions and 115 deletions

View File

@ -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")) {

View File

@ -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<ProviderChoice>({
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(

View File

@ -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<string, { enabled?: boolean; config?: Record<string, unknown> }>;
};
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<string, unknown>;
};
};
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<string, { enabled?: boolean; config?: Record<string, unknown> }>;
};
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,

View File

@ -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<string, unknown> | null {
return value as Record<string, unknown>;
}
function parseConfigPath(pathLabel: string): Array<string | number> | null {
if (!pathLabel || pathLabel === "<root>") {
return [];
}
const parts: Array<string | number> = [];
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<string | number>): 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<string, unknown>;
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<string, unknown>;
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<string>();
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) {

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0]?.[0]?.options;
const options = (prompter.select as ReturnType<typeof vi.fn>).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<string, unknown> | undefined),
enabled: true,
},
},
},
},
installed: true,
}),
);
const { prompter, notes } = createPrompter({
selectValue: "__install_plugin__",
textValue: "tvly-installed-key",
});
(prompter.select as ReturnType<typeof vi.fn>)
.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;

View File

@ -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<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
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<PluginSearchProviderEntry | undefined> {
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<OpenClawConfig> {
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<OpenClawConfig> {
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<SearchProviderFlowIntent | typeof SEARCH_PROVIDER_SKIP_SENTINEL> {
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<OpenClawConfig> {
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<SearchProviderPickerChoice>({
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<SearchProviderPickerChoice>({
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",
});
}

View File

@ -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,
});
}

View File

@ -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.",
);
});
});

View File

@ -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] : []),