diff --git a/CHANGELOG.md b/CHANGELOG.md index cf05272cccd..86f6e09fe89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Agents/context engine plugin interface: add `ContextEngine` plugin slot with full lifecycle hooks (`bootstrap`, `ingest`, `assemble`, `compact`, `afterTurn`, `prepareSubagentSpawn`, `onSubagentEnded`), slot-based registry with config-driven resolution, `LegacyContextEngine` wrapper preserving existing compaction behavior, scoped subagent runtime for plugin runtimes via `AsyncLocalStorage`, and `sessions.get` gateway method. Enables plugins like `lossless-claw` to provide alternative context management strategies without modifying core compaction logic. Zero behavior change when no context engine plugin is configured. (#22201) thanks @jalehman. - CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant. - Docker/Podman extension dependency baking: add `OPENCLAW_EXTENSIONS` so container builds can preinstall selected bundled extension npm dependencies into the image for faster and more reproducible startup in container deployments. (#32223) Thanks @sallyom. +- Onboarding/web search: add provider selection step and full provider list in configure wizard, with SecretRef ref-mode support during onboarding. (#34009) Thanks @kesku and @thewilloftheshadow. ### Breaking diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index 071d91f3b30..28ead36b0c1 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -75,12 +75,15 @@ You can keep it local with `memorySearch.provider = "local"` (no API usage). See [Memory](/concepts/memory). -### 4) Web search tool (Brave / Perplexity via OpenRouter) +### 4) Web search tool -`web_search` uses API keys and may incur usage charges: +`web_search` uses API keys and may incur usage charges depending on your provider: +- **Perplexity Search API**: `PERPLEXITY_API_KEY` - **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey` -- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` +- **Gemini (Google Search)**: `GEMINI_API_KEY` +- **Grok (xAI)**: `XAI_API_KEY` +- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY` See [Web tools](/tools/web). diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 328063a0102..a6bacc5f2a1 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -94,6 +94,12 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - [iMessage](/channels/imessage): legacy `imsg` CLI path + DB access. - DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve ` or use allowlists. + + - Pick a provider: Perplexity, Brave, Gemini, Grok, or Kimi (or skip). + - Paste your API key (QuickStart auto-detects keys from env vars or existing config). + - Skip with `--skip-search`. + - Configure later: `openclaw configure --section web`. + - macOS: LaunchAgent - Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped). diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 5a7ddcd4020..874dc4bf514 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -35,9 +35,10 @@ openclaw agents add -Recommended: set up a Brave Search API key so the agent can use `web_search` -(`web_fetch` works without a key). Easiest path: `openclaw configure --section web` -which stores `tools.web.search.apiKey`. Docs: [Web tools](/tools/web). +The onboarding wizard includes a web search step where you can pick a provider +(Perplexity, Brave, Gemini, Grok, or Kimi) and paste your API key so the agent +can use `web_search`. You can also configure this later with +`openclaw configure --section web`. Docs: [Web tools](/tools/web). ## QuickStart vs Advanced diff --git a/docs/tools/index.md b/docs/tools/index.md index 2418cf88688..0f311516dcd 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -256,7 +256,7 @@ Enable with `tools.loopDetection.enabled: true` (default is `false`). ### `web_search` -Search the web using Brave Search API. +Search the web using Perplexity, Brave, Gemini, Grok, or Kimi. Core parameters: @@ -265,7 +265,7 @@ Core parameters: Notes: -- Requires a Brave API key (recommended: `openclaw configure --section web`, or set `BRAVE_API_KEY`). +- Requires an API key for the chosen provider (recommended: `openclaw configure --section web`). - Enable via `tools.web.search.enabled`. - Responses are cached (default 15 min). - See [Web tools](/tools/web) for setup. diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index eb7dc225ce9..1e4983f85e2 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -505,30 +505,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE // Auto-detect provider from available API keys (priority order) if (raw === "") { - // 1. Brave - if (resolveSearchApiKey(search)) { - logVerbose( - 'web_search: no provider configured, auto-detected "brave" from available API keys', - ); - return "brave"; - } - // 2. Gemini - const geminiConfig = resolveGeminiConfig(search); - if (resolveGeminiApiKey(geminiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "gemini" from available API keys', - ); - return "gemini"; - } - // 3. Kimi - const kimiConfig = resolveKimiConfig(search); - if (resolveKimiApiKey(kimiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "kimi" from available API keys', - ); - return "kimi"; - } - // 4. Perplexity + // 1. Perplexity const perplexityConfig = resolvePerplexityConfig(search); const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); if (perplexityKey) { @@ -537,7 +514,22 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "perplexity"; } - // 5. Grok + // 2. Brave + if (resolveSearchApiKey(search)) { + logVerbose( + 'web_search: no provider configured, auto-detected "brave" from available API keys', + ); + return "brave"; + } + // 3. Gemini + const geminiConfig = resolveGeminiConfig(search); + if (resolveGeminiApiKey(geminiConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "gemini" from available API keys', + ); + return "gemini"; + } + // 4. Grok const grokConfig = resolveGrokConfig(search); if (resolveGrokApiKey(grokConfig)) { logVerbose( @@ -545,9 +537,17 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "grok"; } + // 5. Kimi + const kimiConfig = resolveKimiConfig(search); + if (resolveKimiApiKey(kimiConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "kimi" from available API keys', + ); + return "kimi"; + } } - return "brave"; + return "perplexity"; } function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 7555b5c6b4e..03fb832a041 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -119,6 +119,7 @@ export function registerOnboardCommand(program: Command) { .option("--daemon-runtime ", "Daemon runtime: node|bun") .option("--skip-channels", "Skip channel setup") .option("--skip-skills", "Skip skills setup") + .option("--skip-search", "Skip search provider setup") .option("--skip-health", "Skip health check") .option("--skip-ui", "Skip Control UI/TUI prompts") .option("--node-manager ", "Node manager for skills: npm|pnpm|bun") @@ -193,6 +194,7 @@ export function registerOnboardCommand(program: Command) { daemonRuntime: opts.daemonRuntime as GatewayDaemonRuntime | undefined, skipChannels: Boolean(opts.skipChannels), skipSkills: Boolean(opts.skipSkills), + skipSearch: Boolean(opts.skipSearch), skipHealth: Boolean(opts.skipHealth), skipUi: Boolean(opts.skipUi), nodeManager: opts.nodeManager as NodeManagerChoice | undefined, diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 38fedf8db3c..ac31b6d5f4e 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -166,18 +166,35 @@ async function promptWebToolsConfig( ): Promise { const existingSearch = nextConfig.tools?.web?.search; const existingFetch = nextConfig.tools?.web?.fetch; - const existingProvider = existingSearch?.provider ?? "brave"; - const hasPerplexityKey = Boolean( - existingSearch?.perplexity?.apiKey || process.env.PERPLEXITY_API_KEY, - ); - const hasBraveKey = Boolean(existingSearch?.apiKey || process.env.BRAVE_API_KEY); - const hasSearchKey = existingProvider === "perplexity" ? hasPerplexityKey : hasBraveKey; + const { + SEARCH_PROVIDER_OPTIONS, + resolveExistingKey, + hasExistingKey, + applySearchKey, + hasKeyInEnv, + } = await import("./onboard-search.js"); + type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"]; + + const hasKeyForProvider = (provider: string): boolean => { + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); + if (!entry) { + return false; + } + return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry); + }; + + const existingProvider: string = (() => { + const stored = existingSearch?.provider; + if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { + return stored; + } + return SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? "perplexity"; + })(); note( [ "Web search lets your agent look things up online using the `web_search` tool.", - "Choose a provider: Perplexity Search (recommended) or Brave Search.", - "Both return structured results (title, URL, snippet) for fast research.", + "Choose a provider and paste your API key.", "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), "Web search", @@ -186,30 +203,31 @@ async function promptWebToolsConfig( const enableSearch = guardCancel( await confirm({ message: "Enable web_search?", - initialValue: existingSearch?.enabled ?? hasSearchKey, + initialValue: + existingSearch?.enabled ?? SEARCH_PROVIDER_OPTIONS.some((e) => hasKeyForProvider(e.value)), }), runtime, ); - let nextSearch = { + let nextSearch: Record = { ...existingSearch, enabled: enableSearch, }; if (enableSearch) { + const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => { + const configured = hasKeyForProvider(entry.value); + return { + value: entry.value, + label: entry.label, + hint: configured ? `${entry.hint} · configured` : entry.hint, + }; + }); + const providerChoice = guardCancel( await select({ message: "Choose web search provider", - options: [ - { - value: "perplexity", - label: "Perplexity Search", - }, - { - value: "brave", - label: "Brave Search", - }, - ], + options: providerOptions, initialValue: existingProvider, }), runtime, @@ -217,59 +235,42 @@ async function promptWebToolsConfig( nextSearch = { ...nextSearch, provider: providerChoice }; - if (providerChoice === "perplexity") { - const hasKey = Boolean(existingSearch?.perplexity?.apiKey); - const keyInput = guardCancel( - await text({ - message: hasKey - ? "Perplexity API key (leave blank to keep current or use PERPLEXITY_API_KEY)" - : "Perplexity API key (paste it here; leave blank to use PERPLEXITY_API_KEY)", - placeholder: hasKey ? "Leave blank to keep current" : "pplx-...", - }), - runtime, - ); - const key = String(keyInput ?? "").trim(); - if (key) { - nextSearch = { - ...nextSearch, - perplexity: { ...existingSearch?.perplexity, apiKey: key }, - }; - } else if (!hasKey && !process.env.PERPLEXITY_API_KEY) { - note( - [ - "No key stored yet, so web_search will stay unavailable.", - "Store a key here or set PERPLEXITY_API_KEY in the Gateway environment.", - "Get your API key at: https://www.perplexity.ai/settings/api", - "Docs: https://docs.openclaw.ai/tools/web", - ].join("\n"), - "Web search", - ); - } + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; + const existingKey = resolveExistingKey(nextConfig, providerChoice as SP); + const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP); + const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); + const envVarNames = entry.envKeys.join(" / "); + + const keyInput = guardCancel( + await text({ + message: keyConfigured + ? envAvailable + ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` + : `${entry.label} API key (leave blank to keep current)` + : envAvailable + ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` + : `${entry.label} API key`, + placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, + }), + runtime, + ); + const key = String(keyInput ?? "").trim(); + + if (key || existingKey) { + const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!); + nextSearch = { ...applied.tools?.web?.search }; + } else if (keyConfigured || envAvailable) { + nextSearch = { ...nextSearch }; } else { - const hasKey = Boolean(existingSearch?.apiKey); - const keyInput = guardCancel( - await text({ - message: hasKey - ? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)" - : "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)", - placeholder: hasKey ? "Leave blank to keep current" : "BSA...", - }), - runtime, + note( + [ + "No key stored yet — web_search won't work until a key is available.", + `Store a key here or set ${envVarNames} in the Gateway environment.`, + `Get your API key at: ${entry.signupUrl}`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", ); - const key = String(keyInput ?? "").trim(); - if (key) { - nextSearch = { ...nextSearch, apiKey: key }; - } else if (!hasKey && !process.env.BRAVE_API_KEY) { - note( - [ - "No key stored yet, so web_search will stay unavailable.", - "Store a key here or set BRAVE_API_KEY in the Gateway environment.", - "Get your API key at: https://brave.com/search/api/", - "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 new file mode 100644 index 00000000000..d8ed9e8ce6f --- /dev/null +++ b/src/commands/onboard-search.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { SEARCH_PROVIDER_OPTIONS, setupSearch } from "./onboard-search.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: ((code: number) => { + throw new Error(`unexpected exit ${code}`); + }) as RuntimeEnv["exit"], +}; + +function createPrompter(params: { selectValue?: string; textValue?: string }): { + prompter: WizardPrompter; + notes: Array<{ title?: string; message: string }>; +} { + const notes: Array<{ title?: string; message: string }> = []; + const prompter: WizardPrompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async (message: string, title?: string) => { + notes.push({ title, message }); + }), + 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), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + return { prompter, notes }; +} + +describe("setupSearch", () => { + it("returns config unchanged when user skips", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "__skip__" }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result).toBe(cfg); + }); + + it("sets provider and key for perplexity", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "perplexity", + textValue: "pplx-test-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("pplx-test-key"); + expect(result.tools?.web?.search?.enabled).toBe(true); + }); + + it("sets provider and key for brave", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "brave", + textValue: "BSA-test-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.apiKey).toBe("BSA-test-key"); + }); + + it("sets provider and key for gemini", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "gemini", + textValue: "AIza-test", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("gemini"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.gemini?.apiKey).toBe("AIza-test"); + }); + + it("sets provider and key for grok", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "grok", + textValue: "xai-test", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("grok"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.grok?.apiKey).toBe("xai-test"); + }); + + it("sets provider and key for kimi", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "kimi", + textValue: "sk-moonshot", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("kimi"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.kimi?.apiKey).toBe("sk-moonshot"); + }); + + it("shows missing-key note when no key is provided and no env var", async () => { + const cfg: OpenClawConfig = {}; + const { prompter, notes } = createPrompter({ + selectValue: "brave", + textValue: "", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.enabled).toBeUndefined(); + const missingNote = notes.find((n) => n.message.includes("No API key stored")); + expect(missingNote).toBeDefined(); + }); + + it("keeps existing key when user leaves input blank", async () => { + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "perplexity", + perplexity: { apiKey: "existing-key" }, + }, + }, + }, + }; + const { prompter } = createPrompter({ + selectValue: "perplexity", + textValue: "", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key"); + expect(result.tools?.web?.search?.enabled).toBe(true); + }); + + it("advanced preserves enabled:false when keeping existing key", async () => { + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "perplexity", + enabled: false, + perplexity: { apiKey: "existing-key" }, + }, + }, + }, + }; + const { prompter } = createPrompter({ + selectValue: "perplexity", + textValue: "", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key"); + expect(result.tools?.web?.search?.enabled).toBe(false); + }); + + it("quickstart skips key prompt when config key exists", async () => { + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "perplexity", + perplexity: { apiKey: "stored-pplx-key" }, + }, + }, + }, + }; + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(prompter.text).not.toHaveBeenCalled(); + }); + + it("quickstart preserves enabled:false when search was intentionally disabled", async () => { + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "perplexity", + enabled: false, + perplexity: { apiKey: "stored-pplx-key" }, + }, + }, + }, + }; + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); + expect(result.tools?.web?.search?.enabled).toBe(false); + expect(prompter.text).not.toHaveBeenCalled(); + }); + + it("quickstart falls through to key prompt when no key and no env var", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "grok", textValue: "" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(prompter.text).toHaveBeenCalled(); + expect(result.tools?.web?.search?.provider).toBe("grok"); + expect(result.tools?.web?.search?.enabled).toBeUndefined(); + }); + + it("quickstart skips key prompt when env var is available", async () => { + const orig = process.env.BRAVE_API_KEY; + process.env.BRAVE_API_KEY = "env-brave-key"; + try { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "brave" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(prompter.text).not.toHaveBeenCalled(); + } finally { + if (orig === undefined) { + delete process.env.BRAVE_API_KEY; + } else { + process.env.BRAVE_API_KEY = orig; + } + } + }); + + it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + secretInputMode: "ref", + }); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "PERPLEXITY_API_KEY", + }); + expect(prompter.text).not.toHaveBeenCalled(); + }); + + it("stores env-backed SecretRef when secretInputMode=ref for brave", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "brave" }); + const result = await setupSearch(cfg, runtime, prompter, { + secretInputMode: "ref", + }); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "BRAVE_API_KEY", + }); + expect(prompter.text).not.toHaveBeenCalled(); + }); + + it("stores plaintext key when secretInputMode is unset", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "brave", + textValue: "BSA-plain", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain"); + }); + + it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => { + expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5); + const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); + expect(values).toEqual(["perplexity", "brave", "gemini", "grok", "kimi"]); + }); +}); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts new file mode 100644 index 00000000000..fa12720a25f --- /dev/null +++ b/src/commands/onboard-search.ts @@ -0,0 +1,319 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + DEFAULT_SECRET_PROVIDER_ALIAS, + type SecretInput, + type SecretRef, + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../config/types.secrets.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import type { SecretInputMode } from "./onboard-types.js"; + +export type SearchProvider = "perplexity" | "brave" | "gemini" | "grok" | "kimi"; + +type SearchProviderEntry = { + value: SearchProvider; + label: string; + hint: string; + envKeys: string[]; + placeholder: string; + signupUrl: string; +}; + +export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [ + { + value: "perplexity", + label: "Perplexity Search", + hint: "Structured results · domain/language/freshness filters", + envKeys: ["PERPLEXITY_API_KEY"], + placeholder: "pplx-...", + signupUrl: "https://www.perplexity.ai/settings/api", + }, + { + value: "brave", + label: "Brave Search", + hint: "Structured results · region-specific", + envKeys: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://brave.com/search/api/", + }, + { + value: "gemini", + label: "Gemini (Google Search)", + hint: "Google Search grounding · AI-synthesized", + envKeys: ["GEMINI_API_KEY"], + placeholder: "AIza...", + signupUrl: "https://aistudio.google.com/apikey", + }, + { + value: "grok", + label: "Grok (xAI)", + hint: "xAI web-grounded responses", + envKeys: ["XAI_API_KEY"], + placeholder: "xai-...", + signupUrl: "https://console.x.ai/", + }, + { + value: "kimi", + label: "Kimi (Moonshot)", + hint: "Moonshot web search", + envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], + placeholder: "sk-...", + signupUrl: "https://platform.moonshot.cn/", + }, +] as const; + +export function hasKeyInEnv(entry: SearchProviderEntry): boolean { + return entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); +} + +function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown { + const search = config.tools?.web?.search; + switch (provider) { + case "brave": + return search?.apiKey; + case "perplexity": + return search?.perplexity?.apiKey; + case "gemini": + return search?.gemini?.apiKey; + case "grok": + return search?.grok?.apiKey; + case "kimi": + return search?.kimi?.apiKey; + } +} + +/** Returns the plaintext key string, or undefined for SecretRefs/missing. */ +export function resolveExistingKey( + config: OpenClawConfig, + provider: SearchProvider, +): string | undefined { + return normalizeSecretInputString(rawKeyValue(config, provider)); +} + +/** Returns true if a key is configured (plaintext string or SecretRef). */ +export function hasExistingKey(config: OpenClawConfig, provider: SearchProvider): boolean { + return hasConfiguredSecretInput(rawKeyValue(config, provider)); +} + +/** Build an env-backed SecretRef for a search provider. */ +function buildSearchEnvRef(provider: SearchProvider): SecretRef { + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); + const envVar = entry?.envKeys.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envKeys[0]; + if (!envVar) { + throw new Error( + `No env var mapping for search provider "${provider}" in secret-input-mode=ref.`, + ); + } + return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id: envVar }; +} + +/** Resolve a plaintext key into the appropriate SecretInput based on mode. */ +function resolveSearchSecretInput( + provider: SearchProvider, + key: string, + secretInputMode?: SecretInputMode, +): SecretInput { + if (secretInputMode === "ref") { + return buildSearchEnvRef(provider); + } + return key; +} + +export function applySearchKey( + config: OpenClawConfig, + provider: SearchProvider, + key: SecretInput, +): OpenClawConfig { + const search = { ...config.tools?.web?.search, provider, enabled: true }; + switch (provider) { + case "brave": + search.apiKey = key; + break; + case "perplexity": + search.perplexity = { ...search.perplexity, apiKey: key }; + break; + case "gemini": + search.gemini = { ...search.gemini, apiKey: key }; + break; + case "grok": + search.grok = { ...search.grok, apiKey: key }; + break; + case "kimi": + search.kimi = { ...search.kimi, apiKey: key }; + break; + } + return { + ...config, + tools: { + ...config.tools, + web: { ...config.tools?.web, search }, + }, + }; +} + +function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig { + return { + ...config, + tools: { + ...config.tools, + web: { + ...config.tools?.web, + search: { + ...config.tools?.web?.search, + provider, + enabled: true, + }, + }, + }, + }; +} + +function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig { + if (original.tools?.web?.search?.enabled !== false) { + return result; + } + return { + ...result, + tools: { + ...result.tools, + web: { ...result.tools?.web, search: { ...result.tools?.web?.search, enabled: false } }, + }, + }; +} + +export type SetupSearchOptions = { + quickstartDefaults?: boolean; + secretInputMode?: SecretInputMode; +}; + +export async function setupSearch( + config: OpenClawConfig, + _runtime: RuntimeEnv, + prompter: WizardPrompter, + opts?: SetupSearchOptions, +): Promise { + await prompter.note( + [ + "Web search lets your agent look things up online.", + "Choose a provider and paste your API key.", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + + const existingProvider = config.tools?.web?.search?.provider; + + const options = SEARCH_PROVIDER_OPTIONS.map((entry) => { + const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry); + const hint = configured ? `${entry.hint} · configured` : entry.hint; + return { value: entry.value, label: entry.label, hint }; + }); + + const defaultProvider: SearchProvider = (() => { + if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) { + return existingProvider; + } + const detected = SEARCH_PROVIDER_OPTIONS.find( + (e) => hasExistingKey(config, e.value) || hasKeyInEnv(e), + ); + if (detected) { + return detected.value; + } + return "perplexity"; + })(); + + type PickerValue = SearchProvider | "__skip__"; + const choice = await prompter.select({ + message: "Search provider", + options: [ + ...options, + { + value: "__skip__" as const, + label: "Skip for now", + hint: "Configure later with openclaw configure --section web", + }, + ], + initialValue: defaultProvider as PickerValue, + }); + + if (choice === "__skip__") { + return config; + } + + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!; + const existingKey = resolveExistingKey(config, choice); + const keyConfigured = hasExistingKey(config, choice); + const envAvailable = hasKeyInEnv(entry); + + if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) { + const result = existingKey + ? applySearchKey(config, choice, existingKey) + : applyProviderOnly(config, choice); + return preserveDisabledState(config, result); + } + + if (opts?.secretInputMode === "ref") { + if (keyConfigured) { + return preserveDisabledState(config, applyProviderOnly(config, choice)); + } + const ref = buildSearchEnvRef(choice); + 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", + ); + return applySearchKey(config, choice, ref); + } + + 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(choice, key, opts?.secretInputMode); + return applySearchKey(config, choice, secretInput); + } + + if (existingKey) { + return preserveDisabledState(config, applySearchKey(config, choice, existingKey)); + } + + if (keyConfigured || envAvailable) { + return preserveDisabledState(config, applyProviderOnly(config, choice)); + } + + 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", + ); + + return { + ...config, + tools: { + ...config.tools, + web: { + ...config.tools?.web, + search: { + ...config.tools?.web?.search, + provider: choice, + }, + }, + }, + }; +} diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index fcb823f96b8..9e664b9a66d 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -154,6 +154,7 @@ export type OnboardOptions = { /** @deprecated Legacy alias for `skipChannels`. */ skipProviders?: boolean; skipSkills?: boolean; + skipSearch?: boolean; skipHealth?: boolean; skipUi?: boolean; nodeManager?: NodeManagerChoice; diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 5029a7e9476..5bb57d2ab93 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -70,8 +70,8 @@ describe("web search provider auto-detection", () => { vi.restoreAllMocks(); }); - it("falls back to brave when no keys available", () => { - expect(resolveSearchProvider({})).toBe("brave"); + it("falls back to perplexity when no keys available", () => { + expect(resolveSearchProvider({})).toBe("perplexity"); }); it("auto-detects brave when only BRAVE_API_KEY is set", () => { @@ -109,19 +109,21 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("kimi"); }); - it("follows priority order — brave wins when multiple keys available", () => { + it("follows priority order — perplexity wins when multiple keys available", () => { + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; + process.env.BRAVE_API_KEY = "test-brave-key"; + process.env.GEMINI_API_KEY = "test-gemini-key"; + process.env.XAI_API_KEY = "test-xai-key"; + expect(resolveSearchProvider({})).toBe("perplexity"); + }); + + it("brave wins over gemini and grok when perplexity unavailable", () => { process.env.BRAVE_API_KEY = "test-brave-key"; process.env.GEMINI_API_KEY = "test-gemini-key"; process.env.XAI_API_KEY = "test-xai-key"; expect(resolveSearchProvider({})).toBe("brave"); }); - it("gemini wins over perplexity and grok when brave unavailable", () => { - process.env.GEMINI_API_KEY = "test-gemini-key"; - process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; - expect(resolveSearchProvider({})).toBe("gemini"); - }); - it("explicit provider always wins regardless of keys", () => { process.env.BRAVE_API_KEY = "test-brave-key"; expect( diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index c18f9a375fe..5c8152f0e59 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -444,7 +444,7 @@ export type ToolsConfig = { /** Search provider ("brave", "perplexity", "grok", "gemini", or "kimi"). */ provider?: "brave" | "perplexity" | "grok" | "gemini" | "kimi"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Default search results count (1-10). */ maxResults?: number; /** Timeout in seconds for search requests. */ @@ -454,7 +454,7 @@ export type ToolsConfig = { /** Perplexity-specific configuration (used when provider="perplexity"). */ perplexity?: { /** API key for Perplexity (defaults to PERPLEXITY_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */ baseUrl?: string; /** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */ @@ -463,7 +463,7 @@ export type ToolsConfig = { /** Grok-specific configuration (used when provider="grok"). */ grok?: { /** API key for xAI (defaults to XAI_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Model to use (defaults to "grok-4-1-fast"). */ model?: string; /** Include inline citations in response text as markdown links (default: false). */ @@ -472,14 +472,14 @@ export type ToolsConfig = { /** Gemini-specific configuration (used when provider="gemini"). */ gemini?: { /** Gemini API key (defaults to GEMINI_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ model?: string; }; /** Kimi-specific configuration (used when provider="kimi"). */ kimi?: { /** Moonshot/Kimi API key (defaults to KIMI_API_KEY or MOONSHOT_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Base URL for API requests (defaults to "https://api.moonshot.ai/v1"). */ baseUrl?: string; /** Model to use (defaults to "moonshot-v1-128k"). */ diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index 62f452de39e..fc442389132 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -472,39 +472,86 @@ export async function finalizeOnboardingWizard( ); } - const webSearchProvider = nextConfig.tools?.web?.search?.provider ?? "brave"; - const webSearchKey = - webSearchProvider === "perplexity" - ? (nextConfig.tools?.web?.search?.perplexity?.apiKey ?? "").trim() - : (nextConfig.tools?.web?.search?.apiKey ?? "").trim(); - const webSearchEnv = - webSearchProvider === "perplexity" - ? (process.env.PERPLEXITY_API_KEY ?? "").trim() - : (process.env.BRAVE_API_KEY ?? "").trim(); - const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv); - await prompter.note( - hasWebSearchKey - ? [ + const webSearchProvider = nextConfig.tools?.web?.search?.provider; + const webSearchEnabled = nextConfig.tools?.web?.search?.enabled; + if (webSearchProvider) { + const { SEARCH_PROVIDER_OPTIONS, resolveExistingKey, hasExistingKey, hasKeyInEnv } = + await import("../commands/onboard-search.js"); + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider); + const label = entry?.label ?? webSearchProvider; + const storedKey = resolveExistingKey(nextConfig, webSearchProvider); + const keyConfigured = hasExistingKey(nextConfig, webSearchProvider); + const envAvailable = entry ? hasKeyInEnv(entry) : false; + const hasKey = keyConfigured || envAvailable; + const keySource = storedKey + ? "API key: stored in config." + : keyConfigured + ? "API key: configured via secret reference." + : envAvailable + ? `API key: provided via ${entry?.envKeys.join(" / ")} env var.` + : undefined; + if (webSearchEnabled !== false && hasKey) { + await prompter.note( + [ "Web search is enabled, so your agent can look things up online when needed.", "", - `Provider: ${webSearchProvider === "perplexity" ? "Perplexity Search" : "Brave Search"}`, - webSearchKey - ? `API key: stored in config (tools.web.search.${webSearchProvider === "perplexity" ? "perplexity.apiKey" : "apiKey"}).` - : `API key: provided via ${webSearchProvider === "perplexity" ? "PERPLEXITY_API_KEY" : "BRAVE_API_KEY"} env var (Gateway environment).`, - "Docs: https://docs.openclaw.ai/tools/web", - ].join("\n") - : [ - "To enable web search, your agent will need an API key for either Perplexity Search or Brave Search.", - "", - "Set it up interactively:", - `- Run: ${formatCliCommand("openclaw configure --section web")}`, - "- Choose a provider and paste your API key", - "", - "Alternative: set PERPLEXITY_API_KEY or BRAVE_API_KEY in the Gateway environment (no config changes).", + `Provider: ${label}`, + ...(keySource ? [keySource] : []), "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), - "Web search (optional)", - ); + "Web search", + ); + } else if (!hasKey) { + await prompter.note( + [ + `Provider ${label} is selected but no API key was found.`, + "web_search will not work until a key is added.", + ` ${formatCliCommand("openclaw configure --section web")}`, + "", + `Get your key at: ${entry?.signupUrl ?? "https://docs.openclaw.ai/tools/web"}`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } else { + await prompter.note( + [ + `Web search (${label}) is configured but disabled.`, + `Re-enable: ${formatCliCommand("openclaw configure --section web")}`, + "", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } + } else { + // Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without + // an explicit provider. Runtime auto-detects these, so avoid saying "skipped". + const { SEARCH_PROVIDER_OPTIONS, hasExistingKey, hasKeyInEnv } = + await import("../commands/onboard-search.js"); + const legacyDetected = SEARCH_PROVIDER_OPTIONS.find( + (e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e), + ); + if (legacyDetected) { + await prompter.note( + [ + `Web search is available via ${legacyDetected.label} (auto-detected).`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } else { + await prompter.note( + [ + "Web search was skipped. You can enable it later:", + ` ${formatCliCommand("openclaw configure --section web")}`, + "", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } + } await prompter.note( 'What now: https://openclaw.ai/showcase ("What People Are Building").', diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index 91d761ca569..ecc9c47060e 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -31,8 +31,8 @@ const configureGatewayForOnboarding = vi.hoisted(() => ); const finalizeOnboardingWizard = vi.hoisted(() => vi.fn(async (options) => { - if (!process.env.BRAVE_API_KEY) { - await options.prompter.note("hint", "Web search (optional)"); + if (!options.nextConfig?.tools?.web?.search?.provider) { + await options.prompter.note("Web search was skipped.", "Web search"); } if (options.opts.skipUi) { @@ -263,6 +263,7 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, }, @@ -291,6 +292,7 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, }, @@ -335,6 +337,7 @@ describe("runOnboardingWizard", () => { authChoice: "skip", skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, installDaemon: false, }, @@ -375,6 +378,7 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, }, @@ -384,7 +388,7 @@ describe("runOnboardingWizard", () => { const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; expect(calls.length).toBeGreaterThan(0); - expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true); + expect(calls.some((call) => call?.[1] === "Web search")).toBe(true); } finally { if (prevBraveKey === undefined) { delete process.env.BRAVE_API_KEY; @@ -440,6 +444,7 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, }, @@ -476,6 +481,7 @@ describe("runOnboardingWizard", () => { installDaemon: false, skipProviders: true, skipSkills: true, + skipSearch: true, skipHealth: true, skipUi: true, secretInputMode: "ref", diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 923bc5d7dfb..e2a81537eb7 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -512,6 +512,16 @@ export async function runOnboardingWizard( skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), }); + if (opts.skipSearch) { + await prompter.note("Skipping search setup.", "Search"); + } else { + const { setupSearch } = await import("../commands/onboard-search.js"); + nextConfig = await setupSearch(nextConfig, runtime, prompter, { + quickstartDefaults: flow === "quickstart", + secretInputMode: opts.secretInputMode, + }); + } + if (opts.skipSkills) { await prompter.note("Skipping skills setup.", "Skills"); } else {